Skip to main content

max / quasi

20.8 KB · 518 lines History Blame Raw
1 //! The terminal renderer for quasi.
2 //!
3 //! <!-- wiki: quasi-overview -->
4 //!
5 //! # Why this exists
6 //!
7 //! `quasi-router`'s own diagram says `renderer: webview | tui | egui`, and until
8 //! this crate two of those three did not exist. `quasi-webview` was the only
9 //! renderer of the screen tree, and both hosts are webview hosts: `quasi-axum`
10 //! serves the markup over HTTP, `quasi-tauri` serves the same markup over a
11 //! custom protocol. So every finding that has ever shaped the vocabulary came
12 //! from a webview port.
13 //!
14 //! That is the failure `makeover-layout`'s own header names. A webview can
15 //! express anything, so it never pushes back, and a vocabulary derived from the
16 //! renderer that can express everything comes out CSS-shaped with adapters
17 //! bolted onto the constrained renderers afterwards. The counter-principle is
18 //! to let the constrained consumer set the vocabulary, and quasi was the one
19 //! place it was not being applied.
20 //!
21 //! This crate is the constrained consumer. What it cannot draw is the point of
22 //! it: every place a description says something a terminal has no way to honour
23 //! is a finding, and the findings are the deliverable.
24 //!
25 //! # What it is not
26 //!
27 //! Not an implementation of `quasi_http::Serves`. That trait answers a `String`
28 //! and a content type, which is an HTTP host's contract rather than a
29 //! renderer's; a terminal answers cells in a buffer. The two share the
30 //! description and nothing else, which is worth knowing before reaching for the
31 //! trait's name.
32 //!
33 //! # The shape
34 //!
35 //! Flow layout, top to bottom. Every node answers a height for a width and then
36 //! draws into the rect it was given, which is the smallest thing that composes
37 //! and is what a description with no geometry in it can support. Nothing here
38 //! measures twice.
39 //!
40 //! # Drawing takes two arguments
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. A webview never has to say so because the
46 //! browser holds all three without being asked, so [`View`] is what a terminal
47 //! carries instead.
48 //!
49 //! A host with nothing to say passes `&View::new()`, which draws exactly what
50 //! the description says.
51
52 mod chrome;
53 mod clock;
54 mod focus;
55 mod frame;
56 mod local;
57 mod node;
58 mod outline;
59 mod region;
60 mod reveal;
61 mod runtime;
62 mod view;
63
64 #[cfg(test)]
65 mod tests;
66
67 pub use clock::{COARSE, LINGER, TICK, cadence};
68 pub use focus::{FieldSpot, Spot, spots};
69 pub use local::{Hidden, Local};
70
71 /// Nothing conditional, for a drawing with no screen to evaluate against.
72 static NOTHING_HIDDEN: Hidden<'static> = Hidden::none();
73 pub use runtime::{Delayed, Handed, Key, Runtime, Step};
74 pub use view::{Suggesting, View};
75
76 /// How often this renderer looks again at a [`Slot::live`] region.
77 ///
78 /// The description says the contents move and never says how often to look;
79 /// the rate is picked here, once, so every live region this crate draws moves
80 /// at one speed. The webview's own number, so a screen described once and
81 /// drawn twice does not go stale at two different rates.
82 ///
83 /// Longer than a frame on purpose. A terminal redraws on an event, and a live
84 /// region is the case with no event to redraw on.
85 ///
86 /// [`Slot::live`]: quasi_router::Slot::live
87 pub const CADENCE: std::time::Duration = std::time::Duration::from_secs(10);
88
89 /// This renderer's class, and why it may ignore
90 /// [`Destination::Local`](quasi_router::Destination::Local).
91 ///
92 /// A terminal draws from state it holds and redraws it on the next event, so a
93 /// highlight moving under an arrow key is what a list does here rather than a
94 /// special kind of thing. The split the mark describes exists because a
95 /// browser has no built-in stateful control, and this host is not that.
96 ///
97 /// The one place the mark is read anyway is [`Runtime::send`], which declines a
98 /// local action instead of treating it as an address to hand the host. Ignoring
99 /// a mark is allowed; mistaking it for something else is not.
100 ///
101 /// [`Runtime::send`]: Runtime
102 pub const CLASS: quasi_router::Renderer = quasi_router::Renderer::Client;
103
104 use std::collections::HashMap;
105 use std::sync::Arc;
106
107 use makeover_tui::piece::PieceStyle;
108 use makeover_tui::table::TableStyle;
109 use makeover_tui::{Fidelity, Palette, Theme};
110 use quasi_router::{Chrome, Frame, Node, Screen};
111 use ratatui::buffer::Buffer;
112 use ratatui::layout::Rect;
113
114 /// What a host draws inside a bespoke region.
115 ///
116 /// The terminal's counterpart to [`Webview::fills`], in this host's currency: a
117 /// browser takes markup and a terminal takes cells, so what a host hands over
118 /// here is a drawing rather than a string. Decision 4 is the same on both
119 /// sides -- the renderer hands the space over and never looks at what went in
120 /// it.
121 ///
122 /// Two methods rather than one closure, because this renderer's whole contract
123 /// is that a thing answers a height for a width and then draws into the rect it
124 /// was given. A fill that only drew would be a hole in the scroll arithmetic:
125 /// [`Tui::height`] would report a region shorter than what is on the screen and
126 /// a pane would stop scrolling before the fill's last row. A fill of a fixed
127 /// size answers a constant from [`rows`](Self::rows).
128 ///
129 /// `Send + Sync` so a host can keep one renderer behind a shared handle, which
130 /// is what [`Tui`] being `Clone` is already for.
131 ///
132 /// [`Webview::fills`]: https://docs.rs/quasi-webview
133 pub trait Fill: Send + Sync {
134 /// The rows this fill wants at `width`.
135 fn rows(&self, tui: &Tui, width: u16) -> u16;
136
137 /// Draw into `area`, and answer the rows used.
138 ///
139 /// Handed the renderer so the fill can paint in the theme the screen around
140 /// it is painted in. Never handed the [`View`], which holds what the user
141 /// has done to the *described* screen: a fill's state is the host's, the
142 /// same way an island's is in a browser.
143 fn draw(&self, tui: &Tui, area: Rect, buf: &mut Buffer) -> u16;
144 }
145
146 /// A terminal renderer for a described screen.
147 ///
148 /// Holds what a drawing needs and no screen state: the theme's colours, the
149 /// terminal's colour fidelity, the table and piece styles derived from both,
150 /// and what to put inside a bespoke region.
151 ///
152 /// A host makes one and keeps it. One with something per-screen to fill builds
153 /// one per screen, which is an allocation rather than a rebuild -- the same
154 /// arrangement `Webview` has for the same reason.
155 #[derive(Clone)]
156 pub struct Tui {
157 theme: Theme,
158 palette: Palette,
159 table: TableStyle,
160 piece: PieceStyle,
161 fills: HashMap<String, Arc<dyn Fill>>,
162 reduced_motion: bool,
163 }
164
165 /// The fills by name, since a drawing is not printable.
166 impl std::fmt::Debug for Tui {
167 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
168 f.debug_struct("Tui")
169 .field("theme", &self.theme)
170 .field("palette", &self.palette)
171 .field("table", &self.table)
172 .field("piece", &self.piece)
173 .field("fills", &self.fills.keys().collect::<Vec<_>>())
174 .field("reduced_motion", &self.reduced_motion)
175 .finish()
176 }
177 }
178
179 impl Tui {
180 /// A renderer drawing in this theme, at this terminal's fidelity.
181 #[must_use]
182 pub fn new(theme: Theme, fidelity: Fidelity) -> Self {
183 let theme = theme.for_terminal(fidelity);
184 Self {
185 palette: theme.palette(fidelity),
186 table: TableStyle::from_theme(&theme),
187 piece: PieceStyle::from_theme(&theme),
188 theme,
189 fills: HashMap::new(),
190 reduced_motion: false,
191 }
192 }
193
194 /// Draw for a reader who has asked for less motion, chaining.
195 ///
196 /// A terminal has no `prefers-reduced-motion` to read, which is the whole
197 /// reason this is a setting: the preference is the host's to obtain from its
198 /// own platform, and this is where it lands so a drawing can honour it.
199 ///
200 /// **It stills the activity mark rather than removing it.** A reader asking
201 /// for less motion has asked for the movement to stop, not for the
202 /// information to go away; see `makeover_timing::activity_blink`.
203 #[must_use]
204 pub const fn with_reduced_motion(mut self, reduced: bool) -> Self {
205 self.reduced_motion = reduced;
206 self
207 }
208
209 /// Whether this renderer is drawing for a reader who asked for less motion.
210 #[must_use]
211 pub const fn reduced_motion(&self) -> bool {
212 self.reduced_motion
213 }
214
215 /// Fill the bespoke region with this slot id, chaining.
216 ///
217 /// Keyed by [`Slot::id`] and not by the bespoke name, for the reason the
218 /// webview is: a screen of N rows each carrying a fill shares one name and
219 /// has N ids.
220 ///
221 /// Repeated calls for one id replace. A slot with no entry draws the blocks
222 /// the description put in it and nothing else, which is what a host with no
223 /// fill to offer gets; an entry naming an id the screen does not have is
224 /// ignored rather than drawn anywhere.
225 ///
226 /// [`Slot::id`]: quasi_router::Slot
227 #[must_use]
228 pub fn with_fill(mut self, slot_id: impl Into<String>, fill: impl Fill + 'static) -> Self {
229 self.fills.insert(slot_id.into(), Arc::new(fill));
230 self
231 }
232
233 /// What this renderer puts inside the bespoke region with this id.
234 #[must_use]
235 pub fn fill(&self, slot_id: &str) -> Option<&dyn Fill> {
236 self.fills.get(slot_id).map(AsRef::as_ref)
237 }
238
239 /// The colours this renderer draws in.
240 #[must_use]
241 pub const fn theme(&self) -> &Theme {
242 &self.theme
243 }
244
245 /// The depth palette, for a host painting its own chrome around a screen.
246 #[must_use]
247 pub const fn palette(&self) -> &Palette {
248 &self.palette
249 }
250
251 /// Draw a whole screen into `area`, in the state `view` says it is in.
252 ///
253 /// The title is not drawn. A window title is the host's to set, the same
254 /// way a webview host puts it in `<title>` rather than in the document, and
255 /// a terminal that painted it would be spending a row on something the
256 /// terminal emulator already has a place for.
257 ///
258 /// [`Screen::discovery`] is declined outright: og:type, an indexability
259 /// flag and a canonical URL are facts about being crawled, and nothing
260 /// crawls a terminal.
261 ///
262 /// [`Row::address`] is declined for the same kind of reason, and this says
263 /// so because a member read by one renderer and silently ignored by two is
264 /// how the row types drifted apart in the first place. An
265 /// address is where a *document* holds a row -- what a reader copies out of
266 /// the URL bar and sends to somebody else -- and a terminal has no document
267 /// and no address bar to put one in. What a terminal has instead is
268 /// [`Row::value`], which is the app's own name for the row and is what this
269 /// crate's focus pass already answers with.
270 ///
271 /// [`Row::address`]: quasi_router::Row::address
272 /// [`Row::value`]: quasi_router::Row::value
273 pub fn screen(&self, screen: &Screen, view: &View, area: Rect, buf: &mut Buffer) {
274 self.framed(screen, &Frame::new(), view, area, buf);
275 }
276
277 /// Draw a screen inside the frame a mount put around it.
278 ///
279 /// One `Pass` across both, which is not a tidiness choice: the pass counts
280 /// reachable things so the drawing knows which one is focused, and the
281 /// frame's verbs are reachable. Two passes would restart the count and
282 /// light a verb whenever the caret was on the screen's first control.
283 ///
284 /// # Where the frame goes
285 ///
286 /// The bottom, and the verbs below the status line, which is the reading
287 /// order the measured window already has: what happened, then what to do
288 /// next. A terminal has no stylesheet to defer the question to, so this is
289 /// the renderer deciding, the same way it decides that notices go at the
290 /// top.
291 pub fn framed(
292 &self,
293 screen: &Screen,
294 frame: &Frame,
295 view: &View,
296 area: Rect,
297 buf: &mut Buffer,
298 ) {
299 self.chromed(screen, frame, &Chrome::new(), view, area, buf);
300 }
301
302 /// Draw a screen inside its frame, under the panel the app keeps on screen.
303 ///
304 /// One `Pass` across all three, for the reason [`Tui::framed`] gives: the
305 /// pass counts reachable things, and a panel's controls are reachable.
306 ///
307 /// # Where the panel goes
308 ///
309 /// The very bottom, under the frame. See [`crate::chrome`] for why a
310 /// terminal answers the placement question at all.
311 pub fn chromed(
312 &self,
313 screen: &Screen,
314 frame: &Frame,
315 chrome: &Chrome,
316 view: &View,
317 area: Rect,
318 buf: &mut Buffer,
319 ) {
320 let hidden = reveal::hidden(screen, chrome, view);
321 let mut pass = Pass {
322 tui: self,
323 view,
324 hidden: &hidden,
325 seq: 0,
326 suggesting: None,
327 };
328 let mut rest = area;
329
330 // The tab line above everything, including notices. It is where the
331 // user is, and a terminal reading top to bottom puts that first: a
332 // navigation that appeared under a toast would move when the toast
333 // went. `71aa29b4`.
334 let nav_rows = chrome::nav_rows(chrome).min(rest.height);
335 let nav_area = Rect {
336 height: nav_rows,
337 ..rest
338 };
339 rest = below(rest, nav_rows);
340 chrome::draw_nav(&mut pass, chrome, screen.place.as_deref(), nav_area, buf);
341 // Under the places, on the last of the rows the band asked for. Drawn
342 // after them and reached after them: the caret walk and the drawing
343 // read one order, which is what keeps the highlighted thing the thing
344 // the reader is looking at.
345 if let Some(band) = &chrome.band {
346 let used = chrome::nav_rows(chrome).saturating_sub(1);
347 chrome::draw_search(&mut pass, band, below(nav_area, used), buf);
348 }
349
350 // Notices first and at the top, because a notice belongs to the screen
351 // rather than to a place in it. A webview leaves where they land to the
352 // stylesheet; a terminal has no stylesheet, so this is the renderer
353 // deciding, and the top of the screen is the one place a message about
354 // the whole screen can go without claiming a region.
355 //
356 // Unless the mount says it has a place for one to rest, which is what
357 // a status line is. `Frame::holds` is the rule and it is the router's.
358 for notice in screen.notices.iter().filter(|one| !frame.holds(one)) {
359 let used = node::draw(&mut pass, notice, rest, buf);
360 rest = below(rest, used);
361 }
362
363 // Measured before the regions are drawn and taken off the bottom, so
364 // the screen is handed what is actually left rather than being drawn
365 // over. A frame that wants more rows than the terminal has takes what
366 // there is: a verb the user cannot reach is worse than a screen that
367 // is short, and the alternative is a row drawn off the bottom edge.
368 // The panel first, so it is the band nearest the bottom edge: it
369 // outlives the frame the way the frame outlives the screen.
370 let panel_rows =
371 chrome::rows(self, chrome, rest.width, &Local::of(&hidden, view)).min(rest.height);
372 let (rest, panel) = split_bottom(rest, panel_rows);
373
374 let below_screen = frame::rows(self, frame, screen, rest.width).min(rest.height);
375 let (rest, footer) = split_bottom(rest, below_screen);
376
377 region::screen_regions(&mut pass, screen, rest, buf);
378 frame::draw(&mut pass, frame, screen, footer, buf);
379 chrome::draw(&mut pass, chrome, panel, buf);
380
381 // Last of all, over everything: a list of candidates is the newest
382 // thing on the screen and the only one the reader is looking at.
383 if let Some(under) = pass.suggesting {
384 node::draw_suggestions(self, view, under, buf);
385 }
386 }
387
388 /// Draw one node into `area`, and answer the rows it used.
389 ///
390 /// Never draws outside `area` and never below it: a node handed less room
391 /// than it wants is cut off at the bottom, which is what a terminal does
392 /// with everything. A [`Node::Region`] is the one that scrolls, and it
393 /// reads its offset off the view.
394 pub fn node(&self, node: &Node, view: &View, area: Rect, buf: &mut Buffer) -> u16 {
395 node::draw(
396 &mut Pass {
397 tui: self,
398 view,
399 // No screen to read a watched control off, so a conditional
400 // region or question drawn on its own is drawn. See
401 // [`Tui::height`].
402 hidden: &NOTHING_HIDDEN,
403 seq: 0,
404 suggesting: None,
405 },
406 node,
407 area,
408 buf,
409 )
410 }
411
412 /// The rows `node` wants at `width`.
413 #[must_use]
414 pub fn height(&self, node: &Node, width: u16) -> u16 {
415 // Every region, including one whose condition is not satisfied: this
416 // takes a node rather than a screen and a view, so there is nothing
417 // here to evaluate a condition against. `Tui::chromed` measures with
418 // the view it is drawing under.
419 node::height(self, node, width, &Local::none())
420 }
421
422 /// The tones, headings and marks the drawings take.
423 ///
424 /// Nothing in either was about a described screen — both are `makeover-
425 /// layout` in and a ratatui `Style` out — so they went to `makeover-tui`
426 /// 0.16.0 with the drawings that read them, and every terminal app in the
427 /// tree now says a danger tone the same way.
428 pub(crate) const fn style(&self) -> &PieceStyle {
429 &self.piece
430 }
431 }
432
433 /// One drawing, as it walks the screen.
434 ///
435 /// Carries the count of reachable things passed so far, which is how the
436 /// drawing knows whether the thing it is about to draw is the focused one. The
437 /// count has to advance at exactly the points [`focus::spots`] records one, and
438 /// that agreement is asserted by a test rather than trusted: the two walks are
439 /// separate because one needs a rect and the other does not, and a walk that
440 /// counted differently would light the wrong control.
441 pub(crate) struct Pass<'a> {
442 tui: &'a Tui,
443 view: &'a View,
444 /// The regions and questions that do not apply right now.
445 ///
446 /// Computed once per drawing and read by the drawing and the focus walk
447 /// both, which is what keeps the caret and the picture agreeing about how
448 /// many things there are to stop on. Empty for a screen with nothing
449 /// conditional on it, and empty is what a caller with no view to read
450 /// hands on.
451 hidden: &'a Hidden<'a>,
452 seq: usize,
453 /// Where an open suggestion list is to be drawn, once the screen under it
454 /// has been.
455 ///
456 /// The list floats over what follows the box, so it cannot be drawn where
457 /// the field is drawn: the regions after it would paint over it. The field
458 /// records the room under itself here and `chromed` paints it last, which
459 /// is the same "after everything" the frame and the chrome take and for
460 /// the same reason.
461 ///
462 /// Out of flow rather than in it, which is the opposite of the webview's
463 /// answer and is right in both places: a browser can position a list over
464 /// the document without moving anything, and a terminal that inserted rows
465 /// would push the rest of the form down on every keystroke.
466 suggesting: Option<Rect>,
467 }
468
469 impl<'a> Pass<'a> {
470 /// What the reader has done, as the walks over the description take it.
471 ///
472 /// The drawing holds the two halves separately because it needs each of
473 /// them on its own; the walks it calls into need them together. See
474 /// [`crate::local`].
475 fn local(&self) -> Local<'a> {
476 Local::of(self.hidden, self.view)
477 }
478
479 /// Take the next reachable position, and say whether it is the focused one.
480 fn claim(&mut self) -> bool {
481 let mine = self.seq;
482 self.seq += 1;
483 mine == self.view.focus()
484 }
485 }
486
487 /// `area` split into what is above the last `rows` of it, and those rows.
488 ///
489 /// Saturating rather than panicking on a frame taller than the terminal: the
490 /// whole area becomes the footer and the screen gets nothing, which is a hard
491 /// screen to use and is still better than a row drawn off the bottom edge.
492 fn split_bottom(area: Rect, rows: u16) -> (Rect, Rect) {
493 let rows = rows.min(area.height);
494 let kept = area.height - rows;
495 (
496 Rect {
497 height: kept,
498 ..area
499 },
500 Rect {
501 y: area.y + kept,
502 height: rows,
503 ..area
504 },
505 )
506 }
507
508 /// What is left of `area` after `used` rows from the top.
509 pub(crate) fn below(area: Rect, used: u16) -> Rect {
510 let used = used.min(area.height);
511 Rect {
512 x: area.x,
513 y: area.y + used,
514 width: area.width,
515 height: area.height - used,
516 }
517 }
518