Skip to main content

max / quasi

Grow the form for the forum memberships, the item sales and the media picker Wave 3. Nine screens: forum_memberships' upstream_line, library_pane, settings_pane and table; item_sales' pane, table and row; media_picker's grid_slot, card, name_filter and folder_filter; and quasi/mod.rs's own_prose. Earned by them: - A node member takes a body. `Node::empty` carries a way out on the library's pane and not on the settings pane, so an empty state is a member with a setting rather than a bare one. `offering` is the member that body holds, spelled like `act` because `Node::offering` takes an `Act`. - R2 for `-> Cells` and `-> Field`. item_sales splits its columns from its cells so the cells can name their columns; media_picker has two filter fields that are neither in a form nor in a node. - `cell at <column> <value>`. item_sales' columns are module consts and its cells are a function away, so a positional row would be lined up against headings nobody reading the row can see. A row is all positional or all named. - A table takes an `include`, so a row another shape built is placed as a row. - A region's kind may be a hole. `RegionKind::Widget` carries a name, so it is the one kind a bare ident cannot spell, and media_picker has a `widget(name)` helper beside its three widget regions. Section 4 specified this and its census named the site; this is the first of section 4's unprobed productions to be implemented as written. - `rich` as a node member, for own_prose. - A guard ends an action. `offering "Browse Communities" to external base unless base.is_empty()` is the site: the modifier loop read `unless` as a modifier. `Field::options` added to quasi-router (0.101.6), the accreting half of `Field::select` and `Field::radio`. Same gap and same fix as wave 2's `Table::column` and `Cells::cell`. `filling` removed from the modifier table. It is `Act::filling`, a setting on the control rather than a modifier of the action, and listing it there made media_picker's tiles fail against `Action` instead of reading as the setting they are.
Record
wiki quasi-declare-form section 13.
Author: Max Johnson <me@maxj.phd> · 2026-09-03 19:14 UTC
Signed with PGP, not checked
Commit: 3fe4f1d14325c912ca43cd5f917cf89d9ac850e9
Parent: b7ecad9
5 files changed, +246 insertions, -31 deletions
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "quasi-router"
3 - version = "0.101.5"
3 + version = "0.101.6"
4 4 description = "Host-agnostic router: a request in, a renderer-agnostic description out"
5 5 edition.workspace = true
6 6 rust-version.workspace = true
@@ -150,8 +150,17 @@
150 150
151 151 /// Everything that emits into the enclosing container.
152 152 pub enum Emission {
153 - /// `<member> <arg>*;` -- one of `Node`'s own constructors, by its own name.
154 - Simple { member: Ident, args: Vec<Arg> },
153 + /// `<member> <arg>* ( "{" { item } "}" | ";" )` -- one of `Node`'s own
154 + /// constructors, by its own name, and whatever it is told afterwards.
155 + ///
156 + /// The body is what `forum_memberships` demanded: `Node::empty` carries a
157 + /// way out (`offering`) on the library's pane and not on the settings pane,
158 + /// so an empty state is a member with a setting rather than a bare one.
159 + Simple {
160 + member: Ident,
161 + args: Vec<Arg>,
162 + body: Vec<Item>,
163 + },
155 164 /// `screen <arrangement> <arg> { .. }`
156 165 Screen {
157 166 arrangement: Ident,
@@ -190,14 +199,36 @@
190 199 Table(Vec<Item>),
191 200 /// `column <name> ( "{" .. "}" | ";" )` -- one column and how it narrows.
192 201 Column { name: Arg, body: Vec<Item> },
193 - /// `cells { cell ..; }` -- one row of a table, by position.
202 + /// `cells { cell ..; }` -- one row of a table.
194 203 ///
195 - /// Positional only. A cell that names its column (`Cells::at`) is not a
196 - /// production yet: `git_blame`'s row is the one site that wants it, and it
197 - /// is still hand-written.
204 + /// A row's cells are all positional or all named; mixing the two is what
205 + /// `Cells::at`'s own docs warn against.
198 206 Cells(Vec<Item>),
199 - /// `cell <value> ( "{" .. "}" | ";" )`
200 - Cell { value: Arg, body: Vec<Item> },
207 + /// `cell [ "at" <column> ] <value> ( "{" .. "}" | ";" )`
208 + ///
209 + /// Positional without the column, named with it. `item_sales` demanded the
210 + /// named form: its columns are module consts and its cells are a function
211 + /// away, so a row written by position would be lined up against headings
212 + /// nobody reading the row can see. Naming is `Cells::at`, and the two are
213 + /// not mixed in one row -- that warning is `at`'s own.
214 + Cell {
215 + column: Option<Arg>,
216 + value: Arg,
217 + body: Vec<Item>,
218 + },
219 + /// `offering <arg> to <action> ( "{" { item } "}" | ";" )` -- the way out an
220 + /// empty or failed state offers.
221 + ///
222 + /// Spelled like `act` and placed like a setting, because `Node::offering`
223 + /// takes an `Act` and a setting's arguments are args. Its own member rather
224 + /// than an `act` the container reinterprets: a body holding a control and a
225 + /// body holding a way out would otherwise read the same and mean different
226 + /// things.
227 + Offering {
228 + label: Arg,
229 + action: Action,
230 + body: Vec<Item>,
231 + },
201 232 /// `activate to <action>;` -- what opening this row or cell does.
202 233 ///
203 234 /// A setting in shape, and a member in the tree, because the thing it sets
@@ -211,7 +242,7 @@
211 242 /// `region <arg> as <kind> { .. }`
212 243 Region {
213 244 name: Arg,
214 - kind: Ident,
245 + kind: RegionKind,
215 246 body: Vec<Item>,
216 247 },
217 248 /// `across <fallback> { .. }`
@@ -239,6 +270,20 @@
239 270 },
240 271 }
241 272
273 + /// What kind of region this is: a variant, or a hole that answers with one.
274 + ///
275 + /// `RegionKind::Widget` carries a name, so it is the one kind a bare ident
276 + /// cannot spell. `media_picker` has three widget regions and a `widget(name)`
277 + /// helper beside them, which is the deferred table's own remedy for a value the
278 + /// form cannot say; this lets the declaration call it.
279 + ///
280 + /// Told apart by R1's rule and not by a keyword: a bare uppercase ident is a
281 + /// variant, and anything else is a hole.
282 + pub enum RegionKind {
283 + Variant(Ident),
284 + Supplied(Hole),
285 + }
286 +
242 287 /// `when <predicate>`, or `unless <predicate>`, which is its negation.
243 288 pub struct Guard {
244 289 pub negated: bool,
@@ -17,7 +17,7 @@
17 17
18 18 use crate::ast::{
19 19 Action, Arg, Declaration, Emission, Guard, Hole, HoleRoot, Interpolated, Item, Pattern,
20 - Predicate, Source, Step, StrPart,
20 + Predicate, RegionKind, Source, Step, StrPart,
21 21 };
22 22 use crate::parse::{COMPARISONS, VOCABULARY};
23 23
@@ -44,6 +44,8 @@
44 44 Cells,
45 45 /// One cell: its body says what opening it does.
46 46 Cell,
47 + /// One node member: its body says what it is told afterwards.
48 + Node,
47 49 }
48 50
49 51 pub fn declaration(declaration: &Declaration) -> Result<TokenStream> {
@@ -227,6 +229,29 @@
227 229 ));
228 230 }
229 231 },
232 + "Cells" => match only {
233 + Emission::Cells(body) => cells(body)?,
234 + other => {
235 + return Err(syn::Error::new(
236 + emission_span(other),
237 + "a `-> Cells` shape is its single `cells` member",
238 + ));
239 + }
240 + },
241 + "Field" => match only {
242 + Emission::Field {
243 + kind,
244 + name,
245 + label,
246 + body,
247 + } => field(kind, name, label, body)?,
248 + other => {
249 + return Err(syn::Error::new(
250 + emission_span(other),
251 + "a `-> Field` shape is its single `field` member",
252 + ));
253 + }
254 + },
230 255 other => {
231 256 return Err(syn::Error::new(
232 257 span,
@@ -459,20 +484,68 @@
459 484 Ok((column(name, body)?, quote!(.column(#held))))
460 485 }
461 486 (Container::Table, Emission::Cells(body)) => Ok((cells(body)?, quote!(.row(#held)))),
487 + // A row another shape built. `item_sales` splits its columns from its
488 + // cells on purpose, so the row arrives whole rather than being written
489 + // inside the table.
490 + (Container::Table, Emission::Include(supplier)) => {
491 + let supplier = hole(supplier)?;
492 + Ok((
493 + quote!(::std::convert::Into::into(#supplier)),
494 + quote!(.row(#held)),
495 + ))
496 + }
462 497 (Container::Table, other) => Err(syn::Error::new(
463 498 emission_span(other),
464 - "a table holds columns and rows: write `column <name>;` or `cells { .. }`",
499 + "a table holds columns and rows: write `column <name>;`, `cells { .. }` \
500 + or `include <shape>;`",
465 501 )),
466 - (Container::Cells, Emission::Cell { value, body }) => {
467 - Ok((cell(value, body)?, quote!(.cell(#held))))
502 + (
503 + Container::Cells,
504 + Emission::Cell {
505 + column,
506 + value,
507 + body,
508 + },
509 + ) => {
510 + let built = cell(value, body)?;
511 + Ok(match column {
512 + Some(column) => {
513 + let column = arg(column)?;
514 + (built, quote!(.at(#column, #held)))
515 + }
516 + None => (built, quote!(.cell(#held))),
517 + })
468 518 }
469 519 (Container::Cells, other) => Err(syn::Error::new(
470 520 emission_span(other),
471 521 "a row of a table holds cells: write `cell <value>;`",
472 522 )),
523 + (Container::Cell, Emission::Act { .. }) => Ok((act(emission)?, quote!(.act(#held)))),
473 524 (Container::Cell, other) => Err(syn::Error::new(
474 525 emission_span(other),
475 - "a cell says what opening it does, and nothing else yet",
526 + "a cell holds controls and says what opening it does: write \
527 + `act <label> to <action>;` or `activate to <action>;`",
528 + )),
529 + (
530 + Container::Node,
531 + Emission::Offering {
532 + label,
533 + action,
534 + body,
535 + },
536 + ) => {
537 + let label = arg(label)?;
538 + let called = self::action(action)?;
539 + let way_out = accrete(
540 + body,
541 + Container::Act,
542 + &quote!(::quasi_router::Act::new(#label, #called)),
543 + )?;
544 + Ok((way_out, quote!(.offering(#held))))
545 + }
546 + (Container::Node, other) => Err(syn::Error::new(
547 + emission_span(other),
548 + "a node member is told settings and a way out: write `offering <label> to <action>;`",
476 549 )),
477 550 (Container::Column, other) => Err(syn::Error::new(
478 551 emission_span(other),
@@ -543,9 +616,13 @@
543 616 /// One emission as a `Node` value.
544 617 fn node(emission: &Emission) -> Result<TokenStream> {
545 618 match emission {
546 - Emission::Simple { member, args } => {
619 + Emission::Simple { member, args, body } => {
547 620 let args = args.iter().map(self::arg).collect::<Result<Vec<_>>>()?;
548 - Ok(quote!(::quasi_router::Node::#member(#(#args),*)))
621 + accrete(
622 + body,
623 + Container::Node,
624 + &quote!(::quasi_router::Node::#member(#(#args),*)),
625 + )
549 626 }
550 627 Emission::List(items) => list(items),
551 628 Emission::Table(items) => {
@@ -555,7 +632,7 @@
555 632 Emission::Form { action, body } => form(action, body),
556 633 Emission::Field { kind, .. } => Err(syn::Error::new(
557 634 kind.span(),
558 - "a field is not a node: put it in a `form`",
635 + "a field is not a node: put it in a `form`, or give the shape `-> Field`",
559 636 )),
560 637 Emission::Row { .. } => Err(syn::Error::new(
561 638 emission_span(emission),
@@ -629,6 +706,10 @@
629 706 action.verb.span(),
630 707 "`activate` says what opening a row or a cell does; it is not a node",
631 708 )),
709 + Emission::Offering { action, .. } => Err(syn::Error::new(
710 + action.verb.span(),
711 + "`offering` is the way out an empty state offers; it is not a node",
712 + )),
632 713 Emission::Beside { priority, .. } => Err(syn::Error::new(
633 714 priority.span(),
634 715 "`beside` needs a run in scope, which is rule R3",
@@ -638,12 +719,16 @@
638 719
639 720 /// One region, as the `Slot` it is. A document holds these; a body wraps them
640 721 /// in [`Node::Region`].
641 - fn slot(name: &Arg, kind: &syn::Ident, body: &[Item]) -> Result<TokenStream> {
722 + fn slot(name: &Arg, kind: &RegionKind, body: &[Item]) -> Result<TokenStream> {
642 723 let name = arg(name)?;
724 + let kind = match kind {
725 + RegionKind::Variant(variant) => quote!(::quasi_router::RegionKind::#variant),
726 + RegionKind::Supplied(hole) => self::hole(hole)?,
727 + };
643 728 accrete(
644 729 body,
645 730 Container::Slot,
646 - &quote!(::quasi_router::Slot::new(#name, ::quasi_router::RegionKind::#kind)),
731 + &quote!(::quasi_router::Slot::new(#name, #kind)),
647 732 )
648 733 }
649 734
@@ -812,7 +897,7 @@
812 897 )
813 898 }
814 899
815 - /// One cell: what it says, and what opening it does.
900 + /// One cell: what it says, what it holds, and what opening it does.
816 901 fn cell(value: &Arg, body: &[Item]) -> Result<TokenStream> {
817 902 let value = arg(value)?;
818 903 accrete(
@@ -1050,11 +1135,15 @@
1050 1135 Emission::Act { action, .. } => action.verb.span(),
1051 1136 Emission::Guarded { guard, .. } => guard.span,
1052 1137 Emission::Given { .. } => Span::call_site(),
1053 - Emission::Region { kind, .. } => kind.span(),
1138 + Emission::Region { kind, .. } => match kind {
1139 + RegionKind::Variant(variant) => variant.span(),
1140 + RegionKind::Supplied(_) => Span::call_site(),
1141 + },
1054 1142 Emission::Across { fallback, .. } => fallback.span(),
1055 1143 Emission::Beside { priority, .. } => priority.span(),
1056 1144 Emission::Table(_) | Emission::Cells(_) => Span::call_site(),
1057 1145 Emission::Column { .. } | Emission::Cell { .. } => Span::call_site(),
1058 1146 Emission::Activate(action) => action.verb.span(),
1147 + Emission::Offering { action, .. } => action.verb.span(),
1059 1148 }
1060 1149 }
@@ -17,10 +17,14 @@
17 17
18 18 use crate::ast::{
19 19 Action, Arg, Declaration, Emission, Guard, Hole, HoleRoot, Interpolated, Item, Modifier, Param,
20 - Pattern, Predicate, Source, Step, StrPart,
20 + Pattern, Predicate, RegionKind, Source, Step, StrPart,
21 21 };
22 22
23 23 /// The modifiers, and how many arguments each takes.
24 + ///
25 + /// `filling` is deliberately not here. It is `Act::filling`, a setting on the
26 + /// control, and listing it as a modifier of the action made `media_picker`'s
27 + /// tiles fail against `Action` instead of reading as the setting they are.
24 28 const MODIFIERS: &[(&str, usize)] = &[
25 29 ("navigating", 0),
26 30 ("awaiting", 0),
@@ -31,7 +35,6 @@
31 35 ("saving", 1),
32 36 ("replacing", 1),
33 37 ("copying", 1),
34 - ("filling", 2),
35 38 ("carrying", 2),
36 39 ];
37 40
@@ -43,7 +46,7 @@
43 46 /// having.
44 47 const MEMBERS: &[&str] = &[
45 48 "act", "across", "activate", "beside", "cell", "cells", "column", "field", "form", "given",
46 - "include", "link", "region", "row", "screen", "table",
49 + "include", "link", "offering", "region", "row", "screen", "table",
47 50 ];
48 51
49 52 /// The members that are one of `Node`'s own constructors, called by its name.
@@ -58,6 +61,7 @@
58 61 "banner",
59 62 "failed",
60 63 "empty",
64 + "rich",
61 65 ];
62 66
63 67 /// The arrangements a screen may be laid out in.
@@ -288,6 +292,22 @@
288 292 })
289 293 }
290 294
295 + impl Parse for RegionKind {
296 + fn parse(input: ParseStream) -> Result<Self> {
297 + // R1's rule, applied to a kind: a bare uppercase ident is the variant,
298 + // and anything else is a hole that answers with one.
299 + if input.peek(Ident) && !input.peek2(Token![::]) && !input.peek2(token::Paren) {
300 + let name: Ident = input.fork().parse()?;
301 + let text = name.to_string();
302 + if text.starts_with(|letter: char| letter.is_uppercase()) {
303 + input.parse::<Ident>()?;
304 + return Ok(Self::Variant(name));
305 + }
306 + }
307 + Ok(Self::Supplied(input.parse()?))
308 + }
309 + }
310 +
291 311 impl Parse for Pattern {
292 312 fn parse(input: ParseStream) -> Result<Self> {
293 313 if input.peek(LitInt) {
@@ -403,10 +423,9 @@
403 423 "region" => {
404 424 let name: Arg = input.parse()?;
405 425 input.parse::<Token![as]>()?;
406 - let kind: Ident = input.parse()?;
407 426 Ok(Self::Region {
408 427 name,
409 - kind,
428 + kind: input.parse()?,
410 429 body: block(input)?,
411 430 })
412 431 }
@@ -505,6 +524,12 @@
505 524 }
506 525 "cells" => Ok(Self::Cells(block(input)?)),
507 526 "cell" => {
527 + let column = if input.peek(Ident) && input.fork().parse::<Ident>()? == "at" {
528 + input.parse::<Ident>()?;
529 + Some(input.parse()?)
530 + } else {
531 + None
532 + };
508 533 let value: Arg = input.parse()?;
509 534 let body = if input.peek(token::Brace) {
510 535 block(input)?
@@ -512,7 +537,31 @@
512 537 input.parse::<Token![;]>()?;
513 538 Vec::new()
514 539 };
515 - Ok(Self::Cell { value, body })
540 + Ok(Self::Cell {
541 + column,
542 + value,
543 + body,
544 + })
545 + }
546 + "offering" => {
547 + let label: Arg = input.parse()?;
548 + preposition(input, "to")?;
549 + let action: Action = input.parse()?;
550 + let guard = guard(input)?;
551 + let body = if input.peek(token::Brace) {
552 + block(input)?
553 + } else {
554 + input.parse::<Token![;]>()?;
555 + Vec::new()
556 + };
557 + Ok(guarded(
558 + guard,
559 + Self::Offering {
560 + label,
561 + action,
562 + body,
563 + },
564 + ))
516 565 }
517 566 "activate" => {
518 567 preposition(input, "to")?;
@@ -547,16 +596,28 @@
547 596 }
548 597 name if NODE_MEMBERS.contains(&name) => {
549 598 let mut args = Vec::new();
550 - while !input.peek(Token![;]) && guard_ahead(input)?.is_none() {
599 + while !input.peek(Token![;])
600 + && !input.peek(token::Brace)
601 + && guard_ahead(input)?.is_none()
602 + {
551 603 args.push(input.parse()?);
552 604 }
553 605 let guard = guard(input)?;
554 - input.parse::<Token![;]>()?;
606 + // Told something afterwards, or nothing. `act` reads the same
607 + // way and for the same reason: a member with nothing to say
608 + // about itself should not have to open a block to say so.
609 + let body = if input.peek(token::Brace) {
610 + block(input)?
611 + } else {
612 + input.parse::<Token![;]>()?;
613 + Vec::new()
614 + };
555 615 Ok(guarded(
556 616 guard,
557 617 Self::Simple {
558 618 member: member.clone(),
559 619 args,
620 + body,
560 621 },
561 622 ))
562 623 }
@@ -722,7 +783,11 @@
722 783 };
723 784
724 785 let mut modifiers = Vec::new();
725 - while input.peek(Ident) {
786 + // A guard ends the action rather than being read as a modifier of it.
787 + // `offering "Browse Communities" to external base unless base.is_empty()`
788 + // is the site: without this the guard word is the next word after a
789 + // target and the action swallows it.
790 + while input.peek(Ident) && guard_ahead(input)?.is_none() {
726 791 let name: Ident = input.parse()?;
727 792 let spelling = name.to_string();
728 793 let Some((_, arity)) = MODIFIERS.iter().find(|(known, _)| *known == spelling) else {
@@ -3541,6 +3541,22 @@
3541 3541 }
3542 3542
3543 3543 /// A select offering the given options.
3544 + /// The options offered, appended to the ones already there.
3545 + ///
3546 + /// The accreting half of [`select`](Self::select) and [`radio`](Self::radio),
3547 + /// which take the whole list as an argument. Beside them for the reason
3548 + /// [`Table::column`] sits beside `Table::new`: a caller with no expression
3549 + /// to hold a list in still has to be able to offer options, and `options`
3550 + /// was reachable only by assigning the field.
3551 + ///
3552 + /// Says nothing about the kind. A kind that offers no options ignores them,
3553 + /// which is what [`layout::FieldKind::offers_options`] already decides.
3554 + #[must_use]
3555 + pub fn options(mut self, options: impl IntoIterator<Item = Choice>) -> Self {
3556 + self.options.extend(options);
3557 + self
3558 + }
3559 +
3544 3560 pub fn select(name: impl Into<String>, label: impl Into<String>, options: Vec<Choice>) -> Self {
3545 3561 Self {
3546 3562 options,