Skip to main content

max / quasi

12.5 KB · 304 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 }
94
95 impl View {
96 /// Nothing typed, the first thing focused, nothing scrolled.
97 #[must_use]
98 pub fn new() -> Self {
99 Self::default()
100 }
101
102 /// What is in the box: what has been typed, or what the description offers,
103 /// or nothing.
104 ///
105 /// The order is the whole of the type's job. An untouched field shows what
106 /// the handler put there; a touched one shows what the user did, including
107 /// when what they did was empty it.
108 #[must_use]
109 pub fn typed<'a>(&'a self, field: &'a FieldSpot) -> &'a str {
110 self.showing(&field.name, field.value.as_deref())
111 }
112
113 /// [`typed`](Self::typed) for a caller holding the described field itself
114 /// rather than a walk's record of it, which is what the drawing has.
115 #[must_use]
116 pub fn showing<'a>(&'a self, name: &str, described: Option<&'a str>) -> &'a str {
117 self.edits
118 .get(name)
119 .map(String::as_str)
120 .or(described)
121 .unwrap_or_default()
122 }
123
124 /// What has been typed into a field by name, if anything has.
125 #[must_use]
126 pub fn edit(&self, name: &str) -> Option<&str> {
127 self.edits.get(name).map(String::as_str)
128 }
129
130 /// Put a value in a box.
131 pub fn set(&mut self, name: impl Into<String>, value: impl Into<String>) {
132 self.edits.insert(name.into(), value.into());
133 }
134
135 /// Add a character to a box, starting from whatever is showing in it.
136 pub fn push(&mut self, field: &FieldSpot, ch: char) {
137 let mut value = self.typed(field).to_string();
138 value.push(ch);
139 self.set(&field.name, value);
140 }
141
142 /// Take the last character back out of a box.
143 pub fn backspace(&mut self, field: &FieldSpot) {
144 let mut value = self.typed(field).to_string();
145 value.pop();
146 self.set(&field.name, value);
147 }
148
149 /// Which reachable thing has focus.
150 #[must_use]
151 pub const fn focus(&self) -> usize {
152 self.focus
153 }
154
155 /// Move focus by `steps`, wrapping at both ends.
156 ///
157 /// Wrapping rather than stopping, because a terminal has no scrollbar to
158 /// tell you that you are at the end of the reachable things and pressing tab
159 /// against a dead stop reads as a broken key.
160 pub fn advance(&mut self, steps: isize, reachable: usize) {
161 if reachable == 0 {
162 self.focus = 0;
163 return;
164 }
165 let count = reachable as isize;
166 let at = self.focus.min(reachable - 1) as isize;
167 self.focus = (at + steps).rem_euclid(count) as usize;
168 }
169
170 /// Focus something in particular, if it is there.
171 pub fn focus_on(&mut self, at: usize, reachable: usize) {
172 if at < reachable {
173 self.focus = at;
174 }
175 }
176
177 /// How far a region has been scrolled.
178 #[must_use]
179 pub fn scroll(&self, region: &str) -> u16 {
180 self.scroll.get(region).copied().unwrap_or(0)
181 }
182
183 /// Scroll a region, never above its top.
184 ///
185 /// There is no bottom stop here, and that is deliberate: how far a region
186 /// can scroll is how tall its content is at the width it was given, which
187 /// is a fact the drawing knows and this does not. [`crate::Tui::clamp`] is
188 /// where it gets trimmed, once per draw, with the rect in hand.
189 pub fn scroll_by(&mut self, region: &str, rows: i32) {
190 let at = i32::from(self.scroll(region));
191 let next = u16::try_from((at + rows).max(0)).unwrap_or(u16::MAX);
192 self.scroll.insert(region.to_string(), next);
193 }
194
195 /// Hold a region at this offset.
196 pub fn scrolled_to(&mut self, region: &str, rows: u16) {
197 self.scroll.insert(region.to_string(), rows);
198 }
199
200 /// Whether this value is ticked.
201 #[must_use]
202 pub fn is_ticked(&self, value: &str) -> bool {
203 self.ticked.contains(value)
204 }
205
206 /// Tick it if it is not, untick it if it is.
207 ///
208 /// Staging, never a write. Wiki `explicit-commit-affordance`: the commit
209 /// control is what locks a change in, and a tick that wrote on its own
210 /// would be the change happening with nothing to mark it.
211 pub fn tick(&mut self, value: &str) {
212 if !self.ticked.remove(value) {
213 self.ticked.insert(value.to_owned());
214 }
215 }
216
217 /// Everything ticked, in order.
218 ///
219 /// Ordered because it is a `BTreeSet`, and that is worth relying on: a
220 /// handler reading [`Params::get_all`] gets the same sequence every run, so
221 /// a test over a bulk action is not sorting the answer first.
222 pub fn ticks(&self) -> impl Iterator<Item = &str> {
223 self.ticked.iter().map(String::as_str)
224 }
225
226 /// Start the described ticks off, for the rows that arrive already ticked.
227 ///
228 /// A description can say a row is ticked, and on a screen that has just
229 /// arrived that claim is the only thing there is. Applied on arrival rather
230 /// than read on every draw, because after that the user's ticks are the
231 /// truth and a redraw that went back to the description would undo them.
232 pub fn seed(&mut self, screen: &Screen) {
233 self.ticked = crate::focus::spots(screen)
234 .iter()
235 .filter_map(|spot| match spot {
236 Spot::Row {
237 ticked: Some(true),
238 value: Some(value),
239 ..
240 } => Some(value.clone()),
241 _ => None,
242 })
243 .collect();
244 }
245
246 /// Forget everything typed and scrolled, and go back to the top.
247 ///
248 /// What a whole new screen means. The boxes on it are different boxes, and
249 /// carrying a buffer across would put what was typed into a password field
250 /// into whatever field happens to share its name on the next screen. A
251 /// selection goes the same way and for the same reason: the rows are
252 /// different rows.
253 pub fn reset(&mut self) {
254 self.edits.clear();
255 self.scroll.clear();
256 self.ticked.clear();
257 self.focus = 0;
258 }
259
260 /// The values a form submits, gathered for `names` in the order given.
261 ///
262 /// A checkbox is here by presence, the way HTML submits one, so a box that
263 /// is not ticked sends nothing rather than sending an empty string. That is
264 /// [`Field::value`](quasi_router::Field::value)'s own convention read back
265 /// out.
266 #[must_use]
267 pub fn submission(&self, names: &[String], spots: &[Spot]) -> Params {
268 let mut params = Params::new();
269 for name in names {
270 let Some(field) = spots
271 .iter()
272 .filter_map(Spot::field)
273 .find(|field| &field.name == name)
274 else {
275 continue;
276 };
277 let value = self.typed(field);
278 if matches!(field.kind, layout::FieldKind::Checkbox)
279 && value != quasi_router::Node::SELECTED
280 {
281 continue;
282 }
283 params.insert(name.clone(), value.to_string());
284 }
285 params
286 }
287
288 /// Drop anything held for a field the screen no longer has.
289 ///
290 /// A fragment can replace a region holding half a form, and the buffers for
291 /// the fields that went away would otherwise ride along and be submitted by
292 /// the next form that happens to name one of them.
293 pub fn prune(&mut self, screen: &Screen) {
294 let spots = crate::focus::spots(screen);
295 let live: Vec<&str> = spots
296 .iter()
297 .filter_map(Spot::field)
298 .map(|field| field.name.as_str())
299 .collect();
300 self.edits.retain(|name, _| live.contains(&name.as_str()));
301 self.focus = self.focus.min(spots.len().saturating_sub(1));
302 }
303 }
304