//! The terminal renderer for quasi. //! //! //! //! # Why this exists //! //! `quasi-router`'s own diagram says `renderer: webview | tui | egui`, and until //! this crate two of those three did not exist. `quasi-webview` was the only //! renderer of the screen tree, and both hosts are webview hosts: `quasi-axum` //! serves the markup over HTTP, `quasi-tauri` serves the same markup over a //! custom protocol. So every finding that has ever shaped the vocabulary came //! from a webview port. //! //! That is the failure `makeover-layout`'s own header names. A webview can //! express anything, so it never pushes back, and a vocabulary derived from the //! renderer that can express everything comes out CSS-shaped with adapters //! bolted onto the constrained renderers afterwards. The counter-principle is //! to let the constrained consumer set the vocabulary, and quasi was the one //! place it was not being applied. //! //! This crate is the constrained consumer. What it cannot draw is the point of //! it: every place a description says something a terminal has no way to honour //! is a finding, and the findings are the deliverable. //! //! # What it is not //! //! Not an implementation of `quasi_http::Serves`. That trait answers a `String` //! and a content type, which is an HTTP host's contract rather than a //! renderer's; a terminal answers cells in a buffer. The two share the //! description and nothing else, which is worth knowing before reaching for the //! trait's name. //! //! # The shape //! //! Flow layout, top to bottom. Every node answers a height for a width and then //! draws into the rect it was given, which is the smallest thing that composes //! and is what a description with no geometry in it can support. Nothing here //! measures twice. //! //! # Drawing takes two arguments //! //! A screen and a [`View`]. The description says what the app offers; the view //! says what the user has done to it since it arrived: what is typed, what has //! focus, how far a pane is scrolled. None of the three is in a description and //! none of them belongs there. A webview never has to say so because the //! browser holds all three without being asked, so [`View`] is what a terminal //! carries instead. //! //! A host with nothing to say passes `&View::new()`, which draws exactly what //! the description says. mod chrome; mod clock; mod focus; mod frame; mod local; mod node; mod outline; mod region; mod reveal; mod runtime; mod view; #[cfg(test)] mod tests; pub use clock::{COARSE, LINGER, TICK, cadence}; pub use focus::{FieldSpot, Spot, spots}; pub use local::{Hidden, Local}; /// Nothing conditional, for a drawing with no screen to evaluate against. static NOTHING_HIDDEN: Hidden<'static> = Hidden::none(); pub use runtime::{Delayed, Handed, Key, Runtime, Step}; pub use view::{Suggesting, View}; /// How often this renderer looks again at a [`Slot::live`] region. /// /// The description says the contents move and never says how often to look; /// the rate is picked here, once, so every live region this crate draws moves /// at one speed. The webview's own number, so a screen described once and /// drawn twice does not go stale at two different rates. /// /// Longer than a frame on purpose. A terminal redraws on an event, and a live /// region is the case with no event to redraw on. /// /// [`Slot::live`]: quasi_router::Slot::live pub const CADENCE: std::time::Duration = std::time::Duration::from_secs(10); /// This renderer's class, and why it may ignore /// [`Destination::Local`](quasi_router::Destination::Local). /// /// A terminal draws from state it holds and redraws it on the next event, so a /// highlight moving under an arrow key is what a list does here rather than a /// special kind of thing. The split the mark describes exists because a /// browser has no built-in stateful control, and this host is not that. /// /// The one place the mark is read anyway is [`Runtime::send`], which declines a /// local action instead of treating it as an address to hand the host. Ignoring /// a mark is allowed; mistaking it for something else is not. /// /// [`Runtime::send`]: Runtime pub const CLASS: quasi_router::Renderer = quasi_router::Renderer::Client; use std::collections::HashMap; use std::sync::Arc; use makeover_tui::piece::PieceStyle; use makeover_tui::table::TableStyle; use makeover_tui::{Fidelity, Palette, Theme}; use quasi_router::{Chrome, Frame, Node, Screen}; use ratatui::buffer::Buffer; use ratatui::layout::Rect; /// What a host draws inside a bespoke region. /// /// The terminal's counterpart to [`Webview::fills`], in this host's currency: a /// browser takes markup and a terminal takes cells, so what a host hands over /// here is a drawing rather than a string. Decision 4 is the same on both /// sides -- the renderer hands the space over and never looks at what went in /// it. /// /// Two methods rather than one closure, because this renderer's whole contract /// is that a thing answers a height for a width and then draws into the rect it /// was given. A fill that only drew would be a hole in the scroll arithmetic: /// [`Tui::height`] would report a region shorter than what is on the screen and /// a pane would stop scrolling before the fill's last row. A fill of a fixed /// size answers a constant from [`rows`](Self::rows). /// /// `Send + Sync` so a host can keep one renderer behind a shared handle, which /// is what [`Tui`] being `Clone` is already for. /// /// [`Webview::fills`]: https://docs.rs/quasi-webview pub trait Fill: Send + Sync { /// The rows this fill wants at `width`. fn rows(&self, tui: &Tui, width: u16) -> u16; /// Draw into `area`, and answer the rows used. /// /// Handed the renderer so the fill can paint in the theme the screen around /// it is painted in. Never handed the [`View`], which holds what the user /// has done to the *described* screen: a fill's state is the host's, the /// same way an island's is in a browser. fn draw(&self, tui: &Tui, area: Rect, buf: &mut Buffer) -> u16; } /// A terminal renderer for a described screen. /// /// Holds what a drawing needs and no screen state: the theme's colours, the /// terminal's colour fidelity, the table and piece styles derived from both, /// and what to put inside a bespoke region. /// /// A host makes one and keeps it. One with something per-screen to fill builds /// one per screen, which is an allocation rather than a rebuild -- the same /// arrangement `Webview` has for the same reason. #[derive(Clone)] pub struct Tui { theme: Theme, palette: Palette, table: TableStyle, piece: PieceStyle, fills: HashMap>, reduced_motion: bool, } /// The fills by name, since a drawing is not printable. impl std::fmt::Debug for Tui { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("Tui") .field("theme", &self.theme) .field("palette", &self.palette) .field("table", &self.table) .field("piece", &self.piece) .field("fills", &self.fills.keys().collect::>()) .field("reduced_motion", &self.reduced_motion) .finish() } } impl Tui { /// A renderer drawing in this theme, at this terminal's fidelity. #[must_use] pub fn new(theme: Theme, fidelity: Fidelity) -> Self { let theme = theme.for_terminal(fidelity); Self { palette: theme.palette(fidelity), table: TableStyle::from_theme(&theme), piece: PieceStyle::from_theme(&theme), theme, fills: HashMap::new(), reduced_motion: false, } } /// Draw for a reader who has asked for less motion, chaining. /// /// A terminal has no `prefers-reduced-motion` to read, which is the whole /// reason this is a setting: the preference is the host's to obtain from its /// own platform, and this is where it lands so a drawing can honour it. /// /// **It stills the activity mark rather than removing it.** A reader asking /// for less motion has asked for the movement to stop, not for the /// information to go away; see `makeover_timing::activity_blink`. #[must_use] pub const fn with_reduced_motion(mut self, reduced: bool) -> Self { self.reduced_motion = reduced; self } /// Whether this renderer is drawing for a reader who asked for less motion. #[must_use] pub const fn reduced_motion(&self) -> bool { self.reduced_motion } /// Fill the bespoke region with this slot id, chaining. /// /// Keyed by [`Slot::id`] and not by the bespoke name, for the reason the /// webview is: a screen of N rows each carrying a fill shares one name and /// has N ids. /// /// Repeated calls for one id replace. A slot with no entry draws the blocks /// the description put in it and nothing else, which is what a host with no /// fill to offer gets; an entry naming an id the screen does not have is /// ignored rather than drawn anywhere. /// /// [`Slot::id`]: quasi_router::Slot #[must_use] pub fn with_fill(mut self, slot_id: impl Into, fill: impl Fill + 'static) -> Self { self.fills.insert(slot_id.into(), Arc::new(fill)); self } /// What this renderer puts inside the bespoke region with this id. #[must_use] pub fn fill(&self, slot_id: &str) -> Option<&dyn Fill> { self.fills.get(slot_id).map(AsRef::as_ref) } /// The colours this renderer draws in. #[must_use] pub const fn theme(&self) -> &Theme { &self.theme } /// The depth palette, for a host painting its own chrome around a screen. #[must_use] pub const fn palette(&self) -> &Palette { &self.palette } /// Draw a whole screen into `area`, in the state `view` says it is in. /// /// The title is not drawn. A window title is the host's to set, the same /// way a webview host puts it in `` rather than in the document, and /// a terminal that painted it would be spending a row on something the /// terminal emulator already has a place for. /// /// [`Screen::discovery`] is declined outright: og:type, an indexability /// flag and a canonical URL are facts about being crawled, and nothing /// crawls a terminal. /// /// [`Row::address`] is declined for the same kind of reason, and this says /// so because a member read by one renderer and silently ignored by two is /// how the row types drifted apart in the first place. An /// address is where a *document* holds a row -- what a reader copies out of /// the URL bar and sends to somebody else -- and a terminal has no document /// and no address bar to put one in. What a terminal has instead is /// [`Row::value`], which is the app's own name for the row and is what this /// crate's focus pass already answers with. /// /// [`Row::address`]: quasi_router::Row::address /// [`Row::value`]: quasi_router::Row::value pub fn screen(&self, screen: &Screen, view: &View, area: Rect, buf: &mut Buffer) { self.framed(screen, &Frame::new(), view, area, buf); } /// Draw a screen inside the frame a mount put around it. /// /// One `Pass` across both, which is not a tidiness choice: the pass counts /// reachable things so the drawing knows which one is focused, and the /// frame's verbs are reachable. Two passes would restart the count and /// light a verb whenever the caret was on the screen's first control. /// /// # Where the frame goes /// /// The bottom, and the verbs below the status line, which is the reading /// order the measured window already has: what happened, then what to do /// next. A terminal has no stylesheet to defer the question to, so this is /// the renderer deciding, the same way it decides that notices go at the /// top. pub fn framed( &self, screen: &Screen, frame: &Frame, view: &View, area: Rect, buf: &mut Buffer, ) { self.chromed(screen, frame, &Chrome::new(), view, area, buf); } /// Draw a screen inside its frame, under the panel the app keeps on screen. /// /// One `Pass` across all three, for the reason [`Tui::framed`] gives: the /// pass counts reachable things, and a panel's controls are reachable. /// /// # Where the panel goes /// /// The very bottom, under the frame. See [`crate::chrome`] for why a /// terminal answers the placement question at all. pub fn chromed( &self, screen: &Screen, frame: &Frame, chrome: &Chrome, view: &View, area: Rect, buf: &mut Buffer, ) { let hidden = reveal::hidden(screen, chrome, view); let mut pass = Pass { tui: self, view, hidden: &hidden, seq: 0, suggesting: None, }; let mut rest = area; // The tab line above everything, including notices. It is where the // user is, and a terminal reading top to bottom puts that first: a // navigation that appeared under a toast would move when the toast // went. `71aa29b4`. let nav_rows = chrome::nav_rows(chrome).min(rest.height); let nav_area = Rect { height: nav_rows, ..rest }; rest = below(rest, nav_rows); chrome::draw_nav(&mut pass, chrome, screen.place.as_deref(), nav_area, buf); // Under the places, on the last of the rows the band asked for. Drawn // after them and reached after them: the caret walk and the drawing // read one order, which is what keeps the highlighted thing the thing // the reader is looking at. if let Some(band) = &chrome.band { let used = chrome::nav_rows(chrome).saturating_sub(1); chrome::draw_search(&mut pass, band, below(nav_area, used), buf); } // Notices first and at the top, because a notice belongs to the screen // rather than to a place in it. A webview leaves where they land to the // stylesheet; a terminal has no stylesheet, so this is the renderer // deciding, and the top of the screen is the one place a message about // the whole screen can go without claiming a region. // // Unless the mount says it has a place for one to rest, which is what // a status line is. `Frame::holds` is the rule and it is the router's. for notice in screen.notices.iter().filter(|one| !frame.holds(one)) { let used = node::draw(&mut pass, notice, rest, buf); rest = below(rest, used); } // Measured before the regions are drawn and taken off the bottom, so // the screen is handed what is actually left rather than being drawn // over. A frame that wants more rows than the terminal has takes what // there is: a verb the user cannot reach is worse than a screen that // is short, and the alternative is a row drawn off the bottom edge. // The panel first, so it is the band nearest the bottom edge: it // outlives the frame the way the frame outlives the screen. let panel_rows = chrome::rows(self, chrome, rest.width, &Local::of(&hidden, view)).min(rest.height); let (rest, panel) = split_bottom(rest, panel_rows); let below_screen = frame::rows(self, frame, screen, rest.width).min(rest.height); let (rest, footer) = split_bottom(rest, below_screen); region::screen_regions(&mut pass, screen, rest, buf); frame::draw(&mut pass, frame, screen, footer, buf); chrome::draw(&mut pass, chrome, panel, buf); // Last of all, over everything: a list of candidates is the newest // thing on the screen and the only one the reader is looking at. if let Some(under) = pass.suggesting { node::draw_suggestions(self, view, under, buf); } } /// Draw one node into `area`, and answer the rows it used. /// /// Never draws outside `area` and never below it: a node handed less room /// than it wants is cut off at the bottom, which is what a terminal does /// with everything. A [`Node::Region`] is the one that scrolls, and it /// reads its offset off the view. pub fn node(&self, node: &Node, view: &View, area: Rect, buf: &mut Buffer) -> u16 { node::draw( &mut Pass { tui: self, view, // No screen to read a watched control off, so a conditional // region or question drawn on its own is drawn. See // [`Tui::height`]. hidden: &NOTHING_HIDDEN, seq: 0, suggesting: None, }, node, area, buf, ) } /// The rows `node` wants at `width`. #[must_use] pub fn height(&self, node: &Node, width: u16) -> u16 { // Every region, including one whose condition is not satisfied: this // takes a node rather than a screen and a view, so there is nothing // here to evaluate a condition against. `Tui::chromed` measures with // the view it is drawing under. node::height(self, node, width, &Local::none()) } /// The tones, headings and marks the drawings take. /// /// Nothing in either was about a described screen — both are `makeover- /// layout` in and a ratatui `Style` out — so they went to `makeover-tui` /// 0.16.0 with the drawings that read them, and every terminal app in the /// tree now says a danger tone the same way. pub(crate) const fn style(&self) -> &PieceStyle { &self.piece } } /// One drawing, as it walks the screen. /// /// Carries the count of reachable things passed so far, which is how the /// drawing knows whether the thing it is about to draw is the focused one. The /// count has to advance at exactly the points [`focus::spots`] records one, and /// that agreement is asserted by a test rather than trusted: the two walks are /// separate because one needs a rect and the other does not, and a walk that /// counted differently would light the wrong control. pub(crate) struct Pass<'a> { tui: &'a Tui, view: &'a View, /// The regions and questions that do not apply right now. /// /// Computed once per drawing and read by the drawing and the focus walk /// both, which is what keeps the caret and the picture agreeing about how /// many things there are to stop on. Empty for a screen with nothing /// conditional on it, and empty is what a caller with no view to read /// hands on. hidden: &'a Hidden<'a>, seq: usize, /// Where an open suggestion list is to be drawn, once the screen under it /// has been. /// /// The list floats over what follows the box, so it cannot be drawn where /// the field is drawn: the regions after it would paint over it. The field /// records the room under itself here and `chromed` paints it last, which /// is the same "after everything" the frame and the chrome take and for /// the same reason. /// /// Out of flow rather than in it, which is the opposite of the webview's /// answer and is right in both places: a browser can position a list over /// the document without moving anything, and a terminal that inserted rows /// would push the rest of the form down on every keystroke. suggesting: Option<Rect>, } impl<'a> Pass<'a> { /// What the reader has done, as the walks over the description take it. /// /// The drawing holds the two halves separately because it needs each of /// them on its own; the walks it calls into need them together. See /// [`crate::local`]. fn local(&self) -> Local<'a> { Local::of(self.hidden, self.view) } /// Take the next reachable position, and say whether it is the focused one. fn claim(&mut self) -> bool { let mine = self.seq; self.seq += 1; mine == self.view.focus() } } /// `area` split into what is above the last `rows` of it, and those rows. /// /// Saturating rather than panicking on a frame taller than the terminal: the /// whole area becomes the footer and the screen gets nothing, which is a hard /// screen to use and is still better than a row drawn off the bottom edge. fn split_bottom(area: Rect, rows: u16) -> (Rect, Rect) { let rows = rows.min(area.height); let kept = area.height - rows; ( Rect { height: kept, ..area }, Rect { y: area.y + kept, height: rows, ..area }, ) } /// What is left of `area` after `used` rows from the top. pub(crate) fn below(area: Rect, used: u16) -> Rect { let used = used.min(area.height); Rect { x: area.x, y: area.y + used, width: area.width, height: area.height - used, } }