Skip to main content

max / makeover-immediate

0.13.0: what taking the table in audiofiles found missing 0.12.0 shipped `table` before its one consumer had used it. Adopting it in audiofiles' file list found three things that list already did and the function could not say, all three host idiom rather than description, and all three answered on an egui_extras handle the app never sees: - Body::selected, a predicate asked per row, because set_selected is a method on the row. Without it a file list cannot show what is selected. - Body::scroll_to, because scroll_to_row is a method on the builder. A keyboard cursor that moves off-screen and stays there is the bug it stops. - TableStyle::resizable, which passes the test sticky_header failed: egui_extras offers two settings here and a renderer can honestly make either choice. A fourth is not a knob. Cells are centred on the row's centre line, always, because there is no second honest answer and egui's default (top-aligned) is the one thing it cannot be. Body is also what splits a table's per-frame facts from its description and its style: a row count, a selection and a scroll request are none of them style, and none survive the frame. makeover-layout moves to 0.15.0, which is what the FieldKind::Date note in the lib header already described.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-12 00:18 UTC
Signed with PGP, not checked
Commit: 303b06f1b551441ff037a99e3de6ef5c5493fffd
Parent: e72d0a4
3 files changed, +180 insertions, -14 deletions
M Cargo.toml +2 -2
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makeover-immediate"
3 - version = "0.12.0"
3 + version = "0.13.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"
@@ -12,7 +12,7 @@
12 12 # Exact patch rather than the minor, as the rest of the suite pins: a
13 13 # minor-only requirement is satisfied by a consumer lock holding an earlier
14 14 # patch, which then fails to compile against an API added in a later one.
15 - makeover-layout = "0.14.0"
15 + makeover-layout = "0.15.0"
16 16
17 17 [lints.rust]
18 18 unused = "warn"
M src/lib.rs +37
@@ -66,6 +66,34 @@
66 66 //! why: a content column cannot be measured before the app's closure has drawn
67 67 //! it, so `egui_extras` sizes it and the declared floor budgets it.
68 68 //!
69 + //! # 0.13.0: what the adoption found missing
70 + //!
71 + //! 0.12.0 shipped [`table`] before audiofiles had taken it, and taking it found
72 + //! three things the file list already did that the function could not say. All
73 + //! three are host idiom rather than description, which is why they land here and
74 + //! not in `makeover-layout`, and all three are answered on a handle the app
75 + //! never sees: the `egui_extras` row and builder this crate owns. That is
76 + //! [`table::cell`]'s reasoning again: what the app cannot reach, the renderer
77 + //! owes it.
78 + //!
79 + //! - **A selected row.** [`table::Body::selected`], a predicate asked per row,
80 + //! because `set_selected` is a method on the row. Without it a file list has
81 + //! no way to show what is selected, which is most of what a file list does.
82 + //! - **Scrolling a row into view.** [`table::Body::scroll_to`], because
83 + //! `scroll_to_row` is a method on the builder. A keyboard cursor that moves
84 + //! off-screen and stays there is the bug this prevents.
85 + //! - **Dragging a divider.** [`table::TableStyle::resizable`], which passes the
86 + //! test `sticky_header` failed in 0.12.0: egui_extras offers two settings here
87 + //! and a renderer can honestly make either choice.
88 + //!
89 + //! A fourth was found and is not a knob. Cells are centred on the row's centre
90 + //! line, always, because there is no second honest answer and egui's own default
91 + //! (top-aligned) is the one thing it cannot be.
92 + //!
93 + //! [`table::Body`] is also what splits a table's per-frame facts from its
94 + //! description and from its style. A row count, a selection and a scroll request
95 + //! are none of them style, and none of them survive the frame.
96 + //!
69 97 //! # Forms
70 98 //!
71 99 //! 0.5.0 adds the field vocabulary on top of the depth vocabulary:
@@ -471,6 +499,15 @@
471 499 /// every renderer *could* answer honestly, not that each one does on the day.
472 500 /// A path in a text box is not nothing, and it is what an app that needs this
473 501 /// tomorrow gets today.
502 + ///
503 + /// `FieldKind::Date` and `FieldKind::DateTime` land there too, as of
504 + /// makeover-layout 0.15.0, on the same footing and with one thing owed. A
505 + /// calendar is a sixth control and bare `egui` has none, so a typed value is
506 + /// the honest answer here; what the app gets is the format the description
507 + /// names, `makeover_layout::DATE_FORMAT` and `DATETIME_FORMAT`, which is why
508 + /// those are constants rather than a sentence. audiofiles is the only consumer
509 + /// of this crate and asks for neither today. A calendar popup is the upgrade
510 + /// whenever one does.
474 511 const fn control_shape(kind: FieldKind) -> Control {
475 512 match kind {
476 513 FieldKind::Select => Control::Chosen,
M src/table.rs +141 -12
@@ -108,12 +108,24 @@
108 108 /// for it, and a renderer that turned it on would be adding a claim the
109 109 /// other two cannot make.
110 110 ///
111 - /// The only knob here that is not a metric, and it is the only one because
112 - /// egui_extras already answers the rest. A sticky heading, for one: it is
113 - /// what `TableBuilder::header` does and there is no version that does not,
114 - /// so a field offering the choice would be offering one this renderer cannot
115 - /// make.
111 + /// Not every setting egui_extras has becomes a field here. A sticky heading
112 + /// is what `TableBuilder::header` does and there is no version that does
113 + /// not, so the knob 0.12.0 briefly carried for it offered a choice this
114 + /// renderer cannot make. This one and [`resizable`](Self::resizable) are the
115 + /// two that pass that test.
116 116 pub striped: bool,
117 + /// Whether the user can drag the divider between two columns.
118 + ///
119 + /// The second knob that is not a metric, and it passes the same test
120 + /// `sticky_header` failed: egui_extras offers both settings and a renderer
121 + /// can honestly make either choice. Off by default for `striped`'s reason:
122 + /// the description has no word for it, so a default that turned it on would
123 + /// be this renderer adding a claim the other two cannot make.
124 + ///
125 + /// It does not fight the narrowing. A drag moves a track for the frames it
126 + /// is held; [`cutoff_for`] still decides which columns exist, off the widths
127 + /// the app declared in [`Sizing`], so a resize can never drop a column.
128 + pub resizable: bool,
117 129 }
118 130
119 131 impl Default for TableStyle {
@@ -126,10 +138,56 @@
126 138 ascending: " \u{25B2}",
127 139 descending: " \u{25BC}",
128 140 striped: false,
141 + resizable: false,
129 142 }
130 143 }
131 144 }
132 145
146 + /// The body's own facts for this frame: how many rows, which are selected, and
147 + /// which one to bring into view.
148 + ///
149 + /// Held apart from [`TableStyle`] because none of it is style and none of it
150 + /// survives the frame: a row count changes when a folder does, a selection when
151 + /// the user clicks, and a scroll request exists for exactly one frame. Held
152 + /// apart from the [`Column`] slice because none of it is description either.
153 + /// The description says what a table *is*, and this says what it holds right
154 + /// now.
155 + ///
156 + /// Both of the optional fields are here rather than left to the app because
157 + /// egui_extras answers them on a handle the app never sees: `set_selected` is a
158 + /// method on the row, and `scroll_to_row` a method on the builder, and this
159 + /// crate owns both. That is the same reason [`cell`] exists.
160 + #[derive(Default)]
161 + pub struct Body<'a> {
162 + /// How many rows to draw.
163 + pub rows: usize,
164 + /// Whether a row is selected, by index.
165 + ///
166 + /// A predicate rather than a set, so an app whose selection is a range, a
167 + /// bitmap or a single index does not have to build a collection to be asked.
168 + /// `None` is a table no row of which is selected, which is not the same
169 + /// claim as a predicate that always answers false and costs nothing to make.
170 + pub selected: Option<&'a dyn Fn(usize) -> bool>,
171 + /// A row to bring into view this frame.
172 + ///
173 + /// Set it from a request the app then clears, the way a keyboard cursor
174 + /// moving off-screen raises one: held rather than taken, it would fight
175 + /// every scroll the user makes with the mouse.
176 + pub scroll_to: Option<usize>,
177 + }
178 +
179 + impl std::fmt::Debug for Body<'_> {
180 + // Hand-written because `selected` is a closure and `#[derive(Debug)]` will
181 + // not have it. What is worth printing is whether one was supplied.
182 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
183 + f.debug_struct("Body")
184 + .field("rows", &self.rows)
185 + .field("selected", &self.selected.is_some())
186 + .field("scroll_to", &self.scroll_to)
187 + .finish()
188 + }
189 + }
190 +
133 191 /// The colour a cell of this part takes.
134 192 ///
135 193 /// [`CellPart`] is `#[non_exhaustive]`, and a member added upstream lands on
@@ -245,11 +303,17 @@
245 303
246 304 /// A described table, narrowed for the width available.
247 305 ///
248 - /// `rows` is a count and `draw` is called once per cell of each kept column, in
249 - /// column order. Taking a closure rather than a slice of contents is what keeps
250 - /// the app's own data borrowed one cell at a time, which is
306 + /// `draw` is called once per cell of each kept column, in column order, for each
307 + /// of [`Body::rows`] rows. Taking a closure rather than a slice of contents is
308 + /// what keeps the app's own data borrowed one cell at a time, which is
251 309 /// [`group`](crate::group)'s reasoning and immediate mode's habit.
252 310 ///
311 + /// `body` is borrowed immutably and `draw` is `FnMut`, which is the split a
312 + /// caller has to plan for: a selection read by [`Body::selected`] cannot be the
313 + /// same value `draw` mutates. Snapshot it before the call. That is not this
314 + /// crate imposing anything. It is the borrow the app already takes when it
315 + /// clones its row list to hand egui a closure.
316 + ///
253 317 /// Returns the sortable column whose heading was pressed this frame, if any. The
254 318 /// app owns the ordering, so this reports the press and changes nothing: what a
255 319 /// press *calls* is an address, and the description names none. That is
@@ -261,7 +325,7 @@
261 325 pub fn table<'a>(
262 326 ui: &mut Ui,
263 327 columns: &'a [Column<'a>],
264 - rows: usize,
328 + body: &Body<'_>,
265 329 sizing: &Sizing<'_>,
266 330 palette: &Palette,
267 331 style: &TableStyle,
@@ -277,10 +341,21 @@
277 341 return None;
278 342 }
279 343
280 - let mut builder = TableBuilder::new(ui).striped(style.striped);
344 + let mut builder = TableBuilder::new(ui)
345 + .striped(style.striped)
346 + .resizable(style.resizable)
347 + // Not a knob, because there is no second honest answer: a cell's
348 + // contents sit on the row's centre line. CSS says `vertical-align:
349 + // middle` and a terminal row is one line tall, so a field offering the
350 + // choice would be offering one only this renderer could take. egui's own
351 + // default is top-aligned, which is why it has to be said at all.
352 + .cell_layout(egui::Layout::left_to_right(egui::Align::Center));
281 353 for column in &kept {
282 354 builder = builder.column(track(column, sizing));
283 355 }
356 + if let Some(row) = body.scroll_to {
357 + builder = builder.scroll_to_row(row, None);
358 + }
284 359
285 360 // Written through a Cell rather than returned, because egui_extras hands the
286 361 // header and the body their own closures and neither can return a value past
@@ -297,9 +372,16 @@
297 372 });
298 373 }
299 374 })
300 - .body(|body| {
301 - body.rows(style.row_height, rows, |mut row| {
375 + .body(|table_body| {
376 + table_body.rows(style.row_height, body.rows, |mut row| {
302 377 let index = row.index();
378 + if let Some(selected) = body.selected {
379 + // Before the cells, and on the row rather than on any of
380 + // them: a selection marks the whole row, and a renderer that
381 + // tinted each cell would leave the gaps between them
382 + // unpainted.
383 + row.set_selected(selected(index));
384 + }
303 385 for column in &kept {
304 386 row.col(|ui| draw(ui, column, index));
305 387 }
@@ -517,5 +599,52 @@
517 599 // egui_extras offers it and the other two renderers cannot say it. A
518 600 // default that turned it on would be this renderer adding a claim.
519 601 assert!(!TableStyle::default().striped);
602 + // Same test, same answer, and the reason `sticky_header` failed it: that
603 + // one had no second setting to offer.
604 + assert!(!TableStyle::default().resizable);
605 + }
606 +
607 + #[test]
608 + fn a_body_claims_nothing_until_it_is_asked_to() {
609 + // The default is a table of no rows, no selection and no scroll
610 + // request. All three absences are the honest reading of an app that has
611 + // not said otherwise, which is why they are `Option` and not a
612 + // predicate that always answers false.
613 + let body = Body::default();
614 + assert_eq!(body.rows, 0);
615 + assert!(body.selected.is_none());
616 + assert!(body.scroll_to.is_none());
617 + }
618 +
619 + #[test]
620 + fn a_selection_is_asked_per_row_and_not_collected() {
621 + // A predicate, so an app whose selection is a range or a single index
622 + // does not build a set to be asked. Exercised the way `table` asks it:
623 + // once per row index, in order.
624 + let selected = |index: usize| index.is_multiple_of(2);
625 + let body = Body {
626 + rows: 4,
627 + selected: Some(&selected),
628 + scroll_to: None,
629 + };
630 + let f = body.selected.expect("a predicate was supplied");
631 + assert_eq!(
632 + (0..body.rows).map(f).collect::<Vec<_>>(),
633 + vec![true, false, true, false]
634 + );
635 + }
636 +
637 + #[test]
638 + fn narrowing_reads_the_declared_widths_and_not_a_dragged_track() {
639 + // `resizable` lets the user move a divider, and `cutoff_for` must not
640 + // hear about it: a drag that could drop a column would make the
641 + // narrowing a thing the user does by accident rather than a property of
642 + // the description. That `cutoff_for` takes no `TableStyle` at all is the
643 + // structural half of the guarantee; this is the behavioural half, and it
644 + // is what would fail if a measured width were ever threaded in beside
645 + // the declared one.
646 + let (cols, sz) = (columns(), sizing());
647 + assert_eq!(cutoff_for(&cols, &sz, 300.0), Priority::Optional);
648 + assert_eq!(cutoff_for(&cols, &sz, 200.0), Priority::Secondary);
520 649 }
521 650 }