Skip to main content

max / makeover-immediate

0.12.0: draw a table, for the one consumer that needs all of it 0.11.0 carried CellPart and drew nothing with it. This draws it. The consumer is audiofiles, whose file list is the only table measured in the tree that exercises everything the description says about one at once: sortable headings with carets, fixed and remainder tracks, and buttons inside cells. It had all of that hand-rolled over egui_extras. Two things this forces, and both are named where they land. egui_extras, the first dependency here past egui itself. egui has no table; Grid gives no per-column sizing, no sticky header and no scroll sync, which is why audiofiles reached for egui_extras rather than building on Grid. A third answer here would reimplement that crate worse. It moves in lockstep with egui's version, which is the cost. Palette::action, on the footing Palette::content arrived on at 0.5.0 with the field renderer: a link in a cell is the first thing here that needs the action intent. This is a breaking change to the struct literal, which is what the minor is for. Narrowing differs from makeover-tui's and the module says why. A content column cannot be measured before the app's closure has drawn it, so the two readings split: egui_extras sizes the track and remembers it between frames, and the declared floor budgets the drop decision. The terminal measures both from the cells because it holds them all before it draws any. Nothing counts positions. The cutoff ladder is duplicated across the two renderers because neither depends on the other, so a test asserts its shape rather than trusting the duplication. A cell's part is carried by `cell`, a scoping function the app wraps its contents in, for the reason `frame` is one: the part belongs to the cell, the cell does not exist until the closure runs, and immediate mode has no cascade to carry the answer down. That function is the cascade, for one scope.
Author: Max Johnson <me@maxj.phd> · 2026-08-11 20:13 UTC
Signed with PGP, not checked
Commit: 5ceffdd13dfefdac9f0c1dc1ba3ef64fb0657e51
Parent: e134381
3 files changed, +541 insertions, -7 deletions
M Cargo.toml +2 -1
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makeover-immediate"
3 - version = "0.11.0"
3 + version = "0.12.0"
4 4 edition = "2024"
5 5 description = "The immediate-mode renderer for makeover-layout. Immediate mode is the constraint that matters, not the library: no cascade, no retained tree, one stroke per widget. Backed by egui."
6 6 license = "MIT"
@@ -8,6 +8,7 @@
8 8
9 9 [dependencies]
10 10 egui = { version = "0.35", default-features = false }
11 + egui_extras = { version = "0.35", default-features = false }
11 12 # Exact patch rather than the minor, as the rest of the suite pins: a
12 13 # minor-only requirement is satisfied by a consumer lock holding an earlier
13 14 # patch, which then fails to compile against an API added in a later one.
M src/lib.rs +39 -6
@@ -43,11 +43,28 @@
43 43 //! `egui::Frame` for any depth whose fill is [`Fill::Overlay`], keyed off the
44 44 //! fill rather than the variant.
45 45 //!
46 - //! The same release brings `makeover_layout::CellPart`, which this renderer
47 - //! carries and does not draw. No table is drawn here yet, and the vocabulary is
48 - //! what makes drawing one possible without inventing a private name for what a
49 - //! cell holds -- which is the whole reason it lives in the description rather
50 - //! than in `quasi`.
46 + //! The same release brings `makeover_layout::CellPart`, which 0.11.0 carried
47 + //! and did not draw. [`table`] draws it, below.
48 + //!
49 + //! # 0.12.0: the table
50 + //!
51 + //! [`table`] is the vocabulary 0.11.0 took without using. The consumer is
52 + //! audiofiles, whose file list is the only table in the tree exercising all four
53 + //! of what the description says about one at once: sortable headings with
54 + //! carets, fixed and remainder tracks, and buttons inside cells.
55 + //!
56 + //! Two things it forces, both named where they land:
57 + //!
58 + //! - **`egui_extras`**, this crate's first dependency past egui. egui has no
59 + //! table, and `Grid` gives no per-column sizing, no sticky header and no
60 + //! scroll sync, which is why audiofiles reached for `egui_extras` rather than
61 + //! building on `Grid`. A third answer here would reimplement that crate worse.
62 + //! - **[`Palette::action`]**, on the footing [`Palette::content`] arrived on: a
63 + //! link in a cell is the first thing here needing the action intent.
64 + //!
65 + //! Narrowing works differently from the terminal's and the module header says
66 + //! why: a content column cannot be measured before the app's closure has drawn
67 + //! it, so `egui_extras` sizes it and the declared floor budgets it.
51 68 //!
52 69 //! # Forms
53 70 //!
@@ -86,6 +103,9 @@
86 103 };
87 104 use makeover_layout::{Bevel, Choice, Depth, Edge, Field, FieldKind, Fill, State};
88 105
106 + /// Columns, narrowing, cell parts and the sort caret, over `egui_extras`.
107 + pub mod table;
108 +
89 109 /// The resolved colours this renderer needs, as flat values.
90 110 ///
91 111 /// Built by the app from whatever it already uses to resolve a theme, then
@@ -151,6 +171,18 @@
151 171 /// resolves to. Both readings come from the description rather than from
152 172 /// here: `State::Disabled` names this intent by token.
153 173 pub content_muted: Color32,
174 + /// `action-primary`.
175 + ///
176 + /// What a control is drawn in. Added 0.12.0 with the table renderer, for the
177 + /// reason `content` was added 0.5.0 with the field renderer: a link in a
178 + /// cell is the first thing here that needs the action intent, and a palette
179 + /// should carry what is used.
180 + ///
181 + /// This is the intent [`CellPart`](makeover_layout::CellPart) exists to
182 + /// separate. A cell holding a control took the cell's text colour until the
183 + /// description could say otherwise, which is the drift `makeover-layout`
184 + /// 0.14.0 named and `makeover-webview` 0.25.0 fixed on its own side.
185 + pub action: Color32,
154 186 /// `danger`.
155 187 ///
156 188 /// A field's error message. The one [`makeover_layout::Tone`] this renderer
@@ -674,7 +706,8 @@
674 706 elevation: Color32::from_black_alpha(46),
675 707 content: Color32::from_rgb(5, 5, 5),
676 708 content_muted: Color32::from_rgb(6, 6, 6),
677 - danger: Color32::from_rgb(7, 7, 7),
709 + action: Color32::from_rgb(7, 7, 7),
710 + danger: Color32::from_rgb(8, 8, 8),
678 711 }
679 712 }
680 713
A src/table.rs +500
@@ -1,0 +1,522 @@
1 + //! Columns, narrowing, cell parts and the sort caret, over `egui_extras`.
2 + //!
3 + //! `makeover-webview`'s `list` module and `makeover-tui`'s `table` in the shape
4 + //! immediate mode allows. It owns the same four things: which columns exist, how
5 + //! wide they are, which ones survive a narrow viewport, and what each part of a
6 + //! cell is. It does not own what goes in a cell, which here is not a policy but
7 + //! a fact of the mode: a cell's contents are drawn by the app's own closure, the
8 + //! way [`group`](crate::group) already takes one per field.
9 + //!
10 + //! # Why `egui_extras` and not egui
11 + //!
12 + //! egui itself has no table. [`egui::Grid`] gives no per-column sizing, no
13 + //! sticky header and no scroll sync, which is why audiofiles reached for
14 + //! `egui_extras::TableBuilder` rather than building on `Grid`. Writing a third
15 + //! answer here would be reimplementing that crate worse, so this is a mapping
16 + //! layer over it.
17 + //!
18 + //! It is the first dependency this crate has taken beyond egui itself, and it
19 + //! moves in lockstep with egui's own version, which is the cost worth naming.
20 + //!
21 + //! # What immediate mode costs the narrowing
22 + //!
23 + //! The terminal renderer measures a [`Width::Content`] column from its cells,
24 + //! because it holds every cell before it draws any. Here the cells do not exist
25 + //! until the app's closure runs, so nothing can be measured before the layout is
26 + //! decided.
27 + //!
28 + //! That splits the answer in two, and both halves are honest:
29 + //!
30 + //! - **Sizing** hands a content column to
31 + //! [`egui_extras::Column::auto`], which measures it and holds the result
32 + //! between frames. This is better than the terminal gets, not worse.
33 + //! - **Narrowing** cannot wait for that, so it budgets a content column at the
34 + //! floor the app declared in [`Sizing`]. A column that turns out wider than
35 + //! its floor is still drawn; it is the *decision to drop* that uses the
36 + //! declared number, and a floor is what the app already has to supply for its
37 + //! fill columns.
38 + //!
39 + //! # Why positions are the bug
40 + //!
41 + //! Carried from the other two renderers, because the mistake is not a CSS
42 + //! mistake and not a terminal one. goingson hides its mobile columns with
43 + //! `nth-child(n+5)` against a seven-column table; insert a column left of the
44 + //! cut and the wrong one disappears, silently. A renderer narrows by raising a
45 + //! cutoff and never by counting.
46 +
47 + use crate::Palette;
48 + use egui::{Response, RichText, Sense, Ui};
49 + use egui_extras::{Column as Track, TableBuilder};
50 + use makeover_layout::{CellPart, Column, Priority, Sort, Width};
51 +
52 + /// The cutoffs, weakest first.
53 + ///
54 + /// [`Priority`] is `#[non_exhaustive]` and a tier added upstream has to be added
55 + /// here in its place in the sequence, or a table will never narrow to it. Grep
56 + /// this when adopting a new `makeover-layout`; `makeover-tui` carries the same
57 + /// list for the same reason, and the two have to agree or a description narrows
58 + /// differently in a window than in a terminal.
59 + const CUTOFFS: [Priority; 3] = [
60 + Priority::Optional,
61 + Priority::Secondary,
62 + Priority::Essential,
63 + ];
64 +
65 + /// The lengths the description deferred, in points.
66 + ///
67 + /// [`Width`] says `Content`, `Fixed` or `Fill` and carries no magnitude, because
68 + /// a magnitude is an answer for one renderer and the description is read by
69 + /// three. The other two renderers hold this same type over CSS lengths and over
70 + /// terminal cells.
71 + #[derive(Debug, Clone, Copy, Default)]
72 + pub struct Sizing<'a> {
73 + /// `(column name, points)`. The track for a [`Width::Fixed`] column, the
74 + /// floor for a [`Width::Fill`] one, and the narrowing budget for a
75 + /// [`Width::Content`] one.
76 + pub lengths: &'a [(&'a str, f32)],
77 + /// Used for a column with no entry above.
78 + pub fallback: f32,
79 + }
80 +
81 + impl Sizing<'_> {
82 + /// The length for a named column.
83 + fn length_for(&self, name: &str) -> f32 {
84 + self.lengths
85 + .iter()
86 + .find(|(column, _)| *column == name)
87 + .map_or(self.fallback, |(_, length)| *length)
88 + }
89 + }
90 +
91 + /// The tones and metrics a table draws with.
92 + ///
93 + /// Metrics only, and the tones come from [`Palette`]. That is the division this
94 + /// crate already draws: [`FieldStyle`](crate::FieldStyle) carries gaps and a
95 + /// marker while the colours stay in the palette, and a table's colours are the
96 + /// palette's `content`, `content_muted` and `action` rather than six new ones.
97 + /// `makeover-tui` splits it the other way round because its palette carries no
98 + /// text tones at all.
99 + #[derive(Debug, Clone, Copy, PartialEq)]
100 + pub struct TableStyle {
101 + /// The height of the heading row.
102 + pub header_height: f32,
103 + /// The height of a body row.
104 + pub row_height: f32,
105 + /// Drawn after the heading of an ascending column.
106 + pub ascending: &'static str,
107 + /// Drawn after the heading of a descending column.
108 + pub descending: &'static str,
109 + /// Whether alternate rows take a different background.
110 + ///
111 + /// egui_extras' own striping, off by default: the description has no word
112 + /// for it, and a renderer that turned it on would be adding a claim the
113 + /// other two cannot make.
114 + pub striped: bool,
115 + /// Whether the heading stays put while the body scrolls.
116 + pub sticky_header: bool,
117 + }
118 +
119 + impl Default for TableStyle {
120 + fn default() -> Self {
121 + Self {
122 + header_height: 20.0,
123 + row_height: 18.0,
124 + // The pair audiofiles already draws, so a sorted column points the
125 + // same way here as it does in a terminal.
126 + ascending: " \u{25B2}",
127 + descending: " \u{25BC}",
128 + striped: false,
129 + sticky_header: true,
130 + }
131 + }
132 + }
133 +
134 + /// The colour a cell of this part takes.
135 + ///
136 + /// [`CellPart`] is `#[non_exhaustive]`, and a member added upstream lands on
137 + /// `content`: a part this renderer has not learned draws as text, which is a
138 + /// cell rendering plainly rather than a build that stops. Grep this when
139 + /// adopting a new `makeover-layout`.
140 + #[must_use]
141 + pub const fn part_color(part: Option<CellPart>, palette: &Palette) -> egui::Color32 {
142 + match part {
143 + // A token paints its own background and carries its own tone. What is
144 + // set here is what shows between them, not what paints them.
145 + Some(CellPart::Tokens) => palette.content_muted,
146 + // The drift `CellPart` exists to end: a control in a cell inheriting the
147 + // cell's text colour. Both of these take the action intent instead.
148 + Some(CellPart::Actions | CellPart::Link) => palette.action,
149 + _ => palette.content,
150 + }
151 + }
152 +
153 + /// Draw a cell's contents with the tone its part takes.
154 + ///
155 + /// The app calls this inside its own cell closure, wrapping whatever it draws.
156 + /// A scoping function rather than a parameter on [`table`], for the reason
157 + /// [`frame`](crate::frame) is one: the part is a property of the cell, the cell
158 + /// does not exist until the closure runs, and immediate mode has no cascade to
159 + /// carry the answer down on its own. This is the cascade, for one scope.
160 + ///
161 + /// ```no_run
162 + /// # use makeover_layout::CellPart;
163 + /// # let palette: makeover_immediate::Palette = unimplemented!();
164 + /// # let ui: &mut egui::Ui = unimplemented!();
165 + /// makeover_immediate::table::cell(ui, Some(CellPart::Link), &palette, |ui| {
166 + /// ui.label("opens the item");
167 + /// });
168 + /// ```
169 + pub fn cell<R>(
170 + ui: &mut Ui,
171 + part: Option<CellPart>,
172 + palette: &Palette,
173 + add_contents: impl FnOnce(&mut Ui) -> R,
174 + ) -> R {
175 + let restore = ui.visuals().override_text_color;
176 + ui.visuals_mut().override_text_color = Some(part_color(part, palette));
177 + let out = add_contents(ui);
178 + ui.visuals_mut().override_text_color = restore;
179 + out
180 + }
181 +
182 + /// The heading, with the caret if the table is ordered by this column.
183 + ///
184 + /// A column [`sorted`](Column::sorted) but not [`sortable`](Column::sortable)
185 + /// still gets its caret. Both combinations mean something, which is why the
186 + /// description holds the two fields apart: a list ordered by a key the user
187 + /// cannot change is a real thing, and the caret is how it says so.
188 + #[must_use]
189 + pub fn heading(column: &Column<'_>, style: &TableStyle) -> String {
190 + match column.sorted {
191 + Some(Sort::Ascending) => format!("{}{}", column.name, style.ascending),
192 + Some(Sort::Descending) => format!("{}{}", column.name, style.descending),
193 + None => column.name.to_owned(),
194 + }
195 + }
196 +
197 + /// How wide a column asks to be at its narrowest, in points.
198 + fn min_width(column: &Column<'_>, sizing: &Sizing<'_>) -> f32 {
199 + // Every arm is the declared length, including `Content`: nothing can be
200 + // measured before the app's closure has drawn it. See the module header on
201 + // what immediate mode costs the narrowing.
202 + sizing.length_for(column.name)
203 + }
204 +
205 + /// Whether the columns kept at `cutoff` fit in `width`.
206 + fn fits(columns: &[Column<'_>], sizing: &Sizing<'_>, cutoff: Priority, width: f32) -> bool {
207 + columns
208 + .iter()
209 + .filter(|c| c.kept_at(cutoff))
210 + .map(|c| min_width(c, sizing))
211 + .sum::<f32>()
212 + <= width
213 + }
214 +
215 + /// The weakest cutoff whose columns fit in `width`.
216 + ///
217 + /// Raised until the layout fits, and never past [`Priority::Essential`]: the
218 + /// essential columns are what makes a row identify itself, so a window too
219 + /// narrow for them gets them squeezed rather than dropped. Nothing here counts
220 + /// positions, so which column drops is a property of the column.
221 + #[must_use]
222 + pub fn cutoff_for(columns: &[Column<'_>], sizing: &Sizing<'_>, width: f32) -> Priority {
223 + for cutoff in CUTOFFS {
224 + if fits(columns, sizing, cutoff, width) {
225 + return cutoff;
226 + }
227 + }
228 + Priority::Essential
229 + }
230 +
231 + /// The track for one column.
232 + fn track(column: &Column<'_>, sizing: &Sizing<'_>) -> Track {
233 + match column.width {
234 + // The one place immediate mode beats the terminal: egui_extras measures
235 + // this and remembers it between frames, where `makeover-tui` has to walk
236 + // the cells itself.
237 + Width::Content => Track::auto(),
238 + Width::Fixed => Track::exact(sizing.length_for(column.name)),
239 + // Includes a width added to the description since this renderer was
240 + // built. Taking the slack above a floor is the behaviour that makes no
241 + // claim, which is the same fallback the webview renderer's `auto` track
242 + // is chosen to be.
243 + _ => Track::remainder().at_least(sizing.length_for(column.name)),
244 + }
245 + }
246 +
247 + /// A described table, narrowed for the width available.
248 + ///
249 + /// `rows` is a count and `draw` is called once per cell of each kept column, in
250 + /// column order. Taking a closure rather than a slice of contents is what keeps
251 + /// the app's own data borrowed one cell at a time, which is
252 + /// [`group`](crate::group)'s reasoning and immediate mode's habit.
253 + ///
254 + /// Returns the sortable column whose heading was pressed this frame, if any. The
255 + /// app owns the ordering, so this reports the press and changes nothing: what a
256 + /// press *calls* is an address, and the description names none. That is
257 + /// [`Column::sortable`]'s own documented split.
258 + ///
259 + /// A heading is only pressable when its column says
260 + /// [`sortable`](Column::sortable). A column sorted by a key the user cannot
261 + /// change still draws its caret and does not answer.
262 + pub fn table<'a>(
263 + ui: &mut Ui,
264 + columns: &'a [Column<'a>],
265 + rows: usize,
266 + sizing: &Sizing<'_>,
267 + palette: &Palette,
268 + style: &TableStyle,
269 + mut draw: impl FnMut(&mut Ui, &'a Column<'a>, usize),
270 + ) -> Option<&'a Column<'a>> {
271 + let cutoff = cutoff_for(columns, sizing, ui.available_width());
272 + let kept: Vec<&'a Column<'a>> = columns.iter().filter(|c| c.kept_at(cutoff)).collect();
273 +
274 + // egui_extras panics on a table with no tracks, and a description whose
275 + // every column dropped is reachable: `kept_at` keeps the essential ones, and
276 + // a table described with none at all has nothing to keep.
277 + if kept.is_empty() {
278 + return None;
279 + }
280 +
281 + let mut builder = TableBuilder::new(ui).striped(style.striped);
282 + for column in &kept {
283 + builder = builder.column(track(column, sizing));
284 + }
285 +
286 + // Written through a Cell rather than returned, because egui_extras hands the
287 + // header and the body their own closures and neither can return a value past
288 + // the other.
289 + let pressed = std::cell::Cell::new(None::<&'a Column<'a>>);
290 +
291 + builder
292 + .header(style.header_height, |mut header| {
293 + for column in &kept {
294 + header.col(|ui| {
295 + if press(ui, column, palette, style) {
296 + pressed.set(Some(column));
297 + }
298 + });
299 + }
300 + })
301 + .body(|body| {
302 + body.rows(style.row_height, rows, |mut row| {
303 + let index = row.index();
304 + for column in &kept {
305 + row.col(|ui| draw(ui, column, index));
306 + }
307 + });
308 + });
309 +
310 + pressed.get()
311 + }
312 +
313 + /// One heading, and whether it was pressed.
314 + fn press(ui: &mut Ui, column: &Column<'_>, palette: &Palette, style: &TableStyle) -> bool {
315 + let text = RichText::new(heading(column, style)).strong();
316 + if !column.sortable {
317 + // Muted, and not sensed. A heading a user cannot press must not look
318 + // like one they can, which is the affordance `Column::sortable` exists
319 + // to carry.
320 + ui.label(text.color(palette.content_muted));
321 + return false;
322 + }
323 + let tone = if column.sorted.is_some() {
324 + palette.content
325 + } else {
326 + palette.content_muted
327 + };
328 + let response: Response = ui
329 + .add(egui::Label::new(text.color(tone)).sense(Sense::click()))
330 + .on_hover_cursor(egui::CursorIcon::PointingHand);
331 + response.clicked()
332 + }
333 +
334 + #[cfg(test)]
335 + mod tests {
336 + use super::*;
337 + use egui::Color32;
338 +
339 + fn palette() -> Palette {
340 + Palette {
341 + page: Color32::from_rgb(1, 1, 1),
342 + raised: Color32::from_rgb(2, 2, 2),
343 + overlay: Color32::from_rgb(3, 3, 3),
344 + well: Color32::from_rgb(4, 4, 4),
345 + sunken: Color32::from_rgb(5, 5, 5),
346 + bevel_light: Color32::WHITE,
347 + bevel_dark: Color32::BLACK,
348 + elevation: Color32::from_black_alpha(46),
349 + content: Color32::from_rgb(6, 6, 6),
350 + content_muted: Color32::from_rgb(7, 7, 7),
351 + action: Color32::from_rgb(8, 8, 8),
352 + danger: Color32::from_rgb(9, 9, 9),
353 + }
354 + }
355 +
356 + fn columns() -> Vec<Column<'static>> {
357 + vec![
358 + Column {
359 + name: "name",
360 + width: Width::Fill,
361 + priority: Priority::Essential,
362 + sortable: true,
363 + sorted: Some(Sort::Ascending),
364 + },
365 + Column {
366 + name: "size",
367 + width: Width::Fixed,
368 + priority: Priority::Secondary,
369 + sortable: true,
370 + sorted: None,
371 + },
372 + Column {
373 + name: "note",
374 + width: Width::Content,
375 + priority: Priority::Optional,
376 + sortable: false,
377 + sorted: None,
378 + },
379 + ]
380 + }
381 +
382 + fn sizing() -> Sizing<'static> {
383 + Sizing {
384 + lengths: &[("name", 120.0), ("size", 60.0), ("note", 80.0)],
385 + fallback: 40.0,
386 + }
387 + }
388 +
389 + #[test]
390 + fn narrowing_drops_the_optional_column_first_and_the_essential_one_never() {
391 + let (cols, sz) = (columns(), sizing());
392 + assert_eq!(cutoff_for(&cols, &sz, 300.0), Priority::Optional);
393 + assert_eq!(cutoff_for(&cols, &sz, 200.0), Priority::Secondary);
394 + assert_eq!(cutoff_for(&cols, &sz, 150.0), Priority::Essential);
395 + // Narrower than the essential column, which stays anyway.
396 + assert_eq!(cutoff_for(&cols, &sz, 10.0), Priority::Essential);
397 + }
398 +
399 + #[test]
400 + fn a_column_inserted_left_of_the_cut_does_not_change_what_drops() {
401 + // The goingson bug, as a test. `nth-child(n+5)` against a seven-column
402 + // table hides whatever lands at position five, so inserting a column
403 + // moves the cut onto a different column with nothing edited.
404 + //
405 + // Asserted at a fixed cutoff, because that is where the two ways of
406 + // addressing a column disagree. A narrower budget SHOULD drop more; what
407 + // must not change is which ones, for a given cutoff.
408 + let dropped = |cols: &[Column<'_>], cutoff| -> Vec<String> {
409 + cols.iter()
410 + .filter(|c| !c.kept_at(cutoff))
411 + .map(|c| c.name.to_owned())
412 + .collect()
413 + };
414 + let before = columns();
415 + let mut after = vec![Column {
416 + name: "mark",
417 + width: Width::Fixed,
418 + priority: Priority::Essential,
419 + sortable: false,
420 + sorted: None,
421 + }];
422 + after.extend(columns());
423 +
424 + for cutoff in CUTOFFS {
425 + assert_eq!(dropped(&before, cutoff), dropped(&after, cutoff));
426 + }
427 + assert_eq!(dropped(&before, Priority::Secondary), vec!["note"]);
428 + }
429 +
430 + #[test]
431 + fn the_two_renderers_narrow_a_description_the_same_way() {
432 + // The cutoff ladder is duplicated in `makeover-tui` because neither
433 + // crate depends on the other, and duplication is what drifts. This is
434 + // the assertion that would catch it: the ladder is the description's
435 + // order, weakest first, and a tier added upstream belongs in both.
436 + assert_eq!(CUTOFFS.len(), 3);
437 + assert!(CUTOFFS.windows(2).all(|pair| pair[0] < pair[1]));
438 + assert_eq!(CUTOFFS[0], Priority::Optional);
439 + assert_eq!(CUTOFFS[2], Priority::Essential);
440 + }
441 +
442 + #[test]
443 + fn a_content_column_is_measured_by_egui_and_budgeted_by_its_floor() {
444 + // The split the module header names. The track defers to egui_extras,
445 + // which can measure; the narrowing cannot wait for that and uses the
446 + // declared floor. Both readings of the same column, and both honest.
447 + let cols = columns();
448 + let sz = sizing();
449 + let note = &cols[2];
450 + assert!(matches!(note.width, Width::Content));
451 + assert!((min_width(note, &sz) - 80.0).abs() < f32::EPSILON);
452 + // 120 + 60 + 80 is 260, so 300 fits and 250 does not.
453 + assert!(fits(&cols, &sz, Priority::Optional, 300.0));
454 + assert!(!fits(&cols, &sz, Priority::Optional, 250.0));
455 + }
456 +
457 + #[test]
458 + fn a_column_with_no_length_of_its_own_takes_the_fallback() {
459 + let column = Column {
460 + name: "unlisted",
461 + width: Width::Fixed,
462 + priority: Priority::Essential,
463 + sortable: false,
464 + sorted: None,
465 + };
466 + assert!((min_width(&column, &sizing()) - 40.0).abs() < f32::EPSILON);
467 + }
468 +
469 + #[test]
470 + fn the_parts_a_cell_can_be_are_coloured_apart() {
471 + // The drift `CellPart` exists to end: one colour for a whole cell paints
472 + // a control as though it were text.
473 + let p = palette();
474 + assert_eq!(part_color(Some(CellPart::Value), &p), p.content);
475 + assert_eq!(part_color(Some(CellPart::Tokens), &p), p.content_muted);
476 + assert_eq!(part_color(Some(CellPart::Actions), &p), p.action);
477 + assert_eq!(part_color(Some(CellPart::Link), &p), p.action);
478 + assert_ne!(part_color(Some(CellPart::Link), &p), p.content);
479 + // A cell mixing parts says nothing, and takes the text colour.
480 + assert_eq!(part_color(None, &p), p.content);
481 + }
482 +
483 + #[test]
484 + fn the_ordered_column_draws_a_caret_and_the_others_do_not() {
485 + let style = TableStyle::default();
486 + let cols = columns();
487 + assert_eq!(heading(&cols[0], &style), "name \u{25B2}");
488 + assert_eq!(heading(&cols[1], &style), "size");
489 + assert_eq!(heading(&cols[2], &style), "note");
490 + }
491 +
492 + #[test]
493 + fn a_column_sorted_without_being_sortable_still_draws_its_caret() {
494 + // A list ordered by a key the user cannot change is a real thing to
495 + // describe, which is why the description holds the two fields apart.
496 + let column = Column {
497 + name: "rank",
498 + width: Width::Content,
499 + priority: Priority::Essential,
500 + sortable: false,
Lines truncated