Skip to main content

max / quasi

Mark a form's fields, and close the last container that could not be A form's fields were the fourth bare list and the last refusal in the emitter. They accrete into the same wrapper the other three use, the form absorbs their marks as it takes them, and the renderer records where each field went. `emit`'s loop over the body becomes a function so a marked run can hold fields of its own, which is what a guarded disclosure is. Fixture: the theme picker grows an advanced question behind a guard, with an ungated field either side of it -- MNW's mail settings are the live site, six server questions that are on the form only while it is open. A mark that failed to move would land on one of the neighbours rather than quietly on nothing. Every `Item::Marked` refusal in emit.rs is gone but one, and that one is right: a body answering a value has no container, so there is no run of members for a mark to cover.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session
https://claude.ai/code/session_01P8ostB2UmZJGj5WjSHRSot
Author: Max Johnson <me@maxj.phd> · 2026-09-08 20:15 UTC
Signed with PGP, not checked
Commit: 430dbb6551f5b6928c5bda86fbc5c6a51556c9b9
Parent: 5a962aa
4 files changed, +125 insertions, -34 deletions
M Cargo.lock +4 -4
@@ -6312,6 +6312,10 @@
6312 6312 "winnow 1.0.4",
6313 6313 ]
6314 6314
6315 + [[patch.unused]]
6316 + name = "synckit-client"
6317 + version = "0.10.0"
6318 +
6315 6319 [[patch.unused]]
6316 6320 name = "quasi-type"
6317 6321 version = "0.1.3"
@@ -6331,7 +6335,3 @@
6331 6335 [[patch.unused]]
6332 6336 name = "tagtree"
6333 6337 version = "0.4.1"
6334 -
6335 - [[patch.unused]]
6336 - name = "synckit-client"
6337 - version = "0.10.0"
@@ -51,6 +51,41 @@
51 51 .collect()
52 52 }
53 53
54 + /// What the settings form was asked to draw.
55 + ///
56 + /// The disclosure is the fact a guarded field turns on: MNW's mail settings put
57 + /// six server questions on the form only while it is open.
58 + pub(crate) struct Prefs {
59 + pub themes: Vec<Theme>,
60 + pub advanced: bool,
61 + }
62 +
63 + declare! {
64 + /// The theme picker with an advanced question behind a disclosure.
65 + ///
66 + /// A form's fields are a bare list, so a guard on one is a marked run over
67 + /// the wrapper they accrete into and the form absorbs it. The field either
68 + /// side of the guarded one is what catches a mark that failed to move.
69 + #[staged]
70 + pub(crate) shape settings(prefs: &Prefs) -> Node;
71 +
72 + form put "/api/users/me/console" awaiting {
73 + submit "Save";
74 +
75 + field Select "theme_id" "Console theme" {
76 + for theme in prefs.themes.iter() {
77 + option Choice::new(theme.id.clone(), theme.name.clone()) {
78 + chosen when theme.selected;
79 + }
80 + }
81 + }
82 +
83 + field Text "endpoint" "Server" when prefs.advanced;
84 +
85 + field Text "note" "Note";
86 + }
87 + }
88 +
54 89 declare! {
55 90 /// The theme picker.
56 91 #[staged]
@@ -82,6 +117,28 @@
82 117
83 118 /// The branch is inside the loop, which is the whole point: the rows are
84 119 /// one compiled body and what differs between them is a branch in it.
120 + fn prefs(advanced: bool) -> Prefs {
121 + Prefs {
122 + themes: themes(Some(1)),
123 + advanced,
124 + }
125 + }
126 +
127 + /// A guarded field is a branch, and the fields either side stay outside it.
128 + #[test]
129 + fn a_guarded_field_is_a_branch_in_the_form() {
130 + let webview = Webview::new();
131 + let residual = quasi_webview::stage::derive(&Webview::new(), settings_staged);
132 + for advanced in [false, true] {
133 + let prefs = prefs(advanced);
134 + assert_eq!(
135 + webview.fragment(&settings(&prefs)),
136 + settings_serve(&residual, &prefs),
137 + "advanced {advanced}"
138 + );
139 + }
140 + }
141 +
85 142 #[test]
86 143 fn the_marked_option_is_a_branch_inside_the_row() {
87 144 let residual = residual();
@@ -1723,23 +1723,45 @@
1723 1723 /// open. R9 holds here as everywhere else -- the field is built whether or not
1724 1724 /// it is asked -- so a supplier feeding a question nobody sees is still called
1725 1725 /// and still answers.
1726 - fn form(action: &Action, body: &[Item]) -> Result<TokenStream> {
1727 - let mut submit = None;
1728 - let mut asked = Vec::new();
1729 - let held = format_ident!("asked", span = Span::call_site());
1726 + /// The statements one form body contributes, and the button it names.
1727 + ///
1728 + /// Recursive because a marked run holds fields of its own: a guarded
1729 + /// disclosure puts six questions on the form or none, and they accrete into
1730 + /// the same wrapper the ungated ones do.
1731 + fn form_fields(
1732 + body: &[Item],
1733 + held: &proc_macro2::Ident,
1734 + asked: &mut Vec<TokenStream>,
1735 + submit: &mut Option<TokenStream>,
1736 + ) -> Result<()> {
1730 1737 for (index, item) in body.iter().enumerate() {
1731 1738 match item {
1732 - // A form's fields are a bare `Vec`, so there is no container
1733 - // carrying marks for them yet. Refused rather than dropped: a
1734 - // staged form with a guarded field would otherwise compile to a
1735 - // residual with no branch where the fill program has one, and the
1736 - // filler would find that out on a request.
1737 - Item::Marked { .. } => {
1738 - return Err(syn::Error::new(
1739 - Span::call_site(),
1740 - "a form's fields cannot be marked yet, so this shape cannot be \
1741 - staged; give `Node::Form` its own marks first",
1742 - ));
1739 + // A form's fields accrete into a wrapper for the same reason a
1740 + // panel's members do, and the form absorbs their marks as it takes
1741 + // them. `settings::email`'s advanced block is the site: six server
1742 + // questions that are on the form only while the disclosure is open.
1743 + Item::Marked { site, varies, body } => {
1744 + let mut inner = Vec::new();
1745 + form_fields(body, held, &mut inner, submit)?;
1746 + let varies = self::varies(varies)?;
1747 + let plan = format_ident!("{}", crate::symbolic::PLAN, span = Span::call_site());
1748 + let at = format_ident!("asked_from_{index}", span = Span::call_site());
1749 + let to = format_ident!("asked_to_{index}", span = Span::call_site());
1750 + asked.push(quote!({
1751 + let #at = ::quasi_router::stage::Marking::placed(&#held);
1752 + #(#inner)*
1753 + let #to = ::quasi_router::stage::Marking::placed(&#held);
1754 + ::quasi_router::stage::Marking::mark(
1755 + &mut #held,
1756 + ::quasi_router::stage::Mark {
1757 + scope: #plan.scope(),
1758 + id: #site,
1759 + varies: #varies,
1760 + from: #at,
1761 + to: #to,
1762 + },
1763 + );
1764 + }));
1743 1765 }
1744 1766 Item::Attribute {
1745 1767 name,
@@ -1757,7 +1779,7 @@
1757 1779 "a form always has a button: `submit` takes no guard",
1758 1780 ));
1759 1781 }
1760 - submit = Some(arg(label)?);
1782 + *submit = Some(arg(label)?);
1761 1783 }
1762 1784 Item::Emit(Emission::Field {
1763 1785 kind,
@@ -1766,7 +1788,7 @@
1766 1788 body,
1767 1789 }) => {
1768 1790 let built = field(kind, name, label, body)?;
1769 - asked.push(quote!(#held.push(#built);));
1791 + asked.push(quote!(#held.value.push(#built);));
1770 1792 }
1771 1793 // A question another shape built, which is the courtesy every other
1772 1794 // container already extends: a table takes a row, a cell takes a
@@ -1776,7 +1798,7 @@
1776 1798 // `span` twice in one form and `milliseconds` four times across two.
1777 1799 Item::Emit(Emission::Include(supplier)) => {
1778 1800 let supplier = self::hole(supplier)?;
1779 - asked.push(quote!(#held.push(::std::convert::Into::into(#supplier));));
1801 + asked.push(quote!(#held.value.push(::std::convert::Into::into(#supplier));));
1780 1802 }
1781 1803 Item::Emit(Emission::Guarded { guard, inner }) => {
1782 1804 let Emission::Field {
@@ -1797,7 +1819,7 @@
1797 1819 asked.push(quote!({
1798 1820 let #question = #built;
1799 1821 if #test {
1800 - #held.push(#question);
1822 + #held.value.push(#question);
1801 1823 }
1802 1824 }));
1803 1825 }
@@ -1825,6 +1847,14 @@
1825 1847 }
1826 1848 }
1827 1849 }
1850 + Ok(())
1851 + }
1852 +
1853 + fn form(action: &Action, body: &[Item]) -> Result<TokenStream> {
1854 + let mut submit = None;
1855 + let mut asked = Vec::new();
1856 + let held = format_ident!("asked", span = Span::call_site());
1857 + form_fields(body, &held, &mut asked, &mut submit)?;
1828 1858 let Some(submit) = submit else {
1829 1859 return Err(syn::Error::new(
1830 1860 action.verb.span(),
@@ -1832,18 +1862,18 @@
1832 1862 ));
1833 1863 };
1834 1864 let action = self::action(action)?;
1835 - Ok(quote! {
1836 - ::quasi_router::Node::Form {
1837 - marks: ::quasi_router::stage::Marks::none(),
1865 + Ok(quote! {{
1866 + let mut #held = ::quasi_router::stage::Staged::plain(::std::vec::Vec::new());
1867 + #(#asked)*
1868 + let mut form = ::quasi_router::Node::Form {
1838 1869 action: #action,
1839 1870 submit: ::std::convert::Into::into(#submit),
1840 - fields: {
1841 - let mut #held = ::std::vec::Vec::new();
1842 - #(#asked)*
1843 - #held
1844 - },
1845 - }
1846 - })
1871 + fields: #held.value,
1872 + marks: ::quasi_router::stage::Marks::none(),
1873 + };
1874 + ::quasi_router::stage::Marking::absorb(&mut form, &#held.marks, 0);
1875 + form
1876 + }})
1847 1877 }
1848 1878
1849 1879 /// One field: what it asks for, by name, under a label.
@@ -3050,15 +3050,19 @@
3050 3050 action,
3051 3051 submit,
3052 3052 fields,
3053 - ..
3053 + marks,
3054 3054 } => {
3055 3055 out.push_str("<form");
3056 3056 class_attr(&["form"], opts, out);
3057 3057 action_attrs(action, Fires::Submit, None, None, false, out);
3058 3058 out.push('>');
3059 + let mut marked = crate::stage::Cursor::open(marks);
3059 3060 for field in fields {
3061 + marked.starts(out);
3060 3062 field_group_html(field, opts, doc, out);
3063 + marked.ends(out);
3061 3064 }
3065 + marked.close(marks);
3062 3066 out.push_str("<button type=\"submit\"");
3063 3067 class_attr(&["button", "act-submit"], opts, out);
3064 3068 out.push('>');