Skip to main content

max / quasi

Name the selection, so a tick has somewhere to go `Row::selected` said a row could be ticked and nothing said what the tick was for. A webview hid the hole, because the browser owns a checkbox's checked state, and every app then wrote its own JS to gather the boxes back up. A terminal could not: it drew the box, bound the key, and the key did nothing, which is worse than not drawing the box. The screen names the set, `Row::ticking` says what a row's tick contributes, and `Act::over` says a control acts on the whole of it. Every ticked value is sent under `Node::TICKED`, repeated once per member -- repeated rather than joined, because that is what `Params::get_all` is for and a delimiter would have to be one no value can contain. Ticking stages and never writes, per wiki `explicit-commit-affordance`: a change with no obvious indication is confusing, so the commit control is what locks it in. `Row::toggle` describes the other thing and is left alone, because stopping the screens that use it is work in the apps that have them. quasi-tui holds the set in `View`, beside the edit buffer and the scroll offset it already held, and draws a tick from what is held rather than from what arrived -- the rule `39057019` settled for a field, which is that the description says what arrived and the view says what the user has done since. quasi-webview gives the checkbox its own name and value, and the commit control an hx-include over the class the ticks already share, so the gathering JS has nothing left to do. The include comes out of `action_attrs` and the transport still enters this crate in the two functions the guard names. `Act::over` is not matched against `Screen::selection`, and that is measured rather than lax: a fragment carries no screen, so a webview rendering one has nothing to match against while a terminal, which keeps the screen beside it, does. The two hosts would then disagree about a typo. Both read it the same way, and the name starts choosing between sets when a screen can hold two.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-12 23:51 UTC
Signed with PGP, not checked
Commit: 315ce05fb3f57cf672564db13d88349cee56b520
Parent: 3253b05
9 files changed, +553 insertions, -46 deletions
M Cargo.lock +8 -8
@@ -5151,14 +5151,6 @@
5151 5151 source = "registry+https://github.com/rust-lang/crates.io-index"
5152 5152 checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
5153 5153
5154 - [[patch.unused]]
5155 - name = "synckit-client"
5156 - version = "0.8.0"
5157 -
5158 - [[patch.unused]]
5159 - name = "synckit-config"
5160 - version = "0.2.0"
5161 -
5162 5154 [[patch.unused]]
5163 5155 name = "kberg"
5164 5156 version = "0.1.0"
@@ -5182,3 +5174,11 @@
5182 5174 [[patch.unused]]
5183 5175 name = "makeover-immediate"
5184 5176 version = "0.15.0"
5177 +
5178 + [[patch.unused]]
5179 + name = "synckit-client"
5180 + version = "0.8.0"
5181 +
5182 + [[patch.unused]]
5183 + name = "synckit-config"
5184 + version = "0.2.0"
@@ -1208,6 +1208,37 @@
1208 1208 /// Screen-scoped, because a screen is what this describes. An app-wide
1209 1209 /// shortcut belongs to the app and is not a fact about any one screen.
1210 1210 pub key: Option<String>,
1211 + /// The [`Screen::selection`] this acts on, if it acts on one.
1212 + ///
1213 + /// `5f2b8753`. This is what makes a commit control readable: "Archive" over
1214 + /// a selection is a different sentence from "Archive" on a row, and until
1215 + /// this existed the difference lived in whichever JS gathered the checked
1216 + /// boxes.
1217 + ///
1218 + /// Every ticked [`Row::value`] is sent under [`Node::TICKED`], repeated
1219 + /// once per member. Repeated rather than joined, because a name appearing
1220 + /// many times is what [`Params::get_all`] is for and a delimiter would have
1221 + /// to be one no value can contain.
1222 + ///
1223 + /// # The name does not select between sets yet, and cannot
1224 + ///
1225 + /// A screen holds one selection ([`Screen::selection`]), so being set at
1226 + /// all is what makes a control a commit control, and the name is what makes
1227 + /// it *readable* — "Archive" over `chosen` is a different sentence from
1228 + /// "Archive" on a row.
1229 + ///
1230 + /// Matching it against the screen's name was the first shape and it does
1231 + /// not work, because a renderer does not always have the screen: an
1232 + /// [`Outcome::Fragment`] replaces a region and carries no screen at all, so
1233 + /// a webview rendering one would have had to guess and a terminal, which
1234 + /// keeps the screen beside it, would not. The two hosts would then disagree
1235 + /// about a typo, which is exactly the drift this vocabulary exists to stop.
1236 + /// So both read it the same way, and the name starts choosing between sets
1237 + /// on the day [`Screen::selection`] becomes a map.
1238 + ///
1239 + /// [`Params::get_all`]: crate::Params::get_all
1240 + /// [`Outcome::Fragment`]: crate::Outcome::Fragment
1241 + pub over: Option<String>,
1211 1242 }
1212 1243
1213 1244 impl Act {
@@ -1220,9 +1251,21 @@
1220 1251 state: None,
1221 1252 confirm: None,
1222 1253 key: None,
1254 + over: None,
1223 1255 }
1224 1256 }
1225 1257
1258 + /// This acts on the screen's selection, by name.
1259 + ///
1260 + /// The commit half of a staged tick. See [`over`](Self::over) for what
1261 + /// reaches the handler, and [`Screen::selection`] for why a tick stages
1262 + /// rather than writes.
1263 + #[must_use]
1264 + pub fn over(mut self, selection: impl Into<String>) -> Self {
1265 + self.over = Some(selection.into());
1266 + self
1267 + }
1268 +
1226 1269 /// Ask this before doing it.
1227 1270 #[must_use]
1228 1271 pub fn confirm(mut self, prompt: impl Into<String>) -> Self {
@@ -1400,6 +1443,26 @@
1400 1443 /// control nothing draws — and [`Row::toggling`] is the constructor that
1401 1444 /// makes them agree.
1402 1445 pub toggle: Option<Action>,
1446 + /// What this row's tick contributes to the screen's selection.
1447 + ///
1448 + /// `5f2b8753`. [`selected`](Self::selected) says the row can be ticked;
1449 + /// this says what ticking it *means*, which is the half that was missing.
1450 + /// A set of ticks with nothing in them is not a selection, so a renderer
1451 + /// holding [`Screen::selection`] holds these.
1452 + ///
1453 + /// `value` rather than `id`, matching [`Choice::value`]: throughout this
1454 + /// vocabulary it is the word for what a control contributes when it is
1455 + /// chosen, and a row's tick is the same kind of fact.
1456 + ///
1457 + /// A selectable row without one is the dead affordance this member exists
1458 + /// to end, and [`Row::ticking`] is the constructor that cannot produce it.
1459 + /// It is not enforced here, for [`toggle`](Self::toggle)'s reason: a
1460 + /// description layer that refused to hold a half-built row would refuse it
1461 + /// at the moment the app is still building it.
1462 + ///
1463 + /// [`Choice::value`]: Choice::value
1464 + /// [`Screen::selection`]: Screen::selection
1465 + pub value: Option<String>,
1403 1466 }
1404 1467
1405 1468 impl Row {
@@ -1482,12 +1545,31 @@
1482 1545 ///
1483 1546 /// A row is not selectable until something says so, which is what keeps a
1484 1547 /// checkbox off every list in the app.
1548 + ///
1549 + /// Says nothing about what the tick contributes, so on a screen with a
1550 + /// [`selection`](Screen::selection) it draws a box that joins no set. Reach
1551 + /// for [`ticking`](Self::ticking) instead; this stays for the screens whose
1552 + /// tick is the write, beside [`toggling`](Self::toggling).
1485 1553 #[must_use]
1486 1554 pub const fn selectable(mut self, ticked: bool) -> Self {
1487 1555 self.selected = Some(ticked);
1488 1556 self
1489 1557 }
1490 1558
1559 + /// Make the row tickable under this value, and say whether it is ticked.
1560 + ///
1561 + /// Sets [`selected`](Self::selected) and [`value`](Self::value) together,
1562 + /// which is the pair a screen's [`selection`](Screen::selection) needs.
1563 + /// The two halves exist separately for [`toggling`](Self::toggling)'s
1564 + /// reason — state and identity are different facts about the row — and
1565 + /// this is the constructor that stops them being written apart.
1566 + #[must_use]
1567 + pub fn ticking(mut self, value: impl Into<String>, ticked: bool) -> Self {
1568 + self.selected = Some(ticked);
1569 + self.value = Some(value.into());
1570 + self
1571 + }
1572 +
1491 1573 /// The route selecting this row.
1492 1574 #[must_use]
1493 1575 pub fn activate(mut self, action: Action) -> Self {
@@ -2093,6 +2175,23 @@
2093 2175 /// and `selected` in the next.
2094 2176 pub const SELECTED: &'static str = "value";
2095 2177
2178 + /// The parameter name an [`Act::over`] sends each ticked value under.
2179 + ///
2180 + /// [`SELECTED`](Self::SELECTED)'s sibling, named here for the same reason:
2181 + /// a convention agreed separately by each renderer and each handler is a
2182 + /// convention that holds until one of them is written by someone else.
2183 + ///
2184 + /// Distinct from `SELECTED` rather than shared with it, because the two
2185 + /// carry different counts. A [`Select`](Self::Select) sends one value and a
2186 + /// handler reads it with [`Params::get`]; a selection sends however many
2187 + /// are ticked, including none, and a handler reads it with
2188 + /// [`Params::get_all`]. One name for both would make "the one thing picked"
2189 + /// and "the first of the things ticked" the same read.
2190 + ///
2191 + /// [`Params::get`]: crate::Params::get
2192 + /// [`Params::get_all`]: crate::Params::get_all
2193 + pub const TICKED: &'static str = "ticked";
2194 +
2096 2195 /// A page title.
2097 2196 pub fn page(text: impl Into<String>) -> Self {
2098 2197 Self::Heading {
@@ -2243,6 +2342,41 @@
2243 2342 /// anything about is an indexable website — and an `Option` would make
2244 2343 /// "nobody said" and "indexable" two spellings of one thing.
2245 2344 pub discovery: Discovery,
2345 + /// The name of the set this screen's ticks go into, if it holds one.
2346 + ///
2347 + /// `5f2b8753`. [`Row::selected`] said a row could be ticked and nothing
2348 + /// said what the tick was *for*, so the tick had nowhere to go: a webview
2349 + /// hid the hole because the browser owns a checkbox's checked state, and
2350 + /// every app then wrote its own JS to gather the boxes back up. A terminal
2351 + /// could not hide it. It drew the `[ ]`, bound the key, and the key did
2352 + /// nothing, which is worse than not drawing the box.
2353 + ///
2354 + /// So the screen names the set, each [`Row::value`] is what that row's tick
2355 + /// contributes, and [`Act::over`] is how a control says it acts on the
2356 + /// whole of it. The renderer holds the set the way `quasi-tui` already
2357 + /// holds an edit buffer and a scroll offset, and the commit control reads
2358 + /// it by name.
2359 + ///
2360 + /// # Ticking never writes
2361 + ///
2362 + /// Wiki `explicit-commit-affordance`, the general rule: a change that
2363 + /// happens with no obvious indication is confusing, so a tick stages and
2364 + /// the commit control is what locks it in. [`Row::toggle`] describes the
2365 + /// other thing — screens where the tick *is* the write — and is left alone
2366 + /// here rather than removed, because stopping those screens is work in the
2367 + /// apps that have them.
2368 + ///
2369 + /// # One set per screen
2370 + ///
2371 + /// A screen with two independent sets has not been measured. Naming one is
2372 + /// the smallest thing that closes the hole, and the field grows to a map
2373 + /// when an app turns up wanting two, on the same rule every other member
2374 + /// here arrived under.
2375 + ///
2376 + /// [`Row::selected`]: Row::selected
2377 + /// [`Row::value`]: Row::value
2378 + /// [`Act::over`]: Act::over
2379 + pub selection: Option<String>,
2246 2380 }
2247 2381
2248 2382 /// How a screen is found, shared and indexed.
@@ -2350,9 +2484,20 @@
2350 2484 slots: Vec::new(),
2351 2485 notices: Vec::new(),
2352 2486 discovery: Discovery::default(),
2487 + selection: None,
2353 2488 }
2354 2489 }
2355 2490
2491 + /// This screen holds a set of ticks under this name, chaining.
2492 + ///
2493 + /// The rows that join it say so with [`Row::ticking`], and the control that
2494 + /// acts on it with [`Act::over`]. See [`selection`](Self::selection).
2495 + #[must_use]
2496 + pub fn selecting(mut self, name: impl Into<String>) -> Self {
2497 + self.selection = Some(name.into());
2498 + self
2499 + }
2500 +
2356 2501 /// Whether a crawler should index this screen, chaining.
2357 2502 #[must_use]
2358 2503 pub fn indexed(mut self, indexable: bool) -> Self {
@@ -52,6 +52,12 @@
52 52 /// focus *starts* and never moves it afterwards, because a redraw that
53 53 /// pulled the caret back would take the keyboard off the user.
54 54 wants_focus: bool,
55 + /// The screen's selection this acts on, if it acts on one.
56 + ///
57 + /// The commit half of a staged tick. The runtime reads the set the view
58 + /// is holding and sends it with the call, which is the whole of what
59 + /// makes a bulk action work without a line of gathering code.
60 + over: Option<String>,
55 61 },
56 62 /// Text that goes somewhere. Enter follows it.
57 63 Link {
@@ -76,6 +82,14 @@
76 82 toggle: Option<Action>,
77 83 /// Whether it is ticked, and whether it can be.
78 84 ticked: Option<bool>,
85 + /// What its tick contributes to the screen's selection.
86 + ///
87 + /// `None` on a row that names nothing, which on a screen holding a
88 + /// selection is the dead affordance `5f2b8753` was filed for: the box
89 + /// is drawn, the key is bound, and the tick has nowhere to go. The
90 + /// runtime declines to bind the key in that case rather than binding it
91 + /// to nothing.
92 + value: Option<String>,
79 93 /// What it offers without showing: reached by a key here, by
80 94 /// right-click on a pointer host.
81 95 menu: Vec<Act>,
@@ -224,6 +238,7 @@
224 238 confirm: None,
225 239 key: None,
226 240 wants_focus: false,
241 + over: None,
227 242 });
228 243 }
229 244 }
@@ -274,6 +289,7 @@
274 289 activate: Some(activate),
275 290 toggle: None,
276 291 ticked: None,
292 + value: None,
277 293 menu: Vec::new(),
278 294 });
279 295 }
@@ -323,6 +339,7 @@
323 339 confirm: act.confirm.clone(),
324 340 key: act.key.clone(),
325 341 wants_focus: matches!(act.state, Some(layout::State::Focus)),
342 + over: act.over.clone(),
326 343 },
327 344 });
328 345 }
@@ -374,6 +391,7 @@
374 391 activate: row.activate.clone(),
375 392 toggle: row.toggle.clone(),
376 393 ticked: row.selected,
394 + value: row.value.clone(),
377 395 menu: row.menu.clone(),
378 396 },
379 397 });
@@ -12,7 +12,7 @@
12 12 use ratatui::style::{Modifier, Style};
13 13 use ratatui::text::{Line, Span};
14 14
15 - use crate::{Pass, Tui, below};
15 + use crate::{Pass, Tui, View, below};
16 16
17 17 /// The rows `node` wants at `width`.
18 18 pub(crate) fn height(tui: &Tui, node: &Node, width: u16) -> u16 {
@@ -206,7 +206,7 @@
206 206 if at.height == 0 {
207 207 continue;
208 208 }
209 - draw_gutter(tui, row, focused, at, buf);
209 + draw_gutter(tui, pass.view, row, focused, at, buf);
210 210 let body = Rect {
211 211 x: at.x + gutter,
212 212 width: at.width.saturating_sub(gutter),
@@ -355,11 +355,19 @@
355 355 /// and reversing all of it turns a list into a slab; the gutter is the column
356 356 /// the affordances already live in, so it is where "you are on this one" can be
357 357 /// said without repainting the content.
358 - fn draw_gutter(tui: &Tui, row: &Row, focused: bool, area: Rect, buf: &mut Buffer) {
359 - let tick = match row.selected {
360 - Some(true) => "[x]",
361 - Some(false) => "[ ]",
362 - None => "",
358 + fn draw_gutter(tui: &Tui, view: &View, row: &Row, focused: bool, area: Rect, buf: &mut Buffer) {
359 + // A tickable row that names a value is drawn from the set the view is
360 + // holding, and only an unnamed one falls back to what the description
361 + // said. That is the same rule `39057019` settled for a field: the
362 + // description says what arrived, the view says what the user has done
363 + // since, and a redraw that went back to the description would undo the
364 + // tick the moment anything else on the screen changed.
365 + let tick = match (row.selected, row.value.as_deref()) {
366 + (Some(_), Some(value)) if view.is_ticked(value) => "[x]",
367 + (Some(_), Some(_)) => "[ ]",
368 + (Some(true), None) => "[x]",
369 + (Some(false), None) => "[ ]",
370 + (None, _) => "",
363 371 };
364 372 if !tick.is_empty() {
365 373 buf.set_stringn(
@@ -107,7 +107,7 @@
107 107 /// The request that produced the screen currently showing.
108 108 here: Option<Request>,
109 109 /// A control waiting on its own question being answered.
110 - asked: Option<Action>,
110 + asked: Option<(Action, Params)>,
111 111 /// Something to say once the screen it belongs to has arrived.
112 112 saying: Option<Message>,
113 113 }
@@ -124,6 +124,7 @@
124 124 asked: None,
125 125 saying: None,
126 126 };
127 + runtime.view.seed(&runtime.screen);
127 128 runtime.start_focus();
128 129 runtime
129 130 }
@@ -197,9 +198,13 @@
197 198 // A question owns the keyboard until it is answered. Anything that is
198 199 // not yes is no, which is the safe way round for a prompt that is only
199 200 // ever raised by something destructive.
200 - if let Some(action) = self.asked.take() {
201 + if let Some((action, payload)) = self.asked.take() {
201 202 return match key {
202 - Key::Char('y' | 'Y') | Key::Enter => Self::call(&action),
203 + // The selection was gathered when the question was raised, not
204 + // now. Nothing can tick while a prompt owns the keyboard, so
205 + // the two are the same set -- and reading it here would mean
206 + // the answer depended on state the user could not see.
207 + Key::Char('y' | 'Y') | Key::Enter => Self::send(&action, payload),
203 208 _ => Step::Idle,
204 209 };
205 210 }
@@ -247,14 +252,20 @@
247 252
248 253 Key::Enter => match here {
249 254 Some(Spot::Act {
250 - action, confirm, ..
251 - }) => match confirm {
252 - Some(prompt) => {
253 - self.asked = Some(action);
254 - Step::Ask(prompt)
255 + action,
256 + confirm,
257 + over,
258 + ..
259 + }) => {
260 + let payload = self.gathering(over.as_deref());
261 + match confirm {
262 + Some(prompt) => {
263 + self.asked = Some((action, payload));
264 + Step::Ask(prompt)
265 + }
266 + None => Self::send(&action, payload),
255 267 }
256 - None => Self::call(&action),
257 - },
268 + }
258 269 Some(Spot::Submit { action, names }) => {
259 270 let payload = self.view.submission(
260 271 &names,
@@ -278,14 +289,31 @@
278 289
279 290 Key::Char(' ') if !self.editing() => match here {
280 291 // A tick is a write when the description says it is, and
281 - // client-side selection when it does not. The second is not
282 - // wired to anything yet: nothing names what a selection is
283 - // *for*, which is `033ff3ca`, so a tick with no `toggle` moves
284 - // no state a route could read.
292 + // staged selection when it does not. `toggle` first, because a
293 + // row carrying one has said the tick *is* the write and that
294 + // claim beats the screen's set.
285 295 Some(Spot::Row {
286 296 toggle: Some(action),
287 297 ..
288 298 }) => Self::call(&action),
299 + // Otherwise it joins or leaves the set the screen names. The
300 + // hole `5f2b8753` was filed for was here: this used to be
301 + // `Step::Idle`, so the box was drawn, the key was bound, and
302 + // pressing it did nothing.
303 + //
304 + // Still idle when a row names no value or the screen holds no
305 + // set, which is the same description bug one step earlier. A
306 + // key bound to nothing is what this stopped doing, so it does
307 + // not start doing it again by accepting a tick that cannot be
308 + // read back.
309 + Some(Spot::Row {
310 + ticked: Some(_),
311 + value: Some(value),
312 + ..
313 + }) if self.screen.selection.is_some() => {
314 + self.view.tick(&value);
315 + Step::Idle
316 + }
289 317 _ => Step::Idle,
290 318 },
291 319
@@ -304,12 +332,16 @@
304 332 Spot::Act {
305 333 action,
306 334 key: Some(key),
335 + over,
307 336 ..
308 - } if *key == pressed => Some(action.clone()),
337 + } if *key == pressed => Some((action.clone(), over.clone())),
309 338 _ => None,
310 339 });
311 340 match claimed {
312 - Some(action) => Self::call(&action),
341 + Some((action, over)) => {
342 + let payload = self.gathering(over.as_deref());
343 + Self::send(&action, payload)
344 + }
313 345 None => Step::Idle,
314 346 }
315 347 }
@@ -341,6 +373,10 @@
341 373 self.remember(request, address.as_ref());
342 374 self.screen = screen;
343 375 self.view.reset();
376 + // The rows a new screen says are already ticked. After this the
377 + // user's ticks are the truth, which is why it is applied once
378 + // on arrival rather than read on every draw.
379 + self.view.seed(&self.screen);
344 380 self.start_focus();
345 381 self.announce();
346 382 None
@@ -505,6 +541,29 @@
505 541 }
506 542 }
507 543
544 + /// The ticks a control acting over a selection sends with its call.
545 + ///
546 + /// Empty for a control that names no selection. A control that names one
547 + /// sends the whole set, whatever it called it: see [`Act::over`] for why
548 + /// the name is not matched against the screen's, which is that a webview
549 + /// rendering a fragment has no screen to match it against and the two
550 + /// hosts would then disagree about a typo.
551 + ///
552 + /// Empty is also what a control over a set nobody ticked sends, and the two
553 + /// are deliberately the same. A handler receives a bulk action over
554 + /// nothing, which is a case it has to handle regardless.
555 + ///
556 + /// [`Act::over`]: quasi_router::Act::over
557 + fn gathering(&self, over: Option<&str>) -> Params {
558 + let mut payload = Params::new();
559 + if over.is_some() {
560 + for value in self.view.ticks() {
561 + payload.insert(Node::TICKED.to_owned(), value.to_owned());
562 + }
563 + }
564 + payload
565 + }
566 +
508 567 /// An action as something the host can ask.
509 568 fn call(action: &Action) -> Step {
510 569 Self::send(action, Params::new())
@@ -77,6 +77,16 @@
77 77 }
78 78
79 79 /// Draw a whole screen.
80 + /// What a runtime draws, which is the screen *and* what the user has done to
81 + /// it. `shown` builds a fresh `View`, so it draws the description alone -- and
82 + /// a tick lives in the view, which is the whole of `5f2b8753`.
83 + fn held(runtime: &Runtime, width: u16, height: u16) -> String {
84 + let area = Rect::new(0, 0, width, height);
85 + let mut buf = Buffer::empty(area);
86 + runtime.draw(&tui(), area, &mut buf);
87 + rows(&buf).join(" ")
88 + }
89 +
80 90 fn shown(screen: &Screen, width: u16, height: u16) -> Vec<String> {
81 91 let area = Rect::new(0, 0, width, height);
82 92 let mut buf = Buffer::empty(area);
@@ -1006,3 +1016,109 @@
1006 1016 assert!(out.contains("sidebar-badge"), "{out}");
1007 1017 assert!(out.contains("are called"), "{out}");
1008 1018 }
1019 +
1020 + #[test]
1021 + fn two_ticks_and_a_commit_control_send_both_values() {
1022 + // The end of `5f2b8753`. The screen names the set, each row says what its
1023 + // tick contributes, and the control says it acts over the set -- so a bulk
1024 + // action works from one description with nothing gathering the ticks by
1025 + // hand on either host.
1026 + let mut runtime = Runtime::new(
1027 + Screen::sidebar_content("Mail").selecting("chosen").with(
1028 + Slot::new("main", RegionKind::Pane)
1029 + .with(Node::list([
1030 + Row::new("First").ticking("m-1", false),
1031 + Row::new("Second").ticking("m-2", false),
1032 + Row::new("Third").ticking("m-3", false),
1033 + ]))
1034 + .with(Node::Act(
1035 + Act::new("Archive", Action::post("/mail/archive")).over("chosen"),
1036 + )),
1037 + ),
1038 + );
1039 +
1040 + // Walk to the first row and tick it, then the second.
1041 + assert!(matches!(runtime.key(Key::Char(' ')), Step::Idle));
1042 + runtime.key(Key::Tab);
1043 + assert!(matches!(runtime.key(Key::Char(' ')), Step::Idle));
1044 +
1045 + // Past the third row, onto the control.
1046 + runtime.key(Key::Tab);
1047 + runtime.key(Key::Tab);
1048 + let Step::Call(request) = runtime.key(Key::Enter) else {
1049 + panic!("the commit control calls its route");
1050 + };
1051 +
1052 + assert_eq!(request.path, "/mail/archive");
1053 + assert_eq!(
1054 + request
1055 + .payload
1056 + .get_all(quasi_router::Node::TICKED)
1057 + .collect::<Vec<_>>(),
1058 + ["m-1", "m-2"]
1059 + );
1060 + }
1061 +
1062 + #[test]
1063 + fn a_tick_is_staged_and_never_a_write() {
1064 + // Wiki `explicit-commit-affordance`: a change that happens with no obvious
1065 + // indication is confusing, so space stages and the commit control locks it
1066 + // in. A row carrying `toggle` is the other case and still writes.
1067 + let mut runtime = Runtime::new(
1068 + Screen::sidebar_content("Mail").selecting("chosen").with(
1069 + Slot::new("main", RegionKind::Pane)
1070 + .with(Node::list([Row::new("First").ticking("m-1", false)])),
1071 + ),
1072 + );
1073 +
1074 + assert!(
1075 + matches!(runtime.key(Key::Char(' ')), Step::Idle),
1076 + "a tick calls no route"
1077 + );
1078 + let out = held(&runtime, 40, 6);
1079 + assert!(out.contains("[x]"), "{out}");
1080 + }
1081 +
1082 + #[test]
1083 + fn a_tickable_row_that_names_nothing_still_binds_no_key() {
1084 + // The dead affordance `5f2b8753` was filed for, one step earlier: a row
1085 + // that can be ticked and says nothing about what the tick contributes has
1086 + // nowhere to put it, so the key stays unbound rather than being bound to
1087 + // nothing.
1088 + let mut runtime = Runtime::new(Screen::sidebar_content("Mail").selecting("chosen").with(
1089 + Slot::new("main", RegionKind::Pane).with(Node::list([Row::new("First").selectable(false)])),
1090 + ));
1091 +
1092 + assert!(matches!(runtime.key(Key::Char(' ')), Step::Idle));
1093 + let out = held(&runtime, 40, 6);
1094 + assert!(!out.contains("[x]"), "{out}");
1095 + }
1096 +
1097 + #[test]
1098 + fn a_described_tick_starts_the_set_off_and_the_user_can_take_it_back() {
1099 + // A description can say a row arrives ticked, and after that the user's
1100 + // ticks are the truth -- the same rule `39057019` settled for a field.
1101 + let mut runtime = Runtime::new(
1102 + Screen::sidebar_content("Mail").selecting("chosen").with(
1103 + Slot::new("main", RegionKind::Pane)
1104 + .with(Node::list([Row::new("First").ticking("m-1", true)]))
1105 + .with(Node::Act(
1106 + Act::new("Archive", Action::post("/mail/archive")).over("chosen"),
1107 + )),
1108 + ),
1109 + );
1110 +
1111 + let out = held(&runtime, 40, 6);
1112 + assert!(out.contains("[x]"), "{out}");
1113 +
1114 + // Untick it, then commit: the set is empty, not the description's.
1115 + runtime.key(Key::Char(' '));
1116 + runtime.key(Key::Tab);
1117 + let Step::Call(request) = runtime.key(Key::Enter) else {
1118 + panic!("the commit control calls its route");
1119 + };
1120 + assert_eq!(
1121 + request.payload.get_all(quasi_router::Node::TICKED).count(),
1122 + 0
1123 + );
1124 + }
@@ -28,7 +28,7 @@
28 28 //! what has focus, how far a pane is scrolled, and where the back button goes.
29 29 //! None of the four is in a description and none of them should be.
30 30
31 - use std::collections::BTreeMap;
31 + use std::collections::{BTreeMap, BTreeSet};
32 32
33 33 use makeover_layout as layout;
34 34 use quasi_router::{Params, Screen};
@@ -53,6 +53,22 @@
53 53 /// How far each region has been scrolled, in rows, by
54 54 /// [`Slot::id`](quasi_router::Slot::id).
55 55 scroll: BTreeMap<String, u16>,
56 + /// What has been ticked, by [`Row::value`](quasi_router::Row::value).
57 + ///
58 + /// `5f2b8753`. The fifth thing the browser was quietly providing, and it
59 + /// arrived last because it is the one a browser does *not* fully provide:
60 + /// a checkbox owns its own checked state, but nothing gathers the boxes
61 + /// back up, so every app wrote that part by hand. Here there is no
62 + /// checkbox to own anything, which is what made the hole visible.
63 + ///
64 + /// A set rather than a map from name to bool. Absent is not ticked, and
65 + /// the two spellings of that would otherwise drift.
66 + ///
67 + /// Only the current screen's set, because a screen names one
68 + /// ([`Screen::selection`](quasi_router::Screen::selection)) and a new
69 + /// screen is a new set. Which set it is does not need storing: the screen
70 + /// beside this one says.
71 + ticked: BTreeSet<String>,
56 72 }
57 73
58 74 impl View {
@@ -160,14 +176,63 @@
160 176 self.scroll.insert(region.to_string(), rows);
161 177 }
162 178
179 + /// Whether this value is ticked.
180 + #[must_use]
181 + pub fn is_ticked(&self, value: &str) -> bool {
182 + self.ticked.contains(value)
183 + }
184 +
185 + /// Tick it if it is not, untick it if it is.
186 + ///
187 + /// Staging, never a write. Wiki `explicit-commit-affordance`: the commit
188 + /// control is what locks a change in, and a tick that wrote on its own
189 + /// would be the change happening with nothing to mark it.
190 + pub fn tick(&mut self, value: &str) {
191 + if !self.ticked.remove(value) {
192 + self.ticked.insert(value.to_owned());
193 + }
194 + }
195 +
196 + /// Everything ticked, in order.
197 + ///
198 + /// Ordered because it is a `BTreeSet`, and that is worth relying on: a
199 + /// handler reading [`Params::get_all`] gets the same sequence every run, so
200 + /// a test over a bulk action is not sorting the answer first.
201 + pub fn ticks(&self) -> impl Iterator<Item = &str> {
202 + self.ticked.iter().map(String::as_str)
203 + }
204 +
205 + /// Start the described ticks off, for the rows that arrive already ticked.
206 + ///
207 + /// A description can say a row is ticked, and on a screen that has just
208 + /// arrived that claim is the only thing there is. Applied on arrival rather
209 + /// than read on every draw, because after that the user's ticks are the
210 + /// truth and a redraw that went back to the description would undo them.
211 + pub fn seed(&mut self, screen: &Screen) {
212 + self.ticked = crate::focus::spots(screen)
213 + .iter()
214 + .filter_map(|spot| match spot {
215 + Spot::Row {
216 + ticked: Some(true),
217 + value: Some(value),
218 + ..
219 + } => Some(value.clone()),
220 + _ => None,
221 + })
222 + .collect();
223 + }
224 +
163 225 /// Forget everything typed and scrolled, and go back to the top.
164 226 ///
165 227 /// What a whole new screen means. The boxes on it are different boxes, and
166 228 /// carrying a buffer across would put what was typed into a password field
167 - /// into whatever field happens to share its name on the next screen.
229 + /// into whatever field happens to share its name on the next screen. A
230 + /// selection goes the same way and for the same reason: the rows are
231 + /// different rows.
168 232 pub fn reset(&mut self) {
169 233 self.edits.clear();
170 234 self.scroll.clear();
235 + self.ticked.clear();
171 236 self.focus = 0;
172 237 }
173 238
@@ -127,7 +127,7 @@
127 127 if let Some(action) = writes {
128 128 out.push_str("<div");
129 129 class_attr(&["field-writes"], opts, out);
130 - action_attrs(action, Fires::ChangeInside, None, morphs, out);
130 + action_attrs(action, Fires::ChangeInside, None, morphs, None, out);
131 131 out.push('>');
132 132 }
133 133
@@ -255,6 +255,7 @@
255 255 fires: Fires,
256 256 confirm: Option<&str>,
257 257 morphs: bool,
258 + gathers: Option<&str>,
258 259 out: &mut String,
259 260 ) {
260 261 // An external destination is not htmx's business: nothing swaps, no route
@@ -282,6 +283,20 @@
282 283 out.push('"');
283 284 }
284 285
286 + // Everything the screen's selection has ticked, gathered by the selector
287 + // the caller built. `5f2b8753`: this is the whole of what the per-app JS
288 + // used to do, and it is declarative because a checkbox already submits its
289 + // own name and value -- all that was missing was something saying which
290 + // boxes belong together.
291 + //
292 + // Here rather than in `act_html` because it is htmx, and htmx entering this
293 + // crate anywhere else is what the architectural test forbids.
294 + if let Some(selector) = gathers {
295 + out.push_str(" hx-include=\"");
296 + out.push_str(&escape(selector));
297 + out.push('"');
298 + }
299 +
285 300 // Where the answer goes, when the responder is not ours to ask. Emitted
286 301 // before the verb so the attributes read in the order they are reasoned
287 302 // about: where it lands, then what is sent.
@@ -396,6 +411,15 @@
396 411
397 412 /// A control that calls a route.
398 413 pub(crate) fn act_html(act: &Act, morphs: bool, opts: &Emit, out: &mut String) {
414 + // The commit control for a staged selection gathers every tick on the
415 + // screen. One selector rather than a name per box: a screen holds one set
416 + // (`Screen::selection`), so the class the ticks already carry is what they
417 + // have in common. Built here because it needs `Emit`, which the transport
418 + // function does not take -- a host setting `class_prefix` moves the class
419 + // and the selector together.
420 + let gathered = class("row-select", opts);
421 + let gathers = act.over.as_ref().map(|_| format!(".{gathered}"));
422 + let gathers = gathers.as_deref();
399 423 // `button`, which is makeover's name for this and carries its whole
400 424 // interactive set: the raised bevel, the hover fill, the pressed inset, the
401 425 // focus ring and the disabled treatment. This emitted `act`, a second name
@@ -436,6 +460,7 @@
436 460 Fires::Click,
437 461 act.confirm.as_deref(),
438 462 morphs,
463 + gathers,
439 464 out,
440 465 );
441 466 }
@@ -448,6 +473,7 @@
448 473 Fires::Click,
449 474 act.confirm.as_deref(),
450 475 morphs,
476 + gathers,
451 477 out,
452 478 ),
453 479 }
@@ -497,13 +523,28 @@
497 523 if ticked {
498 524 out.push_str(" checked");
499 525 }
526 + // What the tick contributes to the screen's selection, under the one
527 + // name a handler reads it back by. `5f2b8753`: the app used to bind
528 + // this itself, gathering the checked boxes in JS, because nothing in
529 + // the description said what the ticks were for.
530 + //
531 + // Named rather than left to the browser's `on`, which is what a
532 + // checkbox with no value submits. `on` says a box was checked and not
533 + // which one.
534 + if let Some(value) = &row.value {
535 + out.push_str(" name=\"");
536 + out.push_str(&escape(quasi_router::Node::TICKED));
537 + out.push_str("\" value=\"");
538 + out.push_str(&escape(value));
539 + out.push('"');
540 + }
500 541 // A tick with no route is local state until something submits it, which
501 542 // is what a bulk checkbox is; the app binds those itself. A tick with
502 543 // one is the write, which is `14612ed8`, and htmx's own default trigger
503 544 // for an input is `change` — named anyway, so the markup says what it
504 545 // does rather than relying on a default holding.
505 546 if let Some(action) = &row.toggle {
506 - action_attrs(action, Fires::Change, None, morphs, out);
547 + action_attrs(action, Fires::Change, None, morphs, None, out);
507 548 }
508 549 out.push_str(" aria-label=\"Select\">");
509 550 }
@@ -565,7 +606,7 @@
565 606 let (open, close) = control_tag(action);
566 607 out.push_str(open);
567 608 class_attr(&["row-activate"], opts, out);
568 - action_attrs(action, Fires::Click, None, morphs, out);
609 + action_attrs(action, Fires::Click, None, morphs, None, out);
569 610 out.push('>');
570 611 close
571 612 } else {
@@ -668,7 +709,7 @@
668 709 out.push_str(open);
669 710 class_attr(&[cell_part_class(layout::CellPart::Link)], opts, &mut out);
670 711 out.push_str(" data-act");
671 - action_attrs(action, Fires::Click, None, morphs, &mut out);
712 + action_attrs(action, Fires::Click, None, morphs, None, &mut out);
672 713 out.push('>');
673 714 out.push_str(&escape(text));
674 715 out.push_str(close);
@@ -759,7 +800,7 @@
759 800 } else {
760 801 Fires::Click
761 802 };
762 - action_attrs(action, fires, None, morphs, out);
803 + action_attrs(action, fires, None, morphs, None, out);
763 804 }
764 805 out.push('>');
765 806
@@ -875,7 +916,7 @@
875 916 out.push_str(open);
876 917 class_attr(&["link"], opts, out);
877 918 out.push_str(" data-act");
878 - action_attrs(action, Fires::Click, None, morphs, out);
919 + action_attrs(action, Fires::Click, None, morphs, None, out);
879 920 out.push('>');
880 921 out.push_str(&escape(text));
881 922 out.push_str(close);
@@ -923,7 +964,7 @@
923 964 } => {
924 965 out.push_str("<form");
925 966 class_attr(&["form"], opts, out);
926 - action_attrs(action, Fires::Click, None, morphs, out);
967 + action_attrs(action, Fires::Click, None, morphs, None, out);
927 968 out.push('>');
928 969 for field in fields {
929 970 field_group_html(field, morphs, opts, out);
@@ -956,7 +997,7 @@
956 997 let (open, close) = control_tag(&rest.action);
957 998 out.push_str(open);
958 999 class_attr(&["button", "rest-more"], opts, out);
959 - action_attrs(&rest.action, Fires::Click, None, morphs, out);
1000 + action_attrs(&rest.action, Fires::Click, None, morphs, None, out);
960 1001 out.push('>');
961 1002 match rest.remaining {
962 1003 Some(n) => {
@@ -1025,7 +1066,7 @@
1025 1066 let (open, close) = control_tag(action);
1026 1067 out.push_str(open);
1027 1068 class_attr(&["table-sort"], opts, out);
1028 - action_attrs(action, Fires::Click, None, morphs, out);
1069 + action_attrs(action, Fires::Click, None, morphs, None, out);
1029 1070 out.push('>');
1030 1071 out.push_str(&escape(column.name));
1031 1072 out.push_str(close);
@@ -1077,7 +1118,7 @@
1077 1118 let (open, close) = control_tag(action);
1078 1119 out.push_str(open);
1079 1120 class_attr(&["figure-act"], opts, out);
1080 - action_attrs(action, Fires::Click, None, morphs, out);
1121 + action_attrs(action, Fires::Click, None, morphs, None, out);
1081 1122 out.push('>');
1082 1123 out.push_str(&figure_html(&figure.as_layout(), opts));
1083 1124 out.push_str(close);
@@ -1158,7 +1199,7 @@
1158 1199 }
1159 1200 }
1160 1201 if let Some(action) = action {
1161 - action_attrs(action, Fires::Click, None, morphs, out);
1202 + action_attrs(action, Fires::Click, None, morphs, None, out);
1162 1203 }
1163 1204 }
1164 1205
@@ -1244,13 +1285,13 @@
1244 1285 // and it is the one that needs the picked value, because it is one
1245 1286 // route standing for all the options.
1246 1287 if let Some(own) = own {
1247 - action_attrs(own, Fires::Click, None, morphs, out);
1288 + action_attrs(own, Fires::Click, None, morphs, None, out);
1248 1289 } else if let Some(action) = action {
1249 1290 // The picked value travels under one name, decided once in
1250 1291 // `Node::SELECTED`, rather than agreed per screen between a
1251 1292 // renderer and a handler.
1252 1293 let carrying = action.clone().with(Node::SELECTED, option.value.clone());
1253 - action_attrs(&carrying, Fires::Click, None, morphs, out);
1294 + action_attrs(&carrying, Fires::Click, None, morphs, None, out);
1254 1295 }
1255 1296
1256 1297 out.push('>');
@@ -2469,3 +2469,58 @@
2469 2469 let html = Webview::new().invalidated("a\"><script>x</script>", &Node::text("hi"));
2470 2470 assert!(!html.contains("<script>"), "{html}");
2471 2471 }
2472 +
2473 + #[test]
2474 + fn a_tick_carries_the_value_it_contributes_to_the_selection() {
2475 + // `5f2b8753`. A checkbox with no value submits `on`, which says a box was
2476 + // checked and not which one, so every app gathered them in JS instead.
2477 + let html = fragment(&Node::list([
2478 + Row::new("First").ticking("m-1", false),
2479 + Row::new("Second").ticking("m-2", true),
2480 + ]));
2481 +
2482 + assert!(html.contains("name=\"ticked\" value=\"m-1\""), "{html}");
2483 + assert!(html.contains("name=\"ticked\" value=\"m-2\""), "{html}");
2484 + assert_eq!(html.matches("checked").count(), 1, "{html}");
2485 + }
2486 +
2487 + #[test]
2488 + fn a_commit_control_gathers_every_tick_on_the_screen() {
2489 + // Declarative, because a checkbox already submits its own name and value:
2490 + // all that was missing was something saying which boxes belong together.
2491 + // That is the whole of what the per-app gathering JS did.
2492 + let html = fragment(&Node::Act(
2493 + Act::new("Archive", Action::post("/mail/archive")).over("chosen"),
2494 + ));
2495 +
2496 + assert!(html.contains("hx-include=\".row-select\""), "{html}");
2497 + assert!(html.contains("hx-post=\"/mail/archive\""), "{html}");
2498 + }
2499 +
2500 + #[test]
2501 + fn a_control_over_nothing_gathers_nothing() {
2502 + let html = fragment(&Node::Act(Act::new(
2503 + "Delete",
2504 + Action::post("/mail/1/delete"),
2505 + )));
2506 + assert!(!html.contains("hx-include"), "{html}");
2507 + }
2508 +
2509 + #[test]
2510 + fn the_gathering_selector_follows_a_hosts_class_prefix() {
2511 + // A host setting `Emit::class_prefix` moves the class the ticks carry, and
2512 + // the selector has to move with it or the commit control gathers nothing.
2513 + let emit = Emit {
2514 + class_prefix: "q-",
2515 + ..Emit::default()
2516 + };
2517 + let render = Webview::new().with_emit(emit);
2518 +
2519 + let boxes = render.fragment(&Node::list([Row::new("First").ticking("m-1", false)]));
2520 + let button = render.fragment(&Node::Act(
2521 + Act::new("Archive", Action::post("/mail/archive")).over("chosen"),
2522 + ));
2523 +
2524 + assert!(boxes.contains("q-row-select"), "{boxes}");
2525 + assert!(button.contains("hx-include=\".q-row-select\""), "{button}");
2526 + }