Skip to main content

max / quasi

Let a shape build a menu, and a table say when it is drawn `Vec<Act>` is `Vec<Node>`'s twin and for its reason. `Row::menu` and `Cells::menu` take the whole list, so a menu built conditionally has nowhere to accrete, and the form had no way to say one at all. audiofiles' file list has three: what a folder offers, what a sample offers, and what a chosen set does. The sample's is fourteen entries of which nine are conditional, which is the shape a guard exists for. Every member of one is an `act`, because a menu is nothing but controls. `table` takes a guard after its body, where `list` already took one and for its reason: a table is a member like any other, and a member of an accumulating container may be absent. An empty vault says so with a stand-in, and a table drawn anyway would be a header row over nothing.
Author: Max Johnson <me@maxj.phd> · 2026-09-04 23:16 UTC
Signed with PGP, not checked
Commit: cd555a2d14df6c436685e2ef7c30f8366cc9e28a
Parent: ed1a081
3 files changed, +107 insertions, -10 deletions
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "quasi-declare"
3 - version = "0.1.4"
3 + version = "0.1.5"
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
@@ -91,6 +91,7 @@
91 91 // module around it calls that.
92 92 let returns = match &shaped {
93 93 Shaped::Nodes => quote!(::std::vec::Vec<::quasi_router::Node>),
94 + Shaped::Acts => quote!(::std::vec::Vec<::quasi_router::Act>),
94 95 Shaped::Single { name, optional } => {
95 96 let shaped_type = format_ident!("{}", name, span = returns.span());
96 97 if *optional {
@@ -123,6 +124,14 @@
123 124 /// tab that answers over htmx wraps the same members itself. 52 of the
124 125 /// population's 484 shapes return this, third after `Slot` and `Node`.
125 126 Nodes,
127 + /// `Vec<Act>`: a menu, which is the controls a row holds back.
128 + ///
129 + /// [`Nodes`](Self::Nodes)' twin and for its reason. `Row::menu` and
130 + /// `Cells::menu` take the whole list, so a menu that is built conditionally
131 + /// has nowhere to accrete, and audiofiles' file list has two of them --
132 + /// what a row offers and what a chosen set does. Every member is an `act`,
133 + /// because a menu is nothing but controls.
134 + Acts,
126 135 }
127 136
128 137 /// The type a shape returns, read off the declaration.
@@ -140,14 +149,21 @@
140 149 let Some(inner) = sole_argument(returns, &last.arguments)? else {
141 150 return Err(syn::Error::new_spanned(returns, "`Vec` of what?"));
142 151 };
143 - if !matches!(shaped_name(inner)?, Shaped::Single { ref name, optional: false } if name == "Node")
144 - {
145 - return Err(syn::Error::new_spanned(
152 + return match shaped_name(inner)? {
153 + Shaped::Single {
154 + ref name,
155 + optional: false,
156 + } if name == "Node" => Ok(Shaped::Nodes),
157 + Shaped::Single {
158 + ref name,
159 + optional: false,
160 + } if name == "Act" => Ok(Shaped::Acts),
161 + _ => Err(syn::Error::new_spanned(
146 162 returns,
147 - "the only list a shape builds is `Vec<Node>`, which is a panel's members",
148 - ));
149 - }
150 - return Ok(Shaped::Nodes);
163 + "a shape builds two lists: `Vec<Node>`, a panel's members, and \
164 + `Vec<Act>`, a menu",
165 + )),
166 + };
151 167 }
152 168 if last.ident == "Option" {
153 169 let Some(inner) = sole_argument(returns, &last.arguments)? else {
@@ -169,6 +185,10 @@
169 185 returns,
170 186 "a panel with no members is an empty `Vec<Node>`, not a `None`",
171 187 )),
188 + Shaped::Acts => Err(syn::Error::new_spanned(
189 + returns,
190 + "a menu with no entries is an empty `Vec<Act>`, not a `None`",
191 + )),
172 192 };
173 193 }
174 194 Ok(Shaped::Single {
@@ -202,12 +222,17 @@
202 222 /// an emission produces a value rather than an effect, so the two are
203 223 /// independent within one block.
204 224 fn value_body(items: &[Item], shaped: &Shaped, span: Span) -> Result<TokenStream> {
225 + let shaped = match shaped {
226 + Shaped::Nodes => return nodes(items),
227 + Shaped::Acts => return acts(items),
228 + single => single,
229 + };
205 230 let Shaped::Single {
206 231 name: shaped,
207 232 optional,
208 233 } = shaped
209 234 else {
210 - return nodes(items);
235 + unreachable!("the two lists are answered above")
211 236 };
212 237 let optional = *optional;
213 238 let shaped = shaped.as_str();
@@ -1257,6 +1282,66 @@
1257 1282 }))
1258 1283 }
1259 1284
1285 + /// A menu's entries, in order.
1286 + ///
1287 + /// [`nodes`]' twin, and narrower: every member is an `act`, because a menu is
1288 + /// nothing but controls. A guard and a loop work the way they do everywhere
1289 + /// else, which is the whole reason this exists -- audiofiles' row menu is
1290 + /// fourteen entries of which nine are conditional.
1291 + fn acts(items: &[Item]) -> Result<TokenStream> {
1292 + let held = format_ident!("entries", span = Span::call_site());
1293 + let statements = act_statements(items, &held)?;
1294 + Ok(quote!({
1295 + let mut #held = ::std::vec::Vec::new();
1296 + #(#statements)*
1297 + #held
1298 + }))
1299 + }
1300 +
1301 + fn act_statements(items: &[Item], entries: &proc_macro2::Ident) -> Result<Vec<TokenStream>> {
1302 + items
1303 + .iter()
1304 + .map(|item| match item {
1305 + Item::Bind { name, source } => {
1306 + let value = source_value(source)?;
1307 + Ok(quote!(let #name = #value;))
1308 + }
1309 + Item::For {
1310 + dereferenced,
1311 + binder,
1312 + iterable,
1313 + body,
1314 + } => {
1315 + let iterable = hole(iterable)?;
1316 + let inner = act_statements(body, entries)?;
1317 + let binder = binder_pattern(*dereferenced, binder);
1318 + Ok(quote!(for #binder in #iterable { #(#inner)* }))
1319 + }
1320 + // R9, as everywhere else: the entry is built whether or not the
1321 + // guard places it.
1322 + Item::Emit(Emission::Guarded { guard, inner }) => {
1323 + let test = predicate(guard)?;
1324 + let value = act(inner)?;
1325 + let held = format_ident!("entry", span = Span::call_site());
1326 + Ok(quote!({
1327 + let #held = #value;
1328 + if #test {
1329 + #entries.push(#held);
1330 + }
1331 + }))
1332 + }
1333 + Item::Emit(emission) => {
1334 + let value = act(emission)?;
1335 + Ok(quote!(#entries.push(#value);))
1336 + }
1337 + Item::Attribute { name, .. } => Err(syn::Error::new(
1338 + name.span(),
1339 + "a menu sets nothing: say it on the entry it belongs to",
1340 + )),
1341 + })
1342 + .collect()
1343 + }
1344 +
1260 1345 fn node_statements(items: &[Item], nodes: &proc_macro2::Ident) -> Result<Vec<TokenStream>> {
1261 1346 items
1262 1347 .iter()
@@ -689,7 +689,19 @@
689 689 }
690 690 Ok(guarded(guard, Self::List(rows)))
691 691 }
692 - "table" => Ok(Self::Table(block(input)?)),
692 + // A guard after the body, which is where `list` takes one and for
693 + // its reason: a table is a member like any other, and a member of
694 + // an accumulating container may be absent. audiofiles' file list is
695 + // the site -- an empty vault says so with a stand-in instead, and a
696 + // table drawn anyway would be a header row over nothing.
697 + "table" => {
698 + let body = block(input)?;
699 + let guard = guard(input)?;
700 + if guard.is_some() {
701 + input.parse::<Token![;]>()?;
702 + }
703 + Ok(guarded(guard, Self::Table(body)))
704 + }
693 705 "column" => {
694 706 let name: Arg = input.parse()?;
695 707 // Before the body, where every other guarded member puts it.