Skip to main content

max / makenotwork

Move the server's tables onto Table, naming columns where they can drift Nineteen files, 29 tables. Thirteen now name their columns, which is the case where the row builder and the column list live in different functions and nothing keeps them lined up. The rest had fixed rows written beside their columns and move onto the constructor alone. Two files hoisted an empty column heading into a const so the column and the cell share the string rather than each spelling it, since an empty name is still a name and a mismatch is a silently empty cell.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session
https://claude.ai/code/session_01MptwXZ8k65v19rFmdGAyki
Author: Max Johnson <me@maxj.phd> · 2026-09-03 03:27 UTC
Signed with PGP, not checked
Commit: 52fd5422606f6cc531fc9a008814e23e440b1365
Parent: 2c2f375
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())