Skip to main content

max / makenotwork

Take makeover-tui 0.14.0's table and delete src/tui/widgets.rs The 60 lines widgets.rs held are makeover_tui::table::table and TableStyle::from_theme now, including the rule its comment argued for: selection carried by the background alone, so a red failed upload keeps its colour on the row being looked at. All 13 call sites across the 11 screens describe their columns instead of handing ratatui a hand-written Constraint list. The magnitudes are lifted rather than re-chosen. Priority is the new part and the point: no screen narrowed at any width before, so every one of them overflowed a narrow terminal. Width::Content stays unused because nothing here measures a column, and sortable is false everywhere because nothing here sorts. Selection moved to a TableState, which is what table() hands back a Table for. The selected row scrolls into view now instead of falling off the bottom. The two-cell body indent moved from the first heading string and every first cell onto the area. A column's name is the address its cells are looked up by, so presentation does not belong in it.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-11 20:47 UTC
Signed with PGP, not checked
Commit: 4ff8710a11835fe0e587d91bbb0e596f1012a7d4
Parent: 95cd359
15 files changed, +876 insertions, -472 deletions
M mnw-cli/Cargo.lock +24 -24
@@ -1826,15 +1826,15 @@
1826 1826
1827 1827 [[package]]
1828 1828 name = "makeover-layout"
1829 - version = "0.12.0"
1829 + version = "0.14.0"
1830 1830 source = "registry+https://github.com/rust-lang/crates.io-index"
1831 - checksum = "58edd16523115ed4c9ca6de016693300ac95cf1bb0bd8ccf7fd246213102a7ff"
1831 + checksum = "e08cfaa62476d03061dc86a2befc0c24129399ba37a6f7645fbb72b011abf2f0"
1832 1832
1833 1833 [[package]]
1834 1834 name = "makeover-tui"
1835 - version = "0.12.0"
1835 + version = "0.14.0"
1836 1836 source = "registry+https://github.com/rust-lang/crates.io-index"
1837 - checksum = "88982f3e29d40336e9748e9adafa88278d123c42875aad178e0cea640a128d57"
1837 + checksum = "9532a834ce6d9590ebe43c10fa6b33aef2e87fbe1090a3e693e75c8aaf29adb5"
1838 1838 dependencies = [
1839 1839 "makeover",
1840 1840 "makeover-layout",
@@ -4756,26 +4756,6 @@
4756 4756 source = "registry+https://github.com/rust-lang/crates.io-index"
4757 4757 checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
4758 4758
4759 - [[patch.unused]]
4760 - name = "docengine"
4761 - version = "0.4.0"
4762 -
4763 - [[patch.unused]]
4764 - name = "synckit-config"
4765 - version = "0.2.0"
4766 -
4767 - [[patch.unused]]
4768 - name = "kberg"
4769 - version = "0.1.0"
4770 -
4771 - [[patch.unused]]
4772 - name = "painhours"
4773 - version = "0.1.0"
4774 -
4775 - [[patch.unused]]
4776 - name = "tagtree"
4777 - version = "0.4.0"
4778 -
4779 4759 [[patch.unused]]
4780 4760 name = "quasi-axum"
4781 4761 version = "0.1.0"
@@ -4799,3 +4779,23 @@
4799 4779 [[patch.unused]]
4800 4780 name = "quasi-webview"
4801 4781 version = "0.1.0"
4782 +
4783 + [[patch.unused]]
4784 + name = "synckit-config"
4785 + version = "0.2.0"
4786 +
4787 + [[patch.unused]]
4788 + name = "kberg"
4789 + version = "0.1.0"
4790 +
4791 + [[patch.unused]]
4792 + name = "painhours"
4793 + version = "0.1.0"
4794 +
4795 + [[patch.unused]]
4796 + name = "tagtree"
4797 + version = "0.4.0"
4798 +
4799 + [[patch.unused]]
4800 + name = "docengine"
4801 + version = "0.5.0"
@@ -15,7 +15,7 @@
15 15 # selection; `makeover-tui` is the terminal renderer, and its `theme` feature is
16 16 # the intents-to-ratatui-colours mapping. No colour is named in this crate.
17 17 makeover = "2.5.0"
18 - makeover-tui = { version = "0.12.0", features = ["theme"] }
18 + makeover-tui = { version = "0.14.0", features = ["theme"] }
19 19 tokio = { version = "1", features = ["full"] }
20 20 # `rustls-no-provider` rather than `rustls`: the latter is an alias for
21 21 # `__rustls-aws-lc-rs`, which links a C crypto backend. The provider is ring
@@ -1,15 +1,81 @@
1 1 //! Analytics dashboard — revenue chart, stats, top projects, transactions, export.
2 2
3 + use makeover_tui::makeover_layout::{Column, Priority, Width};
4 + use makeover_tui::table::{self, Cell, Sizing, TableStyle};
3 5 use ratatui::Frame;
4 6 use ratatui::layout::{Constraint, Layout};
5 7 use ratatui::style::{Modifier, Style};
6 8 use ratatui::text::{Line, Span};
7 - use ratatui::widgets::{Bar, BarChart, BarGroup, Block, Borders, Paragraph, Row};
9 + use ratatui::widgets::{Bar, BarChart, BarGroup, Block, Borders, Paragraph, TableState};
8 10
9 11 use crate::format;
10 12
11 13 use super::App;
12 - use super::widgets;
14 +
15 + /// The top-projects columns. Both are essential: a revenue ranking missing
16 + /// either the name or the amount is not a narrower table, it is a different
17 + /// one.
18 + const TOP_PROJECT_COLUMNS: [Column<'static>; 2] = [
19 + Column {
20 + name: "Project",
21 + width: Width::Fill,
22 + priority: Priority::Essential,
23 + sortable: false,
24 + sorted: None,
25 + },
26 + Column {
27 + name: "Revenue",
28 + width: Width::Fixed,
29 + priority: Priority::Essential,
30 + sortable: false,
31 + sorted: None,
32 + },
33 + ];
34 +
35 + /// See the home-screen revenue column on why 15 rather than 12.
36 + const TOP_PROJECT_SIZING: Sizing<'static> = Sizing {
37 + lengths: &[("Project", 20), ("Revenue", 15)],
38 + fallback: 8,
39 + };
40 +
41 + /// The transaction columns. What was bought and for how much is the row; the
42 + /// status and the date qualify it.
43 + const TRANSACTION_COLUMNS: [Column<'static>; 4] = [
44 + Column {
45 + name: "Item",
46 + width: Width::Fill,
47 + priority: Priority::Essential,
48 + sortable: false,
49 + sorted: None,
50 + },
51 + Column {
52 + name: "Amount",
53 + width: Width::Fixed,
54 + priority: Priority::Essential,
55 + sortable: false,
56 + sorted: None,
57 + },
58 + Column {
59 + name: "Status",
60 + width: Width::Fixed,
61 + priority: Priority::Secondary,
62 + sortable: false,
63 + sorted: None,
64 + },
65 + Column {
66 + name: "Date",
67 + width: Width::Fixed,
68 + priority: Priority::Optional,
69 + sortable: false,
70 + sorted: None,
71 + },
72 + ];
73 +
74 + /// The tracks the hand-written `Constraint`s carried.
75 + const TRANSACTION_SIZING: Sizing<'static> = Sizing {
76 + lengths: &[("Item", 20), ("Amount", 12), ("Status", 10), ("Date", 12)],
77 + fallback: 8,
78 + };
13 79
14 80 pub(crate) fn render(frame: &mut Frame, app: &App) {
15 81 let area = frame.area();
@@ -246,27 +312,28 @@
246 312 let empty = Paragraph::new(" No project revenue yet.");
247 313 frame.render_widget(empty, chunks[4]);
248 314 } else {
249 - let rows: Vec<Row> = data
315 + let area = super::indent(chunks[4], 2);
316 + let rows: Vec<Vec<Cell>> = data
250 317 .top_projects
251 318 .iter()
252 319 .map(|p| {
253 - Row::new(vec![
254 - format!(" {}", p.title),
255 - p.revenue().display_compact(app.currency()),
256 - ])
320 + vec![
321 + Cell::new("Project", p.title.as_str()),
322 + Cell::new("Revenue", p.revenue().display_compact(app.currency())),
323 + ]
257 324 })
258 325 .collect();
259 326
260 - // See the home-screen revenue column on why 15 rather than 12.
261 - let widths = [Constraint::Min(20), Constraint::Length(15)];
262 - widgets::render_table(
263 - frame,
264 - &app.theme,
265 - chunks[4],
266 - &[" Project", "Revenue"],
267 - &widths,
268 - rows,
327 + // Nothing selects a row here, so this one draws without a state.
328 + let style = TableStyle::from_theme(&app.theme);
329 + let table = table::table(
330 + &TOP_PROJECT_COLUMNS,
331 + &rows,
332 + &TOP_PROJECT_SIZING,
333 + &style,
334 + area.width,
269 335 );
336 + frame.render_widget(table, area);
270 337 }
271 338 }
272 339 }
@@ -297,44 +364,33 @@
297 364 let empty = Paragraph::new(" No transactions.");
298 365 frame.render_widget(empty, chunks[1]);
299 366 } else {
300 - let rows: Vec<Row> = app
367 + let area = super::indent(chunks[1], 2);
368 + let rows: Vec<Vec<Cell>> = app
301 369 .transactions
302 370 .iter()
303 - .enumerate()
304 - .map(|(i, tx)| {
305 - let title = tx.item_title.as_deref().unwrap_or("--");
306 - let amount = format::format_cents(i64::from(tx.amount_cents), app.currency());
307 - let date = tx.created_at.get(..10).unwrap_or(&tx.created_at);
308 -
309 - Row::new(vec![
310 - format!(" {}", title),
311 - amount,
312 - tx.status.clone(),
313 - date.to_string(),
314 - ])
315 - .style(widgets::selected_style(
316 - &app.theme,
317 - i,
318 - Some(app.selected_index),
319 - ))
371 + .map(|tx| {
372 + vec![
373 + Cell::new("Item", tx.item_title.as_deref().unwrap_or("--")),
374 + Cell::new(
375 + "Amount",
376 + format::format_cents(i64::from(tx.amount_cents), app.currency()),
377 + ),
378 + Cell::new("Status", tx.status.as_str()),
379 + Cell::new("Date", tx.created_at.get(..10).unwrap_or(&tx.created_at)),
380 + ]
320 381 })
321 382 .collect();
322 383
323 - let widths = [
324 - Constraint::Min(20),
325 - Constraint::Length(12),
326 - Constraint::Length(10),
327 - Constraint::Length(12),
328 - ];
329 -
330 - widgets::render_table(
331 - frame,
332 - &app.theme,
333 - chunks[1],
334 - &[" Item", "Amount", "Status", "Date"],
335 - &widths,
336 - rows,
384 + let style = TableStyle::from_theme(&app.theme);
385 + let table = table::table(
386 + &TRANSACTION_COLUMNS,
387 + &rows,
388 + &TRANSACTION_SIZING,
389 + &style,
390 + area.width,
337 391 );
392 + let mut state = TableState::default().with_selected(Some(app.selected_index));
393 + frame.render_stateful_widget(table, area, &mut state);
338 394 }
339 395 }
340 396
@@ -1,13 +1,54 @@
1 1 //! Blog post management screen — list, create, delete posts.
2 2
3 + use makeover_tui::makeover_layout::{Column, Priority, Width};
4 + use makeover_tui::table::{self, Cell, Sizing, TableStyle};
3 5 use ratatui::Frame;
4 - use ratatui::layout::{Constraint, Layout};
6 + use ratatui::layout::{Constraint, Layout, Rect};
5 7 use ratatui::style::{Modifier, Style};
6 8 use ratatui::text::{Line, Span};
7 - use ratatui::widgets::{Block, Borders, Paragraph, Row};
9 + use ratatui::widgets::{Block, Borders, Paragraph, TableState};
8 10
9 11 use super::App;
10 - use super::widgets;
12 +
13 + /// The post list's columns. Status carries the scheduled time as well as
14 + /// published-or-draft, which is why it is the widest fixed track and why it
15 + /// outranks the slug and the date.
16 + const POST_COLUMNS: [Column<'static>; 4] = [
17 + Column {
18 + name: "Title",
19 + width: Width::Fill,
20 + priority: Priority::Essential,
21 + sortable: false,
22 + sorted: None,
23 + },
24 + Column {
25 + name: "Slug",
26 + width: Width::Fixed,
27 + priority: Priority::Optional,
28 + sortable: false,
29 + sorted: None,
30 + },
31 + Column {
32 + name: "Status",
33 + width: Width::Fixed,
34 + priority: Priority::Secondary,
35 + sortable: false,
36 + sorted: None,
37 + },
38 + Column {
39 + name: "Created",
40 + width: Width::Fixed,
41 + priority: Priority::Optional,
42 + sortable: false,
43 + sorted: None,
44 + },
45 + ];
46 +
47 + /// The tracks the hand-written `Constraint`s carried.
48 + const POST_SIZING: Sizing<'static> = Sizing {
49 + lengths: &[("Title", 20), ("Slug", 20), ("Status", 22), ("Created", 12)],
50 + fallback: 12,
51 + };
11 52
12 53 pub(crate) fn render(frame: &mut Frame, app: &App) {
13 54 let area = frame.area();
@@ -107,12 +148,13 @@
107 148 frame.render_widget(keys, chunks[4]);
108 149 }
109 150
110 - fn render_post_table(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
111 - let rows: Vec<Row> = app
151 + fn render_post_table(frame: &mut Frame, app: &App, area: Rect) {
152 + let area = super::indent(area, 2);
153 +
154 + let rows: Vec<Vec<Cell>> = app
112 155 .blog_posts
113 156 .iter()
114 - .enumerate()
115 - .map(|(i, post)| {
157 + .map(|post| {
116 158 let status = if post.is_published {
117 159 "published".to_string()
118 160 } else if let Some(ref pa) = post.publish_at {
@@ -121,35 +163,21 @@
121 163 } else {
122 164 "draft".to_string()
123 165 };
124 - let date = post.created_at.get(..10).unwrap_or(&post.created_at);
125 166
126 - Row::new(vec![
127 - format!(" {}", post.title),
128 - post.slug.clone(),
129 - status,
130 - date.to_string(),
131 - ])
132 - .style(widgets::selected_style(
133 - &app.theme,
134 - i,
135 - Some(app.selected_index),
136 - ))
167 + vec![
168 + Cell::new("Title", post.title.as_str()),
169 + Cell::new("Slug", post.slug.as_str()),
170 + Cell::new("Status", status),
171 + Cell::new(
172 + "Created",
173 + post.created_at.get(..10).unwrap_or(&post.created_at),
174 + ),
175 + ]
137 176 })
138 177 .collect();
139 178
140 - let widths = [
141 - Constraint::Min(20),
142 - Constraint::Length(20),
143 - Constraint::Length(22),
144 - Constraint::Length(12),
145 - ];
146 -
147 - widgets::render_table(
148 - frame,
149 - &app.theme,
150 - area,
151 - &[" Title", "Slug", "Status", "Created"],
152 - &widths,
153 - rows,
154 - );
179 + let style = TableStyle::from_theme(&app.theme);
180 + let table = table::table(&POST_COLUMNS, &rows, &POST_SIZING, &style, area.width);
181 + let mut state = TableState::default().with_selected(Some(app.selected_index));
182 + frame.render_stateful_widget(table, area, &mut state);
155 183 }
@@ -1,13 +1,53 @@
1 1 //! Collections management screen — list collections.
2 2
3 + use makeover_tui::makeover_layout::{Column, Priority, Width};
4 + use makeover_tui::table::{self, Cell, Sizing, TableStyle};
3 5 use ratatui::Frame;
4 6 use ratatui::layout::{Constraint, Layout};
5 7 use ratatui::style::{Modifier, Style};
6 8 use ratatui::text::{Line, Span};
7 - use ratatui::widgets::{Block, Borders, Paragraph, Row};
9 + use ratatui::widgets::{Block, Borders, Paragraph, TableState};
8 10
9 11 use super::App;
10 - use super::widgets;
12 +
13 + /// The collection columns. The title names the collection; whether it is public
14 + /// is the next thing asked of it. The slug and the count drop first.
15 + const COLLECTION_COLUMNS: [Column<'static>; 4] = [
16 + Column {
17 + name: "Title",
18 + width: Width::Fill,
19 + priority: Priority::Essential,
20 + sortable: false,
21 + sorted: None,
22 + },
23 + Column {
24 + name: "Slug",
25 + width: Width::Fixed,
26 + priority: Priority::Optional,
27 + sortable: false,
28 + sorted: None,
29 + },
30 + Column {
31 + name: "Status",
32 + width: Width::Fixed,
33 + priority: Priority::Secondary,
34 + sortable: false,
35 + sorted: None,
36 + },
37 + Column {
38 + name: "Items",
39 + width: Width::Fixed,
40 + priority: Priority::Optional,
41 + sortable: false,
42 + sorted: None,
43 + },
44 + ];
45 +
46 + /// The tracks the hand-written `Constraint`s carried.
47 + const COLLECTION_SIZING: Sizing<'static> = Sizing {
48 + lengths: &[("Title", 20), ("Slug", 20), ("Status", 8), ("Items", 6)],
49 + fallback: 8,
50 + };
11 51
12 52 pub(crate) fn render(frame: &mut Frame, app: &App) {
13 53 let area = frame.area();
@@ -59,41 +99,30 @@
59 99 Paragraph::new(" No collections. Manage collections at makenot.work/dashboard");
60 100 frame.render_widget(empty, chunks[2]);
61 101 } else {
62 - let rows: Vec<Row> = app
102 + let area = super::indent(chunks[2], 2);
103 + let rows: Vec<Vec<Cell>> = app
63 104 .collections
64 105 .iter()
65 - .enumerate()
66 - .map(|(i, c)| {
67 - let status = if c.is_public { "public" } else { "draft" };
68 - Row::new(vec![
69 - format!(" {}", c.title),
70 - c.slug.clone(),
71 - status.to_string(),
72 - c.item_count.to_string(),
73 - ])
74 - .style(widgets::selected_style(
75 - &app.theme,
76 - i,
77 - Some(app.selected_index),
78 - ))
106 + .map(|c| {
107 + vec![
108 + Cell::new("Title", c.title.as_str()),
109 + Cell::new("Slug", c.slug.as_str()),
110 + Cell::new("Status", if c.is_public { "public" } else { "draft" }),
111 + Cell::new("Items", c.item_count.to_string()),
112 + ]
79 113 })
80 114 .collect();
81 115
82 - let widths = [
83 - Constraint::Min(20),
84 - Constraint::Length(20),
85 - Constraint::Length(8),
86 - Constraint::Length(6),
87 - ];
88 -
89 - widgets::render_table(
90 - frame,
91 - &app.theme,
92 - chunks[2],
93 - &[" Title", "Slug", "Status", "Items"],
94 - &widths,
95 - rows,
116 + let style = TableStyle::from_theme(&app.theme);
117 + let table = table::table(
118 + &COLLECTION_COLUMNS,
119 + &rows,
120 + &COLLECTION_SIZING,
121 + &style,
122 + area.width,
96 123 );
124 + let mut state = TableState::default().with_selected(Some(app.selected_index));
125 + frame.render_stateful_widget(table, area, &mut state);
97 126 }
98 127
99 128 if let Some(ref status) = app.collections_status {
@@ -1,15 +1,75 @@
1 1 //! Home screen — project list with stats overview.
2 2
3 + use makeover_tui::makeover_layout::{Column, Priority, Width};
4 + use makeover_tui::table::{self, Cell, Sizing, TableStyle};
3 5 use ratatui::Frame;
4 - use ratatui::layout::{Constraint, Layout};
6 + use ratatui::layout::{Constraint, Layout, Rect};
5 7 use ratatui::style::{Modifier, Style};
6 8 use ratatui::text::{Line, Span};
7 - use ratatui::widgets::{Block, Borders, Paragraph, Row};
9 + use ratatui::widgets::{Block, Borders, Paragraph, TableState};
8 10
9 11 use crate::format;
10 12
11 13 use super::App;
12 - use super::widgets;
14 +
15 + /// The project list's columns.
16 + ///
17 + /// Title is the only one without which a row stops saying which project it is,
18 + /// so it is the only [`Priority::Essential`] one. Revenue and status are what
19 + /// this screen is opened for, which puts them above the type and the count when
20 + /// the window narrows.
21 + const PROJECT_COLUMNS: [Column<'static>; 5] = [
22 + Column {
23 + name: "Title",
24 + width: Width::Fill,
25 + priority: Priority::Essential,
26 + sortable: false,
27 + sorted: None,
28 + },
29 + Column {
30 + name: "Type",
31 + width: Width::Fixed,
32 + priority: Priority::Optional,
33 + sortable: false,
34 + sorted: None,
35 + },
36 + Column {
37 + name: "Status",
38 + width: Width::Fixed,
39 + priority: Priority::Secondary,
40 + sortable: false,
41 + sorted: None,
42 + },
43 + Column {
44 + name: "Items",
45 + width: Width::Fixed,
46 + priority: Priority::Optional,
47 + sortable: false,
48 + sorted: None,
49 + },
50 + Column {
51 + name: "Revenue",
52 + width: Width::Fixed,
53 + priority: Priority::Secondary,
54 + sortable: false,
55 + sorted: None,
56 + },
57 + ];
58 +
59 + /// The tracks the hand-written `Constraint`s carried, lifted rather than
60 + /// re-chosen. Revenue is wider than the amount alone needs: a three-character
61 + /// symbol (`CA$`) plus the `+N` multi-currency marker has to fit without
62 + /// truncating.
63 + const PROJECT_SIZING: Sizing<'static> = Sizing {
64 + lengths: &[
65 + ("Title", 20),
66 + ("Type", 12),
67 + ("Status", 8),
68 + ("Items", 7),
69 + ("Revenue", 15),
70 + ],
71 + fallback: 8,
72 + };
13 73
14 74 pub(crate) fn render(frame: &mut Frame, app: &App) {
15 75 let area = frame.area();
@@ -151,45 +211,25 @@
151 211 }
152 212 }
153 213
154 - fn render_project_table(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
155 - let rows: Vec<Row> = app
214 + fn render_project_table(frame: &mut Frame, app: &App, area: Rect) {
215 + let area = super::indent(area, 2);
216 +
217 + let rows: Vec<Vec<Cell>> = app
156 218 .projects
157 219 .iter()
158 - .enumerate()
159 - .map(|(i, p)| {
160 - let visibility = if p.is_public { "public" } else { "draft" };
161 -
162 - Row::new(vec![
163 - format!(" {}", p.title),
164 - format::format_project_type(&p.project_type).to_string(),
165 - visibility.to_string(),
166 - p.item_count.to_string(),
167 - p.revenue().display_compact(app.currency()),
168 - ])
169 - .style(widgets::selected_style(
170 - &app.theme,
171 - i,
172 - Some(app.selected_index),
173 - ))
220 + .map(|p| {
221 + vec![
222 + Cell::new("Title", p.title.as_str()),
223 + Cell::new("Type", format::format_project_type(&p.project_type)),
224 + Cell::new("Status", if p.is_public { "public" } else { "draft" }),
225 + Cell::new("Items", p.item_count.to_string()),
226 + Cell::new("Revenue", p.revenue().display_compact(app.currency())),
227 + ]
174 228 })
175 229 .collect();
176 230
177 - let widths = [
178 - Constraint::Min(20),
179 - Constraint::Length(12),
180 - Constraint::Length(8),
181 - Constraint::Length(7),
182 - // Wider than the amount alone needs: a three-character symbol (`CA$`)
183 - // plus the `+N` multi-currency marker has to fit without truncating.
184 - Constraint::Length(15),
185 - ];
186 -
187 - widgets::render_table(
188 - frame,
189 - &app.theme,
190 - area,
191 - &[" Title", "Type", "Status", "Items", "Revenue"],
192 - &widths,
193 - rows,
194 - );
231 + let style = TableStyle::from_theme(&app.theme);
232 + let table = table::table(&PROJECT_COLUMNS, &rows, &PROJECT_SIZING, &style, area.width);
233 + let mut state = TableState::default().with_selected(Some(app.selected_index));
234 + frame.render_stateful_widget(table, area, &mut state);
195 235 }
@@ -1,17 +1,71 @@
1 1 //! Item detail screen — view/edit item fields, versions, publish status.
2 2
3 + use makeover_tui::makeover_layout::{Column, Priority, Width};
4 + use makeover_tui::table::{self, Cell, Sizing, TableStyle};
3 5 use ratatui::Frame;
4 - use ratatui::layout::{Constraint, Layout};
6 + use ratatui::layout::{Constraint, Layout, Rect};
5 7 use ratatui::style::{Modifier, Style};
6 8 use ratatui::text::{Line, Span};
7 - use ratatui::widgets::{Block, Borders, Paragraph, Row};
9 + use ratatui::widgets::{Block, Borders, Paragraph};
8 10
9 11 use crate::api::ItemDetail;
10 12 use crate::format;
11 13 use crate::staging;
12 14
13 15 use super::App;
14 - use super::widgets;
16 +
17 + /// The version history columns. The version number carries the marker for the
18 + /// current one and the filename says what was uploaded, so both are essential;
19 + /// the date outranks the size and the download count.
20 + const VERSION_COLUMNS: [Column<'static>; 5] = [
21 + Column {
22 + name: "Version",
23 + width: Width::Fixed,
24 + priority: Priority::Essential,
25 + sortable: false,
26 + sorted: None,
27 + },
28 + Column {
29 + name: "File",
30 + width: Width::Fill,
31 + priority: Priority::Essential,
32 + sortable: false,
33 + sorted: None,
34 + },
35 + Column {
36 + name: "Size",
37 + width: Width::Fixed,
38 + priority: Priority::Optional,
39 + sortable: false,
40 + sorted: None,
41 + },
42 + Column {
43 + name: "Downloads",
44 + width: Width::Fixed,
45 + priority: Priority::Optional,
46 + sortable: false,
47 + sorted: None,
48 + },
49 + Column {
50 + name: "Date",
51 + width: Width::Fixed,
52 + priority: Priority::Secondary,
53 + sortable: false,
54 + sorted: None,
55 + },
56 + ];
57 +
58 + /// The tracks the hand-written `Constraint`s carried.
59 + const VERSION_SIZING: Sizing<'static> = Sizing {
60 + lengths: &[
61 + ("Version", 12),
62 + ("File", 16),
63 + ("Size", 10),
64 + ("Downloads", 10),
65 + ("Date", 12),
66 + ],
67 + fallback: 10,
68 + };
15 69
16 70 pub(crate) fn render(frame: &mut Frame, app: &App) {
17 71 let area = frame.area();
@@ -262,8 +316,10 @@
262 316 frame.render_widget(info, area);
263 317 }
264 318
265 - fn render_versions_table(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
266 - let rows: Vec<Row> = app
319 + fn render_versions_table(frame: &mut Frame, app: &App, area: Rect) {
320 + let area = super::indent(area, 2);
321 +
322 + let rows: Vec<Vec<Cell>> = app
267 323 .item_versions
268 324 .iter()
269 325 .map(|v| {
@@ -271,35 +327,21 @@
271 327 let size = v
272 328 .file_size_bytes
273 329 .map_or_else(|| "--".to_string(), |b| staging::format_bytes(b as u64));
274 - let name = v.file_name.as_deref().unwrap_or("--");
275 - let date = v.created_at.get(..10).unwrap_or(&v.created_at);
276 330
277 - Row::new(vec![
278 - format!(" {}{}", current, v.version_number),
279 - name.to_string(),
280 - size,
281 - v.download_count.to_string(),
282 - date.to_string(),
283 - ])
331 + vec![
332 + Cell::new("Version", format!("{current}{}", v.version_number)),
333 + Cell::new("File", v.file_name.as_deref().unwrap_or("--")),
334 + Cell::new("Size", size),
335 + Cell::new("Downloads", v.download_count.to_string()),
336 + Cell::new("Date", v.created_at.get(..10).unwrap_or(&v.created_at)),
337 + ]
284 338 })
285 339 .collect();
286 340
287 - let widths = [
288 - Constraint::Length(12),
289 - Constraint::Min(16),
290 - Constraint::Length(10),
291 - Constraint::Length(10),
292 - Constraint::Length(12),
293 - ];
294 -
295 - widgets::render_table(
296 - frame,
297 - &app.theme,
298 - area,
299 - &[" Version", "File", "Size", "Downloads", "Date"],
300 - &widths,
301 - rows,
302 - );
341 + // Nothing selects a version, so this one draws without a state.
342 + let style = TableStyle::from_theme(&app.theme);
343 + let table = table::table(&VERSION_COLUMNS, &rows, &VERSION_SIZING, &style, area.width);
344 + frame.render_widget(table, area);
303 345 }
304 346
305 347 /// Which field is being edited on the item detail screen.
@@ -1,13 +1,59 @@
1 1 //! License key management screen — list, generate, revoke keys.
2 2
3 + use makeover_tui::makeover_layout::{Column, Priority, Width};
4 + use makeover_tui::table::{self, Cell, Sizing, TableStyle};
3 5 use ratatui::Frame;
4 - use ratatui::layout::{Constraint, Layout};
6 + use ratatui::layout::{Constraint, Layout, Rect};
5 7 use ratatui::style::{Modifier, Style};
6 8 use ratatui::text::{Line, Span};
7 - use ratatui::widgets::{Block, Borders, Paragraph, Row};
9 + use ratatui::widgets::{Block, Borders, Paragraph, TableState};
8 10
9 11 use super::App;
10 - use super::widgets;
12 +
13 + /// The license key columns. The key code is the row; whether it still works is
14 + /// the next thing asked of it, and the activation count and the date after
15 + /// that.
16 + const KEY_COLUMNS: [Column<'static>; 4] = [
17 + Column {
18 + name: "Key",
19 + width: Width::Fill,
20 + priority: Priority::Essential,
21 + sortable: false,
22 + sorted: None,
23 + },
24 + Column {
25 + name: "Status",
26 + width: Width::Fixed,
27 + priority: Priority::Secondary,
28 + sortable: false,
29 + sorted: None,
30 + },
31 + Column {
32 + name: "Activations",
33 + width: Width::Fixed,
34 + priority: Priority::Optional,
35 + sortable: false,
36 + sorted: None,
37 + },
38 + Column {
39 + name: "Created",
40 + width: Width::Fixed,
41 + priority: Priority::Optional,
42 + sortable: false,
43 + sorted: None,
44 + },
45 + ];
46 +
47 + /// The tracks the hand-written `Constraint`s carried.
48 + const KEY_SIZING: Sizing<'static> = Sizing {
49 + lengths: &[
50 + ("Key", 24),
51 + ("Status", 10),
52 + ("Activations", 12),
53 + ("Created", 12),
54 + ],
55 + fallback: 12,
56 + };
11 57
12 58 pub(crate) fn render(frame: &mut Frame, app: &App) {
13 59 let area = frame.area();
@@ -107,46 +153,32 @@
107 153 frame.render_widget(keys, chunks[4]);
108 154 }
109 155
110 - fn render_key_table(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
111 - let rows: Vec<Row> = app
156 + fn render_key_table(frame: &mut Frame, app: &App, area: Rect) {
157 + let area = super::indent(area, 2);
158 +
159 + let rows: Vec<Vec<Cell>> = app
112 160 .license_keys
113 161 .iter()
114 - .enumerate()
115 - .map(|(i, key)| {
116 - let status = if key.is_revoked { "Revoked" } else { "Active" };
162 + .map(|key| {
117 163 let activations = match key.max_activations {
118 164 Some(max) => format!("{}/{}", key.activation_count, max),
119 165 None => key.activation_count.to_string(),
120 166 };
121 - let date = key.created_at.get(..10).unwrap_or(&key.created_at);
122 167
123 - Row::new(vec![
124 - format!(" {}", key.key_code),
125 - status.to_string(),
126 - activations,
127 - date.to_string(),
128 - ])
129 - .style(widgets::selected_style(
130 - &app.theme,
131 - i,
132 - Some(app.selected_index),
133 - ))
168 + vec![
169 + Cell::new("Key", key.key_code.as_str()),
170 + Cell::new("Status", if key.is_revoked { "Revoked" } else { "Active" }),
171 + Cell::new("Activations", activations),
172 + Cell::new(
173 + "Created",
174 + key.created_at.get(..10).unwrap_or(&key.created_at),
175 + ),
176 + ]
134 177 })
135 178 .collect();
136 179
137 - let widths = [
138 - Constraint::Min(24),
139 - Constraint::Length(10),
140 - Constraint::Length(12),
141 - Constraint::Length(12),
142 - ];
143 -
144 - widgets::render_table(
145 - frame,
146 - &app.theme,
147 - area,
148 - &[" Key", "Status", "Activations", "Created"],
149 - &widths,
150 - rows,
151 - );
180 + let style = TableStyle::from_theme(&app.theme);
181 + let table = table::table(&KEY_COLUMNS, &rows, &KEY_SIZING, &style, area.width);
182 + let mut state = TableState::default().with_selected(Some(app.selected_index));
183 + frame.render_stateful_widget(table, area, &mut state);
152 184 }
@@ -14,7 +14,6 @@
14 14 pub(crate) mod theme;
15 15 pub(crate) mod tiers;
16 16 pub(crate) mod upload;
17 - pub(crate) mod widgets;
18 17
19 18 use std::collections::HashSet;
20 19 use std::path::PathBuf;
@@ -43,6 +42,21 @@
43 42 mod run;
44 43 pub(crate) use run::launch;
45 44
45 + /// A screen's body area, indented by `cells`.
46 + ///
47 + /// Every screen draws its tables two cells in (four, under a settings
48 + /// sub-heading). That indent used to live inside the first heading string and
49 + /// inside the first cell of every row, which put presentation in the name a
50 + /// cell is addressed by. A table describes its columns, so the indent belongs
51 + /// to the area instead.
52 + pub(crate) fn indent(area: ratatui::layout::Rect, cells: u16) -> ratatui::layout::Rect {
53 + ratatui::layout::Rect {
54 + x: area.x + cells,
55 + width: area.width.saturating_sub(cells),
56 + ..area
57 + }
58 + }
59 +
46 60 /// Events sent to the TUI event loop.
47 61 pub(crate) enum AppEvent {
48 62 /// Raw input bytes from the SSH channel.
@@ -526,6 +540,71 @@
526 540 colors
527 541 }
528 542
543 + /// An app with one project in it, so a screen renders a table rather than
544 + /// its empty state.
545 + fn app_with_a_project() -> App {
546 + let mut app = app_on_a_fixed_theme();
547 + app.loading = false;
548 + app.projects = vec![Project {
549 + id: "p1".to_string(),
550 + slug: "field-recordings".to_string(),
551 + title: "Field Recordings".to_string(),
552 + project_type: "sample_pack".to_string(),
553 + is_public: true,
554 + item_count: 12,
555 + revenue_cents: 4200,
556 + currency: Currency::default(),
557 + revenue_cents_by_currency: std::collections::BTreeMap::new(),
558 + }];
559 + app
560 + }
561 +
562 + /// What the home screen draws at a given width.
563 + fn home_at(width: u16) -> String {
564 + let app = app_with_a_project();
565 + let backend = ratatui::backend::TestBackend::new(width, 12);
566 + let mut terminal = Terminal::new(backend).expect("test backend");
567 + terminal
568 + .draw(|frame| {
569 + run::paint_page(frame, &app);
570 + home::render(frame, &app);
571 + })
572 + .expect("home renders");
573 +
574 + terminal
575 + .backend()
576 + .buffer()
577 + .content()
578 + .iter()
579 + .map(ratatui::buffer::Cell::symbol)
580 + .collect()
581 + }
582 +
583 + /// A narrow window drops the columns that can be spared and keeps the one
584 + /// that says which row this is.
585 + ///
586 + /// The screens narrowed at no width before `makeover-tui`'s table: every
587 + /// one of them handed ratatui a hand-written `Constraint` list and
588 + /// overflowed. What drops is a property of the column now, so this asserts
589 + /// against the headings rather than against positions -- inserting a column
590 + /// left of the cut is exactly the bug `Priority` exists to prevent.
591 + #[test]
592 + fn the_project_table_sheds_columns_before_it_sheds_the_title() {
593 + let wide = home_at(120);
594 + assert!(wide.contains("Title"), "the title heading is always drawn");
595 + assert!(wide.contains("Items"), "a wide window keeps every column");
596 +
597 + let narrow = home_at(34);
598 + assert!(
599 + narrow.contains("Title"),
600 + "the essential column survives every width",
601 + );
602 + assert!(
603 + !narrow.contains("Items"),
604 + "an optional column drops before an essential one",
605 + );
606 + }
607 +
529 608 /// No screen may paint a colour that is not in the theme.
530 609 ///
531 610 /// This is the guard the port exists to make possible, and the terminal's
@@ -536,7 +615,10 @@
536 615 /// arriving through a widget's own default.
537 616 #[test]
538 617 fn every_rendered_colour_comes_from_the_theme() {
539 - let app = app_on_a_fixed_theme();
618 + // With a project in it, so the table's own tones are on the buffer this
619 + // reads. An empty screen renders its empty state and asserts nothing
620 + // about the thing most likely to name a colour.
621 + let app = app_with_a_project();
540 622 let permitted = colors_of(&app.theme);
541 623
542 624 let backend = ratatui::backend::TestBackend::new(120, 40);
@@ -1,16 +1,59 @@
1 1 //! Project detail screen — item list within a project.
2 2
3 + use makeover_tui::makeover_layout::{Column, Priority, Width};
4 + use makeover_tui::table::{self, Cell, Sizing, TableStyle};
3 5 use ratatui::Frame;
4 - use ratatui::layout::{Constraint, Layout};
6 + use ratatui::layout::{Constraint, Layout, Rect};
5 7 use ratatui::style::{Modifier, Style};
6 8 use ratatui::text::{Line, Span};
7 - use ratatui::widgets::{Block, Borders, Paragraph, Row};
9 + use ratatui::widgets::{Block, Borders, Paragraph, TableState};
8 10
9 11 use crate::api::Project;
10 12 use crate::format;
11 13
12 14 use super::App;
13 - use super::widgets;
15 +
16 + /// The item list's columns.
17 + ///
18 + /// Title carries the selection marker as well as the name, so it is what a row
19 + /// identifies itself by and the price is what the creator is checking. Type and
20 + /// status drop first.
21 + const ITEM_COLUMNS: [Column<'static>; 4] = [
22 + Column {
23 + name: "Title",
24 + width: Width::Fill,
25 + priority: Priority::Essential,
26 + sortable: false,
27 + sorted: None,
28 + },
29 + Column {
30 + name: "Type",
31 + width: Width::Fixed,
32 + priority: Priority::Optional,
33 + sortable: false,
34 + sorted: None,
35 + },
36 + Column {
37 + name: "Price",
38 + width: Width::Fixed,
39 + priority: Priority::Secondary,
40 + sortable: false,
41 + sorted: None,
42 + },
43 + Column {
44 + name: "Status",
45 + width: Width::Fixed,
46 + priority: Priority::Secondary,
47 + sortable: false,
48 + sorted: None,
49 + },
50 + ];
51 +
52 + /// The tracks the hand-written `Constraint`s carried.
53 + const ITEM_SIZING: Sizing<'static> = Sizing {
54 + lengths: &[("Title", 20), ("Type", 12), ("Price", 10), ("Status", 8)],
55 + fallback: 8,
56 + };
14 57
15 58 pub(crate) fn render(frame: &mut Frame, app: &App, project: &Project) {
16 59 let area = frame.area();
@@ -165,50 +208,42 @@
165 208 frame.render_widget(keys, chunks[6]);
166 209 }
167 210
168 - fn render_item_table(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
211 + fn render_item_table(frame: &mut Frame, app: &App, area: Rect) {
212 + let area = super::indent(area, 2);
169 213 let has_selections = !app.selected_items.is_empty();
170 214
171 - let rows: Vec<Row> = app
215 + let rows: Vec<Vec<Cell>> = app
172 216 .items
173 217 .iter()
174 218 .enumerate()
175 219 .map(|(i, item)| {
176 - let visibility = if item.is_public { "public" } else { "draft" };
177 - let marker = if app.selected_items.contains(&i) {
178 - "[x]"
179 - } else if has_selections {
180 - "[ ]"
220 + // The checkbox appears only once something is selected, so a list
221 + // nobody has marked up reads as a plain list.
222 + let title = if has_selections {
223 + let marker = if app.selected_items.contains(&i) {
224 + "[x]"
225 + } else {
226 + "[ ]"
227 + };
228 + format!("{marker} {}", item.title)
181 229 } else {
182 - " "
230 + item.title.clone()
183 231 };
184 232
185 - Row::new(vec![
186 - format!(" {} {}", marker, item.title),
187 - format::format_item_type(&item.item_type).to_string(),
188 - format::format_price(item.price_cents, app.currency()),
189 - visibility.to_string(),
190 - ])
191 - .style(widgets::selected_style(
192 - &app.theme,
193 - i,
194 - Some(app.selected_index),
195 - ))
233 + vec![
234 + Cell::new("Title", title),
235 + Cell::new("Type", format::format_item_type(&item.item_type)),
236 + Cell::new(
237 + "Price",
238 + format::format_price(item.price_cents, app.currency()),
239 + ),
240 + Cell::new("Status", if item.is_public { "public" } else { "draft" }),
241 + ]
196 242 })
197 243 .collect();
198 244
199 - let widths = [
200 - Constraint::Min(20),
201 - Constraint::Length(12),
202 - Constraint::Length(10),
203 - Constraint::Length(8),
204 - ];
205 -
206 - widgets::render_table(
207 - frame,
208 - &app.theme,
209 - area,
210 - &[" Title", "Type", "Price", "Status"],
211 - &widths,
212 - rows,
213 - );
245 + let style = TableStyle::from_theme(&app.theme);
246 + let table = table::table(&ITEM_COLUMNS, &rows, &ITEM_SIZING, &style, area.width);
247 + let mut state = TableState::default().with_selected(Some(app.selected_index));
248 + frame.render_stateful_widget(table, area, &mut state);
214 249 }
@@ -1,16 +1,56 @@
1 1 //! Promo code management screen — list, create, delete codes.
2 2
3 + use makeover_tui::makeover_layout::{Column, Priority, Width};
4 + use makeover_tui::table::{self, Cell, Sizing, TableStyle};
3 5 use ratatui::Frame;
4 - use ratatui::layout::{Constraint, Layout};
6 + use ratatui::layout::{Constraint, Layout, Rect};
5 7 use ratatui::style::{Modifier, Style};
6 8 use ratatui::text::{Line, Span};
7 - use ratatui::widgets::{Block, Borders, Paragraph, Row};
9 + use ratatui::widgets::{Block, Borders, Paragraph, TableState};
8 10
9 11 use super::App;
10 - use super::widgets;
11 12 use crate::currency::Currency;
12 13 use crate::format;
13 14
15 + /// The promo code columns. A code and what it takes off are the row; what it
16 + /// applies to and how many times it has been used qualify it.
17 + const CODE_COLUMNS: [Column<'static>; 4] = [
18 + Column {
19 + name: "Code",
20 + width: Width::Fill,
21 + priority: Priority::Essential,
22 + sortable: false,
23 + sorted: None,
24 + },
25 + Column {
26 + name: "Discount",
27 + width: Width::Fixed,
28 + priority: Priority::Essential,
29 + sortable: false,
30 + sorted: None,
31 + },
32 + Column {
33 + name: "Scope",
34 + width: Width::Fixed,
35 + priority: Priority::Secondary,
36 + sortable: false,
37 + sorted: None,
38 + },
39 + Column {
40 + name: "Uses",
41 + width: Width::Fixed,
42 + priority: Priority::Optional,
43 + sortable: false,
44 + sorted: None,
45 + },
46 + ];
47 +
48 + /// The tracks the hand-written `Constraint`s carried.
49 + const CODE_SIZING: Sizing<'static> = Sizing {
50 + lengths: &[("Code", 16), ("Discount", 12), ("Scope", 20), ("Uses", 10)],
51 + fallback: 10,
52 + };
53 +
14 54 pub(crate) fn render(frame: &mut Frame, app: &App) {
15 55 let area = frame.area();
16 56
@@ -116,12 +156,13 @@
116 156 frame.render_widget(keys, chunks[4]);
117 157 }
118 158
119 - fn render_code_table(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
120 - let rows: Vec<Row> = app
159 + fn render_code_table(frame: &mut Frame, app: &App, area: Rect) {
160 + let area = super::indent(area, 2);
161 +
162 + let rows: Vec<Vec<Cell>> = app
121 163 .promo_codes
122 164 .iter()
123 - .enumerate()
124 - .map(|(i, code)| {
165 + .map(|code| {
125 166 let discount = format_discount(
126 167 code.discount_type.as_deref(),
127 168 code.discount_value,
@@ -137,35 +178,19 @@
137 178 None => code.use_count.to_string(),
138 179 };
139 180
140 - Row::new(vec![
141 - format!(" {}", code.code),
142 - discount,
143 - scope.to_string(),
144 - uses,
145 - ])
146 - .style(widgets::selected_style(
147 - &app.theme,
148 - i,
149 - Some(app.selected_index),
150 - ))
181 + vec![
182 + Cell::new("Code", code.code.as_str()),
183 + Cell::new("Discount", discount),
184 + Cell::new("Scope", scope),
185 + Cell::new("Uses", uses),
186 + ]
151 187 })
152 188 .collect();
153 189
154 - let widths = [
155 - Constraint::Min(16),
156 - Constraint::Length(12),
157 - Constraint::Length(20),
158 - Constraint::Length(10),
159 - ];
160 -
161 - widgets::render_table(
162 - frame,
163 - &app.theme,
164 - area,
165 - &[" Code", "Discount", "Scope", "Uses"],
166 - &widths,
167 - rows,
168 - );
190 + let style = TableStyle::from_theme(&app.theme);
191 + let table = table::table(&CODE_COLUMNS, &rows, &CODE_SIZING, &style, area.width);
192 + let mut state = TableState::default().with_selected(Some(app.selected_index));
193 + frame.render_stateful_widget(table, area, &mut state);
169 194 }
170 195
171 196 /// A promo discount: a percentage, or an amount off in the creator's own
@@ -1,16 +1,49 @@
1 1 //! Settings screen — profile info, SSH keys, storage meter.
2 2
3 + use makeover_tui::makeover_layout::{Column, Priority, Width};
4 + use makeover_tui::table::{self, Cell, Sizing, TableStyle};
3 5 use ratatui::Frame;
4 6 use ratatui::layout::{Constraint, Layout};
5 7 use ratatui::style::{Modifier, Style};
6 8 use ratatui::text::{Line, Span};
7 - use ratatui::widgets::{Block, Borders, Gauge, Paragraph, Row};
9 + use ratatui::widgets::{Block, Borders, Gauge, Paragraph, TableState};
8 10
9 11 use crate::format;
10 12 use crate::staging;
11 13
12 14 use super::App;
13 - use super::widgets;
15 +
16 + /// The SSH key columns. The label is what a creator recognises a key by; the
17 + /// fingerprint is what identifies it to the server, so it outranks the date.
18 + const SSH_KEY_COLUMNS: [Column<'static>; 3] = [
19 + Column {
20 + name: "Label",
21 + width: Width::Fill,
22 + priority: Priority::Essential,
23 + sortable: false,
24 + sorted: None,
25 + },
26 + Column {
27 + name: "Fingerprint",
28 + width: Width::Fixed,
29 + priority: Priority::Secondary,
30 + sortable: false,
31 + sorted: None,
32 + },
33 + Column {
34 + name: "Added",
35 + width: Width::Fixed,
36 + priority: Priority::Optional,
37 + sortable: false,
38 + sorted: None,
39 + },
40 + ];
41 +
42 + /// The tracks the hand-written `Constraint`s carried.
43 + const SSH_KEY_SIZING: Sizing<'static> = Sizing {
44 + lengths: &[("Label", 20), ("Fingerprint", 24), ("Added", 12)],
45 + fallback: 12,
46 + };
14 47
15 48 pub(crate) fn render(frame: &mut Frame, app: &App) {
16 49 let area = frame.area();
@@ -141,41 +174,26 @@
141 174 let empty = Paragraph::new(" No SSH keys registered.");
142 175 frame.render_widget(empty, chunks[8]);
143 176 } else {
144 - let rows: Vec<Row> = app
177 + // Four cells rather than two: this table sits under a sub-heading.
178 + let area = super::indent(chunks[8], 4);
179 + let rows: Vec<Vec<Cell>> = app
145 180 .ssh_keys
146 181 .iter()
147 - .enumerate()
148 - .map(|(i, key)| {
182 + .map(|key| {
149 183 let fp = key.fingerprint.get(..20).unwrap_or(&key.fingerprint);
150 - let date = key.created_at.get(..10).unwrap_or(&key.created_at);
151 184
152 - Row::new(vec![
153 - format!(" {}", key.label),
154 - format!("{}...", fp),
155 - date.to_string(),
156 - ])
157 - .style(widgets::selected_style(
158 - &app.theme,
159 - i,
160 - Some(app.selected_index),
161 - ))
185 + vec![
186 + Cell::new("Label", key.label.as_str()),
187 + Cell::new("Fingerprint", format!("{fp}...")),
188 + Cell::new("Added", key.created_at.get(..10).unwrap_or(&key.created_at)),
189 + ]
162 190 })
163 191 .collect();
164 192
165 - let widths = [
166 - Constraint::Min(20),
167 - Constraint::Length(24),
168 - Constraint::Length(12),
169 - ];
170 -
171 - widgets::render_table(
172 - frame,
173 - &app.theme,
174 - chunks[8],
175 - &[" Label", "Fingerprint", "Added"],
176 - &widths,
177 - rows,
178 - );
193 + let style = TableStyle::from_theme(&app.theme);
194 + let table = table::table(&SSH_KEY_COLUMNS, &rows, &SSH_KEY_SIZING, &style, area.width);
195 + let mut state = TableState::default().with_selected(Some(app.selected_index));
196 + frame.render_stateful_widget(table, area, &mut state);
179 197 }
180 198
181 199 // Status line
@@ -1,15 +1,61 @@
1 1 //! Subscription tier list screen — read-only view of project tiers.
2 2
3 + use makeover_tui::makeover_layout::{Column, Priority, Width};
4 + use makeover_tui::table::{self, Cell, Sizing, TableStyle};
3 5 use ratatui::Frame;
4 6 use ratatui::layout::{Constraint, Layout};
5 7 use ratatui::style::{Modifier, Style};
6 8 use ratatui::text::{Line, Span};
7 - use ratatui::widgets::{Block, Borders, Paragraph, Row};
9 + use ratatui::widgets::{Block, Borders, Paragraph, TableState};
8 10
9 11 use crate::format;
10 12
11 13 use super::App;
12 - use super::widgets;
14 +
15 + /// The subscription tier columns. A tier is a name and a price, so both are
16 + /// essential; the description is the first thing to go.
17 + const TIER_COLUMNS: [Column<'static>; 4] = [
18 + Column {
19 + name: "Name",
20 + width: Width::Fill,
21 + priority: Priority::Essential,
22 + sortable: false,
23 + sorted: None,
24 + },
25 + Column {
26 + name: "Price",
27 + width: Width::Fixed,
28 + priority: Priority::Essential,
29 + sortable: false,
30 + sorted: None,
31 + },
32 + Column {
33 + name: "Status",
34 + width: Width::Fixed,
35 + priority: Priority::Secondary,
36 + sortable: false,
37 + sorted: None,
38 + },
39 + Column {
40 + name: "Description",
41 + width: Width::Fixed,
42 + priority: Priority::Optional,
43 + sortable: false,
44 + sorted: None,
45 + },
46 + ];
47 +
48 + /// The tracks the hand-written `Constraint`s carried. The description is
49 + /// truncated to fit its 30, which is why it is a fixed track and not a fill.
50 + const TIER_SIZING: Sizing<'static> = Sizing {
51 + lengths: &[
52 + ("Name", 16),
53 + ("Price", 10),
54 + ("Status", 10),
55 + ("Description", 30),
56 + ],
57 + fallback: 10,
58 + };
13 59
14 60 pub(crate) fn render(frame: &mut Frame, app: &App) {
15 61 let area = frame.area();
@@ -67,46 +113,29 @@
67 113 let empty = Paragraph::new(" No subscription tiers for this project.");
68 114 frame.render_widget(empty, chunks[2]);
69 115 } else {
70 - let rows: Vec<Row> = app
116 + let area = super::indent(chunks[2], 2);
117 + let rows: Vec<Vec<Cell>> = app
71 118 .tiers
72 119 .iter()
73 - .enumerate()
74 - .map(|(i, t)| {
75 - let status = if t.is_active { "active" } else { "inactive" };
120 + .map(|t| {
76 121 let desc = if t.description.len() > 30 {
77 122 format!("{}...", &t.description[..27])
78 123 } else {
79 124 t.description.clone()
80 125 };
81 - Row::new(vec![
82 - format!(" {}", t.name),
83 - format::format_price(t.price_cents, app.currency()),
84 - status.to_string(),
85 - desc,
86 - ])
87 - .style(widgets::selected_style(
88 - &app.theme,
89 - i,
90 - Some(app.selected_index),
91 - ))
126 + vec![
127 + Cell::new("Name", t.name.as_str()),
128 + Cell::new("Price", format::format_price(t.price_cents, app.currency())),
129 + Cell::new("Status", if t.is_active { "active" } else { "inactive" }),
130 + Cell::new("Description", desc),
131 + ]
92 132 })
93 133 .collect();
94 134
95 - let widths = [
96 - Constraint::Min(16),
97 - Constraint::Length(10),
98 - Constraint::Length(10),
99 - Constraint::Length(30),
100 - ];
101 -
102 - widgets::render_table(
103 - frame,
104 - &app.theme,
105 - chunks[2],
106 - &[" Name", "Price", "Status", "Description"],
107 - &widths,
108 - rows,
109 - );
135 + let style = TableStyle::from_theme(&app.theme);
136 + let table = table::table(&TIER_COLUMNS, &rows, &TIER_SIZING, &style, area.width);
137 + let mut state = TableState::default().with_selected(Some(app.selected_index));
138 + frame.render_stateful_widget(table, area, &mut state);
110 139 }
111 140
112 141 if let Some(ref status) = app.tiers_status {
@@ -1,16 +1,81 @@
1 1 //! Upload screen — staged files, metadata editing, publish flow.
2 2
3 + use makeover_tui::makeover_layout::{Column, Priority, Width};
4 + use makeover_tui::table::{self, Cell, Sizing, TableStyle};
3 5 use ratatui::Frame;
4 - use ratatui::layout::{Constraint, Layout};
6 + use ratatui::layout::{Constraint, Layout, Rect};
5 7 use ratatui::style::{Modifier, Style};
6 8 use ratatui::text::{Line, Span};
7 - use ratatui::widgets::{Block, Borders, Paragraph, Row};
9 + use ratatui::widgets::{Block, Borders, Paragraph, TableState};
8 10
9 11 use crate::format;
10 12 use crate::staging;
11 13
12 14 use super::App;
13 - use super::widgets;
15 +
16 + /// The staged file list's columns.
17 + ///
18 + /// The filename is what the creator recognises a staged file by, and it carries
19 + /// the editing marker, so it is the essential one. The title and the project
20 + /// are what publishing needs filled in, which keeps them above the size, the
21 + /// type and the price.
22 + const FILE_COLUMNS: [Column<'static>; 6] = [
23 + Column {
24 + name: "File",
25 + width: Width::Fill,
26 + priority: Priority::Essential,
27 + sortable: false,
28 + sorted: None,
29 + },
30 + Column {
31 + name: "Size",
32 + width: Width::Fixed,
33 + priority: Priority::Optional,
34 + sortable: false,
35 + sorted: None,
36 + },
37 + Column {
38 + name: "Type",
39 + width: Width::Fixed,
40 + priority: Priority::Optional,
41 + sortable: false,
42 + sorted: None,
43 + },
44 + Column {
45 + name: "Title",
46 + width: Width::Fixed,
47 + priority: Priority::Secondary,
48 + sortable: false,
49 + sorted: None,
50 + },
51 + Column {
52 + name: "Project",
53 + width: Width::Fixed,
54 + priority: Priority::Secondary,
55 + sortable: false,
56 + sorted: None,
57 + },
58 + Column {
59 + name: "Price",
60 + width: Width::Fixed,
61 + priority: Priority::Optional,
62 + sortable: false,
63 + sorted: None,
64 + },
65 + ];
66 +
67 + /// The tracks the hand-written `Constraint`s carried.
68 + const FILE_SIZING: Sizing<'static> = Sizing {
69 + lengths: &[
70 + ("File", 16),
71 + ("Size", 10),
72 + ("Type", 8),
73 + ("Title", 16),
74 + ("Project", 14),
75 + ("Price", 8),
76 + ],
77 + fallback: 8,
78 + };
14 79
15 80 pub(crate) fn render(frame: &mut Frame, app: &App) {
16 81 let area = frame.area();
@@ -158,15 +223,15 @@
158 223 frame.render_widget(Paragraph::new(text), area);
159 224 }
160 225
161 - fn render_file_table(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
226 + fn render_file_table(frame: &mut Frame, app: &App, area: Rect) {
227 + let area = super::indent(area, 2);
162 228 let selected = app.selected_index;
163 - let rows: Vec<Row> = app
229 +
230 + let rows: Vec<Vec<Cell>> = app
164 231 .staged_files
165 232 .iter()
166 233 .enumerate()
167 234 .map(|(i, sf)| {
168 - let file_type = sf.classification.map_or("?", |c| c.item_type);
169 -
170 235 // Check if user has edited metadata for this file
171 236 let meta = app.file_metadata.get(i);
172 237 let title = meta
@@ -187,37 +252,19 @@
187 252 " "
188 253 };
189 254
190 - Row::new(vec![
191 - format!("{}{}", editing_marker, sf.filename),
192 - staging::format_bytes(sf.size),
193 - file_type.to_string(),
194 - title.clone(),
195 - project.to_string(),
196 - price,
197 - ])
198 - .style(widgets::selected_style(
199 - &app.theme,
200 - i,
201 - Some(app.selected_index),
202 - ))
255 + vec![
256 + Cell::new("File", format!("{editing_marker}{}", sf.filename)),
257 + Cell::new("Size", staging::format_bytes(sf.size)),
258 + Cell::new("Type", sf.classification.map_or("?", |c| c.item_type)),
259 + Cell::new("Title", title),
260 + Cell::new("Project", project),
261 + Cell::new("Price", price),
262 + ]
203 263 })
204 264 .collect();
205 265
206 - let widths = [
207 - Constraint::Min(16),
208 - Constraint::Length(10),
209 - Constraint::Length(8),
210 - Constraint::Length(16),
211 - Constraint::Length(14),
212 - Constraint::Length(8),
213 - ];
214 -
215 - widgets::render_table(
216 - frame,
217 - &app.theme,
218 - area,
219 - &[" File", "Size", "Type", "Title", "Project", "Price"],
220 - &widths,
221 - rows,
222 - );
266 + let style = TableStyle::from_theme(&app.theme);
267 + let table = table::table(&FILE_COLUMNS, &rows, &FILE_SIZING, &style, area.width);
268 + let mut state = TableState::default().with_selected(Some(app.selected_index));
269 + frame.render_stateful_widget(table, area, &mut state);
223 270 }
@@ -1,59 +1,0 @@
1 - //! Shared TUI widget helpers to reduce table-rendering boilerplate.
2 - //!
3 - //! Every colour here is a makeover intent read off [`App::theme`](super::App),
4 - //! never a literal. See `super::theme` for where it comes from.
5 -
6 - use makeover_tui::Theme;
7 - use ratatui::Frame;
8 - use ratatui::layout::Constraint;
9 - use ratatui::style::{Modifier, Style};
10 - use ratatui::widgets::{Row, Table};
11 -
12 - /// Style for the currently selected row. Non-selected rows keep the page style.
13 - ///
14 - /// The selection is carried by the background alone, with no foreground
15 - /// override. A row here can be red for a failed upload or green for a published
16 - /// item, and repainting its text on selection is how that distinction gets lost
17 - /// on exactly the row the creator is looking at. `surface_raised` is the house
18 - /// answer for a selected row (`got`, `viewer`), so a list reads the same in the
19 - /// CLI as in the desktop apps.
20 - pub(crate) fn selected_style(theme: &Theme, i: usize, selected: Option<usize>) -> Style {
21 - if selected == Some(i) {
22 - Style::default()
23 - .bg(theme.surface_raised)
24 - .add_modifier(Modifier::BOLD)
25 - } else {
26 - Style::default()
27 - }
28 - }
29 -
30 - /// The muted style for labels, units, and keybinding hints.
31 - pub(crate) fn muted(theme: &Theme) -> Style {
32 - Style::default().fg(theme.content_muted)
33 - }
34 -
35 - /// Render a table with a styled header row.
36 - ///
37 - /// Callers build their own `Vec<Row>` (using [`selected_style`] for
38 - /// row highlighting) and pass column headers + widths. This function
39 - /// assembles the header, constructs the `Table`, and renders it.
40 - pub(crate) fn render_table(
41 - frame: &mut Frame,
42 - theme: &Theme,
43 - area: ratatui::layout::Rect,
44 - headers: &[&str],
45 - widths: &[Constraint],
46 - rows: Vec<Row>,
47 - ) {
48 - let header = Row::new(
49 - headers
50 - .iter()
51 - .map(std::string::ToString::to_string)
52 - .collect::<Vec<_>>(),
53 - )
54 - .style(muted(theme).add_modifier(Modifier::BOLD))
55 - .bottom_margin(0);
56 -
57 - let table = Table::new(rows, widths).header(header);
58 - frame.render_widget(table, area);
59 - }