Skip to main content

max / makeover-tui

0.26.0: a heading that offers to reorder says so Three states, three tones, per wiki three-tone-convention. A column in force takes style.sorted; one that is sortable and not sorted takes the new style.sortable field, content-secondary under from_theme; one that is not a control keeps style.header. The middle state had nowhere to be said, so a heading you could press was drawn exactly like one you could not. It also draws the idle caret now, in the ascending spelling because that is what a first press gives you. That closes a reflow: measure() sizes from heading(), so a caret appearing with the press widened its own column by two cells and shifted every column after it. A test asserts the two widths are equal. The caret literals are gone. Both style fields default to Sort::glyph() (makeover-layout 0.27.5) and the leading space moved into heading(), written once for all three states rather than baked into two strings and missing from the third. They stay knobs: a terminal is the one host that may not be able to draw U+25B2, and "^" beats a tofu. The colourless default separates the three by caret alone, which is the honest limit: a cell style patches the row's, so a plain cell under a bold header row draws bold. Three tones need three colours, and from_theme is where they are.
Author: Max Johnson <me@maxj.phd> · 2026-08-16 21:53 UTC
Signed with PGP, not checked
Commit: 7fb04d34ace173fad8c43c5e06e69c234ff4be77
Parent: 230bf63
2 files changed, +137 insertions, -23 deletions
M Cargo.toml +2 -2
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makeover-tui"
3 - version = "0.25.1"
3 + version = "0.26.0"
4 4 edition = "2024"
5 5 description = "The terminal renderer for makeover-layout, on ratatui. Colour stops being the constraint above 256 entries; geometry never does, because an edge occupies a whole cell on every side."
6 6 license = "MIT"
@@ -19,7 +19,7 @@
19 19 # compile against an API added in a later one -- which is what `Depth::Overlay`
20 20 # would do here. The rest of the suite has pinned this way since
21 21 # makeover-webview found it the hard way.
22 - makeover-layout = "0.27.0"
22 + makeover-layout = "0.27.5"
23 23 makeover = { version = "2.5", optional = true }
24 24
25 25 [lints.rust]
M src/table.rs +135 -21
@@ -140,6 +140,14 @@
140 140 pub header: Style,
141 141 /// The heading of the column the table is ordered by.
142 142 pub sorted: Style,
143 + /// The heading of a column that offers to reorder and is not doing it now.
144 + ///
145 + /// The middle of three tones (wiki `three-tone-convention`): it answers a
146 + /// press, so it is neither the emphasised thing nor the inert one. A
147 + /// heading that took [`header`](Self::header) here would be indistinguishable
148 + /// from a column that cannot be reordered at all, which is the state this
149 + /// separates it from.
150 + pub sortable: Style,
143 151 /// A cell that is text.
144 152 pub value: Style,
145 153 /// A cell holding badges or chips. They carry their own tone, so this is
@@ -155,9 +163,19 @@
155 163 /// Cells between columns. Counted when deciding what fits, so a table that
156 164 /// narrows and a table that draws agree about the room available.
157 165 pub column_spacing: u16,
158 - /// Drawn after the heading of an ascending column.
166 + /// The caret drawn after the heading of an ascending column.
167 + ///
168 + /// Defaults to [`Sort::glyph`], which is where the spelling lives now: three
169 + /// renderers holding the same literal agreed by coincidence. Still a knob,
170 + /// because a terminal is the one host that may not be able to draw it — a
171 + /// font without the geometric-shapes block leaves a box, and `"^"` is a
172 + /// better caret than a tofu.
173 + ///
174 + /// Bare, with no leading space: the gap is [`heading`]'s, written once for
175 + /// all three states rather than baked into two strings and forgotten in the
176 + /// third.
159 177 pub ascending: &'static str,
160 - /// Drawn after the heading of a descending column.
178 + /// The caret drawn after the heading of a descending column.
161 179 pub descending: &'static str,
162 180 }
163 181
@@ -166,16 +184,20 @@
166 184 Self {
167 185 header: Style::new().add_modifier(Modifier::BOLD),
168 186 sorted: Style::new().add_modifier(Modifier::BOLD),
187 + // Nothing of its own. A cell style patches the row's, so a colour
188 + // is the only thing that could separate this from the header row it
189 + // sits in, and the colourless default has none to spend: the idle
190 + // caret is what says the heading answers a press. `from_theme` is
191 + // where the three tones are real.
192 + sortable: Style::new(),
169 193 value: Style::new(),
170 194 tokens: Style::new(),
171 195 actions: Style::new(),
172 196 link: Style::new().add_modifier(Modifier::UNDERLINED),
173 197 selected: Style::new().add_modifier(Modifier::REVERSED),
174 198 column_spacing: 1,
175 - // The pair audiofiles already draws, so a sorted column points the
176 - // same way in a terminal as it does in the egui browser.
177 - ascending: " \u{25B2}",
178 - descending: " \u{25BC}",
199 + ascending: Sort::Ascending.glyph(),
200 + descending: Sort::Descending.glyph(),
179 201 }
180 202 }
181 203 }
@@ -203,6 +225,7 @@
203 225 sorted: Style::new()
204 226 .fg(theme.content_primary)
205 227 .add_modifier(Modifier::BOLD),
228 + sortable: Style::new().fg(theme.content_secondary),
206 229 value: Style::new().fg(theme.content_primary),
207 230 // A token paints its own background, and a tone underneath it would
208 231 // fight the one sitting on it. Secondary is what shows through the
@@ -216,8 +239,8 @@
216 239 .bg(theme.surface_raised)
217 240 .add_modifier(Modifier::BOLD),
218 241 column_spacing: 1,
219 - ascending: " \u{25B2}",
220 - descending: " \u{25BC}",
242 + ascending: Sort::Ascending.glyph(),
243 + descending: Sort::Descending.glyph(),
221 244 }
222 245 }
223 246
@@ -238,19 +261,31 @@
238 261 }
239 262 }
240 263
241 - /// The heading, with the caret if the table is ordered by this column.
264 + /// The heading, with the caret if this column is ordered by or offers to be.
242 265 ///
243 266 /// A column [`sorted`](Column::sorted) but not
244 267 /// [`sortable`](Column::sortable) still gets its caret. Both combinations mean
245 268 /// something, which is why the description holds the two fields apart: a list
246 269 /// ordered by a key the user cannot change is a real thing, and the caret is how
247 270 /// it says so.
271 + ///
272 + /// A column sortable and *not* sorted draws the idle mark, in the ascending
273 + /// spelling because that is the direction a first press takes. The tone is what
274 + /// separates it from the column in force, and [`header`] picks that; here the
275 + /// point is the width. This is what closes the reflow: pressing a heading used
276 + /// to widen its column by two cells and shift every column after it, because
277 + /// [`measure`] sizes from this function and the caret appeared with the press.
248 278 fn heading<'a>(column: &Column<'a>, style: &TableStyle) -> Line<'a> {
249 - match column.sorted {
250 - Some(Sort::Ascending) => Line::from(format!("{}{}", column.name, style.ascending)),
251 - Some(Sort::Descending) => Line::from(format!("{}{}", column.name, style.descending)),
252 - None => Line::from(column.name),
253 - }
279 + let caret = match column.sorted {
280 + Some(Sort::Ascending) => style.ascending,
281 + Some(Sort::Descending) => style.descending,
282 + None if column.sortable => style.ascending,
283 + None => return Line::from(column.name),
284 + };
285 + // The gap, once, rather than inside each of the two style strings. A
286 + // consumer swapping the glyph for an ASCII one does not have to remember to
287 + // bring a space with it.
288 + Line::from(format!("{} {caret}", column.name))
254 289 }
255 290
256 291 /// How wide a column wants to be, in cells, at its narrowest.
@@ -417,10 +452,14 @@
417 452 .iter()
418 453 .filter(|column| column.kept_at(cutoff))
419 454 .map(|column| {
420 - let tone = if column.sorted.is_some() {
421 - style.sorted
422 - } else {
423 - style.header
455 + // Three states, three tones (wiki `three-tone-convention`). In
456 + // force, offering, and not a control at all -- and the middle
457 + // one is the state that had nowhere to be said, so a heading
458 + // you could press looked exactly like one you could not.
459 + let tone = match (column.sorted, column.sortable) {
460 + (Some(_), _) => style.sorted,
461 + (None, true) => style.sortable,
462 + (None, false) => style.header,
424 463 };
425 464 TrackCell::from(heading(column, style)).style(tone)
426 465 })
@@ -553,6 +592,20 @@
553 592 .collect()
554 593 }
555 594
595 + /// The foreground each of the three heading cells was drawn in.
596 + ///
597 + /// Read off a rendered buffer for [`cell_text`]'s reason: a ratatui `Row`
598 + /// hands nothing back, and what is asserted is what a user sees.
599 + fn cell_colors(row: &Row<'_>) -> Vec<Option<ratatui::style::Color>> {
600 + use ratatui::layout::Rect;
601 + use ratatui::widgets::Widget;
602 + let mut buf = ratatui::buffer::Buffer::empty(Rect::new(0, 0, 60, 1));
603 + Table::new(vec![row.clone()], [Constraint::Length(18); 3])
604 + .column_spacing(1)
605 + .render(Rect::new(0, 0, 60, 1), &mut buf);
606 + (0..3).map(|i| buf[(i * 19, 0)].fg).map(Some).collect()
607 + }
608 +
556 609 #[test]
557 610 fn cells_are_ordered_by_the_columns_and_not_by_the_row() {
558 611 // The row hands them over backwards. The table decides the order, which
@@ -766,16 +819,77 @@
766 819 }
767 820
768 821 #[test]
769 - fn the_ordered_column_draws_a_caret_and_the_others_do_not() {
822 + fn a_heading_carries_a_caret_when_it_is_ordered_by_or_offers_to_be() {
770 823 let style = TableStyle::default();
771 824 let head = header(&columns(), &style, Priority::Optional);
772 825 assert_eq!(
773 826 cell_text(&head),
774 - vec!["name \u{25B2}", "size", "note"],
775 - "only the column in force carries one"
827 + vec!["name \u{25B2}", "size \u{25B2}", "note"],
828 + "in force and offering both carry one; not a control carries none"
776 829 );
777 830 }
778 831
832 + #[test]
833 + fn the_three_states_of_a_heading_are_three_tones() {
834 + // wiki `three-tone-convention`. The middle state is the one that had
835 + // nowhere to be said: a heading you can press looked exactly like one
836 + // you cannot, and the idle caret alone does not separate them, because
837 + // a sorted-but-unsortable column draws a caret too.
838 + use ratatui::style::Color;
839 + let style = TableStyle {
840 + sorted: Style::new().fg(Color::Red),
841 + sortable: Style::new().fg(Color::Green),
842 + header: Style::new().fg(Color::Blue),
843 + ..TableStyle::default()
844 + };
845 + let drawn = cell_colors(&header(&columns(), &style, Priority::Optional));
846 + assert_eq!(
847 + drawn,
848 + vec![Some(Color::Red), Some(Color::Green), Some(Color::Blue)]
849 + );
850 +
851 + // The colourless default separates them by the caret and nothing else,
852 + // and that is the honest limit rather than an oversight: a cell style
853 + // patches the row's, so a plain cell under a bold header row is drawn
854 + // bold whatever it holds. Three tones need three colours, which is what
855 + // `from_theme` is for.
856 + let house = TableStyle::default();
857 + let plain = cell_colors(&header(&columns(), &house, Priority::Optional));
858 + assert_eq!(plain[0], plain[1], "no colour to spend, so none is claimed");
859 + }
860 +
861 + #[test]
862 + fn pressing_a_heading_does_not_move_the_columns_after_it() {
863 + // The reflow the idle caret closes. `measure` sizes from `heading`, so
864 + // a caret that appeared with the press widened its own column by two
865 + // cells and shifted the rest of the row sideways.
866 + let style = TableStyle::default();
867 + let offering = Column {
868 + name: "size",
869 + width: Width::Content,
870 + priority: Priority::Secondary,
871 + sortable: true,
872 + sorted: None,
873 + };
874 + let in_force = Column {
875 + sorted: Some(Sort::Descending),
876 + ..offering
877 + };
878 + let rows: Vec<Vec<Cell<'_>>> = vec![];
879 + assert_eq!(
880 + measure(&offering, &rows, &style),
881 + measure(&in_force, &rows, &style)
882 + );
883 +
884 + // And the column that is not a control at all is narrower, which is the
885 + // width that would be wrong to reserve: it has no caret to draw.
886 + let inert = Column {
887 + sortable: false,
888 + ..offering
889 + };
890 + assert!(measure(&inert, &rows, &style) < measure(&offering, &rows, &style));
891 + }
892 +
779 893 #[test]
780 894 fn a_column_sorted_without_being_sortable_still_draws_its_caret() {
781 895 // A list ordered by a key the user cannot change is a real thing to