max / makenotwork
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
15 files changed,
+876 insertions,
-472 deletions
| @@ -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 | - | } |