max / quasi
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
9 files changed,
+553 insertions,
-46 deletions
| @@ -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 | + | } |