Skip to main content

max / quasi

Let a row say the control it holds back `offers` is `act`'s twin and one word from it, because that is the whole of the difference: a row shows what `act` puts in it and keeps what `offers` gives it until the host is asked. Refused outside a row, where the word means nothing. Earned by audiofiles' sidebar, which greys a vault's Delete when it is the last vault. Until now a menu control had to be written as an expression in an argument -- `offers Act::new(..).tone(..).confirm(..)` -- and an expression cannot carry a guard, so a conditionally dead menu control had no spelling at all. Six sites in the tree were writing the control out that way, which is the thing the form exists to refuse.
Author: Max Johnson <me@maxj.phd> · 2026-09-04 21:35 UTC
Signed with PGP, not checked
Commit: 22a247c091121f69f89185e61fd440a3c4d7b74f
Parent: 8cf758e
4 files changed, +50 insertions, -10 deletions
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "quasi-declare"
3 - version = "0.1.1"
3 + version = "0.1.2"
4 4 description = "The declare! form: a screen description compiled to Rust at build time."
5 5 edition.workspace = true
6 6 rust-version.workspace = true
@@ -202,6 +202,20 @@
202 202 action: Action,
203 203 body: Vec<Item>,
204 204 },
205 + /// `offers <arg> to <action> ( { .. } | ; )` -- `act`'s held-back twin.
206 + ///
207 + /// A control a row carries but does not show: `Row::offers` pushes onto the
208 + /// menu a host reveals when it is asked, where `Row::act` puts the control
209 + /// in the row. One word apart because that is the whole of the difference,
210 + /// and a member rather than a setting because a menu control is told the
211 + /// same things an inline one is -- audiofiles' sidebar greys a vault's
212 + /// Delete when it is the last vault, which a control written as an
213 + /// expression cannot say.
214 + Offers {
215 + label: Arg,
216 + action: Action,
217 + body: Vec<Item>,
218 + },
205 219 /// `table { column ..; cells { .. } }`
206 220 ///
207 221 /// Columns and rows accrete rather than arriving as two lists, which is
@@ -576,6 +576,14 @@
576 576 Ok((self::action(action)?, quote!(.activate(#held))))
577 577 }
578 578 (Container::Row, Emission::Act { .. }) => Ok((act(emission)?, quote!(.act(#held)))),
579 + // `act`'s held-back twin. A row shows what `act` puts in it and keeps
580 + // what `offers` gives it until the host is asked, which is the whole of
581 + // the difference and is why the two are one word apart. A member rather
582 + // than a setting because a menu control is told the same things an
583 + // inline one is: audiofiles' sidebar greys a vault's Delete when it is
584 + // the last vault, and a control written as an expression in an argument
585 + // cannot say that.
586 + (Container::Row, Emission::Offers { .. }) => Ok((act(emission)?, quote!(.offers(#held)))),
579 587 // A control another shape built. `export_act::control` is the export
580 588 // portal's one sentence about saving a file, said once and offered
581 589 // under each card's own label, and a row holds acts rather than nodes.
@@ -1003,6 +1011,10 @@
1003 1011 let control = act(emission)?;
1004 1012 Ok(quote!(::quasi_router::Node::Act(#control)))
1005 1013 }
1014 + Emission::Offers { action, .. } => Err(syn::Error::new(
1015 + action.verb.span(),
1016 + "`offers` is a control a row holds back; outside a row write `act`",
1017 + )),
1006 1018 Emission::Include(supplier) => {
1007 1019 let supplier = hole(supplier)?;
1008 1020 Ok(quote!(::std::convert::Into::into(#supplier)))
@@ -1452,11 +1464,16 @@
1452 1464
1453 1465 /// One control: what it is called, what it does, and what it is like.
1454 1466 fn act(emission: &Emission) -> Result<TokenStream> {
1455 - let Emission::Act {
1467 + let (Emission::Act {
1456 1468 label,
1457 1469 action,
1458 1470 body,
1459 - } = emission
1471 + }
1472 + | Emission::Offers {
1473 + label,
1474 + action,
1475 + body,
1476 + }) = emission
1460 1477 else {
1461 1478 return Err(syn::Error::new(
1462 1479 emission_span(emission),
@@ -1665,7 +1682,7 @@
1665 1682 Emission::Row { .. } | Emission::List(_) => Span::call_site(),
1666 1683 Emission::Form { action, .. } | Emission::Chip { action, .. } => action.verb.span(),
1667 1684 Emission::Field { kind, .. } => kind.span(),
1668 - Emission::Act { action, .. } => action.verb.span(),
1685 + Emission::Act { action, .. } | Emission::Offers { action, .. } => action.verb.span(),
1669 1686 Emission::Guarded { guard, .. } => guard.span,
1670 1687 Emission::Given { .. } => Span::call_site(),
1671 1688 Emission::Region { kind, .. } => match kind {
@@ -57,7 +57,7 @@
57 57 /// having.
58 58 const MEMBERS: &[&str] = &[
59 59 "act", "across", "activate", "at", "beside", "cell", "cells", "chip", "column", "field",
60 - "form", "given", "include", "link", "offering", "region", "row", "screen", "table",
60 + "form", "given", "include", "link", "offering", "offers", "region", "row", "screen", "table",
61 61 ];
62 62
63 63 /// The members that are one of `Node`'s own constructors, called by its name.
@@ -562,7 +562,8 @@
562 562 body: block(input)?,
563 563 })
564 564 }
565 - "act" => {
565 + "act" | "offers" => {
566 + let held_back = member == "offers";
566 567 let label: Arg = input.parse()?;
567 568 preposition(input, "to")?;
568 569 let action: Action = input.parse()?;
@@ -575,10 +576,18 @@
575 576 };
576 577 Ok(guarded(
577 578 guard,
578 - Self::Act {
579 - label,
580 - action,
581 - body,
579 + if held_back {
580 + Self::Offers {
581 + label,
582 + action,
583 + body,
584 + }
585 + } else {
586 + Self::Act {
587 + label,
588 + action,
589 + body,
590 + }
582 591 },
583 592 ))
584 593 }