Skip to main content

max / makenotwork

Take makeover-tui's table in the viewer rollup The 14th of the 14 measured call sites, and the last one still hand-writing the mapping layer. mnw-cli took the module the day makeover-tui 0.14.0 shipped and deleted its widgets.rs; the viewer drew a smaller copy of the same thing and kept it. Four literal Constraints become four Columns with a Priority each, which is the whole difference: at a width where the columns no longer all fit, the one that goes is the one worth least rather than the one written last. Age is the only Secondary column here, because a rollup with the status, the source or the detail dropped is a different screen rather than a narrower one. Source and age take Width::Content, so a run of short names stops spending fourteen columns to say "pom", and the old lengths stay as the floor. Selection moves onto the widget's own highlight. TableStyle::from_theme carries it on the background alone, which is what leaves a FAIL row's danger colour readable on top of it -- the same reason the local helper gives selection a surface instead of reversing. The width the table narrows against is the block's inner width and not the area's. Two columns of border is the difference between the detail fitting and being cut, which is exactly the decision the cutoff makes.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-12 14:49 UTC
Signed with PGP, not checked
Commit: 556504336888897904cfc11b964225c69207f19d
Parent: 64fd563
2 files changed, +130 insertions, -47 deletions
M viewer/Cargo.lock +20 -20
@@ -1067,15 +1067,15 @@
1067 1067
1068 1068 [[package]]
1069 1069 name = "makeover-layout"
1070 - version = "0.12.0"
1070 + version = "0.15.0"
1071 1071 source = "registry+https://github.com/rust-lang/crates.io-index"
1072 - checksum = "58edd16523115ed4c9ca6de016693300ac95cf1bb0bd8ccf7fd246213102a7ff"
1072 + checksum = "9f18920c6ac0fe10a8f7b45c2224d0ad39e4863151b3fbe0fe042c938b9b0a9d"
1073 1073
1074 1074 [[package]]
1075 1075 name = "makeover-tui"
1076 - version = "0.12.0"
1076 + version = "0.14.1"
1077 1077 source = "registry+https://github.com/rust-lang/crates.io-index"
1078 - checksum = "88982f3e29d40336e9748e9adafa88278d123c42875aad178e0cea640a128d57"
1078 + checksum = "1ad0dd7c780d94bff87c8af02333c74950d421fd5719ff02dbebee361e61a837"
1079 1079 dependencies = [
1080 1080 "makeover",
1081 1081 "makeover-layout",
@@ -2902,6 +2902,18 @@
2902 2902 source = "registry+https://github.com/rust-lang/crates.io-index"
2903 2903 checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
2904 2904
2905 + [[patch.unused]]
2906 + name = "kberg"
2907 + version = "0.1.0"
2908 +
2909 + [[patch.unused]]
2910 + name = "painhours"
2911 + version = "0.1.0"
2912 +
2913 + [[patch.unused]]
2914 + name = "tagtree"
2915 + version = "0.4.0"
2916 +
2905 2917 [[patch.unused]]
2906 2918 name = "quasi-axum"
2907 2919 version = "0.1.0"
@@ -2926,22 +2938,6 @@
2926 2938 name = "quasi-webview"
2927 2939 version = "0.1.0"
2928 2940
2929 - [[patch.unused]]
2930 - name = "docengine"
2931 - version = "0.4.0"
2932 -
2933 - [[patch.unused]]
2934 - name = "kberg"
2935 - version = "0.1.0"
2936 -
2937 - [[patch.unused]]
2938 - name = "painhours"
2939 - version = "0.1.0"
2940 -
2941 - [[patch.unused]]
2942 - name = "tagtree"
2943 - version = "0.4.0"
2944 -
2945 2941 [[patch.unused]]
2946 2942 name = "synckit-client"
2947 2943 version = "0.8.0"
@@ -2949,3 +2945,7 @@
2949 2945 [[patch.unused]]
2950 2946 name = "synckit-config"
2951 2947 version = "0.2.0"
2948 +
2949 + [[patch.unused]]
2950 + name = "docengine"
2951 + version = "0.5.0"
@@ -15,12 +15,14 @@
15 15
16 16 use chrono::{DateTime, Utc};
17 17 use makeover_tui::Theme;
18 + use makeover_tui::makeover_layout::{Column, Priority, Width};
19 + use makeover_tui::table::{self, Cell as TableCell, Sizing, TableStyle};
18 20 use ops_status::{Method, Node};
19 21 use ratatui::Frame;
20 22 use ratatui::layout::{Constraint, Layout, Rect};
21 23 use ratatui::style::{Modifier, Style};
22 24 use ratatui::text::{Line, Span};
23 - use ratatui::widgets::{Block, Cell, Clear, List, ListItem, Paragraph, Row, Table, Tabs};
25 + use ratatui::widgets::{Block, Clear, List, ListItem, Paragraph, TableState, Tabs};
24 26
25 27 use crate::model::{Model, Prompt, SourceState, Tab};
26 28 use crate::value;
@@ -128,50 +130,109 @@
128 130 // Rollup
129 131 // ---------------------------------------------------------------------------
130 132
133 + /// The rollup's columns, left to right.
134 + ///
135 + /// The mark's name is empty because the name is what the header row draws, and
136 + /// this column's header always was blank: the glyph says what it is.
137 + ///
138 + /// Nothing here is `Optional`. A rollup with the status or the source name
139 + /// dropped is not a narrower rollup, it is a different screen, and the age is
140 + /// what turns "FAIL" into "FAIL, and it has been that way for two days". Detail
141 + /// absorbs what is left, which is what the old `Min(10)` was saying.
142 + const ROLLUP_COLUMNS: [Column<'static>; 4] = [
143 + Column {
144 + name: "",
145 + width: Width::Fixed,
146 + priority: Priority::Essential,
147 + sortable: false,
148 + sorted: None,
149 + },
150 + Column {
151 + name: "source",
152 + width: Width::Content,
153 + priority: Priority::Essential,
154 + sortable: false,
155 + sorted: None,
156 + },
157 + Column {
158 + name: "age",
159 + width: Width::Content,
160 + priority: Priority::Secondary,
161 + sortable: false,
162 + sorted: None,
163 + },
164 + Column {
165 + name: "detail",
166 + width: Width::Fill,
167 + priority: Priority::Essential,
168 + sortable: false,
169 + sorted: None,
170 + },
171 + ];
172 +
173 + /// The tracks the hand-written `Constraint`s carried, lifted rather than
174 + /// re-chosen. The two `Width::Content` columns measure themselves from the
175 + /// cells and use these only as a floor, so a run of short source names stops
176 + /// spending fourteen columns to say `pom`.
177 + const ROLLUP_SIZING: Sizing<'static> = Sizing {
178 + lengths: &[("", 4), ("source", 14), ("age", 8), ("detail", 10)],
179 + fallback: 8,
180 + };
181 +
131 182 /// Every source at once, worst first.
132 183 ///
133 184 /// Without this the viewer is N tabs you still have to visit one at a time,
134 185 /// which is the situation it replaces, with extra steps.
135 186 fn render_rollup(model: &Model, theme: &Theme, now: DateTime<Utc>, frame: &mut Frame, area: Rect) {
136 187 let order = model.rollup_order(now);
137 - let rows: Vec<Row> = order
188 + let rows: Vec<Vec<TableCell>> = order
138 189 .iter()
139 - .enumerate()
140 - .map(|(row, &index)| {
190 + .map(|&index| {
141 191 let source = &model.sources[index];
142 192 let status = source.status(now);
143 193 let age = match source.age(now) {
144 194 Some(age) => value::duration(age.num_seconds()),
145 195 None => "-".into(),
146 196 };
147 - let style = if row == model.rollup_selected {
148 - selected(theme)
149 - } else {
150 - Style::default().fg(theme.content_primary)
151 - };
152 - Row::new(vec![
153 - Cell::from(value::status_mark(status)).style(value::status_style(theme, status)),
154 - Cell::from(source.name.clone()),
155 - Cell::from(age).style(muted(theme)),
156 - Cell::from(source.summary(now)),
157 - ])
158 - .style(style)
197 + vec![
198 + // The mark styles its own span rather than the cell: a status
199 + // colour is this app's, not a part the table module knows, and
200 + // a span's style sits on top of the cell's.
201 + TableCell::new(
202 + "",
203 + Span::styled(
204 + value::status_mark(status),
205 + value::status_style(theme, status),
206 + ),
207 + ),
208 + TableCell::new("source", source.name.clone()),
209 + TableCell::new("age", Span::styled(age, muted(theme))),
210 + TableCell::new("detail", source.summary(now)),
211 + ]
159 212 })
160 213 .collect();
161 214
162 - let table = Table::new(
163 - rows,
164 - [
165 - Constraint::Length(4),
166 - Constraint::Length(14),
167 - Constraint::Length(8),
168 - Constraint::Min(10),
169 - ],
215 + // The block first, because narrowing is measured against the width the
216 + // table actually gets rather than the width of the area around it. Two
217 + // columns of border is the difference between "detail fits" and "detail
218 + // is cut", which is exactly the decision the cutoff is making.
219 + let block = container(theme, " all sources ");
220 + let inner = block.inner(area);
221 + let table = table::table(
222 + &ROLLUP_COLUMNS,
223 + &rows,
224 + &ROLLUP_SIZING,
225 + &TableStyle::from_theme(theme),
226 + inner.width,
170 227 )
171 - .header(Row::new(vec!["", "source", "age", "detail"]).style(muted(theme)))
172 - .block(container(theme, " all sources "));
228 + .block(block);
173 229
174 - frame.render_widget(table, area);
230 + // Selection through the widget's own highlight rather than a per-row style.
231 + // `TableStyle::from_theme` carries it on the background alone, which is what
232 + // leaves a FAIL row's danger colour on top of it -- the same reason the
233 + // local `selected` helper gives a surface instead of reversing.
234 + let mut state = TableState::default().with_selected(Some(model.rollup_selected));
235 + frame.render_stateful_widget(table, area, &mut state);
175 236 }
176 237
177 238 // ---------------------------------------------------------------------------
@@ -628,6 +689,28 @@
628 689 assert!(text.contains("degr"), "stale-but-green is not ok:\n{text}");
629 690 }
630 691
692 + #[test]
693 + fn a_narrow_rollup_drops_the_age_before_it_drops_the_detail() {
694 + // What the hand-written `Constraint`s could not do: at 80 columns every
695 + // column is drawn, and at a width where they no longer all fit the
696 + // priority decides which one goes rather than the order they were
697 + // written in. Age is the only Secondary column, so it is the only one
698 + // that can go.
699 + let model = Model::new(vec![source(
700 + "pom",
701 + now() - TimeDelta::hours(4),
702 + vec![node("backup", "backup", Status::Ok)],
703 + )]);
704 +
705 + let wide = joined(&draw(&model, now(), 80, 12));
706 + assert!(wide.contains("age"), "the age column at 80 wide:\n{wide}");
707 +
708 + let narrow = joined(&draw(&model, now(), 24, 12));
709 + assert!(!narrow.contains("age"), "age must drop first:\n{narrow}");
710 + assert!(narrow.contains("pom"), "the source stays:\n{narrow}");
711 + assert!(narrow.contains("detail"), "the detail stays:\n{narrow}");
712 + }
713 +
631 714 #[test]
632 715 fn a_source_tab_lists_its_nodes_with_children_indented() {
633 716 let mut parent = node("tier:b", "b (prod-1)", Status::Ok);