Skip to main content

max / quasi

41.9 KB · 992 lines History Blame Raw
1 //! What the user can reach, and the order they reach it in.
2 //!
3 //! Nothing in a description says this. A webview never had to ask: the browser
4 //! builds the tab order out of the document, and the document is the drawing, so
5 //! the order falls out of the markup a renderer already emitted. A terminal
6 //! draws cells, and a cell knows nothing about the one before it.
7 //!
8 //! So focus order is this renderer's policy, and the policy is: **draw order**.
9 //! A thing is reachable when the description gives it something to call, and it
10 //! comes after whatever was drawn above it. That is the same rule the browser
11 //! applies to a document with no `tabindex` in it, which is the shape every
12 //! screen here has.
13 //!
14 //! The walk below mirrors [`crate::node::draw`] step for step, and it has to:
15 //! the drawing counts reachable things as it passes them and lights the one
16 //! whose number matches, so a walk that visited them in another order would
17 //! light the wrong one. The two are kept together deliberately rather than
18 //! being derived from one traversal, because the drawing needs a rect and this
19 //! needs nothing, and threading a rect through a walk that has no use for one
20 //! was the worse of the two couplings.
21 //!
22 //! # What is reachable
23 //!
24 //! Anything the description gives an address to, plus the two affordances that
25 //! are addresses in everything but name: a row that can be ticked, and a field
26 //! that takes typing. A [`Node::Meter`] and a [`Node::Figure`] are readouts and
27 //! are skipped, and a disabled [`Act`] is drawn and passed over, which is what
28 //! `disabled` means on every host.
29 //!
30 //! # A table row is one stop, and its controls are inside it
31 //!
32 //! Everywhere else a reachable thing is its own stop. A table row is the one
33 //! exception: it is a single stop, and the controls in its cells hang off it as
34 //! [`Spot::Row`]'s `inside`. A key steps in, cycles them, and steps back out.
35 //!
36 //! Not a preference. `makeover_tui::table` decides column widths, drops the
37 //! columns a narrow window cannot fit, and answers no coordinates back, so
38 //! nothing here knows where in a row a control was drawn and a flat focus
39 //! order would be a list of places with no places in it. Two steps buys what a
40 //! flat order cannot, and it is close to what a screen reader already gives
41 //! this shape, so a reader moving between the terminal and the webview finds
42 //! the same model. It is preferred over adding per-cell rects to a published
43 //! `makeover-tui` and over hand-laying the table here; per-cell rects stay
44 //! available underneath this later without changing what a user does.
45 //!
46 //! # Reach is this module; focus is the view's
47 //!
48 //! Both are this renderer's, and neither is describable. **Reach** is what this
49 //! module computes: which things can take focus, and in what order. **Focus** is
50 //! which reached thing holds the keyboard right now, and it lives in
51 //! [`crate::View`] because it is a fact about where the user has walked rather
52 //! than about the screen. The **focus ring** is what the drawing paints on it.
53 //!
54 //! The runtime now starts on the first reach unconditionally, which is what it
55 //! did in practice anyway once the user pressed anything. The three terms are
56 //! defined once in `makeover_layout`'s crate header, "Reach, focus and the
57 //! focus ring".
58
59 use makeover_layout as layout;
60 use quasi_router::{Act, Action, Chrome, Consult, Field, Frame, Node, Prefill, Row, Screen, Slot};
61
62 use crate::Local;
63
64 /// One thing the user can reach, and what reaching it offers.
65 #[derive(Debug, Clone, PartialEq, Eq)]
66 pub enum Spot {
67 /// A control. Enter calls it, after its confirmation when it has one.
68 Act {
69 /// What it calls.
70 action: Action,
71 /// What to ask first, if anything.
72 confirm: Option<String>,
73 /// The key that reaches it without walking there.
74 ///
75 /// The one place the description already anticipated a terminal, and
76 /// the runtime is what finally binds it.
77 key: Option<String>,
78 /// The screen's selection this acts on, if it acts on one.
79 ///
80 /// The commit half of a staged tick. The runtime reads the set the view
81 /// is holding and sends it with the call, which is the whole of what
82 /// makes a bulk action work without a line of gathering code.
83 over: Option<String>,
84 /// The names of the values it asked for first, in order.
85 ///
86 /// `Act::asks`. The boxes stand beside the control here rather than
87 /// behind a disclosure, so they are ordinary field stops; what the
88 /// press needs is which of them belong to it, and this is that list.
89 /// Empty for the ordinary control.
90 asks: Vec<String>,
91 /// The box the press writes into, and what lands there.
92 ///
93 /// `Act::fills`. Where inside the box is this renderer's, and a
94 /// terminal has no caret inside a field to insert at -- `d52884b0` is
95 /// why the view holds none -- so the value goes on the end. The
96 /// vocabulary says that is not wrong: a description names the
97 /// destination and never the position.
98 fills: Option<Prefill>,
99 /// The value the press puts on the clipboard.
100 ///
101 /// `Act::copies`. A terminal owns no clipboard any more than it owns a
102 /// route, so this reaches the host as [`Step::Copy`](crate::Step::Copy)
103 /// rather than being written here.
104 copies: Option<String>,
105 },
106 /// Text that goes somewhere. Enter follows it.
107 Link {
108 /// Where it goes.
109 action: Action,
110 },
111 /// A question. Typing edits it; Enter leaves it alone.
112 Field(Box<FieldSpot>),
113 /// The control that answers a whole form.
114 Submit {
115 /// Where the answers go.
116 action: Action,
117 /// The names the form submits, in order, so the runtime can gather the
118 /// values it is holding for them.
119 names: Vec<String>,
120 },
121 /// A row of a list.
122 Row {
123 /// What opening it calls.
124 activate: Option<Action>,
125 /// What ticking it calls, when the tick is itself the write.
126 toggle: Option<Action>,
127 /// Whether it is ticked, and whether it can be.
128 ticked: Option<bool>,
129 /// Whether it is part of a live selection, and whether it can be.
130 ///
131 /// `Row::chosen`'s three states, carried through so the runtime can
132 /// bind the two keys a terminal has for building one and say which of
133 /// them was pressed. Distinct from
134 /// [`ticked`](Self::Row::ticked) all the way down: a tick stages and a
135 /// choice is in force.
136 chosen: Option<bool>,
137 /// What its tick contributes to the screen's selection.
138 ///
139 /// `None` on a row that names nothing, which on a screen holding a
140 /// selection is the dead affordance `5f2b8753` was filed for: the box
141 /// is drawn, the key is bound, and the tick has nowhere to go. The
142 /// runtime declines to bind the key in that case rather than binding it
143 /// to nothing.
144 value: Option<String>,
145 /// What it offers without showing: reached by a key here, by
146 /// right-click on a pointer host.
147 menu: Vec<Act>,
148 /// The branch this row is, keyed the way a fold is remembered.
149 ///
150 /// `None` on a leaf, which is every row described before `Row::open`
151 /// existed. A branch is reachable even when nothing opens or ticks it:
152 /// the disclosure is the affordance, and a row drawing a chevron the
153 /// caret cannot land on is the dead affordance `5f2b8753` was filed
154 /// for, one member along.
155 branch: Option<String>,
156 /// Whether that branch is open, as the reader has left it.
157 open: bool,
158 /// The controls drawn inside the row, in the order they are drawn.
159 ///
160 /// And only ever populated for a table row. A list row's run
161 /// contributes stops of its own right after the row, because a
162 /// terminal draws a list itself and knows where every part of a line
163 /// ended up. A table is laid out by `makeover_tui::table`, which
164 /// decides column widths, drops the columns a narrow window has no
165 /// room for and answers no coordinates back, so nothing here can say
166 /// where in a row a control landed. The row is therefore one stop and
167 /// these are what stepping into it reaches: **the row is the stop, and
168 /// a key cycles what is inside it**, rather than per-cell rects or
169 /// hand-laid tables.
170 inside: Vec<Spot>,
171 },
172 /// The way to the rows a list is not showing.
173 More {
174 /// What asking for more calls.
175 action: Action,
176 },
177 /// The control that adds a slot to a repeating question, or takes one away.
178 ///
179 /// A stop rather than a key binding, because the description says what
180 /// both controls are called and a named control is what a reader can find;
181 /// a chord would be a fact about this renderer that no description could
182 /// have written. It calls no route: pressing it is a change to what the
183 /// reader is holding, which is the third of the three things the member is
184 /// for.
185 Repeat {
186 /// The question, as it was described.
187 ///
188 /// The whole field rather than its name, because the floor, the ceiling
189 /// and the values the description offered all decide what a press does,
190 /// and the runtime holding only a name would have to walk the screen
191 /// again to find them.
192 field: Box<Field>,
193 /// The slot this takes away, or `None` for the control that adds one.
194 at: Option<usize>,
195 },
196 }
197
198 /// A question, and everything the runtime needs to hold what is typed into it.
199 #[derive(Debug, Clone, PartialEq, Eq)]
200 pub struct FieldSpot {
201 /// The name the value is submitted under.
202 pub name: String,
203 /// What kind of value it takes.
204 pub kind: layout::FieldKind,
205 /// What the description offers back, which is what an untouched buffer
206 /// starts from.
207 ///
208 /// Always `None` for a [`layout::FieldKind::Secret`], and that is the whole
209 /// of `39057019`: the description refuses to carry one, on purpose, so the
210 /// runtime's buffer is the only place the typed value has ever lived.
211 pub value: Option<String>,
212 /// The values on offer, for the kinds that offer any.
213 pub options: Vec<String>,
214 /// The longest value it will take, in characters.
215 pub max_length: Option<u32>,
216 /// What setting it calls, for a control that writes on its own.
217 pub writes: Option<Action>,
218 /// What it asks about its own value as it is typed, how long each waits
219 /// first, and what rides along. [`quasi_router::Field::consults`].
220 ///
221 /// Several where the box raises several questions, and empty for the field
222 /// that asks nothing, which is nearly all of them.
223 pub consults: Vec<Consult>,
224 /// The question whose answer is this field's own list of candidates.
225 ///
226 /// [`quasi_router::Field::suggests`]. Beside [`consults`](Self::consults)
227 /// rather than in it, because the answer belongs to this control instead of
228 /// to a region: the runtime holds the candidates on the view, the drawing
229 /// puts them under the box, and picking one writes the value.
230 pub suggests: Option<Consult>,
231 }
232
233 impl Spot {
234 /// What Enter does here, when it does anything.
235 ///
236 /// A field answers `None`: Enter in a text box is not a submit here, the
237 /// way it is in a browser, because a terminal has no implicit submit and
238 /// guessing one would fire a form from the first field the user typed in.
239 #[must_use]
240 pub fn enters(&self) -> Option<&Action> {
241 match self {
242 Self::Act { action, .. }
243 | Self::Link { action }
244 | Self::Submit { action, .. }
245 | Self::More { action } => Some(action),
246 Self::Row { activate, .. } => activate.as_ref(),
247 // Neither answers with a route: a field is typed into, and a
248 // repeat control changes how many boxes there are.
249 Self::Field(_) | Self::Repeat { .. } => None,
250 }
251 }
252
253 /// The question this stands on, when it is one.
254 #[must_use]
255 pub const fn field(&self) -> Option<&FieldSpot> {
256 match self {
257 Self::Field(spot) => Some(spot),
258 _ => None,
259 }
260 }
261 }
262
263 /// A reachable thing, and the region it is in.
264 ///
265 /// The region is here because scrolling needs it. A key that scrolls has to
266 /// scroll something, and the only non-arbitrary answer is the region the user is
267 /// working in, which is the region their focus is in. Carrying it on the walk
268 /// that already visits every reachable thing is cheaper than a second walk that
269 /// would be free to disagree with this one.
270 #[derive(Debug, Clone, PartialEq, Eq)]
271 pub struct Reach {
272 /// The [`Slot::id`] of the region holding it.
273 pub region: String,
274 /// What it is.
275 pub spot: Spot,
276 }
277
278 /// Everything reachable on `screen`, in draw order, with its region.
279 ///
280 /// `hidden` names the regions that do not apply right now, from
281 /// [`crate::reveal::hidden`], and they are walked past: a control the drawing
282 /// left out is not a place the caret can go. An empty slice walks everything
283 /// the screen describes, which is what a caller reading the description rather
284 /// than the picture wants -- pruning what was typed, seeding what was ticked,
285 /// and gathering what a submit sends.
286 #[must_use]
287 pub fn reaches(screen: &Screen, local: &Local<'_>) -> Vec<Reach> {
288 // A frame that rests nothing, so every notice floats and every notice is
289 // drawn at the top. [`reaches_framed`]'s own docs already say this is that
290 // function with a frame that offers nothing; sharing the walk is what makes
291 // the sentence true rather than nearly true.
292 reaches_framed(screen, &Frame::new(), local)
293 }
294
295 /// Everything reachable on `screen`, in draw order. See [`reaches`] for
296 /// `hidden`.
297 #[must_use]
298 pub fn spots(screen: &Screen, local: &Local<'_>) -> Vec<Spot> {
299 reaches(screen, local)
300 .into_iter()
301 .map(|reach| reach.spot)
302 .collect()
303 }
304
305 /// The region a frame's verbs are reported under.
306 ///
307 /// A frame is not a [`Slot`] and has no id of its own, so [`Reach::region`]
308 /// needs a name for it. Reserved rather than derived: a screen carrying a
309 /// region with this id would collide, and a name nothing in the tree uses is
310 /// cheaper than a rule about which ids a screen may not have.
311 pub const FRAME_REGION: &str = "quasi-frame";
312
313 /// The region a notice's own control is reported under.
314 ///
315 /// Reserved for [`FRAME_REGION`]'s reason. A notice belongs to the screen
316 /// rather than to a place in it -- that is why every renderer draws them
317 /// outside the regions -- so there is no [`Slot::id`] to report one under.
318 ///
319 /// Nothing aims a fragment here. A notice arrives with the answer that raised
320 /// it, so there is no address for one to be replaced at.
321 pub const NOTICE_REGION: &str = "quasi-notice";
322
323 /// The region the band's own contents are reported under.
324 ///
325 /// Reserved for [`FRAME_REGION`]'s reason. The band is chrome and has no
326 /// address: it is built once beside the router, and nothing answers into it.
327 pub const BAND_REGION: &str = "quasi-band";
328
329 /// The region a nav place's reach is reported under.
330 ///
331 /// A reserved name for the same reason [`FRAME_REGION`] is one: the nav has no
332 /// address, because nothing answers into it. It is built once and never
333 /// replaced, so there is nothing for a fragment to aim at.
334 pub const NAV_REGION: &str = "quasi-nav";
335
336 /// Everything reachable on `screen`, then the verbs `frame` offers.
337 ///
338 /// The frame's verbs come last because they are drawn last: the order here is
339 /// the order the drawing counts in, and the two disagreeing lights the wrong
340 /// control.
341 ///
342 /// [`reaches`] is this with the frame that offers nothing, so a host that
343 #[must_use]
344 pub fn reaches_framed(screen: &Screen, frame: &Frame, local: &Local<'_>) -> Vec<Reach> {
345 let mut found = Vec::new();
346
347 // The floating notices first, because they are drawn first: above the
348 // regions and below the tab line. `bde35298` is what put anything reachable
349 // in one -- a notice was a sentence until `Node::Notice` grew an act, so
350 // this walk had nothing to find here and did not look.
351 for notice in screen.notices.iter().filter(|one| !frame.holds(one)) {
352 node_spots(notice, NOTICE_REGION, local, &mut found);
353 }
354
355 for slot in crate::region::reachable(screen) {
356 slot_spots(slot, local, &mut found);
357 }
358
359 // Then the footer, in the order `crate::frame::draw` writes it: the notices
360 // the mount rests, then its verbs.
361 for notice in screen.notices.iter().filter(|one| frame.holds(one)) {
362 node_spots(notice, NOTICE_REGION, local, &mut found);
363 }
364 for verb in &frame.verbs {
365 push_act(verb, FRAME_REGION, local, &mut found);
366 }
367 found
368 }
369
370 /// Everything reachable on `screen`, the verbs `frame` offers, then the panel
371 /// the app keeps on screen.
372 ///
373 /// The panel comes last because it is drawn last, which is the rule
374 /// [`reaches_framed`] already states: the order here is the order the drawing
375 /// counts in.
376 ///
377 /// Its reaches are reported under the panel's own id rather than a reserved
378 /// name. That is the difference from a frame: a frame has no address and needs
379 /// [`FRAME_REGION`], and a panel has one because an answer aims at it.
380 ///
381 /// [`reaches_framed`] is this with the chrome that keeps nothing on screen, so
382 #[must_use]
383 pub fn reaches_chromed(
384 screen: &Screen,
385 frame: &Frame,
386 chrome: &Chrome,
387 local: &Local<'_>,
388 ) -> Vec<Reach> {
389 let mut found = Vec::new();
390
391 // The tab line first, because it is drawn first. A terminal user reaches
392 // the places by the same walk that reaches everything else; without this
393 // the nav would be a row they can read and cannot use, which is the failure
394 // `Screen::selection` was added to end one level down.
395 //
396 // Only the row that is drawn: the places, and the sub-places of the one the
397 // screen is in. Counting a sub-place of a tab the user is not on would put
398 // a stop in the walk with nothing on screen at it.
399 // The brand before the places, because it is drawn at the head of that same
400 // row. It goes somewhere -- home, for every app that has ever had one -- so
401 // a walk that skipped it would draw a link the reader cannot follow.
402 if let Some(brand) = chrome.band.as_ref().and_then(|band| band.brand.as_ref()) {
403 found.push(Reach {
404 region: BAND_REGION.to_string(),
405 spot: Spot::Act {
406 action: brand.action.clone(),
407 confirm: None,
408 key: None,
409 over: None,
410 asks: Vec::new(),
411 fills: None,
412 copies: None,
413 },
414 });
415 }
416 for place in &chrome.nav {
417 push_place(place, &mut found);
418 }
419 if let Some(open) = chrome
420 .nav
421 .iter()
422 .find(|place| screen.place.as_deref().is_some_and(|key| place.holds(key)))
423 {
424 for inner in &open.within {
425 push_place(inner, &mut found);
426 }
427 }
428
429 // Then the band's own box, because it is drawn on the row under the places.
430 // A search field in the header is a field like any other -- that is what
431 // `Band::search` being a `Field` buys -- so it is reached by the same walk
432 // and edited by the same buffer, rather than being a row the reader can see
433 // and not use.
434 if let Some(search) = chrome.band.as_ref().and_then(|band| band.search.as_ref()) {
435 node_spots(
436 &quasi_router::Node::Field(Box::new(search.clone())),
437 BAND_REGION,
438 local,
439 &mut found,
440 );
441 }
442
443 found.extend(reaches_framed(screen, frame, local));
444 for panel in &chrome.panels {
445 node_spots(&panel.content, &panel.id, local, &mut found);
446 }
447 found
448 }
449
450 /// One place, as somewhere the walk can stop.
451 fn push_place(place: &quasi_router::Place, found: &mut Vec<Reach>) {
452 found.push(Reach {
453 region: NAV_REGION.to_string(),
454 spot: Spot::Act {
455 action: place.action.clone(),
456 confirm: None,
457 key: None,
458 over: None,
459 asks: Vec::new(),
460 fills: None,
461 copies: None,
462 },
463 });
464 }
465
466 /// A region's reachable things.
467 ///
468 /// A region that is still loading has none. It is drawn as the word "Loading"
469 /// and nothing under it is on screen, so anything counted here would be a
470 /// focusable the user cannot see.
471 fn slot_spots(slot: &Slot, local: &Local<'_>, found: &mut Vec<Reach>) {
472 if matches!(slot.readiness, layout::Readiness::Pending) {
473 return;
474 }
475 // A region that does not apply right now is not drawn, so nothing in it is
476 // a place the caret can go. `079a011e`, and the same list the drawing
477 // reads: the two walks agreeing about which regions are on the screen is
478 // what keeps the highlighted control the one the reader is on.
479 if local.out(&slot.id) {
480 return;
481 }
482 // Every member, at every width. The walk has no width and deliberately
483 // keeps none: its other two callers prune what the user typed and seed
484 // what they ticked, and both of those would be wrong to forget a field
485 // because the window is narrow at the moment they run.
486 //
487 // The cost is that a member ranked below `Priority::Essential` can still
488 // take focus in a window narrow enough to have dropped it, so Tab reaches
489 // something that is not on the screen. Recorded rather than papered over:
490 // fixing it means the navigation walk knowing the width the drawing knows,
491 // which is a wider change than the member that revealed it.
492 // The row the region says its members share, then its body, which is the
493 // order they are drawn in. The caret and the drawing agreeing about that
494 // order is the invariant this walk exists to keep.
495 for placed in slot.run.iter().flat_map(|run| run.members.iter()) {
496 node_spots(&placed.node, &slot.id, local, found);
497 }
498
499 // A region whose children are answers to one question reaches two controls
500 // the body does not hold: each slot's own remove, and the add under the
501 // lot. `f7abbc08`. In the order `region::repeating_body` draws them, which
502 // is the invariant the walk-matches-drawing test checks.
503 if let Some(repeating) = slot.repeating.as_deref() {
504 let standing = slot.body.len();
505 for placed in slot.body.iter() {
506 node_spots(&placed.node, &slot.id, local, found);
507 if let Some(removes) = crate::region::removes_of(&placed.node) {
508 let act = crate::region::bounded(removes, repeating.may_remove(standing));
509 push_act(&act, &slot.id, local, found);
510 }
511 }
512 let add = crate::region::bounded(&repeating.add, repeating.may_add(standing));
513 push_act(&add, &slot.id, local, found);
514 return;
515 }
516
517 for placed in slot.body.iter() {
518 node_spots(&placed.node, &slot.id, local, found);
519 }
520 }
521
522 /// One node's reachable things, in the order it draws them.
523 pub(crate) fn node_spots(node: &Node, region: &str, local: &Local<'_>, found: &mut Vec<Reach>) {
524 // Everything below reads better saying what it found rather than how it is
525 // recorded, and the region is the same for every one of them.
526 macro_rules! push {
527 ($spot:expr) => {
528 found.push(Reach {
529 region: region.to_string(),
530 spot: $spot,
531 })
532 };
533 }
534
535 match node {
536 Node::Act(act) => push_act(act, region, local, found),
537
538 Node::Link { action, .. } => push!(Spot::Link {
539 action: action.clone(),
540 }),
541
542 // A chip carries a route and is drawn as a bracketed label with no
543 // second target in it, which `node.rs` already declined: the `x` a
544 // webview hangs on a chip is a control inside a span. Reaching the chip
545 // is reaching its action, which is the part a terminal can honour.
546 Node::Token(tag) => {
547 if let layout::Token::Chip { .. } = tag.kind
548 && let Some(action) = tag.action.clone()
549 {
550 push!(Spot::Act {
551 action,
552 confirm: None,
553 key: None,
554 over: None,
555 asks: Vec::new(),
556 fills: None,
557 copies: None,
558 });
559 }
560 }
561
562 // A stand-in's way out, when it has one. Spelt as two arms rather than
563 // one holding an `if let`, so that the empty case is named here and not
564 // swept into the catch-all below with the members this renderer has
565 // never heard of.
566 Node::StandIn {
567 act: Some(act), .. } | Node::Notice { act: Some(act), .. } => {
568 push_act(act, region, local, found);
569 }
570 Node::StandIn { act: None, .. } => {}
571
572 Node::Field(field) => push_field(field, region, local, found),
573
574 Node::Form {
575 action, fields, ..
576 } => {
577 for field in fields {
578 push_field(field, region, local, found);
579 }
580 push!(Spot::Submit {
581 action: action.clone(),
582 // Every name the form sends, which for a question answered N
583 // times is N of them. `60d1753c`: one submit carries every
584 // instance, so the names are asked of the same count the boxes
585 // were drawn from.
586 names: fields
587 .iter()
588 .flat_map(|field| submitted(field, local))
589 .collect(),
590 });
591 }
592
593 // Reachable exactly as a list's rows are. A placement changes where a
594 // row is drawn, not whether it can be reached, and the terminal draws
595 // these in the order given -- so tab order and reading order agree
596 // without this having to know anything about the axis.
597 Node::Timeline { entries, .. } => {
598 for entry in entries {
599 push_row(&entry.row, region, local, found);
600 }
601 }
602
603 // A table's rows are reachable and its cells are not, so a control in a
604 // cell is reached by stepping into the row rather than by tabbing to
605 // it. See [`Spot::Row`]'s `inside` for why the layout leaves no other
606 // way, and [`inside`] for the walk.
607 Node::Table {
608 columns,
609 rows,
610 more,
611 ..
612 } => {
613 // A row a shut branch covers is not on the screen, so it is not
614 // reachable and takes no stop. The drawing makes the same reading
615 // from the same function, which is what keeps the caret on the row
616 // it is painted on.
617 for (_, cells) in crate::outline::showing(rows, local.view()) {
618 // **How a row is entered is the one thing the two arrangements
619 // disagree about.** A list row's controls are stops of their
620 // own, walked straight after the row; a table row's cells are
621 // reached by stepping *into* the row, because the grid leaves
622 // no other way. One node since the 2026-09-06 collapse, and
623 // this is where it forks.
624 if columns.is_empty() {
625 push_row(cells, region, local, found);
626 continue;
627 }
628 // A tickable row is reachable even when nothing opens it: the
629 // tick is the affordance, and a row that draws a box a reader
630 // cannot reach is the dead affordance `5f2b8753` was filed for,
631 // one node over. A row that only offers a menu is reachable for
632 // the same reason -- the menu is the affordance, and in a
633 // terminal it is reached from the row and nowhere else, so a row
634 // the caret cannot land on holds acts nothing can get at. A row
635 // that only carries controls in its cells is the third case and
636 // is `27f2331e`: nothing opens it and nothing ticks it, and
637 // until the caret could land on it the Remove button in its
638 // last cell was drawn and unreachable.
639 // `toggle` stays `None` because a table row's tick is always a
640 // member of a set -- `Row::ticking` is the only constructor for
641 // it.
642 if row_reachable(cells) {
643 push!(Spot::Row {
644 activate: cells.activate.clone(),
645 toggle: None,
646 ticked: cells.selected,
647 chosen: cells.chosen,
648 value: cells.value.clone(),
649 menu: cells.menu.clone(),
650 branch: branch_key(cells),
651 open: open_now(cells, local),
652 inside: inside(cells)
653 .into_iter()
654 .filter_map(|(column, part)| {
655 inside_spot(&cells.cells[column].content[part])
656 })
657 .collect(),
658 });
659 }
660 }
661
662 // Prev, then the pages, then Next -- `node::rest_pieces`' own
663 // order, which is the order the line is drawn in and the order the
664 // webview prints them. Read off that function rather than written
665 // out again, because the drawing claims one position per reachable
666 // piece and a second list here is a second answer to how many there
667 // are.
668 //
669 // This was the list arm's alone until the collapse, and `draw_table`
670 // has drawn a table's pager the whole time. So a paged table's Prev
671 // and Next were on the screen and the caret could not land on
672 // either. Same class as `27f2331e` one node over: drawn by one pass
673 // and unknown to the other.
674 if let Some(rest) = more {
675 for piece in crate::node::rest_pieces(rest) {
676 if let Some(action) = piece.action {
677 push!(Spot::More {
678 action: action.clone(),
679 });
680 }
681 }
682 }
683 }
684
685 Node::Region(slot) => slot_spots(slot, local, found),
686
687 // Readouts and prose. Nothing to call, so nothing to stop on.
688 Node::Heading { .. }
689 | Node::Text { .. }
690 | Node::Rich { .. }
691 | Node::Figure(_)
692 // A picture carries no address of its own -- `src` is where the bytes
693 // are, not somewhere the reader goes -- so there is nothing to stop on.
694 // A picture that is meant to be clicked is one inside a `Link`.
695 | Node::Image(_)
696 | Node::Notice { .. }
697 | Node::Meter(_)
698 | Node::Stats { .. } => {}
699
700 // A member added since this renderer last learned the vocabulary.
701 // `Node` is `#[non_exhaustive]`, so this arm is what lets that land
702 // without a lockstep release here.
703 //
704 // Contributing no reach is the safe read, and it is the only honest
705 // one: a `Reach` says where a key press goes, and this cannot know
706 // where. Guessing a `Spot` would send a press to the wrong action,
707 // whereas an unreached node is merely something the reader tabs past.
708 // `node.rs` draws `UNDRAWN` in the same case, so the reader sees that
709 // something is there and sees that it does not respond, rather than
710 // being handed a stop that goes nowhere.
711 _ => {}
712 }
713 }
714
715 /// Whether the caret can land on this table row at all.
716 ///
717 /// One function because two would drift: [`node_spots`] decides what the caret
718 /// can land on and `node.rs` decides what the drawing counts, and a row counted
719 /// in one and not the other shifts every stop below the table by one.
720 /// Whether a row offers anything of its own.
721 ///
722 /// The row's members only: what it holds in its cells is a separate question,
723 /// and the two containers answer it differently on purpose. A table row steps
724 /// into its cells ([`inside`]); a list row's controls are stops of their own,
725 /// walked straight after it.
726 ///
727 /// One predicate since the 2026-09-05 collapse, and it closes a gap. This was
728 /// written twice, and the copy here did not test `toggle` because a table row
729 /// could not carry one. Now that a row is a row, a table row that is a
730 /// checklist item can, and without this it would have been unreachable in a
731 /// terminal: nothing else about such a row is set, so no other clause fires.
732 pub(crate) fn row_offers(row: &Row) -> bool {
733 row.open.is_some()
734 || row.activate.is_some()
735 || row.toggle.is_some()
736 || row.selected.is_some()
737 || row.chosen.is_some()
738 || !row.menu.is_empty()
739 }
740
741 pub(crate) fn row_reachable(cells: &Row) -> bool {
742 row_offers(cells) || !inside(cells).is_empty()
743 }
744
745 /// The parts of a table row the caret can step into, as `(column, part)`
746 /// positions into [`Row::cells`] and [`Cell::content`].
747 ///
748 /// Positions rather than [`Spot`]s, for the same reason [`row_reachable`] is one
749 /// function: the drawing needs to know *which* part to paint lit and the reach
750 /// walk needs to know what pressing it calls, and the two have to be the same
751 /// list read twice or the ring lands on the wrong control.
752 ///
753 /// [`Cell::parts`]: quasi_router::Cell
754 pub(crate) fn inside(cells: &Row) -> Vec<(usize, usize)> {
755 let mut found = Vec::new();
756 for (column, cell) in cells.cells.iter().enumerate() {
757 for (part, node) in cell.content.iter().enumerate() {
758 if inside_spot(node).is_some() {
759 found.push((column, part));
760 }
761 }
762 }
763 found
764 }
765
766 /// What stepping onto one part of a cell reaches, when it reaches anything.
767 ///
768 /// The three inline members that carry an address, matching what [`node_spots`]
769 /// makes reachable in a list row's run: a control, text that goes somewhere, and
770 /// a chip that is a route rather than a label. A disabled control is drawn and
771 /// passed over here exactly as it is everywhere else.
772 ///
773 /// # An act that asks for a value first is not reachable here
774 ///
775 /// [`Act::asks`] puts boxes beside the control, and a cell is one line inside a
776 /// column of a width this walk does not know, so the boxes are not drawn. A stop
777 /// on the control would fire it with nothing gathered under the names it asked
778 /// for, which is worse than not stopping: the row still opens, and the reader
779 /// can see that the control did not answer. No described screen puts one in a
780 /// cell today -- the measured consumers are bulk bars -- and the day one does,
781 /// what it wants is the cell's own line rather than a stop bound to a silent
782 /// send.
783 fn inside_spot(node: &Node) -> Option<Spot> {
784 match node {
785 Node::Act(act)
786 if !act.state.is_some_and(layout::State::suppresses_interaction)
787 && act.asks.is_empty() =>
788 {
789 Some(Spot::Act {
790 action: act.action.clone(),
791 confirm: act.confirm.clone(),
792 key: act.key.clone(),
793 over: act.over.clone(),
794 asks: Vec::new(),
795 fills: act.fills.clone(),
796 copies: act.copies.clone(),
797 })
798 }
799 Node::Link { action, .. } => Some(Spot::Link {
800 action: action.clone(),
801 }),
802 Node::Token(tag) => match (&tag.kind, &tag.action) {
803 (layout::Token::Chip { .. }, Some(action)) => Some(Spot::Act {
804 action: action.clone(),
805 confirm: None,
806 key: None,
807 over: None,
808 asks: Vec::new(),
809 fills: None,
810 copies: None,
811 }),
812 _ => None,
813 },
814 _ => None,
815 }
816 }
817
818 /// A control, unless it is disabled, and the questions it asks before it fires.
819 ///
820 /// The questions come first, in the order they are drawn: they sit above the
821 /// control in `node.rs`, and a reader tabbing forward should reach the box
822 /// before the button that sends what is in it.
823 fn push_act(act: &Act, region: &str, local: &Local<'_>, found: &mut Vec<Reach>) {
824 if act.state.is_some_and(layout::State::suppresses_interaction) {
825 return;
826 }
827 for field in &act.asks {
828 push_field(&field.as_asked(), region, local, found);
829 }
830 found.push(Reach {
831 region: region.to_string(),
832 spot: Spot::Act {
833 action: act.action.clone(),
834 confirm: act.confirm.clone(),
835 key: act.key.clone(),
836 over: act.over.clone(),
837 // Every name the press sends, which for a question answered N
838 // times is N of them, exactly as a form's submit gathers them.
839 asks: act
840 .asks
841 .iter()
842 .flat_map(|field| submitted(&field.as_asked(), local))
843 .collect(),
844 fills: act.fills.clone(),
845 copies: act.copies.clone(),
846 },
847 });
848 }
849
850 /// A question, unless it is hidden.
851 ///
852 /// A hidden field draws nothing and is submitted with the form, so stopping on
853 /// it would be a stop on a blank row.
854 ///
855 /// A question answered N times is N stops and the controls that change N, in
856 /// the order the drawing paints them: `60d1753c`. Both walks read the same
857 /// count off the same [`Local`], which is what keeps the caret and the picture
858 /// agreeing about how many stops a repeating question has.
859 fn push_field(field: &Field, region: &str, local: &Local<'_>, found: &mut Vec<Reach>) {
860 // A question that does not apply is not on the screen, so the caret does
861 // not stop on it. `8fdb814c`, and it is the region rule one level down: the
862 // walk that reads a submission passes `Local::none`, so what was typed into
863 // it is kept and still sent.
864 if local.field_out(&field.name) {
865 return;
866 }
867 let Some(repeat) = &field.repeats else {
868 push_one(field, region, found);
869 return;
870 };
871 let standing = local.standing(field);
872 for at in 0..standing {
873 for slot in field.instance_fields(at) {
874 push_one(&slot, region, found);
875 }
876 if repeat.fewer(standing) {
877 found.push(Reach {
878 region: region.to_string(),
879 spot: Spot::Repeat {
880 field: Box::new(field.clone()),
881 at: Some(at),
882 },
883 });
884 }
885 }
886 if repeat.more(standing) {
887 found.push(Reach {
888 region: region.to_string(),
889 spot: Spot::Repeat {
890 field: Box::new(field.clone()),
891 at: None,
892 },
893 });
894 }
895 }
896
897 /// The names one field submits under: its own, or one per slot.
898 ///
899 /// The count is the reader's, so this is not a function of the description
900 /// alone, which is the whole reason a submit's name list is built during the
901 /// walk rather than off the form.
902 fn submitted(field: &Field, local: &Local<'_>) -> Vec<String> {
903 if field.repeats.is_none() {
904 return vec![field.name.clone()];
905 }
906 (0..local.standing(field))
907 .flat_map(|at| {
908 field
909 .instance_fields(at)
910 .into_iter()
911 .map(|slot| slot.name)
912 .collect::<Vec<_>>()
913 })
914 .collect()
915 }
916
917 /// One box, whether it stands alone or is one slot of a repeating question.
918 fn push_one(field: &Field, region: &str, found: &mut Vec<Reach>) {
919 if matches!(field.kind, layout::FieldKind::Hidden) {
920 return;
921 }
922 found.push(Reach {
923 region: region.to_string(),
924 spot: Spot::Field(Box::new(FieldSpot {
925 name: field.name.clone(),
926 kind: field.kind,
927 value: field.value.clone(),
928 options: field
929 .options
930 .iter()
931 .map(|choice| choice.value.clone())
932 .collect(),
933 max_length: field.max_length,
934 writes: field.writes.clone(),
935 consults: field.consults.clone(),
936 suggests: field.suggests.clone(),
937 })),
938 });
939 }
940
941 /// What a fold on this row is remembered by, if it is a branch at all.
942 fn branch_key(row: &impl quasi_router::Outline) -> Option<String> {
943 row.open().is_some().then(|| row.key())
944 }
945
946 /// Whether this row's branch is open, as the reader has left it.
947 ///
948 /// `false` on a leaf, which nothing reads: the runtime looks at `branch` first.
949 fn open_now(row: &impl quasi_router::Outline, local: &Local<'_>) -> bool {
950 match (row.open(), local.view()) {
951 (Some(described), Some(view)) => view.open(&row.key(), described),
952 (described, _) => described.unwrap_or(false),
953 }
954 }
955
956 /// A row: the row itself when the description gives it something to do, then
957 /// whatever its run carries.
958 ///
959 /// Two stops and not one, because they are two things. A row that opens a
960 /// detail pane and also shows a Remove button offers both, and a terminal that
961 /// collapsed them would make the button unreachable or the row unopenable. A
962 /// row that only shows things is passed over entirely, which is the difference
963 /// between a list and a menu.
964 ///
965 /// The row comes first because it is the whole line and the controls sit on it.
966 fn push_row(row: &Row, region: &str, local: &Local<'_>, found: &mut Vec<Reach>) {
967 if row_offers(row) {
968 found.push(Reach {
969 region: region.to_string(),
970 spot: Spot::Row {
971 activate: row.activate.clone(),
972 toggle: row.toggle.clone(),
973 ticked: row.selected,
974 chosen: row.chosen,
975 value: row.value.clone(),
976 menu: row.menu.clone(),
977 branch: branch_key(row),
978 open: open_now(row, local),
979 // Empty, always. The run below is walked straight after this,
980 // so a list row's controls are stops of their own and there is
981 // nothing to step into.
982 inside: Vec::new(),
983 },
984 });
985 }
986 for cell in &row.cells {
987 for node in &cell.content {
988 node_spots(node, region, local, found);
989 }
990 }
991 }
992