Skip to main content

max / quasi

14.7 KB · 356 lines History Blame Raw
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 //! An overlay holds a second one of these rather than a fifth field being added
32 //! to this one. `Outcome::Over` draws a whole screen over another, and the
33 //! screen underneath keeps its own reach, focus, edits and scroll while it is
34 //! covered: sharing one `View` between the two would mean dismissing a palette
35 //! took the user's typing and scroll position with it. See `Runtime`'s `under`
36 //! stack, which holds the pair.
37 //!
38 //! One more is of the same kind and is deliberately not held here: where the
39 //! caret sits inside a field. `d52884b0`, decided 2026-08-12. It belongs on
40 //! this list by nature, and it is absent because no described screen
41 //! needs it yet: the one measured consumer is goingson's `search.js`, whose
42 //! completion list depends on which token the caret is inside, and that file
43 //! stays JS. Saying so here keeps the boundary explicit, so the next screen that
44 //! wants caret-dependent completion knows this is where it would land rather
45 //! than re-asking whether a description should carry one. It should not.
46
47 use std::collections::{BTreeMap, BTreeSet};
48
49 use makeover_layout as layout;
50 use quasi_router::{Params, Screen};
51
52 use crate::focus::{FieldSpot, Spot};
53
54 /// What the user has done to a screen since it arrived.
55 ///
56 /// A host makes one beside the screen it is holding and keeps the two together.
57 /// Empty is the honest starting state and it draws exactly what the description
58 /// says, which is what every test that predates this passes.
59 #[derive(Debug, Clone, Default, PartialEq, Eq)]
60 pub struct View {
61 /// What has been typed, by [`Field::name`](quasi_router::Field::name).
62 ///
63 /// Absent means untouched, which is different from present and empty: one
64 /// draws the description's value and the other draws a box the user has
65 /// cleared.
66 edits: BTreeMap<String, String>,
67 /// Which reached thing has focus, as an index into [`crate::focus::spots`].
68 ///
69 /// Focus is this renderer's and lives here rather than in a description,
70 /// which is why it survives a redraw: reach is recomputed from the screen,
71 /// focus is a fact about where the user has walked. See
72 /// [`crate::focus`]'s header for the three terms.
73 focus: usize,
74 /// How far each region has been scrolled, in rows, by
75 /// [`Slot::id`](quasi_router::Slot::id).
76 scroll: BTreeMap<String, u16>,
77 /// What has been ticked, by [`Row::value`](quasi_router::Row::value).
78 ///
79 /// `5f2b8753`. The fifth thing the browser was quietly providing, and it
80 /// arrived last because it is the one a browser does *not* fully provide:
81 /// a checkbox owns its own checked state, but nothing gathers the boxes
82 /// back up, so every app wrote that part by hand. Here there is no
83 /// checkbox to own anything, which is what made the hole visible.
84 ///
85 /// A set rather than a map from name to bool. Absent is not ticked, and
86 /// the two spellings of that would otherwise drift.
87 ///
88 /// Only the current screen's set, because a screen names one
89 /// ([`Screen::selection`](quasi_router::Screen::selection)) and a new
90 /// screen is a new set. Which set it is does not need storing: the screen
91 /// beside this one says.
92 ticked: BTreeSet<String>,
93 /// Which child each region is showing, by
94 /// [`Slot::id`](quasi_router::Slot::id).
95 ///
96 /// `4dcd241b`, and the sixth of the same discovery. A description says a
97 /// region shows one of its children at a time and says which one it started
98 /// on; where the reader has moved to since is this renderer's, exactly as
99 /// [`scroll`](Self::scroll) is. A browser owns this too and never made
100 /// anyone notice, because moving a carousel there is a class on an element
101 /// the document already holds.
102 ///
103 /// Absent means the description's own answer still stands, which is what
104 /// makes an untouched screen draw what the handler said.
105 shown: BTreeMap<String, usize>,
106 }
107
108 impl View {
109 /// Nothing typed, the first thing focused, nothing scrolled.
110 #[must_use]
111 pub fn new() -> Self {
112 Self::default()
113 }
114
115 /// What is in the box: what has been typed, or what the description offers,
116 /// or nothing.
117 ///
118 /// The order is the whole of the type's job. An untouched field shows what
119 /// the handler put there; a touched one shows what the user did, including
120 /// when what they did was empty it.
121 #[must_use]
122 pub fn typed<'a>(&'a self, field: &'a FieldSpot) -> &'a str {
123 self.showing(&field.name, field.value.as_deref())
124 }
125
126 /// [`typed`](Self::typed) for a caller holding the described field itself
127 /// rather than a walk's record of it, which is what the drawing has.
128 #[must_use]
129 pub fn showing<'a>(&'a self, name: &str, described: Option<&'a str>) -> &'a str {
130 self.edits
131 .get(name)
132 .map(String::as_str)
133 .or(described)
134 .unwrap_or_default()
135 }
136
137 /// What has been typed into a field by name, if anything has.
138 #[must_use]
139 pub fn edit(&self, name: &str) -> Option<&str> {
140 self.edits.get(name).map(String::as_str)
141 }
142
143 /// Put a value in a box.
144 pub fn set(&mut self, name: impl Into<String>, value: impl Into<String>) {
145 self.edits.insert(name.into(), value.into());
146 }
147
148 /// Add a character to a box, starting from whatever is showing in it.
149 pub fn push(&mut self, field: &FieldSpot, ch: char) {
150 let mut value = self.typed(field).to_string();
151 value.push(ch);
152 self.set(&field.name, value);
153 }
154
155 /// Take the last character back out of a box.
156 pub fn backspace(&mut self, field: &FieldSpot) {
157 let mut value = self.typed(field).to_string();
158 value.pop();
159 self.set(&field.name, value);
160 }
161
162 /// Which reachable thing has focus.
163 #[must_use]
164 pub const fn focus(&self) -> usize {
165 self.focus
166 }
167
168 /// Move focus by `steps`, wrapping at both ends.
169 ///
170 /// Wrapping rather than stopping, because a terminal has no scrollbar to
171 /// tell you that you are at the end of the reachable things and pressing tab
172 /// against a dead stop reads as a broken key.
173 pub fn advance(&mut self, steps: isize, reachable: usize) {
174 if reachable == 0 {
175 self.focus = 0;
176 return;
177 }
178 let count = reachable as isize;
179 let at = self.focus.min(reachable - 1) as isize;
180 self.focus = (at + steps).rem_euclid(count) as usize;
181 }
182
183 /// Focus something in particular, if it is there.
184 pub fn focus_on(&mut self, at: usize, reachable: usize) {
185 if at < reachable {
186 self.focus = at;
187 }
188 }
189
190 /// How far a region has been scrolled.
191 #[must_use]
192 pub fn scroll(&self, region: &str) -> u16 {
193 self.scroll.get(region).copied().unwrap_or(0)
194 }
195
196 /// Scroll a region, never above its top.
197 ///
198 /// There is no bottom stop here, and that is deliberate: how far a region
199 /// can scroll is how tall its content is at the width it was given, which
200 /// is a fact the drawing knows and this does not. [`crate::Tui::clamp`] is
201 /// where it gets trimmed, once per draw, with the rect in hand.
202 pub fn scroll_by(&mut self, region: &str, rows: i32) {
203 let at = i32::from(self.scroll(region));
204 let next = u16::try_from((at + rows).max(0)).unwrap_or(u16::MAX);
205 self.scroll.insert(region.to_string(), next);
206 }
207
208 /// Hold a region at this offset.
209 pub fn scrolled_to(&mut self, region: &str, rows: u16) {
210 self.scroll.insert(region.to_string(), rows);
211 }
212
213 /// Which child a region is showing, given what its description says.
214 ///
215 /// [`scroll`](Self::scroll)'s shape with one difference: a scroll has an
216 /// obvious zero and this does not, so the description's own answer is the
217 /// floor rather than the top of the region.
218 #[must_use]
219 pub fn shown(&self, slot: &quasi_router::Slot) -> Option<usize> {
220 match self.shown.get(&slot.id) {
221 Some(at) => Some((*at).min(slot.body.len().saturating_sub(1))),
222 None => slot.current(),
223 }
224 }
225
226 /// Move a region to another of its children, wrapping at both ends.
227 ///
228 /// Wrapping for [`advance`](Self::advance)'s reason: a terminal has nothing
229 /// to show you that you are at the last frame, so a next key that stops
230 /// dead reads as a broken key rather than as the end of the gallery.
231 ///
232 /// A closed dismissible region opens on its first child, which is the only
233 /// reading of "next" that does anything from closed.
234 pub fn show_by(&mut self, slot: &quasi_router::Slot, steps: isize) {
235 let count = slot.body.len();
236 if count == 0 {
237 return;
238 }
239 let at = match self.shown(slot) {
240 Some(at) => (at as isize + steps).rem_euclid(count as isize) as usize,
241 None => 0,
242 };
243 self.shown.insert(slot.id.clone(), at);
244 }
245
246 /// Show a particular child of a region.
247 pub fn show(&mut self, region: &str, at: usize) {
248 self.shown.insert(region.to_string(), at);
249 }
250
251 /// Whether this value is ticked.
252 #[must_use]
253 pub fn is_ticked(&self, value: &str) -> bool {
254 self.ticked.contains(value)
255 }
256
257 /// Tick it if it is not, untick it if it is.
258 ///
259 /// Staging, never a write. Wiki `explicit-commit-affordance`: the commit
260 /// control is what locks a change in, and a tick that wrote on its own
261 /// would be the change happening with nothing to mark it.
262 pub fn tick(&mut self, value: &str) {
263 if !self.ticked.remove(value) {
264 self.ticked.insert(value.to_owned());
265 }
266 }
267
268 /// Everything ticked, in order.
269 ///
270 /// Ordered because it is a `BTreeSet`, and that is worth relying on: a
271 /// handler reading [`Params::get_all`] gets the same sequence every run, so
272 /// a test over a bulk action is not sorting the answer first.
273 pub fn ticks(&self) -> impl Iterator<Item = &str> {
274 self.ticked.iter().map(String::as_str)
275 }
276
277 /// Start the described ticks off, for the rows that arrive already ticked.
278 ///
279 /// A description can say a row is ticked, and on a screen that has just
280 /// arrived that claim is the only thing there is. Applied on arrival rather
281 /// than read on every draw, because after that the user's ticks are the
282 /// truth and a redraw that went back to the description would undo them.
283 pub fn seed(&mut self, screen: &Screen) {
284 self.ticked = crate::focus::spots(screen)
285 .iter()
286 .filter_map(|spot| match spot {
287 Spot::Row {
288 ticked: Some(true),
289 value: Some(value),
290 ..
291 } => Some(value.clone()),
292 _ => None,
293 })
294 .collect();
295 }
296
297 /// Forget everything typed and scrolled, and go back to the top.
298 ///
299 /// What a whole new screen means. The boxes on it are different boxes, and
300 /// carrying a buffer across would put what was typed into a password field
301 /// into whatever field happens to share its name on the next screen. A
302 /// selection goes the same way and for the same reason: the rows are
303 /// different rows.
304 pub fn reset(&mut self) {
305 self.edits.clear();
306 self.scroll.clear();
307 self.ticked.clear();
308 self.shown.clear();
309 self.focus = 0;
310 }
311
312 /// The values a form submits, gathered for `names` in the order given.
313 ///
314 /// A checkbox is here by presence, the way HTML submits one, so a box that
315 /// is not ticked sends nothing rather than sending an empty string. That is
316 /// [`Field::value`](quasi_router::Field::value)'s own convention read back
317 /// out.
318 #[must_use]
319 pub fn submission(&self, names: &[String], spots: &[Spot]) -> Params {
320 let mut params = Params::new();
321 for name in names {
322 let Some(field) = spots
323 .iter()
324 .filter_map(Spot::field)
325 .find(|field| &field.name == name)
326 else {
327 continue;
328 };
329 let value = self.typed(field);
330 if matches!(field.kind, layout::FieldKind::Checkbox)
331 && value != quasi_router::Node::SELECTED
332 {
333 continue;
334 }
335 params.insert(name.clone(), value.to_string());
336 }
337 params
338 }
339
340 /// Drop anything held for a field the screen no longer has.
341 ///
342 /// A fragment can replace a region holding half a form, and the buffers for
343 /// the fields that went away would otherwise ride along and be submitted by
344 /// the next form that happens to name one of them.
345 pub fn prune(&mut self, screen: &Screen) {
346 let spots = crate::focus::spots(screen);
347 let live: Vec<&str> = spots
348 .iter()
349 .filter_map(Spot::field)
350 .map(|field| field.name.as_str())
351 .collect();
352 self.edits.retain(|name, _| live.contains(&name.as_str()));
353 self.focus = self.focus.min(spots.len().saturating_sub(1));
354 }
355 }
356