max / makenotwork
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
- Claude-Session
- https://claude.ai/code/session_01MptwXZ8k65v19rFmdGAyki
19 files changed,
+787 insertions,
-719 deletions
| @@ -36,7 +36,7 @@ | |||
| 36 | 36 | //! loses anything they can currently see. | |
| 37 | 37 | ||
| 38 | 38 | use makeover_layout as layout; | |
| 39 | - | use quasi_router::screen::{Cell, Cells, Column}; | |
| 39 | + | use quasi_router::screen::{Cell, Cells, Column, Table}; | |
| 40 | 40 | use quasi_router::{Action, Node, RegionKind, Request, Response, RouteError, Slot}; | |
| 41 | 41 | use quasi_webview::Webview; | |
| 42 | 42 | ||
| @@ -133,42 +133,40 @@ | |||
| 133 | 133 | ||
| 134 | 134 | /// The buyers who shared an email. | |
| 135 | 135 | fn table(buyers: &[BuyerView]) -> Node { | |
| 136 | - | Node::Table { | |
| 137 | - | columns: vec![ | |
| 138 | - | Column::new("Username") | |
| 139 | - | .width(layout::Width::Content) | |
| 140 | - | .priority(layout::Priority::Essential), | |
| 141 | - | Column::new("Email") | |
| 142 | - | .width(layout::Width::Fill) | |
| 143 | - | .priority(layout::Priority::Essential), | |
| 144 | - | Column::new("Purchases").width(layout::Width::Content), | |
| 145 | - | Column::new("Total Spent").width(layout::Width::Content), | |
| 146 | - | Column::new("Last Purchase") | |
| 147 | - | .width(layout::Width::Content) | |
| 148 | - | .priority(layout::Priority::Optional), | |
| 149 | - | ], | |
| 150 | - | rows: buyers | |
| 151 | - | .iter() | |
| 152 | - | .map(|buyer| { | |
| 153 | - | Cells::new([ | |
| 154 | - | Cell::new(buyer.username.clone()) | |
| 155 | - | .activate(Action::get(format!("/u/{}", buyer.username)).navigating()), | |
| 156 | - | // Plain text, unlike `library_contacts`, and the templates | |
| 157 | - | // differ the same way: a creator's own list does not link | |
| 158 | - | // the address it is showing. Kept rather than harmonised, | |
| 159 | - | // because which of the two is right is a design question | |
| 160 | - | // and this batch is a conversion. | |
| 161 | - | Cell::new(buyer.email.clone()), | |
| 162 | - | Cell::new(buyer.purchases.clone()), | |
| 163 | - | Cell::new(buyer.spent.clone()), | |
| 164 | - | Cell::new(buyer.last_purchase.clone()), | |
| 165 | - | ]) | |
| 166 | - | }) | |
| 167 | - | .collect(), | |
| 168 | - | // No paging described here: every one of these tables is a | |
| 169 | - | // whole set the handler already counted. | |
| 170 | - | more: None, | |
| 171 | - | } | |
| 136 | + | // Cells by position rather than by name, which is the safe half of the | |
| 137 | + | // choice `Table` offers: the columns and the rows are the one expression | |
| 138 | + | // below, and every buyer contributes the same five cells, so there is no | |
| 139 | + | // seam for a heading and a cell to drift across. Nothing is paged either, | |
| 140 | + | // so no `more`: this is a whole set the handler already counted. | |
| 141 | + | Table::new(vec![ | |
| 142 | + | Column::new("Username") | |
| 143 | + | .width(layout::Width::Content) | |
| 144 | + | .priority(layout::Priority::Essential), | |
| 145 | + | Column::new("Email") | |
| 146 | + | .width(layout::Width::Fill) | |
| 147 | + | .priority(layout::Priority::Essential), | |
| 148 | + | Column::new("Purchases").width(layout::Width::Content), | |
| 149 | + | Column::new("Total Spent").width(layout::Width::Content), | |
| 150 | + | Column::new("Last Purchase") | |
| 151 | + | .width(layout::Width::Content) | |
| 152 | + | .priority(layout::Priority::Optional), | |
| 153 | + | ]) | |
| 154 | + | .rows(buyers.iter().map(|buyer| { | |
| 155 | + | Cells::new([ | |
| 156 | + | Cell::new(buyer.username.clone()) | |
| 157 | + | .activate(Action::get(format!("/u/{}", buyer.username)).navigating()), | |
| 158 | + | // Plain text, unlike `library_contacts`, and the templates | |
| 159 | + | // differ the same way: a creator's own list does not link | |
| 160 | + | // the address it is showing. Kept rather than harmonised, | |
| 161 | + | // because which of the two is right is a design question | |
| 162 | + | // and this batch is a conversion. | |
| 163 | + | Cell::new(buyer.email.clone()), | |
| 164 | + | Cell::new(buyer.purchases.clone()), | |
| 165 | + | Cell::new(buyer.spent.clone()), | |
| 166 | + | Cell::new(buyer.last_purchase.clone()), | |
| 167 | + | ]) | |
| 168 | + | })) | |
| 169 | + | .into() | |
| 172 | 170 | } | |
| 173 | 171 | ||
| 174 | 172 | /// The renderer this screen is drawn with. |
| @@ -31,7 +31,7 @@ | |||
| 31 | 31 | //! and the design system draws the control and the confirmation. | |
| 32 | 32 | ||
| 33 | 33 | use makeover_layout as layout; | |
| 34 | - | use quasi_router::screen::{Act, Cell, Cells, Column}; | |
| 34 | + | use quasi_router::screen::{Act, Cell, Cells, Column, Table}; | |
| 35 | 35 | use quasi_router::{ | |
| 36 | 36 | Action, Document, Node, RegionKind, Request, Response, RouteError, Screen as Described, Slot, | |
| 37 | 37 | }; | |
| @@ -227,31 +227,30 @@ | |||
| 227 | 227 | /// drops, and the meta line's three facts become three columns that can be | |
| 228 | 228 | /// dropped independently rather than a string that wraps. | |
| 229 | 229 | fn items_table(items: &[Item]) -> Node { | |
| 230 | - | Node::Table { | |
| 231 | - | columns: vec![ | |
| 232 | - | Column::new("Item") | |
| 233 | - | .width(layout::Width::Fill) | |
| 234 | - | .priority(layout::Priority::Essential), | |
| 235 | - | Column::new("Type").width(layout::Width::Content), | |
| 236 | - | Column::new("Creator").width(layout::Width::Content), | |
| 237 | - | Column::new("Project").width(layout::Width::Content), | |
| 238 | - | Column::new("Price").width(layout::Width::Content), | |
| 239 | - | ], | |
| 240 | - | rows: items | |
| 241 | - | .iter() | |
| 242 | - | .map(|item| { | |
| 243 | - | Cells::new([ | |
| 244 | - | Cell::new(item.title.clone()), | |
| 245 | - | Cell::new(item.item_type.clone()), | |
| 246 | - | Cell::new(item.creator.clone()), | |
| 247 | - | Cell::new(item.project.clone()), | |
| 248 | - | Cell::new(item.price.clone()), | |
| 249 | - | ]) | |
| 250 | - | .activate(Action::get(format!("/i/{}", item.id)).navigating()) | |
| 251 | - | }) | |
| 252 | - | .collect(), | |
| 253 | - | more: None, | |
| 254 | - | } | |
| 230 | + | // The cells are positional, and stay so because the columns are three lines | |
| 231 | + | // above them in this one expression and every item fills all five. Naming | |
| 232 | + | // buys nothing a reader cannot already see; it earns its keep where a cell | |
| 233 | + | // is conditional or the headings live in another function. | |
| 234 | + | Table::new([ | |
| 235 | + | Column::new("Item") | |
| 236 | + | .width(layout::Width::Fill) | |
| 237 | + | .priority(layout::Priority::Essential), | |
| 238 | + | Column::new("Type").width(layout::Width::Content), | |
| 239 | + | Column::new("Creator").width(layout::Width::Content), | |
| 240 | + | Column::new("Project").width(layout::Width::Content), | |
| 241 | + | Column::new("Price").width(layout::Width::Content), | |
| 242 | + | ]) | |
| 243 | + | .rows(items.iter().map(|item| { | |
| 244 | + | Cells::new([ | |
| 245 | + | Cell::new(item.title.clone()), | |
| 246 | + | Cell::new(item.item_type.clone()), | |
| 247 | + | Cell::new(item.creator.clone()), | |
| 248 | + | Cell::new(item.project.clone()), | |
| 249 | + | Cell::new(item.price.clone()), | |
| 250 | + | ]) | |
| 251 | + | .activate(Action::get(format!("/i/{}", item.id)).navigating()) | |
| 252 | + | })) | |
| 253 | + | .into() | |
| 255 | 254 | } | |
| 256 | 255 | ||
| 257 | 256 | /// The document this screen is drawn in. |
| @@ -6,8 +6,8 @@ | |||
| 6 | 6 | //! # The tier table is a table, and it is the first described one on a public page | |
| 7 | 7 | //! | |
| 8 | 8 | //! Four tiers by four columns, and every cell in two of those columns comes | |
| 9 | - | //! from [`TierPrices`](crate::tier_prices::TierPrices). `Node::Table` says it, | |
| 10 | - | //! the same member `/feed` uses for its item list, so the widths and the | |
| 9 | + | //! from [`TierPrices`](crate::tier_prices::TierPrices). [`Table`] says it, the | |
| 10 | + | //! same builder `/feed` uses for its item list, so the widths and the | |
| 11 | 11 | //! narrow-viewport behaviour are the design system's rather than | |
| 12 | 12 | //! `.wave-table`'s. | |
| 13 | 13 | //! | |
| @@ -37,7 +37,7 @@ | |||
| 37 | 37 | //! whether to apply is told how many creators are actually here. | |
| 38 | 38 | ||
| 39 | 39 | use makeover_layout as layout; | |
| 40 | - | use quasi_router::screen::{Cell, Cells, Column, Figure}; | |
| 40 | + | use quasi_router::screen::{Cell, Cells, Column, Figure, Table}; | |
| 41 | 41 | use quasi_router::{ | |
| 42 | 42 | Action, Document, Node, RegionKind, Request, Response, RouteError, Screen as Described, Slot, | |
| 43 | 43 | }; | |
| @@ -196,28 +196,26 @@ | |||
| 196 | 196 | ||
| 197 | 197 | /// The four tiers, priced from the live figures. | |
| 198 | 198 | fn tier_table(prices: &TierPrices) -> Node { | |
| 199 | - | Node::Table { | |
| 200 | - | columns: vec![ | |
| 201 | - | Column::new("Tier") | |
| 202 | - | .width(layout::Width::Content) | |
| 203 | - | .priority(layout::Priority::Essential), | |
| 204 | - | Column::new("Monthly").width(layout::Width::Content), | |
| 205 | - | Column::new("Best For").width(layout::Width::Fill), | |
| 206 | - | Column::new("Storage").width(layout::Width::Content), | |
| 207 | - | ], | |
| 208 | - | rows: TIERS | |
| 209 | - | .iter() | |
| 210 | - | .map(|tier| { | |
| 211 | - | Cells::new([ | |
| 212 | - | Cell::new(tier.name), | |
| 213 | - | Cell::new(format!("${}", (tier.price)(prices))), | |
| 214 | - | Cell::new(tier.best_for), | |
| 215 | - | Cell::new((tier.storage)(prices)), | |
| 216 | - | ]) | |
| 217 | - | }) | |
| 218 | - | .collect(), | |
| 219 | - | more: None, | |
| 220 | - | } | |
| 199 | + | // Four columns and four cells, written together, with no branch between | |
| 200 | + | // them: every tier is a full row, so position is checkable by eye here and | |
| 201 | + | // naming the columns would be ceremony. | |
| 202 | + | Table::new([ | |
| 203 | + | Column::new("Tier") | |
| 204 | + | .width(layout::Width::Content) | |
| 205 | + | .priority(layout::Priority::Essential), | |
| 206 | + | Column::new("Monthly").width(layout::Width::Content), | |
| 207 | + | Column::new("Best For").width(layout::Width::Fill), | |
| 208 | + | Column::new("Storage").width(layout::Width::Content), | |
| 209 | + | ]) | |
| 210 | + | .rows(TIERS.iter().map(|tier| { | |
| 211 | + | Cells::new([ | |
| 212 | + | Cell::new(tier.name), | |
| 213 | + | Cell::new(format!("${}", (tier.price)(prices))), | |
| 214 | + | Cell::new(tier.best_for), | |
| 215 | + | Cell::new((tier.storage)(prices)), | |
| 216 | + | ]) | |
| 217 | + | })) | |
| 218 | + | .into() | |
| 221 | 219 | } | |
| 222 | 220 | ||
| 223 | 221 | /// What the page asks of this reader, which is the only thing on it that |
| @@ -82,7 +82,7 @@ | |||
| 82 | 82 | //! [`Cell::part`]: quasi_router::screen::Cell::part | |
| 83 | 83 | ||
| 84 | 84 | use makeover_layout as layout; | |
| 85 | - | use quasi_router::screen::{Act, Cell, Cells, Column, Rest, Tag}; | |
| 85 | + | use quasi_router::screen::{Act, Cell, Cells, Column, Rest, Table, Tag}; | |
| 86 | 86 | use quasi_router::{ | |
| 87 | 87 | Action, Document, Node, RegionKind, Request, Response, RouteError, Screen as Described, Slot, | |
| 88 | 88 | }; | |
| @@ -342,22 +342,31 @@ | |||
| 342 | 342 | ||
| 343 | 343 | /// The five columns, and the rows under them. | |
| 344 | 344 | fn table(page: &Page<'_>, surface: Surface) -> Node { | |
| 345 | - | Node::Table { | |
| 346 | - | columns: vec![ | |
| 347 | - | Column::new("Type").width(layout::Width::Content), | |
| 348 | - | Column::new("Name") | |
| 349 | - | .width(layout::Width::Fill) | |
| 350 | - | .priority(layout::Priority::Essential), | |
| 351 | - | Column::new("Tag").width(layout::Width::Content), | |
| 352 | - | Column::new("Price").width(layout::Width::Content), | |
| 353 | - | Column::new("Date").width(layout::Width::Content), | |
| 354 | - | ], | |
| 355 | - | rows: page.items.iter().map(row).collect(), | |
| 356 | - | more: rest(page, surface), | |
| 345 | + | let mut table = Table::new([ | |
| 346 | + | Column::new("Type").width(layout::Width::Content), | |
| 347 | + | Column::new("Name") | |
| 348 | + | .width(layout::Width::Fill) | |
| 349 | + | .priority(layout::Priority::Essential), | |
| 350 | + | Column::new("Tag").width(layout::Width::Content), | |
| 351 | + | Column::new("Price").width(layout::Width::Content), | |
| 352 | + | Column::new("Date").width(layout::Width::Content), | |
| 353 | + | ]) | |
| 354 | + | .rows(page.items.iter().map(row)); | |
| 355 | + | ||
| 356 | + | if let Some(rest) = rest(page, surface) { | |
| 357 | + | table = table.more(rest); | |
| 357 | 358 | } | |
| 359 | + | ||
| 360 | + | table.into() | |
| 358 | 361 | } | |
| 359 | 362 | ||
| 360 | 363 | /// One item. | |
| 364 | + | /// | |
| 365 | + | /// The cells name their columns rather than counting to them, because the | |
| 366 | + | /// headings are in [`table`] and this is a different function: position is only | |
| 367 | + | /// checkable when both halves are in front of you, and here they never are. The | |
| 368 | + | /// names must match [`table`]'s `Column::new` strings exactly, since a name no | |
| 369 | + | /// column carries is dropped rather than reported. | |
| 361 | 370 | fn row(item: &DiscoverItem) -> Cells { | |
| 362 | 371 | let price = if item.is_free { | |
| 363 | 372 | let mut free = Tag::badge("Free"); | |
| @@ -367,16 +376,21 @@ | |||
| 367 | 376 | Cell::new(item.price.clone()) | |
| 368 | 377 | }; | |
| 369 | 378 | ||
| 370 | - | Cells::new([ | |
| 371 | - | Cell::new(String::new()).token(Tag::badge(item.item_type.clone())), | |
| 379 | + | Cells::default() | |
| 380 | + | .at( | |
| 381 | + | "Type", | |
| 382 | + | Cell::new(String::new()).token(Tag::badge(item.item_type.clone())), | |
| 383 | + | ) | |
| 372 | 384 | // The name is the link text and the creator rides under it in the same | |
| 373 | 385 | // cell, which is what `Cell::activate` picks: the first text part. | |
| 374 | - | Cell::new(item.name.clone()).part(Node::text(item.creator.clone())), | |
| 375 | - | Cell::new(item.primary_tag.clone()), | |
| 376 | - | price, | |
| 377 | - | Cell::new(item.date.clone()), | |
| 378 | - | ]) | |
| 379 | - | .activate(Action::get(format!("/i/{}", item.id)).navigating()) | |
| 386 | + | .at( | |
| 387 | + | "Name", | |
| 388 | + | Cell::new(item.name.clone()).part(Node::text(item.creator.clone())), | |
| 389 | + | ) | |
| 390 | + | .at("Tag", Cell::new(item.primary_tag.clone())) | |
| 391 | + | .at("Price", price) | |
| 392 | + | .at("Date", Cell::new(item.date.clone())) | |
| 393 | + | .activate(Action::get(format!("/i/{}", item.id)).navigating()) | |
| 380 | 394 | } | |
| 381 | 395 | ||
| 382 | 396 | /// What the reader has not been shown, and every way to ask for it. |
| @@ -32,7 +32,7 @@ | |||
| 32 | 32 | //! status renders an empty list. | |
| 33 | 33 | ||
| 34 | 34 | use makeover_layout as layout; | |
| 35 | - | use quasi_router::screen::{Act, Cell, Cells, Column, Tag}; | |
| 35 | + | use quasi_router::screen::{Act, Cell, Cells, Column, Table, Tag}; | |
| 36 | 36 | use quasi_router::{Action, Node, RegionKind, Request, Response, RouteError, Slot}; | |
| 37 | 37 | use quasi_webview::Webview; | |
| 38 | 38 | ||
| @@ -215,37 +215,34 @@ | |||
| 215 | 215 | ||
| 216 | 216 | /// The memberships, written once for both screens. | |
| 217 | 217 | fn table(memberships: &[MembershipView]) -> Node { | |
| 218 | - | Node::Table { | |
| 219 | - | columns: vec![ | |
| 220 | - | Column::new("Community") | |
| 221 | - | .width(layout::Width::Fill) | |
| 222 | - | .priority(layout::Priority::Essential), | |
| 223 | - | Column::new("Role").width(layout::Width::Content), | |
| 224 | - | Column::new("Posts").width(layout::Width::Content), | |
| 225 | - | Column::new("Joined") | |
| 226 | - | .width(layout::Width::Content) | |
| 227 | - | .priority(layout::Priority::Optional), | |
| 228 | - | ], | |
| 229 | - | rows: memberships | |
| 230 | - | .iter() | |
| 231 | - | .map(|membership| { | |
| 232 | - | Cells::new([ | |
| 233 | - | // The destination is Multithreaded, so it leaves. That is | |
| 234 | - | // the description saying it rather than the reader finding | |
| 235 | - | // out: a host with no browser can decide what to do with a | |
| 236 | - | // link off its own service. | |
| 237 | - | Cell::new(membership.community.clone()) | |
| 238 | - | .activate(Action::external(membership.profile_url.clone())), | |
| 239 | - | Cell::tag(Tag::badge(membership.role.clone())), | |
| 240 | - | Cell::new(membership.posts.clone()), | |
| 241 | - | Cell::new(membership.joined.clone()), | |
| 242 | - | ]) | |
| 243 | - | }) | |
| 244 | - | .collect(), | |
| 245 | - | // No paging described here: every one of these tables is a | |
| 246 | - | // whole set the handler already counted. | |
| 247 | - | more: None, | |
| 248 | - | } | |
| 218 | + | // Positional cells. Both screens take this table whole rather than picking | |
| 219 | + | // columns out of it, so the headings and the row stay the one expression | |
| 220 | + | // below and every membership contributes the same four cells. Nothing is | |
| 221 | + | // paged, so no `more`: this is a whole set the handler already counted. | |
| 222 | + | Table::new(vec![ | |
| 223 | + | Column::new("Community") | |
| 224 | + | .width(layout::Width::Fill) | |
| 225 | + | .priority(layout::Priority::Essential), | |
| 226 | + | Column::new("Role").width(layout::Width::Content), | |
| 227 | + | Column::new("Posts").width(layout::Width::Content), | |
| 228 | + | Column::new("Joined") | |
| 229 | + | .width(layout::Width::Content) | |
| 230 | + | .priority(layout::Priority::Optional), | |
| 231 | + | ]) | |
| 232 | + | .rows(memberships.iter().map(|membership| { | |
| 233 | + | Cells::new([ | |
| 234 | + | // The destination is Multithreaded, so it leaves. That is | |
| 235 | + | // the description saying it rather than the reader finding | |
| 236 | + | // out: a host with no browser can decide what to do with a | |
| 237 | + | // link off its own service. | |
| 238 | + | Cell::new(membership.community.clone()) | |
| 239 | + | .activate(Action::external(membership.profile_url.clone())), | |
| 240 | + | Cell::tag(Tag::badge(membership.role.clone())), | |
| 241 | + | Cell::new(membership.posts.clone()), | |
| 242 | + | Cell::new(membership.joined.clone()), | |
| 243 | + | ]) | |
| 244 | + | })) | |
| 245 | + | .into() | |
| 249 | 246 | } | |
| 250 | 247 | ||
| 251 | 248 | /// The renderer both screens are drawn with. |
| @@ -30,7 +30,7 @@ | |||
| 30 | 30 | use std::collections::HashMap; | |
| 31 | 31 | ||
| 32 | 32 | use makeover_layout as layout; | |
| 33 | - | use quasi_router::screen::{Cell, Cells, Column, Lexeme}; | |
| 33 | + | use quasi_router::screen::{Cell, Cells, Column, Lexeme, Table}; | |
| 34 | 34 | use quasi_router::{Action, Document, Node, RegionKind, Run, Screen, Slot}; | |
| 35 | 35 | use quasi_webview::Webview; | |
| 36 | 36 | ||
| @@ -140,37 +140,35 @@ | |||
| 140 | 140 | fn table(view: &View<'_>) -> Node { | |
| 141 | 141 | let base = format!("/git/{}/{}", view.owner, view.repo); | |
| 142 | 142 | ||
| 143 | - | Node::Table { | |
| 144 | - | columns: vec![ | |
| 145 | - | // The commit is the identity of the row: everything else is a fact | |
| 146 | - | // about it, and a narrow viewport that dropped it would leave a | |
| 147 | - | // blame table that blames nobody. | |
| 148 | - | Column::new("Commit") | |
| 149 | - | .width(layout::Width::Content) | |
| 150 | - | .priority(layout::Priority::Essential), | |
| 151 | - | Column::new("Author") | |
| 152 | - | .width(layout::Width::Content) | |
| 153 | - | .priority(layout::Priority::Secondary), | |
| 154 | - | Column::new("Date") | |
| 155 | - | .width(layout::Width::Content) | |
| 156 | - | .priority(layout::Priority::Optional), | |
| 157 | - | Column::new("Line") | |
| 158 | - | .width(layout::Width::Content) | |
| 159 | - | .priority(layout::Priority::Secondary), | |
| 160 | - | Column::new("Code") | |
| 161 | - | .width(layout::Width::Fill) | |
| 162 | - | .priority(layout::Priority::Essential), | |
| 163 | - | ], | |
| 164 | - | rows: view | |
| 165 | - | .lines | |
| 166 | - | .iter() | |
| 167 | - | .map(|line| row(view, &base, line)) | |
| 168 | - | .collect(), | |
| 169 | - | more: None, | |
| 170 | - | } | |
| 143 | + | Table::new(vec![ | |
| 144 | + | // The commit is the identity of the row: everything else is a fact | |
| 145 | + | // about it, and a narrow viewport that dropped it would leave a | |
| 146 | + | // blame table that blames nobody. | |
| 147 | + | Column::new("Commit") | |
| 148 | + | .width(layout::Width::Content) | |
| 149 | + | .priority(layout::Priority::Essential), | |
| 150 | + | Column::new("Author") | |
| 151 | + | .width(layout::Width::Content) | |
| 152 | + | .priority(layout::Priority::Secondary), | |
| 153 | + | Column::new("Date") | |
| 154 | + | .width(layout::Width::Content) | |
| 155 | + | .priority(layout::Priority::Optional), | |
| 156 | + | Column::new("Line") | |
| 157 | + | .width(layout::Width::Content) | |
| 158 | + | .priority(layout::Priority::Secondary), | |
| 159 | + | Column::new("Code") | |
| 160 | + | .width(layout::Width::Fill) | |
| 161 | + | .priority(layout::Priority::Essential), | |
| 162 | + | ]) | |
| 163 | + | .rows(view.lines.iter().map(|line| row(view, &base, line))) | |
| 164 | + | .into() | |
| 171 | 165 | } | |
| 172 | 166 | ||
| 173 | 167 | /// One blamed line. | |
| 168 | + | /// | |
| 169 | + | /// The cells name their columns because the column list is a function away: a | |
| 170 | + | /// row written by position here would be lined up against headings nobody | |
| 171 | + | /// reading this function can see. | |
| 174 | 172 | fn row(view: &View<'_>, base: &str, line: &BlameLine) -> Cells { | |
| 175 | 173 | let commit = format!("{base}/commit/{}", line.commit_oid); | |
| 176 | 174 | ||
| @@ -192,17 +190,19 @@ | |||
| 192 | 190 | }); | |
| 193 | 191 | } | |
| 194 | 192 | ||
| 195 | - | Cells::new([ | |
| 196 | - | identity, | |
| 197 | - | Cell::new(line.author_name.clone()), | |
| 198 | - | Cell::new(line.time_formatted.clone()), | |
| 199 | - | Cell::new(line.lineno.to_string()), | |
| 200 | - | Cell::default().part(Node::Code { | |
| 201 | - | runs: vec![Lexeme::plain(&line.content)], | |
| 202 | - | language: None, | |
| 203 | - | inline: true, | |
| 204 | - | }), | |
| 205 | - | ]) | |
| 193 | + | Cells::default() | |
| 194 | + | .at("Commit", identity) | |
| 195 | + | .at("Author", Cell::new(line.author_name.clone())) | |
| 196 | + | .at("Date", Cell::new(line.time_formatted.clone())) | |
| 197 | + | .at("Line", Cell::new(line.lineno.to_string())) | |
| 198 | + | .at( | |
| 199 | + | "Code", | |
| 200 | + | Cell::default().part(Node::Code { | |
| 201 | + | runs: vec![Lexeme::plain(&line.content)], | |
| 202 | + | language: None, | |
| 203 | + | inline: true, | |
| 204 | + | }), | |
| 205 | + | ) | |
| 206 | 206 | } | |
| 207 | 207 | ||
| 208 | 208 | /// The document this screen is drawn in. |
| @@ -19,7 +19,7 @@ | |||
| 19 | 19 | //! gap open while the page was still a template; it is gone with the template. | |
| 20 | 20 | ||
| 21 | 21 | use makeover_layout as layout; | |
| 22 | - | use quasi_router::screen::{Cell, Cells, Column, Lexeme}; | |
| 22 | + | use quasi_router::screen::{Cell, Cells, Column, Lexeme, Table}; | |
| 23 | 23 | use quasi_router::{Action, Document, Node, RegionKind, Run, Screen, Slot}; | |
| 24 | 24 | use quasi_webview::Webview; | |
| 25 | 25 | ||
| @@ -151,9 +151,15 @@ | |||
| 151 | 151 | fn listing(frame: &Frame<'_>, tree: &Tree<'_>) -> Node { | |
| 152 | 152 | let mut rows = Vec::with_capacity(tree.items.len() + 1); | |
| 153 | 153 | ||
| 154 | + | // Rows are built here and the columns are declared at the bottom of the | |
| 155 | + | // function, so the cells name their columns: `..` and an entry are two | |
| 156 | + | // separate constructions that would otherwise have to agree with a heading | |
| 157 | + | // list neither of them can see. | |
| 154 | 158 | if let Some(parent) = tree.parent { | |
| 155 | 159 | rows.push( | |
| 156 | - | Cells::new([Cell::new(".."), Cell::new(String::new())]) | |
| 160 | + | Cells::default() | |
| 161 | + | .at("Name", Cell::new("..")) | |
| 162 | + | .at("Size", Cell::new(String::new())) | |
| 157 | 163 | .activate(Action::get(frame.tree(parent)).navigating()), | |
| 158 | 164 | ); | |
| 159 | 165 | } | |
| @@ -174,31 +180,31 @@ | |||
| 174 | 180 | }; | |
| 175 | 181 | ||
| 176 | 182 | rows.push( | |
| 177 | - | Cells::new([ | |
| 178 | - | Cell::new(name), | |
| 179 | - | Cell::new( | |
| 180 | - | item.size | |
| 181 | - | .as_ref() | |
| 182 | - | .map(crate::routes::git::format_size) | |
| 183 | - | .unwrap_or_default(), | |
| 184 | - | ), | |
| 185 | - | ]) | |
| 186 | - | .activate(Action::get(frame.tree(&path)).navigating()), | |
| 183 | + | Cells::default() | |
| 184 | + | .at("Name", Cell::new(name)) | |
| 185 | + | .at( | |
| 186 | + | "Size", | |
| 187 | + | Cell::new( | |
| 188 | + | item.size | |
| 189 | + | .as_ref() | |
| 190 | + | .map(crate::routes::git::format_size) | |
| 191 | + | .unwrap_or_default(), | |
| 192 | + | ), | |
| 193 | + | ) | |
| 194 | + | .activate(Action::get(frame.tree(&path)).navigating()), | |
| 187 | 195 | ); | |
| 188 | 196 | } | |
| 189 | 197 | ||
| 190 | - | Node::Table { | |
| 191 | - | columns: vec![ | |
| 192 | - | Column::new("Name") | |
| 193 | - | .width(layout::Width::Fill) | |
| 194 | - | .priority(layout::Priority::Essential), | |
| 195 | - | Column::new("Size") | |
| 196 | - | .width(layout::Width::Content) | |
| 197 | - | .priority(layout::Priority::Secondary), | |
| 198 | - | ], | |
| 199 | - | rows, | |
| 200 | - | more: None, | |
| 201 | - | } | |
| 198 | + | Table::new(vec![ | |
| 199 | + | Column::new("Name") | |
| 200 | + | .width(layout::Width::Fill) | |
| 201 | + | .priority(layout::Priority::Essential), | |
| 202 | + | Column::new("Size") | |
| 203 | + | .width(layout::Width::Content) | |
| 204 | + | .priority(layout::Priority::Secondary), | |
| 205 | + | ]) | |
| 206 | + | .rows(rows) | |
| 207 | + | .into() | |
| 202 | 208 | } | |
| 203 | 209 | ||
| 204 | 210 | /// The strip over a file: how big it is, and the other three ways to read it. | |
| @@ -274,40 +280,35 @@ | |||
| 274 | 280 | /// what a reader links to: `#L42` is the address of a line, and a block that | |
| 275 | 281 | /// owned its own lines would have nothing to hang one on. | |
| 276 | 282 | fn source(file: &File<'_>) -> Node { | |
| 277 | - | Node::Table { | |
| 278 | - | columns: vec![ | |
| 279 | - | Column::new("Line") | |
| 280 | - | .width(layout::Width::Content) | |
| 281 | - | .priority(layout::Priority::Secondary), | |
| 282 | - | Column::new("Code") | |
| 283 | - | .width(layout::Width::Fill) | |
| 284 | - | .priority(layout::Priority::Essential), | |
| 285 | - | ], | |
| 286 | - | rows: file | |
| 287 | - | .lines | |
| 288 | - | .iter() | |
| 289 | - | .enumerate() | |
| 290 | - | .map(|(at, runs)| { | |
| 291 | - | let number = at + 1; | |
| 292 | - | Cells::new([ | |
| 293 | - | // The number is a link to itself, which is how a reader | |
| 294 | - | // gets the address of a line into their clipboard. | |
| 295 | - | Cell::default().part(Node::Link { | |
| 296 | - | text: number.to_string(), | |
| 297 | - | action: Action::get(format!("#L{number}")), | |
| 298 | - | }), | |
| 299 | - | Cell::default().part(Node::Code { | |
| 300 | - | runs: runs.clone(), | |
| 301 | - | language: file.language.map(str::to_owned), | |
| 302 | - | inline: true, | |
| 303 | - | }), | |
| 304 | - | ]) | |
| 305 | - | // `#L42`, which is what `page-git-file.js` scrolls to. | |
| 306 | - | .identified(format!("L{number}")) | |
| 307 | - | }) | |
| 308 | - | .collect(), | |
| 309 | - | more: None, | |
| 310 | - | } | |
| 283 | + | // Two columns, two cells, both written here: the row stays positional | |
| 284 | + | // because the headings it answers to are three lines above it. | |
| 285 | + | Table::new(vec![ | |
| 286 | + | Column::new("Line") | |
| 287 | + | .width(layout::Width::Content) | |
| 288 | + | .priority(layout::Priority::Secondary), | |
| 289 | + | Column::new("Code") | |
| 290 | + | .width(layout::Width::Fill) | |
| 291 | + | .priority(layout::Priority::Essential), | |
| 292 | + | ]) | |
| 293 | + | .rows(file.lines.iter().enumerate().map(|(at, runs)| { | |
| 294 | + | let number = at + 1; | |
| 295 | + | Cells::new([ | |
| 296 | + | // The number is a link to itself, which is how a reader | |
| 297 | + | // gets the address of a line into their clipboard. | |
| 298 | + | Cell::default().part(Node::Link { | |
| 299 | + | text: number.to_string(), | |
| 300 | + | action: Action::get(format!("#L{number}")), | |
| 301 | + | }), | |
| 302 | + | Cell::default().part(Node::Code { | |
| 303 | + | runs: runs.clone(), | |
| 304 | + | language: file.language.map(str::to_owned), | |
| 305 | + | inline: true, | |
| 306 | + | }), | |
| 307 | + | ]) | |
| 308 | + | // `#L42`, which is what `page-git-file.js` scrolls to. | |
| 309 | + | .identified(format!("L{number}")) | |
| 310 | + | })) | |
| 311 | + | .into() | |
| 311 | 312 | } | |
| 312 | 313 | ||
| 313 | 314 | /// The document a browse page is drawn in. |
| @@ -34,7 +34,7 @@ | |||
| 34 | 34 | //! field. The hidden inputs existed because these were vanilla `<form>`s. | |
| 35 | 35 | ||
| 36 | 36 | use makeover_layout as layout; | |
| 37 | - | use quasi_router::screen::{Cell, Cells, Column, Field, Lexeme, Row}; | |
| 37 | + | use quasi_router::screen::{Cell, Cells, Column, Field, Lexeme, Row, Table}; | |
| 38 | 38 | use quasi_router::{Act, Action, Document, Node, RegionKind, Run, Screen, Slot}; | |
| 39 | 39 | use quasi_webview::Webview; | |
| 40 | 40 | ||
| @@ -597,15 +597,21 @@ | |||
| 597 | 597 | fn hunks(file: &DiffFile) -> Node { | |
| 598 | 598 | let mut rows = Vec::new(); | |
| 599 | 599 | ||
| 600 | + | // A hunk header and a diff line are two separate constructions, and the | |
| 601 | + | // column list is at the bottom of the function where neither of them can | |
| 602 | + | // see it, so both name their columns rather than counting to them. | |
| 600 | 603 | for hunk in &file.hunks { | |
| 601 | 604 | // The hunk header is not a line of either side, so it carries no | |
| 602 | 605 | // change: `None` is what says "this row is not part of the diff's two | |
| 603 | - | // sides", and the renderer draws it as the caption it is. | |
| 604 | - | rows.push(Cells::new([ | |
| 605 | - | Cell::default(), | |
| 606 | - | Cell::default(), | |
| 607 | - | Cell::new(hunk.header.clone()), | |
| 608 | - | ])); | |
| 606 | + | // sides", and the renderer draws it as the caption it is. The two | |
| 607 | + | // line-number cells are empty because a caption has no line number, | |
| 608 | + | // not because something has to fill the space before the header. | |
| 609 | + | rows.push( | |
| 610 | + | Cells::default() | |
| 611 | + | .at("Old", Cell::default()) | |
| 612 | + | .at("New", Cell::default()) | |
| 613 | + | .at("Line", Cell::new(hunk.header.clone())), | |
| 614 | + | ); | |
| 609 | 615 | ||
| 610 | 616 | for line in &hunk.lines { | |
| 611 | 617 | let change = match line.origin { | |
| @@ -614,35 +620,41 @@ | |||
| 614 | 620 | _ => layout::Change::Context, | |
| 615 | 621 | }; | |
| 616 | 622 | rows.push( | |
| 617 | - | Cells::new([ | |
| 618 | - | Cell::new(line.old_lineno.map(|n| n.to_string()).unwrap_or_default()), | |
| 619 | - | Cell::new(line.new_lineno.map(|n| n.to_string()).unwrap_or_default()), | |
| 620 | - | Cell::default().part(Node::Code { | |
| 621 | - | runs: vec![Lexeme::plain(&line.content)], | |
| 622 | - | language: None, | |
| 623 | - | inline: true, | |
| 624 | - | }), | |
| 625 | - | ]) | |
| 626 | - | .changed(change), | |
| 623 | + | Cells::default() | |
| 624 | + | .at( | |
| 625 | + | "Old", | |
| 626 | + | Cell::new(line.old_lineno.map(|n| n.to_string()).unwrap_or_default()), | |
| 627 | + | ) | |
| 628 | + | .at( | |
| 629 | + | "New", | |
| 630 | + | Cell::new(line.new_lineno.map(|n| n.to_string()).unwrap_or_default()), | |
| 631 | + | ) | |
| 632 | + | .at( | |
| 633 | + | "Line", | |
| 634 | + | Cell::default().part(Node::Code { | |
| 635 | + | runs: vec![Lexeme::plain(&line.content)], | |
| 636 | + | language: None, | |
| 637 | + | inline: true, | |
| 638 | + | }), | |
| 639 | + | ) | |
| 640 | + | .changed(change), | |
| 627 | 641 | ); | |
| 628 | 642 | } | |
| 629 | 643 | } | |
| 630 | 644 | ||
| 631 | - | Node::Table { | |
| 632 | - | columns: vec![ | |
| 633 | - | Column::new("Old") | |
| 634 | - | .width(layout::Width::Content) | |
| 635 | - | .priority(layout::Priority::Optional), | |
| 636 | - | Column::new("New") | |
| 637 | - | .width(layout::Width::Content) | |
| 638 | - | .priority(layout::Priority::Secondary), | |
| 639 | - | Column::new("Line") | |
| 640 | - | .width(layout::Width::Fill) | |
| 641 | - | .priority(layout::Priority::Essential), | |
| 642 | - | ], | |
| 643 | - | rows, | |
| 644 | - | more: None, | |
| 645 | - | } | |
| 645 | + | Table::new(vec![ | |
| 646 | + | Column::new("Old") | |
| 647 | + | .width(layout::Width::Content) | |
| 648 | + | .priority(layout::Priority::Optional), | |
| 649 | + | Column::new("New") | |
| 650 | + | .width(layout::Width::Content) | |
| 651 | + | .priority(layout::Priority::Secondary), | |
| 652 | + | Column::new("Line") | |
| 653 | + | .width(layout::Width::Fill) | |
| 654 | + | .priority(layout::Priority::Essential), | |
| 655 | + | ]) | |
| 656 | + | .rows(rows) | |
| 657 | + | .into() | |
| 646 | 658 | } | |
| 647 | 659 | ||
| 648 | 660 | /// Machine text in a line of reading. |
| @@ -28,7 +28,7 @@ | |||
| 28 | 28 | //! markup and drops the reason leaves the next reader to rediscover it. | |
| 29 | 29 | ||
| 30 | 30 | use makeover_layout as layout; | |
| 31 | - | use quasi_router::screen::{Cell, Cells, Column, Rest}; | |
| 31 | + | use quasi_router::screen::{Cell, Cells, Column, Rest, Table}; | |
| 32 | 32 | use quasi_router::{ | |
| 33 | 33 | Action, Document, Node, RegionKind, Request, Response, RouteError, Screen as Described, Slot, | |
| 34 | 34 | }; | |
| @@ -145,25 +145,27 @@ | |||
| 145 | 145 | ||
| 146 | 146 | /// The repositories, as a table, with whatever pages remain. | |
| 147 | 147 | fn listing(loaded: &Loaded) -> Node { | |
| 148 | - | Node::Table { | |
| 149 | - | columns: vec![ | |
| 150 | - | Column::new("Repository") | |
| 151 | - | .width(layout::Width::Content) | |
| 152 | - | .priority(layout::Priority::Essential), | |
| 153 | - | Column::new("Description").width(layout::Width::Fill), | |
| 154 | - | ], | |
| 155 | - | rows: loaded | |
| 156 | - | .repos | |
| 157 | - | .iter() | |
| 158 | - | .map(|repo| { | |
| 159 | - | Cells::new([ | |
| 160 | - | Cell::new(format!("{}/{}", repo.owner, repo.name)), | |
| 161 | - | Cell::new(repo.description.clone()), | |
| 162 | - | ]) | |
| 163 | - | .activate(Action::get(format!("/git/{}/{}", repo.owner, repo.name)).navigating()) | |
| 164 | - | }) | |
| 165 | - | .collect(), | |
| 166 | - | more: rest(loaded), | |
| 148 | + | // Two columns, two cells, all four written here, so the row stays | |
| 149 | + | // positional: naming would buy nothing a reader cannot already check by | |
| 150 | + | // looking up four lines. | |
| 151 | + | let table = Table::new(vec![ | |
| 152 | + | Column::new("Repository") | |
| 153 | + | .width(layout::Width::Content) | |
| 154 | + | .priority(layout::Priority::Essential), | |
| 155 | + | Column::new("Description").width(layout::Width::Fill), | |
| 156 | + | ]) | |
| 157 | + | .rows(loaded.repos.iter().map(|repo| { | |
| 158 | + | Cells::new([ | |
| 159 | + | Cell::new(format!("{}/{}", repo.owner, repo.name)), | |
| 160 | + | Cell::new(repo.description.clone()), | |
| 161 | + | ]) | |
| 162 | + | .activate(Action::get(format!("/git/{}/{}", repo.owner, repo.name)).navigating()) | |
| 163 | + | })); | |
| 164 | + | ||
| 165 | + | // One page of one is the whole listing, and [`rest`] answers `None` for it. | |
| 166 | + | match rest(loaded) { | |
| 167 | + | Some(rest) => table.more(rest).into(), | |
| 168 | + | None => table.into(), | |
| 167 | 169 | } | |
| 168 | 170 | } | |
| 169 | 171 |
| @@ -169,7 +169,7 @@ | |||
| 169 | 169 | //! [`Action::replacing_enclosing`]: quasi_router::Action::replacing_enclosing | |
| 170 | 170 | ||
| 171 | 171 | use makeover_layout as layout; | |
| 172 | - | use quasi_router::screen::{Act, Cell, Cells, Column, Tag}; | |
| 172 | + | use quasi_router::screen::{Act, Cell, Cells, Column, Table, Tag}; | |
| 173 | 173 | use quasi_router::{Action, Node, RegionKind, Slot}; | |
| 174 | 174 | use quasi_webview::Webview; | |
| 175 | 175 | ||
| @@ -225,28 +225,43 @@ | |||
| 225 | 225 | } | |
| 226 | 226 | ||
| 227 | 227 | /// The versions that exist. | |
| 228 | + | /// | |
| 229 | + | /// The column list lives here and the cells live in [`row`], a function down, | |
| 230 | + | /// so the cells name their columns rather than counting to them: position is | |
| 231 | + | /// only safe while both halves are in front of you at once, and these two are | |
| 232 | + | /// not. The heading strings below are the whole of what [`row`] has to match. | |
| 233 | + | /// | |
| 234 | + | /// No `.more(..)`: every version of an item arrives in one query, so nothing is | |
| 235 | + | /// held back and there is no rest to ask for. | |
| 228 | 236 | fn table(item_id: &str, versions: &[Version]) -> Node { | |
| 229 | - | Node::Table { | |
| 230 | - | columns: vec![ | |
| 231 | - | Column::new("Version").width(layout::Width::Content), | |
| 232 | - | Column::new("Label").width(layout::Width::Fill), | |
| 233 | - | Column::new("File") | |
| 234 | - | .width(layout::Width::Fill) | |
| 235 | - | .priority(layout::Priority::Essential), | |
| 236 | - | Column::new("Size").width(layout::Width::Content), | |
| 237 | - | Column::new("Downloads").width(layout::Width::Content), | |
| 238 | - | Column::new("").width(layout::Width::Content), | |
| 239 | - | ], | |
| 240 | - | rows: versions | |
| 241 | - | .iter() | |
| 242 | - | .map(|version| row(item_id, version)) | |
| 243 | - | .collect(), | |
| 244 | - | // Every version of an item, in one query. Nothing is held back, so | |
| 245 | - | // there is no rest to ask for. | |
| 246 | - | more: None, | |
| 247 | - | } | |
| 237 | + | Table::new([ | |
| 238 | + | Column::new(COL_VERSION).width(layout::Width::Content), | |
| 239 | + | Column::new(COL_LABEL).width(layout::Width::Fill), | |
| 240 | + | Column::new(COL_FILE) | |
| 241 | + | .width(layout::Width::Fill) | |
| 242 | + | .priority(layout::Priority::Essential), | |
| 243 | + | Column::new(COL_SIZE).width(layout::Width::Content), | |
| 244 | + | Column::new(COL_DOWNLOADS).width(layout::Width::Content), | |
| 245 | + | Column::new(COL_ACTS).width(layout::Width::Content), | |
| 246 | + | ]) | |
| 247 | + | .rows(versions.iter().map(|version| row(item_id, version))) | |
| 248 | + | .into() | |
| 248 | 249 | } | |
| 249 | 250 | ||
| 251 | + | /// The headings, written once. | |
| 252 | + | /// | |
| 253 | + | /// A cell that names a column nothing has is dropped silently, so the two | |
| 254 | + | /// halves share the string rather than each spelling it. [`COL_ACTS`] is the | |
| 255 | + | /// unlabelled column the Download and Delete controls sit in: a heading over a | |
| 256 | + | /// pair of buttons says nothing a reader needs, and the empty name is still the | |
| 257 | + | /// name the cell has to match. | |
| 258 | + | const COL_VERSION: &str = "Version"; | |
| 259 | + | const COL_LABEL: &str = "Label"; | |
| 260 | + | const COL_FILE: &str = "File"; | |
| 261 | + | const COL_SIZE: &str = "Size"; | |
| 262 | + | const COL_DOWNLOADS: &str = "Downloads"; | |
| 263 | + | const COL_ACTS: &str = ""; | |
| 264 | + | ||
| 250 | 265 | /// One version. | |
| 251 | 266 | fn row(item_id: &str, version: &Version) -> Cells { | |
| 252 | 267 | let mut number = Tag::badge(format!("v{}", version.number)); | |
| @@ -277,14 +292,16 @@ | |||
| 277 | 292 | } | |
| 278 | 293 | acts.push(super::version_delete_act::act(item_id, &version.id)); | |
| 279 | 294 | ||
| 280 | - | Cells::new([ | |
| 281 | - | Cell::new(String::new()).token(number), | |
| 282 | - | Cell::new(version.label.clone().unwrap_or_default()), | |
| 283 | - | Cell::new(file), | |
| 284 | - | Cell::new(version.size.clone()), | |
| 285 | - | Cell::new(version.downloads.to_string()), | |
| 286 | - | Cell::acts(acts), | |
| 287 | - | ]) | |
| 295 | + | Cells::default() | |
| 296 | + | .at(COL_VERSION, Cell::new(String::new()).token(number)) | |
| 297 | + | .at( | |
| 298 | + | COL_LABEL, | |
| 299 | + | Cell::new(version.label.clone().unwrap_or_default()), | |
| 300 | + | ) | |
| 301 | + | .at(COL_FILE, Cell::new(file)) | |
| 302 | + | .at(COL_SIZE, Cell::new(version.size.clone())) | |
| 303 | + | .at(COL_DOWNLOADS, Cell::new(version.downloads.to_string())) | |
| 304 | + | .at(COL_ACTS, Cell::acts(acts)) | |
| 288 | 305 | } | |
| 289 | 306 | ||
| 290 | 307 | #[cfg(test)] |
| @@ -36,7 +36,7 @@ | |||
| 36 | 36 | //! [`super::project_members`] left `/api/projects/{id}/members` alone. | |
| 37 | 37 | ||
| 38 | 38 | use makeover_layout as layout; | |
| 39 | - | use quasi_router::screen::{Act, Cell, Cells, Column, Tag}; | |
| 39 | + | use quasi_router::screen::{Act, Cell, Cells, Column, Table, Tag}; | |
| 40 | 40 | use quasi_router::{Method, Node, RegionKind, Request, Response, RouteError, Slot}; | |
| 41 | 41 | use quasi_webview::Webview; | |
| 42 | 42 | ||
| @@ -93,22 +93,38 @@ | |||
| 93 | 93 | } | |
| 94 | 94 | ||
| 95 | 95 | /// The transaction history. | |
| 96 | + | /// | |
| 97 | + | /// The columns are declared here and the cells are built in [`row`], so the | |
| 98 | + | /// cells name their columns rather than counting to them. Position would ask a | |
| 99 | + | /// reader of either function to hold the other one in their head, and the | |
| 100 | + | /// refund column at the end is the one that would move if this list ever grew a | |
| 101 | + | /// heading in the middle. | |
| 96 | 102 | fn table(item: ItemId, sales: &[SaleRow]) -> Node { | |
| 97 | - | Node::Table { | |
| 98 | - | columns: vec![ | |
| 99 | - | Column::new("Date") | |
| 100 | - | .width(layout::Width::Content) | |
| 101 | - | .priority(layout::Priority::Essential), | |
| 102 | - | Column::new("Buyer").width(layout::Width::Fill), | |
| 103 | - | Column::new("Amount").width(layout::Width::Content), | |
| 104 | - | Column::new("Status").width(layout::Width::Content), | |
| 105 | - | Column::new("").width(layout::Width::Content), | |
| 106 | - | ], | |
| 107 | - | rows: sales.iter().map(|sale| row(item, sale)).collect(), | |
| 108 | - | more: None, | |
| 109 | - | } | |
| 103 | + | Table::new([ | |
| 104 | + | Column::new(COL_DATE) | |
| 105 | + | .width(layout::Width::Content) | |
| 106 | + | .priority(layout::Priority::Essential), | |
| 107 | + | Column::new(COL_BUYER).width(layout::Width::Fill), | |
| 108 | + | Column::new(COL_AMOUNT).width(layout::Width::Content), | |
| 109 | + | Column::new(COL_STATUS).width(layout::Width::Content), | |
| 110 | + | Column::new(COL_ACTS).width(layout::Width::Content), | |
| 111 | + | ]) | |
| 112 | + | .rows(sales.iter().map(|sale| row(item, sale))) | |
| 113 | + | .into() | |
| 110 | 114 | } | |
| 111 | 115 | ||
| 116 | + | /// The headings, written once so the two halves cannot drift apart. | |
| 117 | + | /// | |
| 118 | + | /// A name no column has is dropped without a word, which is what makes a shared | |
| 119 | + | /// constant worth more here than the literal. [`COL_ACTS`] is deliberately | |
| 120 | + | /// empty: the Refund control needs no heading over it, and the empty string is | |
| 121 | + | /// still the name its cell has to match. | |
| 122 | + | const COL_DATE: &str = "Date"; | |
| 123 | + | const COL_BUYER: &str = "Buyer"; | |
| 124 | + | const COL_AMOUNT: &str = "Amount"; | |
| 125 | + | const COL_STATUS: &str = "Status"; | |
| 126 | + | const COL_ACTS: &str = ""; | |
| 127 | + | ||
| 112 | 128 | /// One sale. | |
| 113 | 129 | fn row(item: ItemId, sale: &SaleRow) -> Cells { | |
| 114 | 130 | let mut status = Tag::badge(sale.status.clone()); | |
| @@ -130,13 +146,12 @@ | |||
| 130 | 146 | ); | |
| 131 | 147 | } | |
| 132 | 148 | ||
| 133 | - | Cells::new([ | |
| 134 | - | Cell::new(sale.date.clone()), | |
| 135 | - | Cell::new(sale.buyer.clone()), | |
| 136 | - | Cell::new(sale.amount_display.clone()), | |
| 137 | - | Cell::new(String::new()).token(status), | |
| 138 | - | action, | |
| 139 | - | ]) | |
| 149 | + | Cells::default() | |
| 150 | + | .at(COL_DATE, Cell::new(sale.date.clone())) | |
| 151 | + | .at(COL_BUYER, Cell::new(sale.buyer.clone())) | |
| 152 | + | .at(COL_AMOUNT, Cell::new(sale.amount_display.clone())) | |
| 153 | + | .at(COL_STATUS, Cell::new(String::new()).token(status)) | |
| 154 | + | .at(COL_ACTS, action) | |
| 140 | 155 | } | |
| 141 | 156 | ||
| 142 | 157 | /// The badge tone the template set with `data-tone`. |
| @@ -28,7 +28,7 @@ | |||
| 28 | 28 | //! its own data is the answer for this batch. | |
| 29 | 29 | ||
| 30 | 30 | use makeover_layout as layout; | |
| 31 | - | use quasi_router::screen::{Act, Cell, Cells, Column}; | |
| 31 | + | use quasi_router::screen::{Act, Cell, Cells, Column, Table}; | |
| 32 | 32 | use quasi_router::{Action, Method, Node, RegionKind, Request, Response, RouteError, Slot}; | |
| 33 | 33 | use quasi_webview::Webview; | |
| 34 | 34 | ||
| @@ -186,87 +186,82 @@ | |||
| 186 | 186 | ||
| 187 | 187 | /// The buyers who shared an email. | |
| 188 | 188 | fn buyers_table(buyers: &[BuyerView]) -> Node { | |
| 189 | - | Node::Table { | |
| 190 | - | columns: vec![ | |
| 191 | - | Column::new("Username") | |
| 192 | - | .width(layout::Width::Content) | |
| 193 | - | .priority(layout::Priority::Essential), | |
| 194 | - | Column::new("Email") | |
| 195 | - | .width(layout::Width::Fill) | |
| 196 | - | .priority(layout::Priority::Essential), | |
| 197 | - | Column::new("Purchases").width(layout::Width::Content), | |
| 198 | - | Column::new("Total Spent").width(layout::Width::Content), | |
| 199 | - | Column::new("Last Purchase") | |
| 200 | - | .width(layout::Width::Content) | |
| 201 | - | .priority(layout::Priority::Optional), | |
| 202 | - | ], | |
| 203 | - | rows: buyers | |
| 204 | - | .iter() | |
| 205 | - | .map(|buyer| { | |
| 206 | - | Cells::new([ | |
| 207 | - | // The two flavours of a linked value in one row. A profile | |
| 208 | - | // is a route this server answers, so it stays inside the | |
| 209 | - | // app; an address is not, so it leaves. | |
| 210 | - | // | |
| 211 | - | // Navigating (`00ee7af5`): a profile is a whole document, | |
| 212 | - | // not a region, so the anchor is the whole of it and the | |
| 213 | - | // swap is dropped. Without it htmx would morph a | |
| 214 | - | // `<!doctype html>` page over the cell it was clicked in. | |
| 215 | - | Cell::new(buyer.username.clone()) | |
| 216 | - | .activate(Action::get(format!("/u/{}", buyer.username)).navigating()), | |
| 217 | - | Cell::new(buyer.email.clone()) | |
| 218 | - | .activate(Action::external(format!("mailto:{}", buyer.email))), | |
| 219 | - | Cell::new(buyer.purchases.clone()), | |
| 220 | - | Cell::new(buyer.spent.clone()), | |
| 221 | - | Cell::new(buyer.last_purchase.clone()), | |
| 222 | - | ]) | |
| 223 | - | }) | |
| 224 | - | .collect(), | |
| 225 | - | // No paging described here: every one of these tables is a | |
| 226 | - | // whole set the handler already counted. | |
| 227 | - | more: None, | |
| 228 | - | } | |
| 189 | + | // Positional cells, deliberately: the headings and the row are the one | |
| 190 | + | // expression below and every buyer contributes the same five cells, so | |
| 191 | + | // naming each column would buy nothing a reader cannot already see. Nothing | |
| 192 | + | // is paged, so no `more`: this is a whole set the handler already counted. | |
| 193 | + | Table::new(vec![ | |
| 194 | + | Column::new("Username") | |
| 195 | + | .width(layout::Width::Content) | |
| 196 | + | .priority(layout::Priority::Essential), | |
| 197 | + | Column::new("Email") | |
| 198 | + | .width(layout::Width::Fill) | |
| 199 | + | .priority(layout::Priority::Essential), | |
| 200 | + | Column::new("Purchases").width(layout::Width::Content), | |
| 201 | + | Column::new("Total Spent").width(layout::Width::Content), | |
| 202 | + | Column::new("Last Purchase") | |
| 203 | + | .width(layout::Width::Content) | |
| 204 | + | .priority(layout::Priority::Optional), | |
| 205 | + | ]) | |
| 206 | + | .rows(buyers.iter().map(|buyer| { | |
| 207 | + | Cells::new([ | |
| 208 | + | // The two flavours of a linked value in one row. A profile | |
| 209 | + | // is a route this server answers, so it stays inside the | |
| 210 | + | // app; an address is not, so it leaves. | |
| 211 | + | // | |
| 212 | + | // Navigating (`00ee7af5`): a profile is a whole document, | |
| 213 | + | // not a region, so the anchor is the whole of it and the | |
| 214 | + | // swap is dropped. Without it htmx would morph a | |
| 215 | + | // `<!doctype html>` page over the cell it was clicked in. | |
| 216 | + | Cell::new(buyer.username.clone()) | |
| 217 | + | .activate(Action::get(format!("/u/{}", buyer.username)).navigating()), | |
| 218 | + | Cell::new(buyer.email.clone()) | |
| 219 | + | .activate(Action::external(format!("mailto:{}", buyer.email))), | |
| 220 | + | Cell::new(buyer.purchases.clone()), | |
| 221 | + | Cell::new(buyer.spent.clone()), | |
| 222 | + | Cell::new(buyer.last_purchase.clone()), | |
| 223 | + | ]) | |
| 224 | + | })) | |
| 225 | + | .into() | |
| 229 | 226 | } | |
| 230 | 227 | ||
| 231 | 228 | /// The creators this reader has shared an email with. | |
| 232 | 229 | fn shared_table(shared: &[SharedView]) -> Node { | |
| 233 | - | Node::Table { | |
| 234 | - | columns: vec![ | |
| 235 | - | Column::new("Creator") | |
| 236 | - | .width(layout::Width::Fill) | |
| 237 | - | .priority(layout::Priority::Essential), | |
| 238 | - | Column::new("") | |
| 239 | - | .width(layout::Width::Content) | |
| 240 | - | .priority(layout::Priority::Essential), | |
| 241 | - | ], | |
| 242 | - | rows: shared | |
| 243 | - | .iter() | |
| 244 | - | .map(|creator| { | |
| 245 | - | Cells::new([ | |
| 246 | - | // The display name is what the row reads as and the username | |
| 247 | - | // is where it goes, which is the pairing the template made | |
| 248 | - | // with a nested `{% if let %}` inside the anchor. | |
| 249 | - | Cell::new(creator.name.clone()) | |
| 250 | - | .activate(Action::get(format!("/u/{}", creator.username)).navigating()), | |
| 251 | - | Cell::acts([Act::new( | |
| 252 | - | "Revoke", | |
| 253 | - | // This screen's own route, under its own nest. The API's | |
| 254 | - | // answers 204, which htmx never swaps, so the row stayed | |
| 255 | - | // after a successful revoke. See `revoke`. | |
| 256 | - | Action::delete(format!("{PATH}/revoke/{}", creator.seller_id)).awaiting(), | |
| 257 | - | ) | |
| 258 | - | // The template asked with hx-confirm. Said here, a | |
| 259 | - | // terminal host asks in its own way and no host can | |
| 260 | - | // forget to ask. | |
| 261 | - | .confirm(format!("Revoke contact sharing with {}?", creator.username)) | |
| 262 | - | .tone(layout::Tone::Danger)]), | |
| 263 | - | ]) | |
| 264 | - | }) | |
| 265 | - | .collect(), | |
| 266 | - | // No paging described here: every one of these tables is a | |
| 267 | - | // whole set the handler already counted. | |
| 268 | - | more: None, | |
| 269 | - | } | |
| 230 | + | // Positional, and here it is the only sensible reading: the act column has | |
| 231 | + | // no heading to name, so a named cell would have to spell the empty string | |
| 232 | + | // and the pairing would be less obvious than the order already makes it. | |
| 233 | + | // Both cells are drawn for every creator. Nothing is paged, so no `more`: | |
| 234 | + | // this is a whole set the handler already counted. | |
| 235 | + | Table::new(vec![ | |
| 236 | + | Column::new("Creator") | |
| 237 | + | .width(layout::Width::Fill) | |
| 238 | + | .priority(layout::Priority::Essential), | |
| 239 | + | Column::new("") | |
| 240 | + | .width(layout::Width::Content) | |
| 241 | + | .priority(layout::Priority::Essential), | |
| 242 | + | ]) | |
| 243 | + | .rows(shared.iter().map(|creator| { | |
| 244 | + | Cells::new([ | |
| 245 | + | // The display name is what the row reads as and the username | |
| 246 | + | // is where it goes, which is the pairing the template made | |
| 247 | + | // with a nested `{% if let %}` inside the anchor. | |
| 248 | + | Cell::new(creator.name.clone()) | |
| 249 | + | .activate(Action::get(format!("/u/{}", creator.username)).navigating()), | |
| 250 | + | Cell::acts([Act::new( | |
| 251 | + | "Revoke", | |
| 252 | + | // This screen's own route, under its own nest. The API's | |
| 253 | + | // answers 204, which htmx never swaps, so the row stayed | |
| 254 | + | // after a successful revoke. See `revoke`. | |
| 255 | + | Action::delete(format!("{PATH}/revoke/{}", creator.seller_id)).awaiting(), | |
| 256 | + | ) | |
| 257 | + | // The template asked with hx-confirm. Said here, a | |
| 258 | + | // terminal host asks in its own way and no host can | |
| 259 | + | // forget to ask. | |
| 260 | + | .confirm(format!("Revoke contact sharing with {}?", creator.username)) | |
| 261 | + | .tone(layout::Tone::Danger)]), | |
| 262 | + | ]) | |
| 263 | + | })) | |
| 264 | + | .into() | |
| 270 | 265 | } | |
| 271 | 266 | ||
| 272 | 267 | /// The renderer this screen is drawn with. |
| @@ -70,7 +70,7 @@ | |||
| 70 | 70 | use makeover_layout as layout; | |
| 71 | 71 | use quasi_router::{ | |
| 72 | 72 | Action, Cell, Cells, Choice, Column, Consult, Document, Field, Meter, Node, RegionKind, | |
| 73 | - | Request, Response, RouteError, Row, Screen, Slot, | |
| 73 | + | Request, Response, RouteError, Row, Screen, Slot, Table, | |
| 74 | 74 | }; | |
| 75 | 75 | use quasi_webview::Webview; | |
| 76 | 76 | ||
| @@ -534,8 +534,12 @@ | |||
| 534 | 534 | fn results(outcome: &Outcome) -> Slot { | |
| 535 | 535 | let mut slot = Slot::new(RESULTS, RegionKind::Pane) | |
| 536 | 536 | .with(Node::banner(tone(outcome.verdict), &outcome.headline)) | |
| 537 | - | .with(Node::Table { | |
| 538 | - | columns: vec![ | |
| 537 | + | // Three columns and three fixed rows, written in one expression, so | |
| 538 | + | // the cells stay positional: you can read a row against its heading | |
| 539 | + | // without leaving the call, and every row carries the same three cells | |
| 540 | + | // whatever the arithmetic said. Naming them would buy nothing here. | |
| 541 | + | .with(Node::from( | |
| 542 | + | Table::new([ | |
| 539 | 543 | Column::new("Monthly") | |
| 540 | 544 | .width(layout::Width::Fill) | |
| 541 | 545 | .priority(layout::Priority::Essential), | |
| @@ -545,8 +549,8 @@ | |||
| 545 | 549 | Column::new("The other platform") | |
| 546 | 550 | .width(layout::Width::Content) | |
| 547 | 551 | .priority(layout::Priority::Essential), | |
| 548 | - | ], | |
| 549 | - | rows: vec![ | |
| 552 | + | ]) | |
| 553 | + | .rows([ | |
| 550 | 554 | Cells::new([ | |
| 551 | 555 | Cell::new("You sell"), | |
| 552 | 556 | Cell::new(outcome.gross.clone()), | |
| @@ -562,9 +566,8 @@ | |||
| 562 | 566 | Cell::new(outcome.mnw_rate.clone()), | |
| 563 | 567 | Cell::new(outcome.other_rate.clone()), | |
| 564 | 568 | ]), | |
| 565 | - | ], | |
| 566 | - | more: None, | |
| 567 | - | }); | |
| 569 | + | ]), | |
| 570 | + | )); | |
| 568 | 571 | ||
| 569 | 572 | // Where the reader's volume sits against the crossover, which is the whole | |
| 570 | 573 | // of what the hand-drawn two-segment bar said. Absent when there is no |
| @@ -42,7 +42,7 @@ | |||
| 42 | 42 | //! for no reason a reader could otherwise guess. | |
| 43 | 43 | ||
| 44 | 44 | use makeover_layout as layout; | |
| 45 | - | use quasi_router::screen::{Cell, Cells, Column, Figure, Tag}; | |
| 45 | + | use quasi_router::screen::{Cell, Cells, Column, Figure, Table, Tag}; | |
| 46 | 46 | use quasi_router::{Action, Node, RegionKind, Slot}; | |
| 47 | 47 | use quasi_webview::Webview; | |
| 48 | 48 | ||
| @@ -196,25 +196,25 @@ | |||
| 196 | 196 | /// A table rather than the template's `<ul>` of two `<span>`s. The two columns | |
| 197 | 197 | /// were already a table pretending not to be, and a described one gets its | |
| 198 | 198 | /// header row back. | |
| 199 | + | /// | |
| 200 | + | /// The cells stay positional rather than naming their columns. Naming exists to | |
| 201 | + | /// stop a cell list drifting from a column list it cannot see, and here the two | |
| 202 | + | /// lists are the same expression: every row is the same two cells, nothing is | |
| 203 | + | /// conditional, and a reader checking the order reads eight lines to do it. | |
| 199 | 204 | fn top_items(items: &[ContentItem]) -> Node { | |
| 200 | - | Node::Table { | |
| 201 | - | columns: vec![ | |
| 202 | - | Column::new("Item") | |
| 203 | - | .width(layout::Width::Fill) | |
| 204 | - | .priority(layout::Priority::Essential), | |
| 205 | - | Column::new("Revenue").width(layout::Width::Content), | |
| 206 | - | ], | |
| 207 | - | rows: items | |
| 208 | - | .iter() | |
| 209 | - | .map(|item| { | |
| 210 | - | Cells::new([ | |
| 211 | - | Cell::new(item.title.clone()), | |
| 212 | - | Cell::new(item.revenue.clone()), | |
| 213 | - | ]) | |
| 214 | - | }) | |
| 215 | - | .collect(), | |
| 216 | - | more: None, | |
| 217 | - | } | |
| 205 | + | Table::new([ | |
| 206 | + | Column::new("Item") | |
| 207 | + | .width(layout::Width::Fill) | |
| 208 | + | .priority(layout::Priority::Essential), | |
| 209 | + | Column::new("Revenue").width(layout::Width::Content), | |
| 210 | + | ]) | |
| 211 | + | .rows(items.iter().map(|item| { | |
| 212 | + | Cells::new([ | |
| 213 | + | Cell::new(item.title.clone()), | |
| 214 | + | Cell::new(item.revenue.clone()), | |
| 215 | + | ]) | |
| 216 | + | })) | |
| 217 | + | .into() | |
| 218 | 218 | } | |
| 219 | 219 | ||
| 220 | 220 | #[cfg(test)] |
| @@ -77,7 +77,7 @@ | |||
| 77 | 77 | //! it survives a swap, which the class did not. | |
| 78 | 78 | ||
| 79 | 79 | use makeover_layout as layout; | |
| 80 | - | use quasi_router::screen::{Act, Cell, Cells, Choice, Column, Consult, Field, Tag}; | |
| 80 | + | use quasi_router::screen::{Act, Cell, Cells, Choice, Column, Consult, Field, Table, Tag}; | |
| 81 | 81 | use quasi_router::{Action, Node, RegionKind, Slot}; | |
| 82 | 82 | use quasi_webview::Webview; | |
| 83 | 83 | ||
| @@ -750,13 +750,9 @@ | |||
| 750 | 750 | } | |
| 751 | 751 | } | |
| 752 | 752 | ||
| 753 | - | Node::Table { | |
| 754 | - | columns: columns(slug, view), | |
| 755 | - | rows, | |
| 756 | - | // No paging: the handler reads the project's whole catalogue and the | |
| 757 | - | // table shows what the filters left. | |
| 758 | - | more: None, | |
| 759 | - | } | |
| 753 | + | // No `more`: the handler reads the project's whole catalogue and the table | |
| 754 | + | // shows what the filters left. | |
| 755 | + | Table::new(columns(slug, view)).rows(rows).into() | |
| 760 | 756 | } | |
| 761 | 757 | ||
| 762 | 758 | /// One item, and everything that can be done to it. | |
| @@ -800,17 +796,22 @@ | |||
| 800 | 796 | format!("{} ({})", item.item_type, item.children.len()) | |
| 801 | 797 | }; | |
| 802 | 798 | ||
| 803 | - | Cells::new([ | |
| 804 | - | position, | |
| 805 | - | title, | |
| 806 | - | Cell::new(kind), | |
| 807 | - | Cell::new(item.price.clone()), | |
| 808 | - | Cell::new(item.sales.to_string()), | |
| 809 | - | Cell::new(item.revenue.clone()), | |
| 810 | - | Cell::tag(Tag::badge(item.status.clone()).tone(tone(item.status_tone))), | |
| 811 | - | Cell::acts(acts_for(slug, view, item)), | |
| 812 | - | ]) | |
| 813 | - | .ticking(item.id.clone(), view.ticked) | |
| 799 | + | // The cells name their columns. `columns` is two functions away and decides | |
| 800 | + | // both the order and how many there are, so anything counted to a position | |
| 801 | + | // here would be counted against a list this function cannot see. | |
| 802 | + | Cells::default() | |
| 803 | + | .at("#", position) | |
| 804 | + | .at("Item", title) | |
| 805 | + | .at("Type", Cell::new(kind)) | |
| 806 | + | .at("Price", Cell::new(item.price.clone())) | |
| 807 | + | .at("Sales", Cell::new(item.sales.to_string())) | |
| 808 | + | .at("Revenue", Cell::new(item.revenue.clone())) | |
| 809 | + | .at( | |
| 810 | + | "Status", | |
| 811 | + | Cell::tag(Tag::badge(item.status.clone()).tone(tone(item.status_tone))), | |
| 812 | + | ) | |
| 813 | + | .at("Actions", Cell::acts(acts_for(slug, view, item))) | |
| 814 | + | .ticking(item.id.clone(), view.ticked) | |
| 814 | 815 | } | |
| 815 | 816 | ||
| 816 | 817 | /// A bundle's child, which is a row of the same table and not a table of its own. | |
| @@ -820,21 +821,32 @@ | |||
| 820 | 821 | /// it by the arrow the title carries. It is tickable for the reason the shipped | |
| 821 | 822 | /// checkbox is: a child can be published or repriced with the rest. | |
| 822 | 823 | fn child_row(view: &View, child: &ContentItem) -> Cells { | |
| 823 | - | Cells::new([ | |
| 824 | - | Cell::new(""), | |
| 825 | - | Cell::new(format!("\u{21b3} {}", child.title)) | |
| 826 | - | .activate(Action::external(format!("/dashboard/item/{}", child.id))), | |
| 827 | - | Cell::new(child.item_type.clone()), | |
| 828 | - | Cell::new(child.price.clone()), | |
| 829 | - | Cell::new(child.sales.to_string()), | |
| 830 | - | Cell::new(child.revenue.clone()), | |
| 831 | - | Cell::tag(Tag::badge(child.status.clone()).tone(tone(child.status_tone))), | |
| 832 | - | Cell::acts([Act::new( | |
| 833 | - | "Edit", | |
| 834 | - | Action::external(format!("/dashboard/item/{}", child.id)), | |
| 835 | - | )]), | |
| 836 | - | ]) | |
| 837 | - | .ticking(child.id.clone(), view.ticked) | |
| 824 | + | // Named for the reason [`item_row`]'s are, and with one more: a child row | |
| 825 | + | // has nothing to put in the position column, and an empty cell that has to | |
| 826 | + | // be counted past is exactly what naming removes. `#` is simply not | |
| 827 | + | // mentioned here. | |
| 828 | + | Cells::default() | |
| 829 | + | .at( | |
| 830 | + | "Item", | |
| 831 | + | Cell::new(format!("\u{21b3} {}", child.title)) | |
| 832 | + | .activate(Action::external(format!("/dashboard/item/{}", child.id))), | |
| 833 | + | ) | |
| 834 | + | .at("Type", Cell::new(child.item_type.clone())) | |
| 835 | + | .at("Price", Cell::new(child.price.clone())) | |
| 836 | + | .at("Sales", Cell::new(child.sales.to_string())) | |
| 837 | + | .at("Revenue", Cell::new(child.revenue.clone())) | |
| 838 | + | .at( | |
| 839 | + | "Status", | |
| 840 | + | Cell::tag(Tag::badge(child.status.clone()).tone(tone(child.status_tone))), | |
| 841 | + | ) | |
| 842 | + | .at( | |
| 843 | + | "Actions", | |
| 844 | + | Cell::acts([Act::new( | |
| 845 | + | "Edit", | |
| 846 | + | Action::external(format!("/dashboard/item/{}", child.id)), | |
| 847 | + | )]), | |
| 848 | + | ) | |
| 849 | + | .ticking(child.id.clone(), view.ticked) | |
| 838 | 850 | } | |
| 839 | 851 | ||
| 840 | 852 | /// The row's own controls, which are not the selection's. | |
| @@ -873,80 +885,75 @@ | |||
| 873 | 885 | ||
| 874 | 886 | /// What is recoverable, and for how long. | |
| 875 | 887 | fn deleted_table(slug: &str, view: &View, deleted: &[DeletedItemRow]) -> Node { | |
| 876 | - | Node::Table { | |
| 877 | - | columns: vec![ | |
| 878 | - | Column::new("Title") | |
| 879 | - | .width(layout::Width::Fill) | |
| 880 | - | .priority(layout::Priority::Essential), | |
| 881 | - | Column::new("Deleted").width(layout::Width::Content), | |
| 882 | - | Column::new("") | |
| 883 | - | .width(layout::Width::Content) | |
| 884 | - | .priority(layout::Priority::Essential), | |
| 885 | - | ], | |
| 886 | - | rows: deleted | |
| 887 | - | .iter() | |
| 888 | - | .map(|item| { | |
| 889 | - | Cells::new([ | |
| 890 | - | Cell::new(item.title.clone()), | |
| 891 | - | Cell::new(item.deleted_at.clone()), | |
| 892 | - | Cell::acts([Act::new( | |
| 893 | - | "Restore", | |
| 894 | - | view.write(slug, &format!("restore/{}", item.id)), | |
| 895 | - | )]), | |
| 896 | - | ]) | |
| 897 | - | }) | |
| 898 | - | .collect(), | |
| 899 | - | more: None, | |
| 900 | - | } | |
| 888 | + | // Positional, unlike the items table above: the three columns and the three | |
| 889 | + | // cells are one expression, and every row says all three. Naming buys | |
| 890 | + | // nothing a reader cannot already see by looking up ten lines. | |
| 891 | + | Table::new([ | |
| 892 | + | Column::new("Title") | |
| 893 | + | .width(layout::Width::Fill) | |
| 894 | + | .priority(layout::Priority::Essential), | |
| 895 | + | Column::new("Deleted").width(layout::Width::Content), | |
| 896 | + | Column::new("") | |
| 897 | + | .width(layout::Width::Content) | |
| 898 | + | .priority(layout::Priority::Essential), | |
| 899 | + | ]) | |
| 900 | + | .rows(deleted.iter().map(|item| { | |
| 901 | + | Cells::new([ | |
| 902 | + | Cell::new(item.title.clone()), | |
| 903 | + | Cell::new(item.deleted_at.clone()), | |
| 904 | + | Cell::acts([Act::new( | |
| 905 | + | "Restore", | |
| 906 | + | view.write(slug, &format!("restore/{}", item.id)), | |
| 907 | + | )]), | |
| 908 | + | ]) | |
| 909 | + | })) | |
| 910 | + | .into() | |
| 901 | 911 | } | |
| 902 | 912 | ||
| 903 | 913 | /// The project's blog posts, which share this tab and not much else. | |
| 904 | 914 | fn posts_table(slug: &str, view: &View, posts: &[BlogPostDashboardRow]) -> Node { | |
| 905 | - | Node::Table { | |
| 906 | - | columns: vec![ | |
| 907 | - | Column::new("Title") | |
| 908 | - | .width(layout::Width::Fill) | |
| 909 | - | .priority(layout::Priority::Essential), | |
| 910 | - | Column::new("Status").width(layout::Width::Content), | |
| 911 | - | Column::new("Published") | |
| 912 | - | .width(layout::Width::Content) | |
| 913 | - | .priority(layout::Priority::Optional), | |
| 914 | - | Column::new("Actions") | |
| 915 | - | .width(layout::Width::Content) | |
| 916 | - | .priority(layout::Priority::Essential), | |
| 917 | - | ], | |
| 918 | - | rows: posts | |
| 919 | - | .iter() | |
| 920 | - | .map(|post| { | |
| 921 | - | Cells::new([ | |
| 922 | - | Cell::new(post.title.clone()) | |
| 923 | - | .activate(Action::external(format!("/p/{slug}/blog/{}", post.slug))), | |
| 924 | - | Cell::tag(Tag::badge(post.status.clone()).tone(tone(post.status_tone))), | |
| 925 | - | Cell::new(post.published_at.clone()), | |
| 926 | - | Cell::acts([ | |
| 927 | - | Act::new( | |
| 928 | - | "View", | |
| 929 | - | Action::external(format!("/p/{slug}/blog/{}", post.slug)), | |
| 930 | - | ), | |
| 931 | - | Act::new( | |
| 932 | - | "Edit", | |
| 933 | - | Action::external(format!( | |
| 934 | - | "/dashboard/project/{slug}/blog/new?post={}", | |
| 935 | - | post.id | |
| 936 | - | )), | |
| 937 | - | ), | |
| 938 | - | Act::new( | |
| 939 | - | "Delete", | |
| 940 | - | view.write(slug, &format!("blog/{}/delete", post.id)), | |
| 941 | - | ) | |
| 942 | - | .tone(layout::Tone::Danger) | |
| 943 | - | .confirm("Delete this blog post?"), | |
| 944 | - | ]), | |
| 945 | - | ]) | |
| 946 | - | }) | |
| 947 | - | .collect(), | |
| 948 | - | more: None, | |
| 949 | - | } | |
| 915 | + | // Positional for [`deleted_table`]'s reason: four columns and four cells in | |
| 916 | + | // one expression, and no row that says fewer. | |
| 917 | + | Table::new([ | |
| 918 | + | Column::new("Title") | |
| 919 | + | .width(layout::Width::Fill) | |
| 920 | + | .priority(layout::Priority::Essential), | |
| 921 | + | Column::new("Status").width(layout::Width::Content), | |
| 922 | + | Column::new("Published") | |
| 923 | + | .width(layout::Width::Content) | |
| 924 | + | .priority(layout::Priority::Optional), | |
| 925 | + | Column::new("Actions") | |
| 926 | + | .width(layout::Width::Content) | |
| 927 | + | .priority(layout::Priority::Essential), | |
| 928 | + | ]) | |
| 929 | + | .rows(posts.iter().map(|post| { | |
| 930 | + | Cells::new([ | |
| 931 | + | Cell::new(post.title.clone()) | |
| 932 | + | .activate(Action::external(format!("/p/{slug}/blog/{}", post.slug))), | |
| 933 | + | Cell::tag(Tag::badge(post.status.clone()).tone(tone(post.status_tone))), | |
| 934 | + | Cell::new(post.published_at.clone()), | |
| 935 | + | Cell::acts([ | |
| 936 | + | Act::new( | |
| 937 | + | "View", | |
| 938 | + | Action::external(format!("/p/{slug}/blog/{}", post.slug)), | |
| 939 | + | ), | |
| 940 | + | Act::new( | |
| 941 | + | "Edit", | |
| 942 | + | Action::external(format!( | |
| 943 | + | "/dashboard/project/{slug}/blog/new?post={}", | |
| 944 | + | post.id | |
| 945 | + | )), | |
| 946 | + | ), | |
| 947 | + | Act::new( | |
| 948 | + | "Delete", | |
| 949 | + | view.write(slug, &format!("blog/{}/delete", post.id)), | |
| 950 | + | ) | |
| 951 | + | .tone(layout::Tone::Danger) | |
| 952 | + | .confirm("Delete this blog post?"), | |
| 953 | + | ]), | |
| 954 | + | ]) | |
| 955 | + | })) | |
| 956 | + | .into() | |
| 950 | 957 | } | |
| 951 | 958 | ||
| 952 | 959 | #[cfg(test)] |
| @@ -50,7 +50,7 @@ | |||
| 50 | 50 | //! appears inside a conditional inside a table cell. | |
| 51 | 51 | ||
| 52 | 52 | use makeover_layout as layout; | |
| 53 | - | use quasi_router::screen::{Act, Cell, Cells, Column, Field, Tag}; | |
| 53 | + | use quasi_router::screen::{Act, Cell, Cells, Column, Field, Table, Tag}; | |
| 54 | 54 | use quasi_router::{Action, Method, Node, RegionKind, Request, Response, RouteError, Slot}; | |
| 55 | 55 | use quasi_webview::Webview; | |
| 56 | 56 | ||
| @@ -202,23 +202,26 @@ | |||
| 202 | 202 | } | |
| 203 | 203 | ||
| 204 | 204 | /// Who is on the project. | |
| 205 | + | /// | |
| 206 | + | /// The columns are declared here and the cells are built in [`row`], a function | |
| 207 | + | /// away. Position is only safe when both lists are in front of you at once, so | |
| 208 | + | /// the cells name their columns and this list is the only place the order is | |
| 209 | + | /// decided. | |
| 205 | 210 | fn table(members: &[ProjectMemberRow], project: &str) -> Node { | |
| 206 | - | Node::Table { | |
| 207 | - | columns: vec![ | |
| 208 | - | Column::new("Member") | |
| 209 | - | .width(layout::Width::Fill) | |
| 210 | - | .priority(layout::Priority::Essential), | |
| 211 | - | Column::new("Role").width(layout::Width::Content), | |
| 212 | - | Column::new("Split").width(layout::Width::Content), | |
| 213 | - | Column::new("Stripe").width(layout::Width::Content), | |
| 214 | - | Column::new("Added") | |
| 215 | - | .width(layout::Width::Content) | |
| 216 | - | .priority(layout::Priority::Optional), | |
| 217 | - | Column::new("").width(layout::Width::Content), | |
| 218 | - | ], | |
| 219 | - | rows: members.iter().map(|m| row(m, project)).collect(), | |
| 220 | - | more: None, | |
| 221 | - | } | |
| 211 | + | Table::new([ | |
| 212 | + | Column::new("Member") | |
| 213 | + | .width(layout::Width::Fill) | |
| 214 | + | .priority(layout::Priority::Essential), | |
| 215 | + | Column::new("Role").width(layout::Width::Content), | |
| 216 | + | Column::new("Split").width(layout::Width::Content), | |
| 217 | + | Column::new("Stripe").width(layout::Width::Content), | |
| 218 | + | Column::new("Added") | |
| 219 | + | .width(layout::Width::Content) | |
| 220 | + | .priority(layout::Priority::Optional), | |
| 221 | + | Column::new("").width(layout::Width::Content), | |
| 222 | + | ]) | |
| 223 | + | .rows(members.iter().map(|m| row(m, project))) | |
| 224 | + | .into() | |
| 222 | 225 | } | |
| 223 | 226 | ||
| 224 | 227 | /// One collaborator. | |
| @@ -245,25 +248,35 @@ | |||
| 245 | 248 | layout::Tone::Warning | |
| 246 | 249 | }; | |
| 247 | 250 | ||
| 248 | - | Cells::new([ | |
| 251 | + | // Each cell names the column it belongs to. The headings live in [`table`], | |
| 252 | + | // so counting to a position here would be counting against a list this | |
| 253 | + | // function cannot see. | |
| 254 | + | Cells::default() | |
| 249 | 255 | // The template drew the display name and `@username` as two lines in | |
| 250 | 256 | // one `<td>`. A cell is a run of leaves and has no second line, so the | |
| 251 | 257 | // handle rides in the value where a reader still sees it. | |
| 252 | - | Cell::new(format!("{shown} (@{})", member.username)) | |
| 253 | - | .activate(Action::get(format!("/u/{}", member.username)).navigating()), | |
| 254 | - | Cell::new(member.role.clone()), | |
| 255 | - | split, | |
| 256 | - | Cell::new(String::new()).token(stripe), | |
| 257 | - | Cell::new(member.added_at.clone()), | |
| 258 | - | Cell::new(String::new()).act( | |
| 259 | - | Act::new( | |
| 260 | - | "Remove", | |
| 261 | - | Action::delete(format!("{NEST}/{project}/{}", member.user_id)), | |
| 262 | - | ) | |
| 263 | - | .tone(layout::Tone::Danger) | |
| 264 | - | .confirm(format!("Remove {shown} from this project?")), | |
| 265 | - | ), | |
| 266 | - | ]) | |
| 258 | + | .at( | |
| 259 | + | "Member", | |
| 260 | + | Cell::new(format!("{shown} (@{})", member.username)) | |
| 261 | + | .activate(Action::get(format!("/u/{}", member.username)).navigating()), | |
| 262 | + | ) | |
| 263 | + | .at("Role", Cell::new(member.role.clone())) | |
| 264 | + | .at("Split", split) | |
| 265 | + | .at("Stripe", Cell::new(String::new()).token(stripe)) | |
| 266 | + | .at("Added", Cell::new(member.added_at.clone())) | |
| 267 | + | // The last column carries no heading, because the button says what it | |
| 268 | + | // does. An empty name is still the name the cell has to match. | |
| 269 | + | .at( | |
| 270 | + | "", | |
| 271 | + | Cell::new(String::new()).act( | |
| 272 | + | Act::new( | |
| 273 | + | "Remove", | |
| 274 | + | Action::delete(format!("{NEST}/{project}/{}", member.user_id)), | |
| 275 | + | ) | |
| 276 | + | .tone(layout::Tone::Danger) | |
| 277 | + | .confirm(format!("Remove {shown} from this project?")), | |
| 278 | + | ), | |
| 279 | + | ) | |
| 267 | 280 | } | |
| 268 | 281 | ||
| 269 | 282 | /// The project this write is about, and the reader's right to touch it. |
| @@ -20,7 +20,7 @@ | |||
| 20 | 20 | //! `/pricing` would give the site-wide shortcut a pricing-shaped address and | |
| 21 | 21 | //! move it the first time a second screen converted. | |
| 22 | 22 | ||
| 23 | - | use quasi_router::screen::{Cell, Cells, Column}; | |
| 23 | + | use quasi_router::screen::{Cell, Cells, Column, Table}; | |
| 24 | 24 | use quasi_router::{ | |
| 25 | 25 | Action, Chrome, Node, Outcome, RegionKind, Request, Response, RouteError, Router, Screen, Slot, | |
| 26 | 26 | }; | |
| @@ -79,11 +79,9 @@ | |||
| 79 | 79 | Screen::list_detail("Keyboard shortcuts", false).with( | |
| 80 | 80 | Slot::new("shortcuts", RegionKind::Modal) | |
| 81 | 81 | .label("Keyboard shortcuts") | |
| 82 | - | .with(Node::Table { | |
| 83 | - | columns: vec![Column::new("Key"), Column::new("Does")], | |
| 84 | - | rows, | |
| 85 | - | more: None, | |
| 86 | - | }), | |
| 82 | + | .with(Node::from( | |
| 83 | + | Table::new([Column::new("Key"), Column::new("Does")]).rows(rows), | |
| 84 | + | )), | |
| 87 | 85 | ), | |
| 88 | 86 | ) | |
| 89 | 87 | .into()) |