Skip to main content

max / quasi

quasi-tui: the interaction runtime, and the second argument it needed The drawing half was a function of the description alone. This is the half the browser was supplying, written out: focus order, what is typed, how far a pane is scrolled, and where the back button goes. Settles 39057019 in favour of the second argument. Tui::screen takes a View beside the Screen, because Field::value refuses to carry a secret on purpose, so a runtime that rewrote the description before drawing it would have had to defeat that refusal to draw the dots. The description says what the server offers and the view says what the user has done since. Focus order is renderer policy and the policy is draw order, derived in focus.rs and counted by the drawing as it passes each reachable thing. The two walks are separate and a test holds them together: focusing the nth thing has to change the picture, which is what breaks silently if they drift. Act::key and Act::confirm were drawn and declined by the drawing half and are honoured here. layout::State::Focus decides where focus starts and never where it returns to. Found on the way: a focused empty field drew nothing at all, so there was no caret to show where the typing would land.
Author: Max Johnson <me@maxj.phd> · 2026-08-12 17:17 UTC
Signed with PGP, not checked
Commit: 6d26f1df4fea1530787df4bc6df01a6411e26aa3
Parent: d314810
7 files changed, +1971 insertions, -112 deletions
@@ -30,24 +30,39 @@
30 30 //! description and nothing else, which is worth knowing before reaching for the
31 31 //! trait's name.
32 32 //!
33 - //! Not an event loop either, yet. This half is drawing: a screen in, a buffer
34 - //! out, no state. Focus order, scroll position, field editing and history are
35 - //! the interaction runtime, and they are the half that is real work.
36 - //!
37 33 //! # The shape
38 34 //!
39 35 //! Flow layout, top to bottom. Every node answers a height for a width and then
40 36 //! draws into the rect it was given, which is the smallest thing that composes
41 37 //! and is what a description with no geometry in it can support. Nothing here
42 38 //! measures twice.
39 + //!
40 + //! # Drawing takes two arguments, and that is the first finding it produced
41 + //!
42 + //! A screen and a [`View`]. The description says what the app offers; the view
43 + //! says what the user has done to it since it arrived — what is typed, what has
44 + //! focus, how far a pane is scrolled. None of the three is in a description and
45 + //! none of them belongs there, and a webview never had to say so because the
46 + //! browser holds all three without being asked. `39057019` is the finding and
47 + //! [`View`] carries the argument.
48 + //!
49 + //! A host with nothing to say passes `&View::new()`, which draws exactly what
50 + //! the description says.
43 51
52 + mod focus;
44 53 mod node;
45 54 mod region;
55 + mod runtime;
46 56 mod text;
57 + mod view;
47 58
48 59 #[cfg(test)]
49 60 mod tests;
50 61
62 + pub use focus::{FieldSpot, Spot, spots};
63 + pub use runtime::{Key, Runtime, Step};
64 + pub use view::View;
65 +
51 66 use makeover_layout as layout;
52 67 use makeover_tui::table::TableStyle;
53 68 use makeover_tui::{Fidelity, Palette, Theme};
@@ -92,7 +107,7 @@
92 107 &self.palette
93 108 }
94 109
95 - /// Draw a whole screen into `area`.
110 + /// Draw a whole screen into `area`, in the state `view` says it is in.
96 111 ///
97 112 /// The title is not drawn. A window title is the host's to set, the same
98 113 /// way a webview host puts it in `<title>` rather than in the document, and
@@ -102,7 +117,12 @@
102 117 /// [`Screen::discovery`] is declined outright: og:type, an indexability
103 118 /// flag and a canonical URL are facts about being crawled, and nothing
104 119 /// crawls a terminal.
105 - pub fn screen(&self, screen: &Screen, area: Rect, buf: &mut Buffer) {
120 + pub fn screen(&self, screen: &Screen, view: &View, area: Rect, buf: &mut Buffer) {
121 + let mut pass = Pass {
122 + tui: self,
123 + view,
124 + seq: 0,
125 + };
106 126 let mut rest = area;
107 127
108 128 // Notices first and at the top, because a notice belongs to the screen
@@ -111,20 +131,30 @@
111 131 // deciding, and the top of the screen is the one place a message about
112 132 // the whole screen can go without claiming a region.
113 133 for notice in &screen.notices {
114 - let used = self.node(notice, rest, buf);
134 + let used = node::draw(&mut pass, notice, rest, buf);
115 135 rest = below(rest, used);
116 136 }
117 137
118 - region::screen_regions(self, screen, rest, buf);
138 + region::screen_regions(&mut pass, screen, rest, buf);
119 139 }
120 140
121 141 /// Draw one node into `area`, and answer the rows it used.
122 142 ///
123 143 /// Never draws outside `area` and never below it: a node handed less room
124 144 /// than it wants is cut off at the bottom, which is what a terminal does
125 - /// with everything. Scrolling is the runtime's, not the drawing's.
126 - pub fn node(&self, node: &Node, area: Rect, buf: &mut Buffer) -> u16 {
127 - node::draw(self, node, area, buf)
145 + /// with everything. A [`Node::Region`] is the one that scrolls, and it
146 + /// reads its offset off the view.
147 + pub fn node(&self, node: &Node, view: &View, area: Rect, buf: &mut Buffer) -> u16 {
148 + node::draw(
149 + &mut Pass {
150 + tui: self,
151 + view,
152 + seq: 0,
153 + },
154 + node,
155 + area,
156 + buf,
157 + )
128 158 }
129 159
130 160 /// The rows `node` wants at `width`.
@@ -165,6 +195,42 @@
165 195 }
166 196 }
167 197
198 + /// One drawing, as it walks the screen.
199 + ///
200 + /// Carries the count of reachable things passed so far, which is how the
201 + /// drawing knows whether the thing it is about to draw is the focused one. The
202 + /// count has to advance at exactly the points [`focus::spots`] records one, and
203 + /// that agreement is asserted by a test rather than trusted: the two walks are
204 + /// separate because one needs a rect and the other does not, and a walk that
205 + /// counted differently would light the wrong control.
206 + pub(crate) struct Pass<'a> {
207 + tui: &'a Tui,
208 + view: &'a View,
209 + seq: usize,
210 + }
211 +
212 + impl Pass<'_> {
213 + /// Take the next reachable position, and say whether it is the focused one.
214 + fn claim(&mut self) -> bool {
215 + let mine = self.seq;
216 + self.seq += 1;
217 + mine == self.view.focus()
218 + }
219 +
220 + /// The style that says "this is the one you are on".
221 + ///
222 + /// Reversed video, which is the affordance a cell has left after colour is
223 + /// spent on tone and bold on weight. A webview says it with an outline; a
224 + /// terminal has no outline that is not four more cells.
225 + fn focused(focused: bool, style: Style) -> Style {
226 + if focused {
227 + style.add_modifier(Modifier::REVERSED)
228 + } else {
229 + style
230 + }
231 + }
232 + }
233 +
168 234 /// What is left of `area` after `used` rows from the top.
169 235 fn below(area: Rect, used: u16) -> Rect {
170 236 let used = used.min(area.height);
@@ -12,7 +12,7 @@
12 12 use ratatui::style::{Modifier, Style};
13 13 use ratatui::text::{Line, Span};
14 14
15 - use crate::{Tui, below, text};
15 + use crate::{Pass, Tui, below, text};
16 16
17 17 /// The rows `node` wants at `width`.
18 18 pub(crate) fn height(tui: &Tui, node: &Node, width: u16) -> u16 {
@@ -23,9 +23,9 @@
23 23 Node::Rich { source } => {
24 24 text::spans_height(&rich_spans(tui, source, rich_base(tui)), width)
25 25 }
26 - Node::Act(act) => text::line_height(&act_line(tui, act), width),
26 + Node::Act(act) => text::line_height(&act_line(tui, act, false), width),
27 27 Node::Link { text: label, .. } => text::height(label, width),
28 - Node::Token(tag) => text::line_height(&Line::from(tag_span(tui, tag)), width),
28 + Node::Token(tag) => text::line_height(&Line::from(tag_span(tui, tag, false)), width),
29 29 Node::Figure(figure) => figure_height(tui, figure, width),
30 30 Node::Notice { text: content, .. } => text::height(content, width),
31 31 Node::StandIn { message, act, .. } => {
@@ -44,14 +44,14 @@
44 44 let gutter = list_gutter(rows);
45 45 rows.iter()
46 46 .map(|row| {
47 - text::line_height(&row_line(tui, row), width.saturating_sub(gutter)).max(1)
47 + text::line_height(&row_line(tui, row, &[]), width.saturating_sub(gutter)).max(1)
48 48 })
49 49 .sum::<u16>()
50 50 + u16::from(more.is_some())
51 51 }
52 52 Node::Table { columns, rows } => table_height(columns, rows),
53 53 Node::Select { options, .. } => {
54 - text::line_height(&Line::from(select_spans(tui, options, None)), width)
54 + text::line_height(&Line::from(select_spans(tui, options, None, &[])), width)
55 55 }
56 56 Node::Meter(meter) => text::line_height(&meter_line(tui, meter), width),
57 57 Node::Stats { figures } => figures
@@ -63,11 +63,23 @@
63 63 }
64 64
65 65 /// Draw `node` at the top of `area`, and answer the rows it used.
66 - pub(crate) fn draw(tui: &Tui, node: &Node, area: Rect, buf: &mut Buffer) -> u16 {
66 + ///
67 + /// The reachable things are counted as they are passed, in the order
68 + /// [`crate::focus::spots`] records them, so that the one whose number matches
69 + /// the view's focus can be drawn lit. A node that is not reachable does not
70 + /// count, and a node that is drawn but unreachable — a disabled control, a
71 + /// hidden field — does not count either.
72 + pub(crate) fn draw(pass: &mut Pass<'_>, node: &Node, area: Rect, buf: &mut Buffer) -> u16 {
67 73 if area.width == 0 || area.height == 0 {
74 + // A node with no room still holds its place in the count. The screen is
75 + // the same screen whether or not the terminal is tall enough to show
76 + // all of it, and a focus order that changed as the window was resized
77 + // would move the user's place under them.
78 + count(pass, node);
68 79 return 0;
69 80 }
70 81
82 + let tui = pass.tui;
71 83 match node {
72 84 Node::Heading { level, text: title } => text::draw(title, tui.heading(*level), area, buf),
73 85
@@ -89,22 +101,24 @@
89 101 text::draw_spans(&rich_spans(tui, source, rich_base(tui)), area, buf)
90 102 }
91 103
92 - Node::Act(act) => text::draw_line(&act_line(tui, act), area, buf),
104 + Node::Act(act) => {
105 + let focused = claim_act(pass, act);
106 + text::draw_line(&act_line(tui, act, focused), area, buf)
107 + }
93 108
94 109 // A link is text and an address, and a terminal cannot put the address
95 110 // under the words the way an anchor does. Underlined, which is the one
96 111 // affordance a cell has that says "this goes somewhere", and the
97 112 // address is the runtime's to follow when the link has focus.
98 - Node::Link { text: label, .. } => text::draw(
99 - label,
100 - Style::default()
101 - .fg(tui.theme().action_primary)
102 - .add_modifier(Modifier::UNDERLINED),
103 - area,
104 - buf,
105 - ),
113 + Node::Link { text: label, .. } => {
114 + let focused = pass.claim();
115 + text::draw(label, Pass::focused(focused, link_style(tui)), area, buf)
116 + }
106 117
107 - Node::Token(tag) => text::draw_line(&Line::from(tag_span(tui, tag)), area, buf),
118 + Node::Token(tag) => {
119 + let focused = claim_tag(pass, tag);
120 + text::draw_line(&Line::from(tag_span(tui, tag, focused)), area, buf)
121 + }
108 122
109 123 Node::Figure(figure) => draw_figure(tui, figure, area, buf),
110 124
@@ -132,27 +146,34 @@
132 146 };
133 147 let used = text::draw(message, style, area, buf);
134 148 match act {
135 - Some(act) => used + text::draw_line(&act_line(tui, act), below(area, used), buf),
149 + Some(act) => {
150 + let focused = claim_act(pass, act);
151 + used + text::draw_line(&act_line(tui, act, focused), below(area, used), buf)
152 + }
136 153 None => used,
137 154 }
138 155 }
139 156
140 - Node::Field(field) => draw_field(tui, field, area, buf),
157 + Node::Field(field) => draw_field(pass, field, area, buf),
141 158
142 159 Node::Form { submit, fields, .. } => {
143 160 let mut used = 0;
144 161 for field in fields {
145 - used += draw_field(tui, field, below(area, used), buf);
162 + used += draw_field(pass, field, below(area, used), buf);
146 163 }
147 164 // The submit, drawn as the act it is. The form's own action is not
148 165 // drawn: an address is not a thing a cell can show, and the runtime
149 166 // is what follows it.
167 + let focused = pass.claim();
150 168 used + text::draw_line(
151 169 &Line::from(vec![Span::styled(
152 170 format!("[ {submit} ]"),
153 - Style::default()
154 - .fg(tui.theme().selection_on)
155 - .bg(tui.theme().action_primary),
171 + Pass::focused(
172 + focused,
173 + Style::default()
174 + .fg(tui.theme().selection_on)
175 + .bg(tui.theme().action_primary),
176 + ),
156 177 )]),
157 178 below(area, used),
158 179 buf,
@@ -168,12 +189,17 @@
168 189 let gutter = list_gutter(rows);
169 190 let mut used = 0;
170 191 for row in rows {
171 - let line = row_line(tui, row);
192 + // Claimed before the room is checked, because the count is a
193 + // fact about the description and the room is a fact about the
194 + // window.
195 + let focused = claim_row(pass, row);
196 + let parts = claim_parts(pass, row);
197 + let line = row_line(tui, row, &parts);
172 198 let at = below(area, used);
173 199 if at.height == 0 {
174 - break;
200 + continue;
175 201 }
176 - draw_gutter(tui, row, at, buf);
202 + draw_gutter(tui, row, focused, at, buf);
177 203 let body = Rect {
178 204 x: at.x + gutter,
179 205 width: at.width.saturating_sub(gutter),
@@ -183,13 +209,14 @@
183 209 }
184 210 match more {
185 211 Some(rest) => {
212 + let focused = pass.claim();
186 213 let label = rest.remaining.map_or_else(
187 214 || "More".to_string(),
188 215 |remaining| format!("{remaining} more"),
189 216 );
190 217 used + text::draw(
191 218 &label,
192 - Style::default().fg(tui.theme().action_primary),
219 + Pass::focused(focused, Style::default().fg(tui.theme().action_primary)),
193 220 below(area, used),
194 221 buf,
195 222 )
@@ -198,17 +225,27 @@
198 225 }
199 226 }
200 227
201 - Node::Table { columns, rows } => draw_table(tui, columns, rows, area, buf),
228 + Node::Table { columns, rows } => draw_table(pass, columns, rows, area, buf),
202 229
203 230 Node::Select {
204 - options, chosen, ..
231 + options,
232 + chosen,
233 + action,
234 + ..
205 235 } => {
206 236 // Segmented, toggle and tabs draw the same here: a row of labels
207 237 // with the chosen one lit. The three differ in how much room they
208 238 // claim and how they are grouped, which is a geometry question, and
209 239 // a terminal has one cell size and no groups.
240 + let reachable: Vec<bool> = options
241 + .iter()
242 + .map(|(_, own)| {
243 + let calls = own.is_some() || action.is_some();
244 + calls && pass.claim()
245 + })
246 + .collect();
210 247 text::draw_line(
211 - &Line::from(select_spans(tui, options, chosen.as_deref())),
248 + &Line::from(select_spans(tui, options, chosen.as_deref(), &reachable)),
212 249 area,
213 250 buf,
214 251 )
@@ -229,18 +266,75 @@
229 266 used
230 267 }
231 268
232 - Node::Region(slot) => crate::region::draw(tui, slot, area, buf),
269 + Node::Region(slot) => crate::region::draw(pass, slot, area, buf),
233 270 }
234 271 }
235 272
273 + /// Advance the count past a node that was not drawn, so that a screen too tall
274 + /// for its terminal keeps the focus order it had when it fit.
275 + fn count(pass: &mut Pass<'_>, node: &Node) {
276 + let mut found = Vec::new();
277 + // The region's name does not matter here: only how many things were passed.
278 + crate::focus::node_spots(node, "", &mut found);
279 + pass.seq += found.len();
280 + }
281 +
282 + /// Claim a control, unless it is disabled and therefore unreachable.
283 + fn claim_act(pass: &mut Pass<'_>, act: &Act) -> bool {
284 + !act.state.is_some_and(layout::State::suppresses_interaction) && pass.claim()
285 + }
286 +
287 + /// Claim a chip, which is the only tag that answers anything.
288 + fn claim_tag(pass: &mut Pass<'_>, tag: &Tag) -> bool {
289 + matches!(tag.kind, layout::Token::Chip { .. }) && tag.action.is_some() && pass.claim()
290 + }
291 +
292 + /// Claim a row, when the description gives it something to do.
293 + fn claim_row(pass: &mut Pass<'_>, row: &Row) -> bool {
294 + let reachable = row.activate.is_some()
295 + || row.toggle.is_some()
296 + || row.selected.is_some()
297 + || !row.menu.is_empty();
298 + reachable && pass.claim()
299 + }
300 +
301 + /// Claim whatever the row's own run carries, one answer per part.
302 + fn claim_parts(pass: &mut Pass<'_>, row: &Row) -> Vec<bool> {
303 + row.parts
304 + .iter()
305 + .map(|Part { node, .. }| match node {
306 + Node::Act(act) => claim_act(pass, act),
307 + Node::Link { .. } => pass.claim(),
308 + Node::Token(tag) => claim_tag(pass, tag),
309 + _ => false,
310 + })
311 + .collect()
312 + }
313 +
314 + /// The style text that goes somewhere takes.
315 + fn link_style(tui: &Tui) -> Style {
316 + Style::default()
317 + .fg(tui.theme().action_primary)
318 + .add_modifier(Modifier::UNDERLINED)
319 + }
320 +
236 321 /// The columns a list spends before its rows.
237 322 ///
238 - /// Four for a tick, because `[x] ` is four cells; two for the current marker
239 - /// alone; none when the list says neither.
323 + /// Four for a tick, because `[x] ` is four cells; two for the marker alone;
324 + /// none when the list needs neither.
325 + ///
326 + /// A row that can be reached takes the marker's two columns whether or not it
327 + /// is the current one, because focus is drawn there and a gutter of zero would
328 + /// put the caret over the first word. That is the drawing paying for an
329 + /// interaction, which is what a gutter is: the description says the row can be
330 + /// opened, and this is the terminal's way of showing which one is about to be.
240 331 fn list_gutter(rows: &[Row]) -> u16 {
241 332 if rows.iter().any(|row| row.selected.is_some()) {
242 333 4
243 - } else if rows.iter().any(|row| row.current) {
334 + } else if rows
335 + .iter()
336 + .any(|row| row.current || row.activate.is_some() || !row.menu.is_empty())
337 + {
244 338 2
245 339 } else {
246 340 0
@@ -248,7 +342,12 @@
248 342 }
249 343
250 344 /// The tick and the current marker, in the columns before a row.
251 - fn draw_gutter(tui: &Tui, row: &Row, area: Rect, buf: &mut Buffer) {
345 + ///
346 + /// The focus lands here rather than on the row's words. A row is a whole line
347 + /// and reversing all of it turns a list into a slab; the gutter is the column
348 + /// the affordances already live in, so it is where "you are on this one" can be
349 + /// said without repainting the content.
350 + fn draw_gutter(tui: &Tui, row: &Row, focused: bool, area: Rect, buf: &mut Buffer) {
252 351 let tick = match row.selected {
253 352 Some(true) => "[x]",
254 353 Some(false) => "[ ]",
@@ -260,17 +359,17 @@
260 359 area.y,
261 360 tick,
262 361 3,
263 - Style::default().fg(tui.theme().content_secondary),
362 + Pass::focused(focused, Style::default().fg(tui.theme().content_secondary)),
264 363 );
265 364 return;
266 365 }
267 - if row.current {
366 + if row.current || focused {
268 367 buf.set_stringn(
269 368 area.x,
270 369 area.y,
271 370 ">",
272 371 1,
273 - Style::default().fg(tui.theme().action_primary),
372 + Pass::focused(focused, Style::default().fg(tui.theme().action_primary)),
274 373 );
275 374 }
276 375 }
@@ -281,13 +380,16 @@
281 380 /// terminal had to know the fixed sequence -- primary, secondary, meta, bar,
282 381 /// tokens, actions -- and hardcode it; here it reads what the description says,
283 382 /// in the order it says it, and the role picks the style.
284 - fn row_line(tui: &Tui, row: &Row) -> Line<'static> {
383 + /// `focus` carries one answer per part, in the run's own order, and is empty
384 + /// for the callers that are measuring rather than drawing.
385 + fn row_line(tui: &Tui, row: &Row, focus: &[bool]) -> Line<'static> {
285 386 let mut spans = Vec::new();
286 - for Part { role, node } in &row.parts {
387 + for (index, Part { role, node }) in row.parts.iter().enumerate() {
287 388 if !spans.is_empty() {
288 389 spans.push(Span::raw(" "));
289 390 }
290 - spans.extend(inline_spans(tui, node, part_style(tui, *role)));
391 + let focused = focus.get(index).copied().unwrap_or(false);
392 + spans.extend(inline_spans(tui, node, part_style(tui, *role), focused));
291 393 }
292 394 // `Row::menu` is not drawn, and that is the description's own instruction:
293 395 // a menu is reached by right-click on a pointer host, long-press on a touch
@@ -310,7 +412,7 @@
310 412 }
311 413
312 414 /// One leaf of a run as spans, under the run's own style.
313 - fn inline_spans(tui: &Tui, node: &Node, inherited: Style) -> Vec<Span<'static>> {
415 + fn inline_spans(tui: &Tui, node: &Node, inherited: Style, focused: bool) -> Vec<Span<'static>> {
314 416 match node {
315 417 Node::Text { text, tone } => {
316 418 let style = match tone {
@@ -320,13 +422,11 @@
320 422 vec![Span::styled(text.clone(), style)]
321 423 }
322 424 Node::Rich { source } => rich_spans(tui, source, inherited),
323 - Node::Token(tag) => vec![tag_span(tui, tag)],
324 - Node::Act(act) => act_line(tui, act).spans,
425 + Node::Token(tag) => vec![tag_span(tui, tag, focused)],
426 + Node::Act(act) => act_line(tui, act, focused).spans,
325 427 Node::Link { text, .. } => vec![Span::styled(
326 428 text.clone(),
327 - Style::default()
328 - .fg(tui.theme().action_primary)
329 - .add_modifier(Modifier::UNDERLINED),
429 + Pass::focused(focused, link_style(tui)),
330 430 )],
331 431 Node::Meter(meter) => meter_line(tui, meter).spans,
332 432 Node::Figure(figure) => vec![Span::styled(
@@ -436,12 +536,17 @@
436 536 }
437 537
438 538 /// A tag as one span.
439 - fn tag_span(tui: &Tui, tag: &Tag) -> Span<'static> {
539 + ///
540 + /// A latched chip and a focused one both read as reversed, which is a collision
541 + /// a terminal cannot avoid: latched is "this filter is on" and focused is "you
542 + /// are here", and there is one spare axis for two facts. Filed rather than
543 + /// resolved by inventing a third look nobody would read.
544 + fn tag_span(tui: &Tui, tag: &Tag, focused: bool) -> Span<'static> {
440 545 let style = tui.tone(tag.tone);
441 546 let style = if tag.latched {
442 547 style.add_modifier(Modifier::REVERSED)
443 548 } else {
444 - style
549 + Pass::focused(focused, style)
445 550 };
446 551 // A chip's removable half is not drawn. The `x` a webview hangs on a chip
447 552 // is a second control inside one span, and a terminal reaches a control by
@@ -457,12 +562,12 @@
457 562 }
458 563
459 564 /// A control as a line.
460 - fn act_line(tui: &Tui, act: &Act) -> Line<'static> {
565 + fn act_line(tui: &Tui, act: &Act, focused: bool) -> Line<'static> {
461 566 let disabled = act.state.is_some_and(layout::State::suppresses_interaction);
462 567 let style = if disabled {
463 568 Style::default().fg(tui.theme().content_muted)
464 569 } else {
465 - tui.tone(act.tone)
570 + Pass::focused(focused, tui.tone(act.tone))
466 571 };
467 572
468 573 // The key is the one place the description already anticipated a terminal,
@@ -547,7 +652,7 @@
547 652 label + body + note
548 653 }
549 654
550 - fn draw_field(tui: &Tui, field: &Field, area: Rect, buf: &mut Buffer) -> u16 {
655 + fn draw_field(pass: &mut Pass<'_>, field: &Field, area: Rect, buf: &mut Buffer) -> u16 {
551 656 // A hidden field is data travelling with the form, so there is nothing to
552 657 // draw and the runtime submits it. The one field kind a terminal and a
553 658 // webview agree on completely.
@@ -555,6 +660,16 @@
555 660 return 0;
556 661 }
557 662
663 + let focused = pass.claim();
664 + let tui = pass.tui;
665 + // What is in the box, which is the view's answer and not the description's.
666 + // See this crate's header, and `39057019`.
667 + let held = pass.view.showing(&field.name, field.value.as_deref());
668 +
669 + if area.width == 0 || area.height == 0 {
670 + return 0;
671 + }
672 +
558 673 let label = if field.required {
559 674 format!("{} *", field.label)
560 675 } else {
@@ -567,25 +682,21 @@
567 682 buf,
568 683 );
569 684
570 - let value = field.value.clone().unwrap_or_default();
571 685 let placeholder = field.placeholder.clone().unwrap_or_default();
572 - let well = Style::default().fg(tui.theme().content_primary);
686 + let well = Pass::focused(focused, Style::default().fg(tui.theme().content_primary));
573 687 let muted = Style::default().fg(tui.theme().content_muted);
574 688
575 689 used += match field.kind {
576 - layout::FieldKind::Checkbox => {
577 - let ticked = field.value.as_deref() == Some(Node::SELECTED);
578 - text::draw(
579 - if ticked { "[x]" } else { "[ ]" },
580 - well,
581 - below(area, used),
582 - buf,
583 - )
584 - }
690 + layout::FieldKind::Checkbox => text::draw(
691 + if held == Node::SELECTED { "[x]" } else { "[ ]" },
692 + well,
693 + below(area, used),
694 + buf,
695 + ),
585 696 layout::FieldKind::Select | layout::FieldKind::Radio => {
586 697 let mut rows = 0;
587 698 for choice in &field.options {
588 - let chosen = field.value.as_deref() == Some(choice.value.as_str());
699 + let chosen = held == choice.value;
589 700 let mark = if chosen { "(*)" } else { "( )" };
590 701 rows += text::draw(
591 702 &format!("{mark} {}", choice.label),
@@ -596,23 +707,26 @@
596 707 }
597 708 rows
598 709 }
599 - // A secret has no described value, ever: `Field::value` drops what it
600 - // is handed when the kind is `Secret`, deliberately, because a password
601 - // that comes back down the wire is a password in a page and in a proxy
602 - // log. So there is nothing here to dot out, and an empty well is the
603 - // whole of what a drawing can say.
604 - //
605 - // The finding underneath it: a browser owns the contents of an `input`,
606 - // so a webview never needed the description to carry them. A terminal
607 - // owns nothing, so what the user has typed lives in the runtime's
608 - // buffer and the renderer has to be handed it. This is the first node
609 - // whose drawing is not a function of the description alone.
610 - layout::FieldKind::Secret => text::draw("[ ]", muted, below(area, used), buf),
710 + // A secret's dots come from the view's buffer and can come from nowhere
711 + // else. `Field::value` drops what it is handed when the kind is
712 + // `Secret`, deliberately, because a password that comes back down the
713 + // wire is a password in a page and in a proxy log, so the description
714 + // carries nothing to dot out and this is the one node that would be
715 + // undrawable without the second argument. `39057019`.
716 + layout::FieldKind::Secret if held.is_empty() => {
717 + empty_well(&placeholder, muted, well, focused, below(area, used), buf)
718 + }
719 + layout::FieldKind::Secret => {
720 + let dots = "*".repeat(held.chars().count());
721 + text::draw(&dots, well, below(area, used), buf).max(1)
722 + }
611 723 // A file field has no way back on a terminal any more than it has on an
612 724 // HTTP host, which `3b830122` already filed. The name is drawn and
613 725 // picking one is the runtime's.
614 - _ if value.is_empty() => text::draw(&placeholder, muted, below(area, used), buf).max(1),
615 - _ => text::draw(&value, well, below(area, used), buf),
726 + _ if held.is_empty() => {
727 + empty_well(&placeholder, muted, well, focused, below(area, used), buf)
728 + }
729 + _ => text::draw(held, well, below(area, used), buf),
616 730 };
617 731
618 732 // The error wins over the hint, the same order a webview uses: a hint is
@@ -631,14 +745,42 @@
631 745 }
632 746 }
633 747
748 + /// A box with nothing in it: the ghost text, and the caret when it has focus.
749 + ///
750 + /// The caret is not decoration. An empty field under a style is an empty field,
751 + /// so a focused one with no placeholder drew literally nothing and there was no
752 + /// way to tell the box was where the typing would go. A browser has a blinking
753 + /// bar for this and gets it without asking; a terminal has one cell of reversed
754 + /// video, put on the first column of the box, which is where the first
755 + /// character will land.
756 + fn empty_well(
757 + placeholder: &str,
758 + muted: Style,
759 + well: Style,
760 + focused: bool,
761 + area: Rect,
Lines truncated
@@ -26,7 +26,7 @@
26 26 use ratatui::layout::Rect;
27 27 use ratatui::style::{Modifier, Style};
28 28
29 - use crate::{Tui, below, text};
29 + use crate::{Pass, Tui, below, text};
30 30
31 31 /// How wide a sidebar is, in columns.
32 32 ///
@@ -37,7 +37,7 @@
37 37 const LIST_SHARE: u16 = 40;
38 38
39 39 /// Lay a screen's regions out and draw them.
40 - pub(crate) fn screen_regions(tui: &Tui, screen: &Screen, area: Rect, buf: &mut Buffer) {
40 + pub(crate) fn screen_regions(pass: &mut Pass<'_>, screen: &Screen, area: Rect, buf: &mut Buffer) {
41 41 let mut rest = area;
42 42
43 43 // Bands stack at the top, full width, in the order they were said. A band
@@ -48,15 +48,11 @@
48 48 .iter()
49 49 .filter(|slot| matches!(slot.kind, RegionKind::Band))
50 50 {
51 - let used = draw(tui, slot, rest, buf);
51 + let used = draw(pass, slot, rest, buf);
52 52 rest = below(rest, used);
53 53 }
54 54
55 - let body: Vec<&Slot> = screen
56 - .slots
57 - .iter()
58 - .filter(|slot| !matches!(slot.kind, RegionKind::Band | RegionKind::Modal))
59 - .collect();
55 + let body = body_slots(screen);
60 56
61 57 match screen.arrangement {
62 58 layout::Arrangement::SidebarContent => {
@@ -65,28 +61,29 @@
65 61 let mut content = right;
66 62 for slot in &body {
67 63 if matches!(slot.kind, RegionKind::Sidebar) {
68 - let used = draw(tui, slot, below(left, sidebars), buf);
64 + let used = draw(pass, slot, below(left, sidebars), buf);
69 65 sidebars += used;
70 66 } else {
71 - let used = draw(tui, slot, content, buf);
67 + let used = draw(pass, slot, content, buf);
72 68 content = below(content, used);
73 69 }
74 70 }
75 71 }
76 72 layout::Arrangement::ListDetail { tabbed } => {
77 73 if tabbed {
78 - // One at a time, and nothing says which. See the module header.
74 + // One at a time, and nothing says which. `body_slots` has
75 + // already cut the rest away, so this is the one region there is.
79 76 if let Some(first) = body.first() {
80 - draw(tui, first, rest, buf);
77 + draw(pass, first, rest, buf);
81 78 }
82 79 } else {
83 80 let (left, right) = split(rest, rest.width * LIST_SHARE / 100);
84 81 let mut detail = right;
85 82 for (index, slot) in body.iter().enumerate() {
86 83 if index == 0 {
87 - draw(tui, slot, left, buf);
84 + draw(pass, slot, left, buf);
88 85 } else {
89 - let used = draw(tui, slot, detail, buf);
86 + let used = draw(pass, slot, detail, buf);
90 87 detail = below(detail, used);
91 88 }
92 89 }
@@ -102,10 +99,57 @@
102 99 .iter()
103 100 .filter(|slot| matches!(slot.kind, RegionKind::Modal))
104 101 {
105 - draw(tui, slot, centred(area), buf);
102 + draw(pass, slot, centred(area), buf);
106 103 }
107 104 }
108 105
106 + /// The regions that fill the body, after the arrangement has had its say.
107 + ///
108 + /// The tabbed cut lives here and only here. It is the one place a described
109 + /// region can be on the screen or not, so the drawing and the focus walk have
110 + /// to agree about it, and two copies of "the first one, and nothing says which"
111 + /// is two chances to disagree.
112 + fn body_slots(screen: &Screen) -> Vec<&Slot> {
113 + let body = screen
114 + .slots
115 + .iter()
116 + .filter(|slot| !matches!(slot.kind, RegionKind::Band | RegionKind::Modal));
117 +
118 + match screen.arrangement {
119 + layout::Arrangement::ListDetail { tabbed: true } => body.take(1).collect(),
120 + _ => body.collect(),
121 + }
122 + }
123 +
124 + /// Every region the user can see, in the order it is drawn.
125 + ///
126 + /// What the focus walk reads. A region that is not drawn holds nothing
127 + /// reachable, which is why this is a question about slots rather than about
128 + /// nodes: a tab that is not showing has controls in it, and stopping on one
129 + /// would move focus to a place with nothing on screen.
130 + ///
131 + /// **A modal takes the whole of it.** A dialog you can tab out of is not a
132 + /// dialog, and this is the one place the drawing order and the focus order
133 + /// deliberately differ: the screen behind a modal is still painted, because
134 + /// covering it costs rows and says nothing, and it is still unreachable.
135 + pub(crate) fn reachable(screen: &Screen) -> Vec<&Slot> {
136 + let modals: Vec<&Slot> = screen
137 + .slots
138 + .iter()
139 + .filter(|slot| matches!(slot.kind, RegionKind::Modal))
140 + .collect();
141 + if !modals.is_empty() {
142 + return modals;
143 + }
144 +
145 + screen
146 + .slots
147 + .iter()
148 + .filter(|slot| matches!(slot.kind, RegionKind::Band))
149 + .chain(body_slots(screen))
150 + .collect()
151 + }
152 +
109 153 /// The rows a region wants at `width`.
110 154 pub(crate) fn height(tui: &Tui, slot: &Slot, width: u16) -> u16 {
111 155 let inner = width.saturating_sub(2);
@@ -119,11 +163,22 @@
119 163 }
120 164
121 165 /// Draw one region, and answer the rows it used.
122 - pub(crate) fn draw(tui: &Tui, slot: &Slot, area: Rect, buf: &mut Buffer) -> u16 {
166 + pub(crate) fn draw(pass: &mut Pass<'_>, slot: &Slot, area: Rect, buf: &mut Buffer) -> u16 {
167 + // A region still loading holds nothing reachable, here and in the focus
168 + // walk both: what is on the screen is the word "Loading", and a control
169 + // counted under it would be a place the caret could go with nothing to see.
170 + let pending = matches!(slot.readiness, layout::Readiness::Pending);
171 +
123 172 if area.width == 0 || area.height == 0 {
173 + if !pending {
174 + for node in &slot.body {
175 + crate::node::draw(pass, node, area, buf);
176 + }
177 + }
124 178 return 0;
125 179 }
126 180
181 + let tui = pass.tui;
127 182 // The frame, from the depth the region's kind implies. This is the whole
128 183 // reason `makeover-tui` is a dependency rather than a nice-to-have: a
129 184 // raised region is drawn the same way here as in every other terminal app
@@ -139,7 +194,7 @@
139 194 // `Readiness` is the loading axis, and a terminal has no spinner that is
140 195 // not a clock. It says so in words instead, which loses the motion and
141 196 // keeps the fact.
142 - if matches!(slot.readiness, layout::Readiness::Pending) {
197 + if pending {
143 198 let used = text::draw(
144 199 "Loading",
145 200 Style::default()
@@ -156,10 +211,7 @@
156 211 // them is the host's to draw, and this renderer has no fill mechanism to
157 212 // offer it. That is a gap rather than a decline: `Webview::with_fill` has
158 213 // no counterpart here.
159 - let mut used = 0;
160 - for node in &slot.body {
161 - used += crate::node::draw(tui, node, below(inner, used), buf);
162 - }
214 + let used = body(pass, slot, inner, buf);
163 215
164 216 // `Slot::id` is not drawn anywhere. It is a fragment address, and a
165 217 // terminal redraws rather than swapping, so it costs nothing and says
@@ -167,6 +219,62 @@
167 219 used + if framed(depth) { 2 } else { 0 }
168 220 }
169 221
222 + /// A region's contents, at the offset the view is holding it at.
223 + ///
224 + /// Scrolling is the runtime's and the clipping is the drawing's, and this is
225 + /// where the two meet. Flow layout draws from the top of the rect it is given,
226 + /// so an offset cannot be honoured by moving the rect: a node starting above
227 + /// the window would draw its first row at the window's first row. What works is
228 + /// to draw the region at its full height into a buffer of its own and copy the
229 + /// window out, which costs an allocation per scrolled region and nothing at all
230 + /// for a region sitting at the top, which is every region until someone
231 + /// scrolls.
232 + ///
233 + /// The offset is clamped here rather than in [`crate::View`], because how far a
234 + /// region can scroll is how tall it is at the width it was given, and the width
235 + /// is not known until this point.
236 + fn body(pass: &mut Pass<'_>, slot: &Slot, inner: Rect, buf: &mut Buffer) -> u16 {
237 + let offset = pass.view.scroll(&slot.id);
238 + if offset == 0 {
239 + let mut used = 0;
240 + for node in &slot.body {
241 + used += crate::node::draw(pass, node, below(inner, used), buf);
242 + }
243 + return used.min(inner.height);
244 + }
245 +
246 + let content: u16 = slot
247 + .body
248 + .iter()
249 + .map(|node| crate::node::height(pass.tui, node, inner.width))
250 + .sum();
251 + let offset = offset.min(content.saturating_sub(inner.height));
252 +
253 + let tall = Rect {
254 + height: content.max(inner.height),
255 + ..inner
256 + };
257 + let mut scratch = Buffer::empty(tall);
258 + let mut used = 0;
259 + for node in &slot.body {
260 + used += crate::node::draw(pass, node, below(tall, used), &mut scratch);
261 + }
262 +
263 + let shown = inner.height.min(content.saturating_sub(offset));
264 + for row in 0..shown {
265 + for column in 0..inner.width {
266 + let from = (inner.x + column, inner.y + offset + row);
267 + let to = (inner.x + column, inner.y + row);
268 + if let Some(cell) = scratch.cell(from).cloned()
269 + && let Some(target) = buf.cell_mut(to)
270 + {
271 + *target = cell;
272 + }
273 + }
274 + }
275 + shown
276 + }
277 +
170 278 /// Whether a depth is drawn with a border.
171 279 ///
172 280 /// Flat is not: a band and a plain pane are arrangement, and boxing every one
@@ -15,7 +15,7 @@
15 15 use ratatui::layout::Rect;
16 16 use ratatui::style::Modifier;
17 17
18 - use crate::Tui;
18 + use crate::{Tui, View};
19 19
20 20 /// A renderer in a shipped theme, at full colour.
21 21 ///
@@ -46,10 +46,15 @@
46 46 }
47 47
48 48 /// Draw one node into a buffer of this size.
49 + ///
50 + /// With an empty [`View`], which is what "the description and nothing else"
51 + /// looks like now that drawing takes two arguments: nothing typed, nothing
52 + /// focused yet, nothing scrolled. Every assertion in this file that predates
53 + /// the interaction runtime still reads the same picture through it.
49 54 fn buffer(node: &Node, width: u16, height: u16) -> Buffer {
50 55 let area = Rect::new(0, 0, width, height);
51 56 let mut buf = Buffer::empty(area);
52 - tui().node(node, area, &mut buf);
57 + tui().node(node, &View::new(), area, &mut buf);
53 58 buf
54 59 }
55 60
@@ -75,7 +80,7 @@
75 80 fn shown(screen: &Screen, width: u16, height: u16) -> Vec<String> {
76 81 let area = Rect::new(0, 0, width, height);
77 82 let mut buf = Buffer::empty(area);
78 - tui().screen(screen, area, &mut buf);
83 + tui().screen(screen, &View::new(), area, &mut buf);
79 84 rows(&buf)
80 85 }
81 86
@@ -349,3 +354,605 @@
349 354 assert!(out.iter().any(|row| row.contains("Loading")), "{out:?}");
350 355 assert!(!out.iter().any(|row| row.contains("Ready")), "{out:?}");
351 356 }
357 +
358 + // The interaction runtime. Everything below is the half the browser was
359 + // supplying: focus order, what is typed, where a key goes, what comes back.
360 +
361 + use crate::focus::{Reach, Spot};
362 + use crate::{Key, Runtime, Step};
363 + use quasi_router::{Address, Message, Method, Outcome, Request, Response, Rest};
364 +
365 + /// A screen with one region holding these nodes.
366 + fn screen_of(nodes: impl IntoIterator<Item = Node>) -> Screen {
367 + Screen::sidebar_content("Test").with(
368 + nodes
369 + .into_iter()
370 + .fold(Slot::new("main", RegionKind::Pane), Slot::with),
371 + )
372 + }
373 +
374 + /// The path a step is calling, for a step that calls one.
375 + fn calling(step: &Step) -> Option<&str> {
376 + match step {
377 + Step::Call(request) => Some(request.path.as_str()),
378 + _ => None,
379 + }
380 + }
381 +
382 + #[test]
383 + fn the_focus_walk_and_the_drawing_count_the_same_things() {
384 + // The one invariant holding the two walks together. `focus.rs` decides how
385 + // many reachable things a screen has and `node.rs` counts them as it draws,
386 + // and if they ever disagree the caret lights a different control from the
387 + // one Enter would call. Asserted over a screen carrying one of everything
388 + // that can be reached.
389 + let screen = screen_of([
390 + Node::Act(Act::new("Save", Action::post("/save"))),
391 + Node::Act(Act::new("Gone", Action::post("/gone")).disabled()),
392 + Node::Link {
393 + text: "Docs".into(),
394 + action: Action::get("/docs"),
395 + },
396 + Node::field(Field::new(layout::FieldKind::Text, "name", "Name")),
397 + Node::field(Field::new(layout::FieldKind::Hidden, "csrf", "")),
398 + Node::Form {
399 + action: Action::post("/new"),
400 + submit: "Create".into(),
401 + fields: vec![
402 + Field::new(layout::FieldKind::Text, "title", "Title"),
403 + Field::new(layout::FieldKind::Secret, "password", "Password"),
404 + ],
405 + },
406 + Node::list([
407 + Row::new("Open me").activate(Action::get("/one")),
408 + Row::new("Just words"),
409 + ])
410 + .and_more(Rest::more(Action::get("/more"))),
411 + Node::Select {
412 + kind: layout::Selector::Tabs,
413 + options: vec![
414 + (Choice::plain("a"), Some(Action::get("/a"))),
415 + (Choice::plain("b"), None),
416 + ],
417 + chosen: Some("a".into()),
418 + action: None,
419 + },
420 + Node::Table {
421 + columns: vec![Column::new("Name")],
422 + rows: vec![Cells::new(["one"]).activate(Action::get("/row"))],
423 + },
424 + ]);
425 +
426 + let expected = crate::focus::spots(&screen).len();
427 +
428 + // The count the drawing keeps is private, so it is read through the only
429 + // thing it drives: focusing the nth reachable thing has to change the
430 + // picture. The baseline is focus one past the end, where nothing is lit.
431 + //
432 + // What this catches is the walks drifting apart. If the drawing counted
433 + // fewer things than `spots` records, the last indices would light nothing
434 + // and come back identical to the baseline; if it counted them in another
435 + // order, the caret would still move but a later test would find it on the
436 + // wrong control. This is the cheap half, and it is the half that breaks
437 + // silently.
438 + let area = Rect::new(0, 0, 60, 40);
439 + let draw = |view: &View| {
440 + let mut buf = Buffer::empty(area);
441 + tui().screen(&screen, view, area, &mut buf);
442 + buf
443 + };
444 +
445 + let mut past = View::new();
446 + past.focus_on(expected, expected + 1);
447 + let unlit = draw(&past);
448 +
449 + for at in 0..expected {
450 + let mut view = View::new();
451 + view.focus_on(at, expected);
452 + assert_ne!(
453 + draw(&view),
454 + unlit,
455 + "focusing {at} of {expected} changed nothing on the screen"
456 + );
457 + }
458 + }
459 +
460 + #[test]
461 + fn a_secret_field_draws_what_was_typed_and_the_description_never_carries_it() {
462 + // `39057019`. The description refuses to hold a password, so the dots can
463 + // only come from the view, and this is the node that would be undrawable
464 + // without the second argument.
465 + let field = Field::new(layout::FieldKind::Secret, "password", "Password").value("hunter2");
466 + assert_eq!(field.value, None, "a secret refuses a described value");
467 +
468 + let node = Node::field(field);
469 + let area = Rect::new(0, 0, 30, 4);
470 +
471 + let mut buf = Buffer::empty(area);
472 + tui().node(&node, &View::new(), area, &mut buf);
473 + assert!(
474 + !rows(&buf).iter().any(|row| row.contains('*')),
475 + "nothing typed yet"
476 + );
477 +
478 + let mut view = View::new();
479 + view.set("password", "hunter2");
480 + let mut buf = Buffer::empty(area);
481 + tui().node(&node, &view, area, &mut buf);
482 + assert!(
483 + rows(&buf).iter().any(|row| row.contains("*******")),
484 + "{:?}",
485 + rows(&buf)
486 + );
487 + }
488 +
489 + #[test]
490 + fn tab_walks_the_screen_and_enter_calls_what_it_lands_on() {
491 + let mut runtime = Runtime::new(screen_of([
492 + Node::Act(Act::new("First", Action::post("/first"))),
493 + Node::Act(Act::new("Second", Action::post("/second"))),
494 + ]));
495 +
496 + assert_eq!(calling(&runtime.key(Key::Enter)), Some("/first"));
497 + assert_eq!(runtime.key(Key::Tab), Step::Idle);
498 + assert_eq!(calling(&runtime.key(Key::Enter)), Some("/second"));
499 + // Wrapping, because a dead stop at the end reads as a broken key.
500 + runtime.key(Key::Tab);
501 + assert_eq!(calling(&runtime.key(Key::Enter)), Some("/first"));
502 + }
503 +
504 + #[test]
505 + fn a_disabled_control_is_drawn_and_never_landed_on() {
506 + let mut runtime = Runtime::new(screen_of([
507 + Node::Act(Act::new("Gone", Action::post("/gone")).disabled()),
508 + Node::Act(Act::new("Live", Action::post("/live"))),
509 + ]));
510 + assert_eq!(calling(&runtime.key(Key::Enter)), Some("/live"));
511 + }
512 +
513 + #[test]
514 + fn the_description_says_which_control_to_start_on() {
515 + // `layout::State::Focus` is a fact the screen can state, and this is the one
516 + // place it is honoured: where focus starts, never where it goes back to.
517 + let mut runtime = Runtime::new(screen_of([
518 + Node::Act(Act::new("First", Action::post("/first"))),
519 + Node::Act(Act {
520 + state: Some(layout::State::Focus),
521 + ..Act::new("Wanted", Action::post("/wanted"))
522 + }),
523 + ]));
524 + assert_eq!(calling(&runtime.key(Key::Enter)), Some("/wanted"));
525 + }
526 +
527 + #[test]
528 + fn a_key_the_description_named_reaches_its_control_from_anywhere() {
529 + // `Act::key` is the one place the vocabulary already anticipated a
530 + // terminal, and this is the renderer that binds it.
531 + let mut runtime = Runtime::new(screen_of([
532 + Node::Act(Act::new("First", Action::post("/first"))),
533 + Node::Act(Act::new("New", Action::get("/new")).key("n")),
534 + ]));
535 + assert_eq!(calling(&runtime.key(Key::Char('n'))), Some("/new"));
536 + // A key nothing claimed does nothing rather than something surprising.
537 + assert_eq!(runtime.key(Key::Char('z')), Step::Idle);
538 + }
539 +
540 + #[test]
541 + fn a_control_that_asks_first_is_not_called_until_it_is_answered() {
542 + let mut runtime = Runtime::new(screen_of([Node::Act(
543 + Act::new("Delete", Action::post("/delete")).confirm("Delete this?"),
544 + )]));
545 +
546 + assert_eq!(
547 + runtime.key(Key::Enter),
548 + Step::Ask("Delete this?".to_string())
549 + );
550 + assert!(runtime.asking());
551 + assert_eq!(runtime.key(Key::Char('n')), Step::Idle);
552 +
553 + assert!(matches!(runtime.key(Key::Enter), Step::Ask(_)));
554 + assert_eq!(calling(&runtime.key(Key::Char('y'))), Some("/delete"));
555 + }
556 +
557 + #[test]
558 + fn typing_fills_a_box_and_a_form_submits_what_is_in_it() {
559 + let mut runtime = Runtime::new(screen_of([Node::Form {
560 + action: Action::post("/new"),
561 + submit: "Create".into(),
562 + fields: vec![
563 + Field::new(layout::FieldKind::Text, "title", "Title"),
564 + Field::new(layout::FieldKind::Checkbox, "urgent", "Urgent"),
565 + ],
566 + }]));
567 +
568 + assert!(runtime.editing());
569 + for ch in "Ship".chars() {
570 + runtime.key(Key::Char(ch));
571 + }
572 + runtime.key(Key::Backspace);
573 +
574 + // Onto the checkbox, which takes any key as a flip rather than as a
575 + // character, then onto the submit.
576 + runtime.key(Key::Tab);
577 + runtime.key(Key::Char(' '));
578 + runtime.key(Key::Tab);
579 +
580 + let Step::Call(request) = runtime.key(Key::Enter) else {
581 + panic!("the submit calls its route");
582 + };
583 + assert_eq!(request.path, "/new");
584 + assert_eq!(request.method, Method::Post);
585 + assert_eq!(request.payload.get("title"), Some("Shi"));
586 + assert_eq!(request.payload.get("urgent"), Some(Node::SELECTED));
587 + }
588 +
589 + #[test]
590 + fn an_unticked_box_sends_nothing_the_way_a_browser_sends_nothing() {
591 + let mut runtime = Runtime::new(screen_of([Node::Form {
592 + action: Action::post("/new"),
593 + submit: "Create".into(),
594 + fields: vec![Field::new(layout::FieldKind::Checkbox, "urgent", "Urgent")],
595 + }]));
596 + runtime.key(Key::Tab);
597 + let Step::Call(request) = runtime.key(Key::Enter) else {
598 + panic!("the submit calls its route");
599 + };
600 + assert!(!request.payload.contains("urgent"), "{:?}", request.payload);
601 + }
602 +
603 + #[test]
604 + fn a_field_that_writes_as_it_changes_writes_on_the_keystroke() {
605 + // `Field::changes` says the change is the write, and a terminal has no
606 + // `input` event to debounce, so every keystroke is one call. That is the
607 + // description read literally, and the cost of reading it literally is
608 + // filed rather than papered over with a delay this renderer invented.
609 + let mut runtime = Runtime::new(screen_of([Node::field(
610 + Field::new(layout::FieldKind::Text, "query", "Search").changes(Action::post("/search")),
611 + )]));
612 + let Step::Call(request) = runtime.key(Key::Char('a')) else {
613 + panic!("a change writes");
614 + };
615 + assert_eq!(request.path, "/search");
616 + assert_eq!(request.payload.get("query"), Some("a"));
617 + }
618 +
619 + #[test]
620 + fn a_screen_is_a_place_and_a_write_is_not() {
621 + let mut runtime = Runtime::new(screen_of([Node::text("first")]));
622 +
623 + // A read that answered a screen is somewhere to come back to.
624 + runtime.apply(
625 + &Request::get("/two"),
626 + Response {
627 + outcome: Outcome::Screen(screen_of([Node::text("second")])),
628 + notice: None,
629 + address: None,
630 + },
631 + );
632 + runtime.apply(
633 + &Request::get("/three"),
634 + Response {
635 + outcome: Outcome::Screen(screen_of([Node::text("third")])),
636 + notice: None,
637 + address: None,
638 + },
639 + );
640 +
641 + assert_eq!(calling(&runtime.key(Key::Escape)), Some("/two"));
642 +
643 + // A write is not a place, so it leaves nothing behind to go back to.
644 + let mut runtime = Runtime::new(screen_of([Node::text("first")]));
645 + runtime.apply(
646 + &Request::post("/save"),
647 + Response {
648 + outcome: Outcome::Screen(screen_of([Node::text("saved")])),
649 + notice: None,
650 + address: None,
651 + },
652 + );
653 + assert_eq!(runtime.key(Key::Escape), Step::Idle);
654 + }
655 +
656 + #[test]
657 + fn a_response_can_say_it_is_not_a_place_when_the_derivation_would_say_it_is() {
658 + let mut runtime = Runtime::new(screen_of([Node::text("first")]));
659 + runtime.apply(
660 + &Request::get("/transient"),
661 + Response {
662 + outcome: Outcome::Screen(screen_of([Node::text("transient")])),
663 + notice: None,
664 + address: Some(Address::Unchanged),
665 + },
666 + );
667 + assert_eq!(runtime.key(Key::Escape), Step::Idle);
668 + }
669 +
670 + #[test]
671 + fn a_fragment_replaces_one_region_and_keeps_the_rest_of_the_screen() {
672 + let screen = Screen::sidebar_content("Test")
673 + .with(Slot::new("side", RegionKind::Sidebar).with(Node::text("kept")))
674 + .with(Slot::new("main", RegionKind::Pane).with(Node::text("old")));
675 + let mut runtime = Runtime::new(screen);
676 +
677 + let follow = runtime.apply(
678 + &Request::post("/change"),
679 + Response {
680 + outcome: Outcome::Fragment {
681 + region: "main".into(),
682 + node: Node::text("new"),
683 + },
684 + notice: None,
685 + address: None,
686 + },
687 + );
688 + assert!(follow.is_none());
689 +
690 + let out = shown(runtime.screen(), 40, 8);
691 + assert!(out.iter().any(|row| row.contains("kept")), "{out:?}");
692 + assert!(out.iter().any(|row| row.contains("new")), "{out:?}");
693 + assert!(!out.iter().any(|row| row.contains("old")), "{out:?}");
694 + }
695 +
696 + #[test]
697 + fn a_fragment_naming_a_region_that_is_not_there_says_so() {
698 + // `Screen::replace` answers false rather than panicking, and the caller is
699 + // the one that can act on it. A terminal drawing nothing would look like a
700 + // control that does nothing at all.
701 + let mut runtime = Runtime::new(screen_of([Node::text("here")]));
702 + runtime.apply(
703 + &Request::post("/change"),
704 + Response {
705 + outcome: Outcome::Fragment {
706 + region: "nowhere".into(),
707 + node: Node::text("new"),
708 + },
709 + notice: None,
710 + address: None,
711 + },
712 + );
713 + let out = shown(runtime.screen(), 60, 8);
714 + assert!(out.iter().any(|row| row.contains("nowhere")), "{out:?}");
715 + }
716 +
717 + #[test]
718 + fn going_somewhere_else_is_a_second_request_the_host_performs() {
719 + let mut runtime = Runtime::new(screen_of([Node::text("here")]));
720 + let follow = runtime.apply(
721 + &Request::post("/delete"),
722 + Response {
723 + outcome: Outcome::Goto(Action::get("/list")),
724 + notice: None,
725 + address: None,
726 + },
727 + );
728 + assert_eq!(
729 + follow.map(|request| request.path),
730 + Some("/list".to_string())
731 + );
732 + }
733 +
734 + #[test]
735 + fn what_a_response_says_lands_on_the_screen_it_belongs_to() {
736 + let mut runtime = Runtime::new(screen_of([Node::text("here")]));
737 + runtime.apply(
738 + &Request::post("/save"),
739 + Response {
740 + outcome: Outcome::Screen(screen_of([Node::text("after")])),
741 + notice: Some(Message {
742 + kind: layout::Notice::Banner,
743 + tone: layout::Tone::Success,
744 + text: "Saved".into(),
745 + undo: None,
746 + }),
747 + address: None,
748 + },
749 + );
750 + let out = shown(runtime.screen(), 40, 6);
751 + assert_eq!(out[0], "Saved");
752 + }
753 +
754 + #[test]
755 + fn a_new_screen_forgets_what_was_typed_into_the_old_one() {
756 + // Two screens can name the same field, and carrying a buffer across would
757 + // put what was typed into one box into a different box that happens to
758 + // share its name.
759 + let mut runtime = Runtime::new(screen_of([Node::field(Field::new(
760 + layout::FieldKind::Text,
761 + "name",
762 + "Name",
763 + ))]));
764 + runtime.key(Key::Char('a'));
765 + assert_eq!(runtime.view().edit("name"), Some("a"));
766 +
767 + runtime.apply(
768 + &Request::get("/other"),
769 + Response {
770 + outcome: Outcome::Screen(screen_of([Node::field(Field::new(
771 + layout::FieldKind::Text,
772 + "name",
773 + "Different question, same name",
774 + ))])),
775 + notice: None,
776 + address: None,
777 + },
778 + );
779 + assert_eq!(runtime.view().edit("name"), None);
780 + }
781 +
782 + #[test]
783 + fn a_scrolled_region_shows_the_rows_under_the_ones_it_started_with() {
784 + let slot =
785 + Slot::new("main", RegionKind::Pane).extend((0..10).map(|n| Node::text(format!("row {n}"))));
786 + let node = Node::Region(slot);
787 + let area = Rect::new(0, 0, 20, 4);
788 +
789 + // Row 0 of the buffer is the region's own frame, so the contents start on
790 + // row 1 and the window is what is left after the frame takes two.
791 + let mut buf = Buffer::empty(area);
792 + tui().node(&node, &View::new(), area, &mut buf);
793 + assert!(rows(&buf)[1].contains("row 0"), "{:?}", rows(&buf));
794 +
795 + let mut view = View::new();
796 + view.scrolled_to("main", 3);
797 + let mut buf = Buffer::empty(area);
798 + tui().node(&node, &view, area, &mut buf);
799 + let out = rows(&buf);
800 + assert!(out[1].contains("row 3"), "{out:?}");
801 + assert!(!out.iter().any(|row| row.contains("row 0")), "{out:?}");
802 + }
803 +
804 + #[test]
805 + fn scrolling_stops_at_the_bottom_of_what_there_is() {
806 + // The view holds a number and the drawing clamps it, because how far a
807 + // region can scroll is how tall it is at the width it was handed, and the
808 + // width is not known until it is drawn.
809 + let slot =
810 + Slot::new("main", RegionKind::Pane).extend((0..6).map(|n| Node::text(format!("row {n}"))));
811 + let node = Node::Region(slot);
812 + let area = Rect::new(0, 0, 20, 4);
813 +
814 + // Six rows into a window of two, so the furthest down it can go is row 4
815 + // at the top: an offset past the end shows the last screenful and not a
816 + // blank region.
817 + let mut view = View::new();
818 + view.scrolled_to("main", 99);
819 + let mut buf = Buffer::empty(area);
820 + tui().node(&node, &view, area, &mut buf);
821 + let out = rows(&buf);
Lines truncated
@@ -1,0 +1,384 @@
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 + use makeover_layout as layout;
31 + use quasi_router::{Act, Action, Field, Node, Part, Row, Screen, Slot};
32 +
33 + /// One thing the user can reach, and what reaching it offers.
34 + #[derive(Debug, Clone, PartialEq, Eq)]
35 + pub enum Spot {
36 + /// A control. Enter calls it, after its confirmation when it has one.
37 + Act {
38 + /// What it calls.
39 + action: Action,
40 + /// What to ask first, if anything.
41 + confirm: Option<String>,
42 + /// The key that reaches it without walking there.
43 + ///
44 + /// The one place the description already anticipated a terminal, and
45 + /// the runtime is what finally binds it.
46 + key: Option<String>,
47 + /// Whether the description says this is the control to start on.
48 + ///
49 + /// [`layout::State::Focused`] is a fact two parties now claim: the
50 + /// screen says which control matters, and the view holds where the user
51 + /// has walked to since. The rule is that the description decides where
52 + /// focus *starts* and never moves it afterwards, because a redraw that
53 + /// pulled the caret back would take the keyboard off the user.
54 + wants_focus: bool,
55 + },
56 + /// Text that goes somewhere. Enter follows it.
57 + Link {
58 + /// Where it goes.
59 + action: Action,
60 + },
61 + /// A question. Typing edits it; Enter leaves it alone.
62 + Field(Box<FieldSpot>),
63 + /// The control that answers a whole form.
64 + Submit {
65 + /// Where the answers go.
66 + action: Action,
67 + /// The names the form submits, in order, so the runtime can gather the
68 + /// values it is holding for them.
69 + names: Vec<String>,
70 + },
71 + /// A row of a list.
72 + Row {
73 + /// What opening it calls.
74 + activate: Option<Action>,
75 + /// What ticking it calls, when the tick is itself the write.
76 + toggle: Option<Action>,
77 + /// Whether it is ticked, and whether it can be.
78 + ticked: Option<bool>,
79 + /// What it offers without showing: reached by a key here, by
80 + /// right-click on a pointer host.
81 + menu: Vec<Act>,
82 + },
83 + /// One option of a selector.
84 + Choice {
85 + /// What picking it calls.
86 + action: Action,
87 + },
88 + /// The way to the rows a list is not showing.
89 + More {
90 + /// What asking for more calls.
91 + action: Action,
92 + },
93 + }
94 +
95 + /// A question, and everything the runtime needs to hold what is typed into it.
96 + #[derive(Debug, Clone, PartialEq, Eq)]
97 + pub struct FieldSpot {
98 + /// The name the value is submitted under.
99 + pub name: String,
100 + /// What kind of value it takes.
101 + pub kind: layout::FieldKind,
102 + /// What the description offers back, which is what an untouched buffer
103 + /// starts from.
104 + ///
105 + /// Always `None` for a [`layout::FieldKind::Secret`], and that is the whole
106 + /// of `39057019`: the description refuses to carry one, on purpose, so the
107 + /// runtime's buffer is the only place the typed value has ever lived.
108 + pub value: Option<String>,
109 + /// The values on offer, for the kinds that offer any.
110 + pub options: Vec<String>,
111 + /// The longest value it will take, in characters.
112 + pub max_length: Option<u32>,
113 + /// What changing it calls, for a control that writes on its own.
114 + pub changes: Option<Action>,
115 + }
116 +
117 + impl Spot {
118 + /// What Enter does here, when it does anything.
119 + ///
120 + /// A field answers `None`: Enter in a text box is not a submit here, the
121 + /// way it is in a browser, because a terminal has no implicit submit and
122 + /// guessing one would fire a form from the first field the user typed in.
123 + #[must_use]
124 + pub fn enters(&self) -> Option<&Action> {
125 + match self {
126 + Self::Act { action, .. }
127 + | Self::Link { action }
128 + | Self::Submit { action, .. }
129 + | Self::Choice { action }
130 + | Self::More { action } => Some(action),
131 + Self::Row { activate, .. } => activate.as_ref(),
132 + Self::Field(_) => None,
133 + }
134 + }
135 +
136 + /// The question this stands on, when it is one.
137 + #[must_use]
138 + pub const fn field(&self) -> Option<&FieldSpot> {
139 + match self {
140 + Self::Field(spot) => Some(spot),
141 + _ => None,
142 + }
143 + }
144 + }
145 +
146 + /// A reachable thing, and the region it is in.
147 + ///
148 + /// The region is here because scrolling needs it. A key that scrolls has to
149 + /// scroll something, and the only non-arbitrary answer is the region the user is
150 + /// working in, which is the region their focus is in. Carrying it on the walk
151 + /// that already visits every reachable thing is cheaper than a second walk that
152 + /// would be free to disagree with this one.
153 + #[derive(Debug, Clone, PartialEq, Eq)]
154 + pub struct Reach {
155 + /// The [`Slot::id`] of the region holding it.
156 + pub region: String,
157 + /// What it is.
158 + pub spot: Spot,
159 + }
160 +
161 + /// Everything reachable on `screen`, in draw order, with its region.
162 + #[must_use]
163 + pub fn reaches(screen: &Screen) -> Vec<Reach> {
164 + let mut found = Vec::new();
165 + for slot in crate::region::reachable(screen) {
166 + slot_spots(slot, &mut found);
167 + }
168 + found
169 + }
170 +
171 + /// Everything reachable on `screen`, in draw order.
172 + #[must_use]
173 + pub fn spots(screen: &Screen) -> Vec<Spot> {
174 + reaches(screen)
175 + .into_iter()
176 + .map(|reach| reach.spot)
177 + .collect()
178 + }
179 +
180 + /// A region's reachable things.
181 + ///
182 + /// A region that is still loading has none. It is drawn as the word "Loading"
183 + /// and nothing under it is on screen, so anything counted here would be a
184 + /// focusable the user cannot see.
185 + fn slot_spots(slot: &Slot, found: &mut Vec<Reach>) {
186 + if matches!(slot.readiness, layout::Readiness::Pending) {
187 + return;
188 + }
189 + for node in &slot.body {
190 + node_spots(node, &slot.id, found);
191 + }
192 + }
193 +
194 + /// One node's reachable things, in the order it draws them.
195 + pub(crate) fn node_spots(node: &Node, region: &str, found: &mut Vec<Reach>) {
196 + // Everything below reads better saying what it found rather than how it is
197 + // recorded, and the region is the same for every one of them.
198 + macro_rules! push {
199 + ($spot:expr) => {
200 + found.push(Reach {
201 + region: region.to_string(),
202 + spot: $spot,
203 + })
204 + };
205 + }
206 +
207 + match node {
208 + Node::Act(act) => push_act(act, region, found),
209 +
210 + Node::Link { action, .. } => push!(Spot::Link {
211 + action: action.clone(),
212 + }),
213 +
214 + // A chip carries a route and is drawn as a bracketed label with no
215 + // second target in it, which `node.rs` already declined: the `x` a
216 + // webview hangs on a chip is a control inside a span. Reaching the chip
217 + // is reaching its action, which is the part a terminal can honour.
218 + Node::Token(tag) => {
219 + if let layout::Token::Chip { .. } = tag.kind
220 + && let Some(action) = tag.action.clone()
221 + {
222 + push!(Spot::Act {
223 + action,
224 + confirm: None,
225 + key: None,
226 + wants_focus: false,
227 + });
228 + }
229 + }
230 +
231 + Node::StandIn { act, .. } => {
232 + if let Some(act) = act {
233 + push_act(act, region, found);
234 + }
235 + }
236 +
237 + Node::Field(field) => push_field(field, region, found),
238 +
239 + Node::Form {
240 + action,
241 + fields,
242 + submit: _,
243 + } => {
244 + for field in fields {
245 + push_field(field, region, found);
246 + }
247 + push!(Spot::Submit {
248 + action: action.clone(),
249 + names: fields.iter().map(|field| field.name.clone()).collect(),
250 + });
251 + }
252 +
253 + Node::List { rows, more } => {
254 + for row in rows {
255 + push_row(row, region, found);
256 + }
257 + if let Some(rest) = more {
258 + push!(Spot::More {
259 + action: rest.action.clone(),
260 + });
261 + }
262 + }
263 +
264 + // A table's rows are reachable and its cells are not. A cell holding a
265 + // control is drawn through `makeover_tui::table`, which lays cells out
266 + // by column width and answers no coordinates back, so there is nothing
267 + // here that could say where inside a row a control ended up. Reaching
268 + // the row is what a terminal can do honestly; reaching the third
269 + // control in the fourth cell is a finding.
270 + Node::Table { rows, .. } => {
271 + for cells in rows {
272 + if let Some(activate) = cells.activate.clone() {
273 + push!(Spot::Row {
274 + activate: Some(activate),
275 + toggle: None,
276 + ticked: None,
277 + menu: Vec::new(),
278 + });
279 + }
280 + }
281 + }
282 +
283 + Node::Select {
284 + options, action, ..
285 + } => {
286 + for (choice, own) in options {
287 + // An option carrying nothing falls back to the strip's action
288 + // with its value substituted, which is what the description
289 + // says the fallback is. An option with neither is a label.
290 + let call = own.clone().or_else(|| {
291 + action
292 + .clone()
293 + .map(|action| action.with(Node::SELECTED, choice.value.clone()))
294 + });
295 + if let Some(action) = call {
296 + push!(Spot::Choice { action });
297 + }
298 + }
299 + }
300 +
301 + Node::Region(slot) => slot_spots(slot, found),
302 +
303 + // Readouts and prose. Nothing to call, so nothing to stop on.
304 + Node::Heading { .. }
305 + | Node::Text { .. }
306 + | Node::Rich { .. }
307 + | Node::Figure(_)
308 + | Node::Notice { .. }
309 + | Node::Meter(_)
310 + | Node::Stats { .. } => {}
311 + }
312 + }
313 +
314 + /// A control, unless it is disabled.
315 + fn push_act(act: &Act, region: &str, found: &mut Vec<Reach>) {
316 + if act.state.is_some_and(layout::State::suppresses_interaction) {
317 + return;
318 + }
319 + found.push(Reach {
320 + region: region.to_string(),
321 + spot: Spot::Act {
322 + action: act.action.clone(),
323 + confirm: act.confirm.clone(),
324 + key: act.key.clone(),
325 + wants_focus: matches!(act.state, Some(layout::State::Focus)),
326 + },
327 + });
328 + }
329 +
330 + /// A question, unless it is hidden.
331 + ///
332 + /// A hidden field draws nothing and is submitted with the form, so stopping on
333 + /// it would be a stop on a blank row.
334 + fn push_field(field: &Field, region: &str, found: &mut Vec<Reach>) {
335 + if matches!(field.kind, layout::FieldKind::Hidden) {
336 + return;
337 + }
338 + found.push(Reach {
339 + region: region.to_string(),
340 + spot: Spot::Field(Box::new(FieldSpot {
341 + name: field.name.clone(),
342 + kind: field.kind,
343 + value: field.value.clone(),
344 + options: field
345 + .options
346 + .iter()
347 + .map(|choice| choice.value.clone())
348 + .collect(),
349 + max_length: field.max_length,
350 + changes: field.changes.clone(),
351 + })),
352 + });
353 + }
354 +
355 + /// A row: the row itself when the description gives it something to do, then
356 + /// whatever its run carries.
357 + ///
358 + /// Two stops and not one, because they are two things. A row that opens a
359 + /// detail pane and also shows a Remove button offers both, and a terminal that
360 + /// collapsed them would make the button unreachable or the row unopenable. A
361 + /// row that only shows things is passed over entirely, which is the difference
362 + /// between a list and a menu.
363 + ///
364 + /// The row comes first because it is the whole line and the controls sit on it.
365 + fn push_row(row: &Row, region: &str, found: &mut Vec<Reach>) {
366 + if row.activate.is_some()
367 + || row.toggle.is_some()
368 + || row.selected.is_some()
369 + || !row.menu.is_empty()
370 + {
371 + found.push(Reach {
372 + region: region.to_string(),
373 + spot: Spot::Row {
374 + activate: row.activate.clone(),
375 + toggle: row.toggle.clone(),
376 + ticked: row.selected,
377 + menu: row.menu.clone(),
378 + },
379 + });
380 + }
381 + for Part { node, .. } in &row.parts {
382 + node_spots(node, region, found);
383 + }
384 + }
@@ -1,0 +1,486 @@
1 + //! The half a webview host never writes.
2 + //!
3 + //! `quasi-axum` and `quasi-tauri` both answer a request with markup and stop.
4 + //! Everything between one request and the next — which control is under the
5 + //! caret, what the user has typed into it, what a key means, where the back
6 + //! button goes — is the browser's, and neither adapter contains a line of it.
7 + //! A terminal has no browser under it, so this is that half, written out.
8 + //!
9 + //! # It does not own the router
10 + //!
11 + //! [`Runtime`] turns keys into [`Request`]s and applies [`Response`]s, and it
12 + //! never calls a handler. The host holds the router and the state and does the
13 + //! calling, which keeps this free of the state type and makes every binding
14 + //! below testable without standing up an app.
15 + //!
16 + //! ```text
17 + //! key ──► Runtime::key ──► Step::Call(request)
18 + //! │
19 + //! host: router.handle(&state, request)
20 + //! │
21 + //! Runtime::apply ◄── Response
22 + //! ```
23 + //!
24 + //! # The bindings are this renderer's, and the description reaches two of them
25 + //!
26 + //! Nothing in a description says what Tab does, so the table below is policy.
27 + //! The two exceptions are the two the vocabulary already carries: [`Act::key`]
28 + //! names the key that reaches a control, and [`Act::confirm`] names the question
29 + //! to ask before doing it. Both were drawn and declined by the drawing half,
30 + //! and this is where they are honoured.
31 + //!
32 + //! | Key | What it does |
33 + //! |---|---|
34 + //! | Tab, Down | the next reachable thing |
35 + //! | `BackTab`, Up | the previous one |
36 + //! | Enter | call what is under the caret |
37 + //! | Space | tick the row under the caret |
38 + //! | `PageUp`, `PageDown` | scroll the region the caret is in |
39 + //! | Backspace | take a character back out of a field |
40 + //! | printable | type into a field, or reach the control that named the key |
41 + //! | Escape | back, or dismiss the question |
42 +
43 + use makeover_layout as layout;
44 + use quasi_router::{Action, Message, Method, Node, Outcome, Params, Request, Response, Screen};
45 + use ratatui::buffer::Buffer;
46 + use ratatui::layout::Rect;
47 +
48 + use crate::focus::{Reach, Spot};
49 + use crate::{Tui, View};
50 +
51 + /// A key, named the way this crate wants to talk about one.
52 + ///
53 + /// Not crossterm's, deliberately. A host maps its own events onto this in a
54 + /// dozen lines, and in exchange the bindings below are testable without a
55 + /// terminal and this crate does not make every consumer take a backend it might
56 + /// not be using.
57 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
58 + pub enum Key {
59 + /// A character the user typed.
60 + Char(char),
61 + /// Confirm, follow, submit.
62 + Enter,
63 + /// Forward through the reachable things.
64 + Tab,
65 + /// Backward through them.
66 + BackTab,
67 + /// Take a character back.
68 + Backspace,
69 + /// Out, back, never mind.
70 + Escape,
71 + /// Up one reachable thing.
72 + Up,
73 + /// Down one reachable thing.
74 + Down,
75 + /// A screen's worth backwards.
76 + PageUp,
77 + /// A screen's worth forwards.
78 + PageDown,
79 + }
80 +
81 + /// What the host should do about a key.
82 + #[derive(Debug, Clone, PartialEq, Eq)]
83 + pub enum Step {
84 + /// Nothing left to do but redraw.
85 + Idle,
86 + /// Ask the router this, then hand the answer to [`Runtime::apply`].
87 + Call(Request),
88 + /// Ask this question. The next key answers it: `y` or Enter does the thing,
89 + /// anything else does not.
90 + Ask(String),
91 + /// Somewhere outside the app. The host opens it, and nothing comes back.
92 + Open(String),
93 + }
94 +
95 + /// A screen, what the user has done to it, and how they got here.
96 + #[derive(Debug, Clone)]
97 + pub struct Runtime {
98 + screen: Screen,
99 + view: View,
100 + /// The places behind this one, most recent last.
101 + ///
102 + /// Requests rather than addresses, because going back means asking again
103 + /// and a request is what asking takes. [`Address`](quasi_router::Address)
104 + /// carries a string for a browser's address bar, which a terminal does not
105 + /// have.
106 + history: Vec<Request>,
107 + /// The request that produced the screen currently showing.
108 + here: Option<Request>,
109 + /// A control waiting on its own question being answered.
110 + asked: Option<Action>,
111 + /// Something to say once the screen it belongs to has arrived.
112 + saying: Option<Message>,
113 + }
114 +
115 + impl Runtime {
116 + /// Start on this screen, with nothing typed and nothing behind it.
117 + #[must_use]
118 + pub fn new(screen: Screen) -> Self {
119 + let mut runtime = Self {
120 + screen,
121 + view: View::new(),
122 + history: Vec::new(),
123 + here: None,
124 + asked: None,
125 + saying: None,
126 + };
127 + runtime.start_focus();
128 + runtime
129 + }
130 +
131 + /// The screen being shown.
132 + #[must_use]
133 + pub const fn screen(&self) -> &Screen {
134 + &self.screen
135 + }
136 +
137 + /// What the user has done to it.
138 + #[must_use]
139 + pub const fn view(&self) -> &View {
140 + &self.view
141 + }
142 +
143 + /// Everything reachable on it, in focus order.
144 + #[must_use]
145 + pub fn reaches(&self) -> Vec<Reach> {
146 + crate::focus::reaches(&self.screen)
147 + }
148 +
149 + /// Whether the caret is in a field, which is what decides whether a
150 + /// printable key is a shortcut or a character.
151 + ///
152 + /// A host wanting `q` to quit asks this first. Quitting is the host's and
153 + /// not a binding here, because a key that closes the app is a fact about
154 + /// the app rather than about the screen.
155 + #[must_use]
156 + pub fn editing(&self) -> bool {
157 + self.focused().is_some_and(|spot| spot.field().is_some())
158 + }
159 +
160 + /// Whether a question is waiting to be answered.
161 + #[must_use]
162 + pub const fn asking(&self) -> bool {
163 + self.asked.is_some()
164 + }
165 +
166 + /// Draw it.
167 + pub fn draw(&self, tui: &Tui, area: Rect, buf: &mut Buffer) {
168 + tui.screen(&self.screen, &self.view, area, buf);
169 + }
170 +
171 + /// What is under the caret.
172 + #[must_use]
173 + pub fn focused(&self) -> Option<Spot> {
174 + let mut reaches = crate::focus::reaches(&self.screen);
175 + if self.view.focus() >= reaches.len() {
176 + return None;
177 + }
178 + Some(reaches.swap_remove(self.view.focus()).spot)
179 + }
180 +
181 + /// Take a key, and say what the host should do about it.
182 + pub fn key(&mut self, key: Key) -> Step {
183 + // A question owns the keyboard until it is answered. Anything that is
184 + // not yes is no, which is the safe way round for a prompt that is only
185 + // ever raised by something destructive.
186 + if let Some(action) = self.asked.take() {
187 + return match key {
188 + Key::Char('y' | 'Y') | Key::Enter => Self::call(&action),
189 + _ => Step::Idle,
190 + };
191 + }
192 +
193 + let reaches = crate::focus::reaches(&self.screen);
194 + let count = reaches.len();
195 + let here = reaches
196 + .get(self.view.focus())
197 + .map(|reach| reach.spot.clone());
198 +
199 + match key {
200 + Key::Tab | Key::Down => {
201 + self.view.advance(1, count);
202 + Step::Idle
203 + }
204 + Key::BackTab | Key::Up => {
205 + self.view.advance(-1, count);
206 + Step::Idle
207 + }
208 +
209 + Key::PageDown | Key::PageUp => {
210 + // The region the caret is in, because it is the one the user is
211 + // working in. A screen with focus nowhere scrolls nothing,
212 + // which is honest: there is no "the pane" on a screen with
213 + // several.
214 + if let Some(reach) = reaches.get(self.view.focus()) {
215 + let rows = if matches!(key, Key::PageDown) {
216 + 10
217 + } else {
218 + -10
219 + };
220 + self.view.scroll_by(&reach.region, rows);
221 + }
222 + Step::Idle
223 + }
224 +
225 + Key::Escape => self.back(),
226 +
227 + Key::Backspace => {
228 + if let Some(field) = here.as_ref().and_then(Spot::field) {
229 + self.view.backspace(field);
230 + }
231 + Step::Idle
232 + }
233 +
234 + Key::Enter => match here {
235 + Some(Spot::Act {
236 + action, confirm, ..
237 + }) => match confirm {
238 + Some(prompt) => {
239 + self.asked = Some(action);
240 + Step::Ask(prompt)
241 + }
242 + None => Self::call(&action),
243 + },
244 + Some(Spot::Submit { action, names }) => {
245 + let payload = self.view.submission(
246 + &names,
247 + &reaches
248 + .iter()
249 + .map(|reach| reach.spot.clone())
250 + .collect::<Vec<_>>(),
251 + );
252 + Self::send(&action, payload)
253 + }
254 + // A field takes Enter and does nothing with it. A browser
255 + // submits the form around it, and doing that here would fire a
256 + // write from the first box the user finished typing in; the
257 + // submit is one Tab away and says what it does.
258 + Some(Spot::Field(_)) | None => Step::Idle,
259 + Some(other) => match other.enters() {
260 + Some(action) => Self::call(&action.clone()),
261 + None => Step::Idle,
262 + },
263 + },
264 +
265 + Key::Char(' ') if !self.editing() => match here {
266 + // A tick is a write when the description says it is, and
267 + // client-side selection when it does not. The second is not
268 + // wired to anything yet: nothing names what a selection is
269 + // *for*, which is `033ff3ca`, so a tick with no `toggle` moves
270 + // no state a route could read.
271 + Some(Spot::Row {
272 + toggle: Some(action),
273 + ..
274 + }) => Self::call(&action),
275 + _ => Step::Idle,
276 + },
277 +
278 + Key::Char(ch) => {
279 + if let Some(field) = here.as_ref().and_then(Spot::field).cloned() {
280 + self.type_into(&field, ch);
281 + return self.after_typing(here.as_ref());
282 + }
283 + // Not in a field, so the key is a shortcut if any control on
284 + // the screen claimed it. `Act::key` is text rather than a
285 + // modelled chord, so this is a string comparison against what
286 + // the description wrote, and a name this renderer does not
287 + // understand simply never matches.
288 + let pressed = ch.to_string();
289 + let claimed = reaches.iter().find_map(|reach| match &reach.spot {
290 + Spot::Act {
291 + action,
292 + key: Some(key),
293 + ..
294 + } if *key == pressed => Some(action.clone()),
295 + _ => None,
296 + });
297 + match claimed {
298 + Some(action) => Self::call(&action),
299 + None => Step::Idle,
300 + }
301 + }
302 + }
303 + }
304 +
305 + /// Put what the router answered onto the screen.
306 + ///
307 + /// Answers with a follow-up request when the response says to go somewhere
308 + /// else, which the host performs the same way it performed the first one.
309 + /// `request` is what was asked, because whether an answer is a place is
310 + /// derived from it: a read that answered a whole screen is somewhere you
311 + /// can come back to, and a write is not.
312 + pub fn apply(&mut self, request: &Request, response: Response) -> Option<Request> {
313 + let Response {
314 + outcome,
315 + notice,
316 + address,
317 + } = response;
318 + self.saying = notice.or(self.saying.take());
319 +
320 + match outcome {
321 + Outcome::Screen(screen) => {
322 + self.remember(request, address.as_ref());
323 + self.screen = screen;
324 + self.view.reset();
325 + self.start_focus();
326 + self.announce();
327 + None
328 + }
329 + Outcome::Fragment { region, node } => {
330 + // A region that is not there is the description bug
331 + // `Screen::replace` describes, and a terminal can say so
332 + // rather than swallowing it: the region it named is gone, and
333 + // drawing nothing would look like a control that does nothing.
334 + if !self.screen.replace(&region, node) {
335 + self.saying = Some(Message {
336 + kind: layout::Notice::Banner,
337 + tone: layout::Tone::Danger,
338 + text: format!("nothing on this screen is called `{region}`"),
339 + undo: None,
340 + });
341 + }
342 + self.view.prune(&self.screen);
343 + self.announce();
344 + None
345 + }
346 + Outcome::Goto(action) => match Self::call(&action) {
347 + Step::Call(request) => Some(request),
348 + // An external destination is the host's to open, and there is
349 + // nothing to come back for.
350 + _ => None,
351 + },
352 + }
353 + }
354 +
355 + /// Go back, if there is anywhere to go.
356 + fn back(&mut self) -> Step {
357 + match self.history.pop() {
358 + Some(request) => {
359 + self.here = Some(request.clone());
360 + Step::Call(request)
361 + }
362 + None => Step::Idle,
363 + }
364 + }
365 +
366 + /// Note where we were, before we leave it.
367 + ///
368 + /// The derivation the response's own documentation describes: a read that
369 + /// answered a screen is a place, everything else is not, and
370 + /// [`Address`](quasi_router::Address) is the override for the two cases the
371 + /// derivation cannot reach.
372 + fn remember(&mut self, request: &Request, address: Option<&quasi_router::Address>) {
373 + let place = match address {
374 + Some(quasi_router::Address::Enters(_)) => true,
375 + Some(quasi_router::Address::Unchanged) => false,
376 + Some(quasi_router::Address::Replaces(_)) => {
377 + self.here = Some(request.clone());
378 + return;
379 + }
380 + None => request.method == Method::Get,
381 + };
382 + if place && let Some(previous) = self.here.replace(request.clone()) {
383 + self.history.push(previous);
384 + }
385 + }
386 +
387 + /// Put whatever the response wanted said onto the screen it belongs to.
388 + fn announce(&mut self) {
389 + // A message's `undo` is dropped, and that is a decline rather than an
390 + // oversight: `Node::Notice` has nowhere to hang a control, so the way
391 + // back that the response offered has no cell to sit in. Filed.
392 + if let Some(Message {
393 + kind, tone, text, ..
394 + }) = self.saying.take()
395 + {
396 + self.screen.notices.push(Node::Notice { kind, tone, text });
397 + }
398 + }
399 +
400 + /// Start on what the description says matters, when it says anything.
401 + fn start_focus(&mut self) {
402 + let reaches = crate::focus::reaches(&self.screen);
403 + let at = reaches.iter().position(|reach| {
404 + matches!(
405 + reach.spot,
406 + Spot::Act {
407 + wants_focus: true,
408 + ..
409 + }
410 + )
411 + });
412 + if let Some(at) = at {
413 + self.view.focus_on(at, reaches.len());
414 + }
415 + }
416 +
417 + /// Type into a field, honouring what the description says it will take.
418 + fn type_into(&mut self, field: &crate::FieldSpot, ch: char) {
419 + if matches!(field.kind, layout::FieldKind::Checkbox) {
420 + // A checkbox holds one of two values, so a key does not type into
421 + // it: any key flips it, which is what space does to one in a
422 + // browser and is the only sentence a box with two states can hear.
423 + let ticked = self.view.typed(field) == Node::SELECTED;
424 + let next = if ticked {
425 + String::new()
426 + } else {
427 + Node::SELECTED.to_string()
428 + };
429 + self.view.set(&field.name, next);
430 + return;
431 + }
432 +
433 + // `Field::max_length` is a rule the description carries and every
434 + // renderer emits in its host's idiom. A browser stops accepting
435 + // characters, and so does this.
436 + if let Some(limit) = field.max_length
437 + && self.view.typed(field).chars().count() >= limit as usize
438 + {
439 + return;
440 + }
441 + self.view.push(field, ch);
442 + }
443 +
444 + /// What a keystroke in a field costs, when the field writes as it changes.
445 + fn after_typing(&mut self, here: Option<&Spot>) -> Step {
446 + match here.and_then(Spot::field).and_then(|field| {
447 + field
448 + .changes
449 + .clone()
450 + .map(|action| (action, field.name.clone()))
451 + }) {
452 + // A field that writes on every change writes on every keystroke
453 + // here, which is what `Field::changes` says and is wrong for a text
454 + // box: a webview debounces on `input` and nothing in the
455 + // description says a delay is allowed. Filed rather than debounced
456 + // to a number this renderer made up.
457 + Some((action, name)) => {
458 + let value = self.view.edit(&name).unwrap_or_default().to_string();
459 + let payload = Params::new().with(name, value);
460 + Self::send(&action, payload)
461 + }
462 + None => Step::Idle,
463 + }
464 + }
465 +
466 + /// An action as something the host can ask.
467 + fn call(action: &Action) -> Step {
468 + Self::send(action, Params::new())
469 + }
470 +
471 + /// An action, plus values the control is sending that are not on it.
472 + fn send(action: &Action, extra: Params) -> Step {
473 + let Some(path) = action.destination.route() else {
474 + return Step::Open(action.destination.as_str().to_string());
475 + };
476 + let mut payload = extra;
477 + payload.absorb(action.params.clone());
478 + Step::Call(Request {
479 + method: action.method,
480 + path: path.to_string(),
481 + captures: Params::new(),
482 + payload,
483 + carried: action.carried.clone(),
484 + })
485 + }
486 + }
@@ -1,0 +1,217 @@
1 + //! The state a terminal owns because nothing else will.
2 + //!
3 + //! This type is the answer to `39057019`, and the finding is worth restating
4 + //! because the answer only makes sense next to it. `Field::value` is what a
5 + //! handler re-offers after a refused write. It is not what is in the box right
6 + //! now, and for a [`layout::FieldKind::Secret`] it is nothing at all, on
7 + //! purpose: a password that comes back down the wire is a password in a page
8 + //! and in a proxy log. A browser never made anyone notice, because a browser
9 + //! owns the contents of an `<input>` and redraws it on every keystroke without
10 + //! asking the description for permission.
11 + //!
12 + //! A terminal owns nothing. So the drawing of an editable screen is not a
13 + //! function of the description alone, and the two ways to admit that were:
14 + //! hand the renderer a second argument, or have the runtime rewrite the
15 + //! description before drawing it.
16 + //!
17 + //! **The second argument won.** Rewriting keeps [`crate::Tui`] a pure function
18 + //! of one argument by making the runtime lie about what the handler said, and
19 + //! the lie is not free: `Field::value` refuses to hold a secret, so a runtime
20 + //! that wrote the typed password into the description would have had to defeat
21 + //! that refusal to draw the dots. The guarantee that no renderer emits a secret
22 + //! is worth more than the pure signature, and this way the two facts stay
23 + //! separate: the description says what the server offers, and this says what the
24 + //! user has done since.
25 + //!
26 + //! Once it exists it holds the rest of what the browser was quietly providing,
27 + //! because it turns out to be the same discovery four times: what is typed,
28 + //! what has focus, how far a pane is scrolled, and where the back button goes.
29 + //! None of the four is in a description and none of them should be.
30 +
31 + use std::collections::BTreeMap;
32 +
33 + use makeover_layout as layout;
34 + use quasi_router::{Params, Screen};
35 +
36 + use crate::focus::{FieldSpot, Spot};
37 +
38 + /// What the user has done to a screen since it arrived.
39 + ///
40 + /// A host makes one beside the screen it is holding and keeps the two together.
41 + /// Empty is the honest starting state and it draws exactly what the description
42 + /// says, which is what every test that predates this passes.
43 + #[derive(Debug, Clone, Default, PartialEq, Eq)]
44 + pub struct View {
45 + /// What has been typed, by [`Field::name`](quasi_router::Field::name).
46 + ///
47 + /// Absent means untouched, which is different from present and empty: one
48 + /// draws the description's value and the other draws a box the user has
49 + /// cleared.
50 + edits: BTreeMap<String, String>,
51 + /// Which reachable thing has focus, as an index into [`crate::focus::spots`].
52 + focus: usize,
53 + /// How far each region has been scrolled, in rows, by
54 + /// [`Slot::id`](quasi_router::Slot::id).
55 + scroll: BTreeMap<String, u16>,
56 + }
57 +
58 + impl View {
59 + /// Nothing typed, the first thing focused, nothing scrolled.
60 + #[must_use]
61 + pub fn new() -> Self {
62 + Self::default()
63 + }
64 +
65 + /// What is in the box: what has been typed, or what the description offers,
66 + /// or nothing.
67 + ///
68 + /// The order is the whole of the type's job. An untouched field shows what
69 + /// the handler put there; a touched one shows what the user did, including
70 + /// when what they did was empty it.
71 + #[must_use]
72 + pub fn typed<'a>(&'a self, field: &'a FieldSpot) -> &'a str {
73 + self.showing(&field.name, field.value.as_deref())
74 + }
75 +
76 + /// [`typed`](Self::typed) for a caller holding the described field itself
77 + /// rather than a walk's record of it, which is what the drawing has.
78 + #[must_use]
79 + pub fn showing<'a>(&'a self, name: &str, described: Option<&'a str>) -> &'a str {
80 + self.edits
81 + .get(name)
82 + .map(String::as_str)
83 + .or(described)
84 + .unwrap_or_default()
85 + }
86 +
87 + /// What has been typed into a field by name, if anything has.
88 + #[must_use]
89 + pub fn edit(&self, name: &str) -> Option<&str> {
90 + self.edits.get(name).map(String::as_str)
91 + }
92 +
93 + /// Put a value in a box.
94 + pub fn set(&mut self, name: impl Into<String>, value: impl Into<String>) {
95 + self.edits.insert(name.into(), value.into());
96 + }
97 +
98 + /// Add a character to a box, starting from whatever is showing in it.
99 + pub fn push(&mut self, field: &FieldSpot, ch: char) {
100 + let mut value = self.typed(field).to_string();
101 + value.push(ch);
102 + self.set(&field.name, value);
103 + }
104 +
105 + /// Take the last character back out of a box.
106 + pub fn backspace(&mut self, field: &FieldSpot) {
107 + let mut value = self.typed(field).to_string();
108 + value.pop();
109 + self.set(&field.name, value);
110 + }
111 +
112 + /// Which reachable thing has focus.
113 + #[must_use]
114 + pub const fn focus(&self) -> usize {
115 + self.focus
116 + }
117 +
118 + /// Move focus by `steps`, wrapping at both ends.
119 + ///
120 + /// Wrapping rather than stopping, because a terminal has no scrollbar to
121 + /// tell you that you are at the end of the reachable things and pressing tab
122 + /// against a dead stop reads as a broken key.
123 + pub fn advance(&mut self, steps: isize, reachable: usize) {
124 + if reachable == 0 {
125 + self.focus = 0;
126 + return;
127 + }
128 + let count = reachable as isize;
129 + let at = self.focus.min(reachable - 1) as isize;
130 + self.focus = (at + steps).rem_euclid(count) as usize;
131 + }
132 +
133 + /// Focus something in particular, if it is there.
134 + pub fn focus_on(&mut self, at: usize, reachable: usize) {
135 + if at < reachable {
136 + self.focus = at;
137 + }
138 + }
139 +
140 + /// How far a region has been scrolled.
141 + #[must_use]
142 + pub fn scroll(&self, region: &str) -> u16 {
143 + self.scroll.get(region).copied().unwrap_or(0)
144 + }
145 +
146 + /// Scroll a region, never above its top.
147 + ///
148 + /// There is no bottom stop here, and that is deliberate: how far a region
149 + /// can scroll is how tall its content is at the width it was given, which
150 + /// is a fact the drawing knows and this does not. [`crate::Tui::clamp`] is
151 + /// where it gets trimmed, once per draw, with the rect in hand.
152 + pub fn scroll_by(&mut self, region: &str, rows: i32) {
153 + let at = i32::from(self.scroll(region));
154 + let next = u16::try_from((at + rows).max(0)).unwrap_or(u16::MAX);
155 + self.scroll.insert(region.to_string(), next);
156 + }
157 +
158 + /// Hold a region at this offset.
159 + pub fn scrolled_to(&mut self, region: &str, rows: u16) {
160 + self.scroll.insert(region.to_string(), rows);
161 + }
162 +
163 + /// Forget everything typed and scrolled, and go back to the top.
164 + ///
165 + /// What a whole new screen means. The boxes on it are different boxes, and
166 + /// 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.
168 + pub fn reset(&mut self) {
169 + self.edits.clear();
170 + self.scroll.clear();
171 + self.focus = 0;
172 + }
173 +
174 + /// The values a form submits, gathered for `names` in the order given.
175 + ///
176 + /// A checkbox is here by presence, the way HTML submits one, so a box that
177 + /// is not ticked sends nothing rather than sending an empty string. That is
178 + /// [`Field::value`](quasi_router::Field::value)'s own convention read back
179 + /// out.
180 + #[must_use]
181 + pub fn submission(&self, names: &[String], spots: &[Spot]) -> Params {
182 + let mut params = Params::new();
183 + for name in names {
184 + let Some(field) = spots
185 + .iter()
186 + .filter_map(Spot::field)
187 + .find(|field| &field.name == name)
188 + else {
189 + continue;
190 + };
191 + let value = self.typed(field);
192 + if matches!(field.kind, layout::FieldKind::Checkbox)
193 + && value != quasi_router::Node::SELECTED
194 + {
195 + continue;
196 + }
197 + params.insert(name.clone(), value.to_string());
198 + }
199 + params
200 + }
201 +
202 + /// Drop anything held for a field the screen no longer has.
203 + ///
204 + /// A fragment can replace a region holding half a form, and the buffers for
205 + /// the fields that went away would otherwise ride along and be submitted by
206 + /// the next form that happens to name one of them.
207 + pub fn prune(&mut self, screen: &Screen) {
208 + let spots = crate::focus::spots(screen);
209 + let live: Vec<&str> = spots
210 + .iter()
211 + .filter_map(Spot::field)
212 + .map(|field| field.name.as_str())
213 + .collect();
214 + self.edits.retain(|name, _| live.contains(&name.as_str()));
215 + self.focus = self.focus.min(spots.len().saturating_sub(1));
216 + }
217 + }