Skip to main content

max / quasi

Remove Field::over and its renderer support Ruled against in b1e49553 and left in place because removal was a renderer-level change with its own test surface. Still zero consumers: a picker over the selection is an Act that asks, so the value rides with the press and the press is the commit wiki explicit-commit-affordance requires. Gone from the router (the field, its builder, both initializers, the docs), from quasi-webview's field write path, from quasi-tui's FieldSpot and the three runtime sites that read it, and from quasi-immediate's three field gather sites. Act::over is untouched: it has 14 consumers and is the commit half of a staged tick. Two renderer tests whose whole subject was the member are gone; the rest kept their subject and lost only the over line. Stale prose references updated, including a rustdoc link in screen.rs that the removal would have broken.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session
https://claude.ai/code/session_01DwpiantpUgohzML4xr6KeQ
Author: Max Johnson <me@maxj.phd> · 2026-08-30 17:25 UTC
Signed with PGP, not checked
Commit: f2a61e395d6947d00c4c8eec5058aa6870de22ef
Parent: a04c7f7
7 files changed, +17 insertions, -213 deletions
@@ -887,7 +887,7 @@
887 887 // hears it the same way it hears a typed one.
888 888 pass.stirred.insert(name.to_owned());
889 889 if let Some(action) = &described.writes {
890 - let mut payload = gathered(pass, described.over.as_deref());
890 + let mut payload = Params::new();
891 891 payload.insert(name.to_owned(), if on { "on" } else { "" }.to_owned());
892 892 pass.fire(action, payload, None);
893 893 }
@@ -916,7 +916,7 @@
916 916 let Some(action) = &described.writes else {
917 917 return;
918 918 };
919 - let mut payload = gathered(pass, described.over.as_deref());
919 + let mut payload = Params::new();
920 920 payload.insert(lower_name.to_owned(), lower_value.to_owned());
921 921 payload.insert(upper_name.to_owned(), upper_value.to_owned());
922 922 pass.fire(action, payload, None);
@@ -1102,11 +1102,7 @@
1102 1102 {
1103 1103 pass.view.wrote(&name, &buffer);
1104 1104 if let Some(action) = &described.writes {
1105 - // The ticks travel with it when the picker writes over the screen's
1106 - // selection: a bulk bar's "Set priority" is a field over the set
1107 - // where its "Delete" is an act over the same set, and a handler
1108 - // reads the two the same way.
1109 - let mut payload = gathered(pass, described.over.as_deref());
1105 + let mut payload = Params::new();
1110 1106 payload.insert(name.clone(), buffer.clone());
1111 1107 // Both ends, because an interval is one answer. A handler reading
1112 1108 // only the end that moved would narrow the filter to a single
@@ -1136,9 +1132,8 @@
1136 1132 if now >= due {
1137 1133 pass.view.consulted(Asking::Field(name.clone()), at);
1138 1134 // This box's value, plus whatever else the question said it
1139 - // carries. No ticks: a consult asks about this box, and
1140 - // `Field::over` belongs to `changes` and says which set a picker
1141 - // writes over.
1135 + // carries. No ticks: a consult asks about this box, never about a
1136 + // set of rows.
1142 1137 let mut payload = pass.view.contributed(&consult.sends);
1143 1138 payload.insert(name.clone(), buffer.clone());
1144 1139 pass.fire(&consult.action, payload, None);
@@ -2141,8 +2141,6 @@
2141 2141 /// wiki `explicit-commit-affordance`: the reader ticks rows, chooses a
2142 2142 /// value, and the write lands on every row ticked. That is a large enough
2143 2143 /// act to deserve a control that says so before it happens.
2144 - /// [`over`](Self::over) states that a write covers the selection; it does
2145 - /// not make a silent one acceptable.
2146 2144 ///
2147 2145 /// **Anything that does not write.** A filter, a sort, a re-ask: a control
2148 2146 /// whose action fetches a different view of data it leaves alone. Saying it
@@ -2189,46 +2187,6 @@
2189 2187 /// [`after`](Consult::after): a wait the description states rather than a
2190 2188 /// number each renderer picks. Writes complete; questions debounce.
2191 2189 pub writes: Option<Action>,
2192 - /// The [`Screen::selection`] this writes over, if it writes over one.
2193 - ///
2194 - /// # Ruled against, `b1e49553`, 2026-08-28. Nothing in the tree carries it.
2195 - ///
2196 - /// It was added because a bulk bar is not only buttons. Measured on
2197 - /// goingson's task list, 2026-08-15: of the five controls over its
2198 - /// selection, "Complete" and "Delete" are acts and three apply a value to
2199 - /// the set, and said with acts alone a project picker over 40 projects is
2200 - /// 40 buttons in a bar. That measurement still holds. What was wrong was
2201 - /// the conclusion drawn from it.
2202 - ///
2203 - /// A picker over the selection is an [`Act`] that
2204 - /// [`asks`](Act::asks), not a [`Field`] that writes. The value rides with
2205 - /// the press, so 40 projects are still one control, and the press is the
2206 - /// commit the reader needs. wiki `explicit-commit-affordance` requires
2207 - /// that commit: a tick stages and never writes (`5f2b8753`), so a bar whose
2208 - /// ticks stage and whose pickers write on change is half staged and half
2209 - /// live. A write landing on N rows that are mostly off screen is the case
2210 - /// where a missing indication costs most.
2211 - ///
2212 - /// The two bulk bars written after that measurement had already reached
2213 - /// this on their own: goingson's `emails.rs` says Snooze with
2214 - /// `Act::asking`, and MNW's content table says Set Price and Add Tag the
2215 - /// same way. goingson's task list was the last consumer and was converted
2216 - /// with this ruling.
2217 - ///
2218 - /// # What it still means, for as long as it is here
2219 - ///
2220 - /// Sends what a [`writes`](Self::writes) write sends, the value under this
2221 - /// field's [`name`](Self::name), plus every ticked row's value under
2222 - /// [`Node::TICKED`], exactly as [`Act::over`] does.
2223 - ///
2224 - /// Only meaningful beside [`writes`](Self::writes): a field with no route
2225 - /// of its own submits with its form, and a form gathering the screen's
2226 - /// ticks is a different thing that nothing has asked for.
2227 - ///
2228 - /// Kept rather than removed here because removing a vocabulary member is
2229 - /// its own decision with its own renderer and test cost, and this task's
2230 - /// business was the bar. It has no consumer as of the conversion above.
2231 - pub over: Option<String>,
2232 2190 /// What this asks while the user is still typing, and how long it waits.
2233 2191 ///
2234 2192 /// See [`Consult`]. Distinct from [`writes`](Self::writes) in the two
@@ -2591,7 +2549,6 @@
2591 2549 value: None,
2592 2550 upper_value: None,
2593 2551 writes: None,
2594 - over: None,
2595 2552 consults: Vec::new(),
2596 2553 suggests: None,
2597 2554 repeats: None,
@@ -2820,28 +2777,16 @@
2820 2777 /// What every renderer draws for a member of [`Act::asks`]. A field there
2821 2778 /// is answered by the press that asked for it, so a
2822 2779 /// [`writes`](Self::writes) route on it would fire a second write for the
2823 - /// same value, and [`over`](Self::over) would send the ticks twice.
2824 - /// [`consults`](Self::consults) survives: asking whether a tag slug is
2825 - /// taken is a question about the value, not a write of it.
2780 + /// same value. [`consults`](Self::consults) survives: asking whether a tag
2781 + /// slug is taken is a question about the value, not a write of it.
2826 2782 #[must_use]
2827 2783 pub fn as_asked(&self) -> Self {
2828 2784 Self {
2829 2785 writes: None,
2830 - over: None,
2831 2786 ..self.clone()
2832 2787 }
2833 2788 }
2834 2789
2835 - /// Changing this writes over the screen's selection, chaining.
2836 - ///
2837 - /// Says nothing on its own: pair it with [`writes`](Self::writes), which
2838 - /// is the route the value and the ticks are sent to.
2839 - #[must_use]
2840 - pub fn over(mut self, selection: impl Into<String>) -> Self {
2841 - self.over = Some(selection.into());
2842 - self
2843 - }
2844 -
2845 2790 /// A select offering the given options.
2846 2791 pub fn select(name: impl Into<String>, label: impl Into<String>, options: Vec<Choice>) -> Self {
2847 2792 Self {
@@ -4758,9 +4703,9 @@
4758 4703 /// screen already knows, and a fragment is markup a terminal or an egui
4759 4704 /// host has nowhere to put.
4760 4705 ///
4761 - /// A field here carries no [`Field::writes`] and no [`Field::over`]. It is
4762 - /// answered by the control that asked for it, so a renderer that honoured a
4763 - /// write on it as well would fire twice for one value.
4706 + /// A field here carries no [`Field::writes`]. It is answered by the control
4707 + /// that asked for it, so a renderer that honoured a write on it as well
4708 + /// would fire twice for one value.
4764 4709 ///
4765 4710 /// # The disclosure is the renderer's
4766 4711 ///
@@ -8107,15 +8052,13 @@
8107 8052 #[test]
8108 8053 fn a_box_a_verb_asked_for_writes_nothing_of_its_own() {
8109 8054 // The value is answered by the press. A write of the box's own would
8110 - // send it twice, and `over` on it would send the ticks twice.
8055 + // send it twice.
8111 8056 let asked = Field::new(layout::FieldKind::Text, "tag", "Tag slug")
8112 8057 .writes(Action::post("/items/tag"))
8113 - .over("chosen")
8114 8058 .consulting(Consult::new(Action::get("/tags/known")))
8115 8059 .as_asked();
8116 8060
8117 8061 assert!(asked.writes.is_none());
8118 - assert!(asked.over.is_none());
8119 8062 // A question about the value survives: asking whether a slug is taken
8120 8063 // is not a write of it.
8121 8064 assert_eq!(asked.consults.len(), 1);
@@ -237,12 +237,6 @@
237 237 /// to a region: the runtime holds the candidates on the view, the drawing
238 238 /// puts them under the box, and picking one writes the value.
239 239 pub suggests: Option<Consult>,
240 - /// The screen's selection it writes over, if it writes over one.
241 - ///
242 - /// [`Spot::Act`]'s `over`, on the other kind of control. A bulk bar holds
243 - /// both: "Delete" is an act over the set and "Set priority" is a picker
244 - /// over it.
245 - pub over: Option<String>,
246 240 }
247 241
248 242 impl Spot {
@@ -850,7 +844,6 @@
850 844 writes: field.writes.clone(),
851 845 consults: field.consults.clone(),
852 846 suggests: field.suggests.clone(),
853 - over: field.over.clone(),
854 847 })),
855 848 });
856 849 }
@@ -1041,7 +1041,7 @@
1041 1041 // asks *while it is being typed into*, which is why
1042 1042 // `after_typing` is not called here.
1043 1043 if let Some(action) = field.writes.clone() {
1044 - let mut payload = self.gathering(field.over.as_deref());
1044 + let mut payload = Params::new();
1045 1045 payload.insert(name, value);
1046 1046 return self.send(&action, payload);
1047 1047 }
@@ -1848,7 +1848,6 @@
1848 1848 }
1849 1849 let action = field.writes.clone()?;
1850 1850 let name = field.name.clone();
1851 - let over = field.over.clone();
1852 1851 // Seeded from the description when the box is untouched, which is what
1853 1852 // is showing in it. `unwrap_or_default` here read an untouched box as
1854 1853 // empty, so walking through a form wrote every prefilled field it
@@ -1859,7 +1858,7 @@
1859 1858 return None;
1860 1859 }
1861 1860 self.view.wrote(&name, &value);
1862 - let mut payload = self.gathering(over.as_deref());
1861 + let mut payload = Params::new();
1863 1862 payload.insert(name, value);
1864 1863 Some(self.send(&action, payload))
1865 1864 }
@@ -1869,7 +1868,6 @@
1869 1868 return Step::Idle;
1870 1869 };
1871 1870 let name = field.name.clone();
1872 - let over = field.over.clone();
1873 1871
1874 1872 // `8032fe61`. A keystroke is not a write. `Field::writes` means the
1875 1873 // change is complete, which is what `quasi-webview` has always emitted
@@ -1888,18 +1886,13 @@
1888 1886 || field.kind.offers_themes());
1889 1887 if let Some(action) = field.writes.clone().filter(|_| !built_up) {
1890 1888 let value = self.view.edit(&name).unwrap_or_default().to_string();
1891 - // The ticks travel with it when the picker writes over the
1892 - // screen's selection, the same set an act over one sends. A
1893 - // bulk bar's "Set priority" is this and its "Delete" is the
1894 - // act, and a handler reads both the same way.
1895 - let mut payload = self.gathering(over.as_deref());
1889 + let mut payload = Params::new();
1896 1890 payload.insert(name, value);
1897 1891 return self.send(&action, payload);
1898 1892 }
1899 1893
1900 1894 // Asking about the value carries the value and nothing else. No ticks:
1901 - // a consult is a question about this box, not a control over a set,
1902 - // and `Field::over` is documented as meaningless without `changes`.
1895 + // a consult is a question about this box, not a control over a set.
1903 1896 // The question the field owns is asked alongside the ones it merely
1904 1897 // asks, and it is asked the same way: the answer differs, not the
1905 1898 // asking. Dropping under its floor closes the list rather than leaving
@@ -1238,27 +1238,6 @@
1238 1238 assert!(matches!(runtime.key(Key::Backspace), Step::Idle));
1239 1239 }
1240 1240
1241 - #[test]
1242 - fn a_consult_asks_about_the_box_and_not_about_the_selection() {
1243 - // `Field::over` belongs to `changes`: it says which set a picker writes
1244 - // over. A question about what is in this box gathers no ticks, or a
1245 - // validate route would receive a bag of unrelated row values.
1246 - let mut runtime = Runtime::new(screen_of([
1247 - Node::list([Row::new("One").selectable(true)]),
1248 - Node::field(
1249 - Field::new(layout::FieldKind::Text, "slug", "Slug")
1250 - .consults(Action::get("/api/validate/slug"))
1251 - .over("rows"),
1252 - ),
1253 - ]));
1254 - // The caret starts on the row; one Tab is past it and into the box.
1255 - runtime.key(Key::Tab);
1256 - let Step::CallAfter { asks } = runtime.key(Key::Char('a')) else {
1257 - panic!("the caret is in the field");
1258 - };
1259 - assert_eq!(asks[0].request.payload.get(Node::TICKED), None);
1260 - }
1261 -
1262 1241 #[test]
1263 1242 fn one_keystroke_can_pose_two_questions_at_two_waits() {
1264 1243 // `N8`. MNW's discover search asks a suggestion route and a results route
@@ -2045,48 +2024,6 @@
2045 2024 assert_eq!(request.path, "/tasks/archive");
2046 2025 }
2047 2026
2048 - #[test]
2049 - fn a_picker_over_the_selection_sends_the_ticks_with_its_value() {
2050 - // The half `Act::over` could not cover: three of the five controls on
2051 - // goingson's bulk bar apply a value to the set rather than acting on it,
2052 - // and said with acts alone a project picker over forty projects is forty
2053 - // buttons.
2054 - let mut runtime = Runtime::new(
2055 - Screen::sidebar_content("Tasks").selecting("chosen").with(
2056 - Slot::new("main", RegionKind::Pane)
2057 - .with(Node::Table {
2058 - columns: vec![quasi_router::Column::new("title")],
2059 - rows: vec![Cells::new(["First"]).ticking("t-1", false)],
2060 - more: None,
2061 - })
2062 - .with(Node::field(
2063 - Field::new(layout::FieldKind::Text, "priority", "Priority")
2064 - .writes(Action::post("/tasks/list/priority"))
2065 - .over("chosen"),
2066 - )),
2067 - ),
2068 - );
2069 -
2070 - assert!(matches!(runtime.key(Key::Char(' ')), Step::Idle));
2071 - runtime.key(Key::Tab);
2072 - runtime.key(Key::Char('H'));
2073 - // On leaving rather than on the keystroke since `8032fe61`. What this test
2074 - // is about is unchanged: the ticks travel with the picker's value.
2075 - let Step::Call(request) = runtime.key(Key::Tab) else {
2076 - panic!("a field that writes as it changes calls when the value is complete");
2077 - };
2078 -
2079 - assert_eq!(request.path, "/tasks/list/priority");
2080 - assert_eq!(request.payload.get("priority"), Some("H"));
2081 - assert_eq!(
2082 - request
2083 - .payload
2084 - .get_all(quasi_router::Node::TICKED)
2085 - .collect::<Vec<_>>(),
2086 - ["t-1"]
2087 - );
2088 - }
2089 -
2090 2027 #[test]
2091 2028 fn a_control_that_asks_for_a_value_draws_the_box_and_sends_what_is_typed() {
2092 2029 // `033ff3ca`. MNW's bulk bar reveals a box behind "Set Price"; a terminal
@@ -315,34 +315,10 @@
315 315 }
316 316
317 317 if let Some(action) = writes {
318 - // A field writing over the screen's selection gathers every tick, the
319 - // same way a commit act does and through the same selector. `Act::over`
320 - // was the whole story until a bulk bar turned out to hold pickers as
321 - // well as buttons: "set the project on these forty" is a control with
322 - // options, not forty buttons.
323 - let gathers = field
324 - .over
325 - .as_ref()
326 - .map(|_| format!(".{}", class("row-select", opts)));
327 318 out.push_str("<div");
328 319 class_attr(&["field-writes"], opts, out);
329 320 width_attr(field.width, out);
330 - // The same hook an act over a selection carries. The script disables
331 - // the control inside this wrapper rather than the wrapper, which is
332 - // what a browser can actually disable.
333 - if let Some(over) = &field.over {
334 - out.push_str(" data-over=\"");
335 - escape_into(over, out);
336 - out.push('"');
337 - }
338 - action_attrs(
339 - action,
340 - Fires::ChangeInside,
341 - None,
342 - gathers.as_deref(),
343 - false,
344 - out,
345 - );
321 + action_attrs(action, Fires::ChangeInside, None, None, false, out);
346 322 out.push('>');
347 323 }
348 324
@@ -3408,15 +3408,6 @@
3408 3408 ));
3409 3409 assert!(act.contains("data-over=\"chosen\""), "{act}");
3410 3410
3411 - // A picker over the set carries it too. The script disables the control
3412 - // inside the wrapper, which is what a browser can disable.
3413 - let picker = fragment(&Node::field(
3414 - Field::select("priority", "Priority", vec![Choice::plain("High")])
3415 - .writes(Action::post("/tasks/list/priority"))
3416 - .over("chosen"),
3417 - ));
3418 - assert!(picker.contains("data-over=\"chosen\""), "{picker}");
3419 -
3420 3411 // And an ordinary control does not, so the script never touches it.
3421 3412 let plain = fragment(&Node::Act(Act::new("Save", Action::post("/save"))));
3422 3413 assert!(!plain.contains("data-over"), "{plain}");
@@ -3456,8 +3447,7 @@
3456 3447 let html = fragment(&Node::Act(
3457 3448 Act::new("Add Tag", Action::post("/items/tag")).asking(
3458 3449 Field::new(layout::FieldKind::Text, "tag", "Tag slug")
3459 - .writes(Action::post("/items/tag/live"))
3460 - .over("chosen"),
3450 + .writes(Action::post("/items/tag/live")),
3461 3451 ),
3462 3452 ));
3463 3453
@@ -3601,29 +3591,6 @@
3601 3591 assert!(!plain.contains("row-select"), "{plain}");
3602 3592 }
3603 3593
3604 - #[test]
3605 - fn a_picker_can_write_over_the_selection_and_still_find_its_own_value() {
3606 - // A bulk bar is not only buttons: "set the project on these forty" is a
3607 - // control with options. Both selectors land in ONE `hx-include`, because
3608 - // two of them on an element is markup a parser drops half of -- which is
3609 - // what a field over a selection would have emitted before `Fires::include`.
3610 - let html = fragment(&Node::field(
3611 - Field::select(
3612 - "priority",
3613 - "Priority",
3614 - vec![Choice::plain("High"), Choice::plain("Low")],
3615 - )
3616 - .writes(Action::post("/tasks/list/priority"))
3617 - .over("chosen"),
3618 - ));
3619 -
3620 - assert_eq!(html.matches("hx-include").count(), 1, "{html}");
3621 - assert!(
3622 - html.contains("hx-include=\"find input, find select, find textarea, .row-select\""),
3623 - "{html}"
3624 - );
3625 - }
3626 -
3627 3594 #[test]
3628 3595 fn a_field_that_writes_without_a_selection_gathers_only_itself() {
3629 3596 let html = fragment(&Node::field(