//! What the app keeps on screen, in cells. //! //! Then `71aa29b4`. [`Chrome`] is host-agnostic and lives in `quasi-router`; //! what is here is the terminal's answer to it — the things that are on the //! screen whatever screen is showing, and the places the app has. //! //! # At the very bottom, below the frame //! //! A terminal has no floating, which is the whole reason the description //! declines to say where a panel sits: "bottom right, over the content" is a //! browser's word. So the renderer decides, and the decision is a band off the //! bottom, under the frame the mount supplied. Under it rather than over it //! because the lifetimes stack that way: the screen is replaced by every //! navigation, the frame outlives the screen inside it, and the panel outlives //! both. //! //! The rows come off the screen's area rather than being painted over it, for //! the reason [`crate::frame`] gives: a band drawn on top would cover whatever //! the last region put there. //! //! Several panels stack in declaration order, which is what the description //! gave and is not this renderer reading anything into it. [`Role`] is read all //! the same: a [`Role::Status`] panel is the app's condition and belongs where a //! status line goes, so it is drawn last, closest to the bottom edge, under any //! [`Role::Activity`] band. That is this host answering the placement question //! its own way, which is the arrangement the vocabulary asks for. //! //! # A band is the tab line with the app's name on it //! //! [`Chrome::band`] says the header is one thing. A browser needs that because //! a stylesheet cannot make a bar out of elements that are not siblings; a //! terminal has no such problem, so what the band buys here is the two members //! the nav never carried. The brand is drawn at the head of the tab line, and //! the search box takes a row under it. //! //! [`Disclose`](quasi_router::Disclose) is ignored, which is what the //! vocabulary says a renderer with no notion of "not enough room" does: a //! terminal draws the width it was given, and hiding the places behind a //! control the user would have to find would be inventing a gesture nothing //! asked for. //! //! # The nav is a tab line across the top //! //! A terminal has no tab bar, so the renderer decides again, and the decision is //! one row at the very top: the places separated by spaces, the current one in //! reverse video. Two levels are drawn as two rows, the second holding the //! places inside the current one only — a terminal is 24 rows tall and every //! sub-place of every tab would spend three of them on navigation. use quasi_router::{Band, Chrome, Node, Place, Role}; use ratatui::buffer::Buffer; use ratatui::layout::Rect; use crate::{Local, Pass, Tui}; /// The rows the chrome wants at `width`, panels and nav together. pub(crate) fn rows(tui: &Tui, chrome: &Chrome, width: u16, local: &Local<'_>) -> u16 { let panels: u16 = chrome .panels .iter() .map(|panel| crate::node::height(tui, &panel.content, width, local)) .sum(); panels.saturating_add(nav_rows(chrome)) } /// The rows the band and the nav want across the top. /// /// None, one, or two when the current place holds more, plus one for a band /// that offers a search box. A band with a brand and no places is still a row: /// the app's name is what is in it. pub(crate) fn nav_rows(chrome: &Chrome) -> u16 { let band = chrome.band.as_ref(); let branded = band.is_some_and(|band| band.brand.is_some()); if chrome.nav.is_empty() && !branded { return u16::from(band.is_some_and(|band| band.search.is_some())); } let levels = 1 + u16::from(chrome.nav.iter().any(|place| !place.within.is_empty())); levels + u16::from(band.is_some_and(|band| band.search.is_some())) } /// Draw the tab line: the places, and the ones inside where the user is. /// /// One row, or two when the current place holds others. The second row is the /// current place's own sub-places and nobody else's: a terminal is 24 rows tall /// and every sub-place of every tab would spend three of them on navigation. /// /// Reverse video for the current one, because a terminal has two ways to say /// "this one" and the other is a colour the theme may have spent already. pub(crate) fn draw_nav( pass: &mut Pass<'_>, chrome: &Chrome, at: Option<&str>, area: Rect, buf: &mut Buffer, ) { let band = chrome.band.as_ref(); let branded = band.and_then(|band| band.brand.as_ref()); if (chrome.nav.is_empty() && branded.is_none()) || area.height == 0 { return; } let mut rest = area; if !chrome.nav.is_empty() || branded.is_some() { line(pass, branded, &chrome.nav, at, rest, buf); rest = down(rest, 1); } // The places inside where the user is. `holds` is asked rather than the key // compared, so a screen naming a sub-place lights its tab and opens its row. let Some(open) = chrome .nav .iter() .find(|place| at.is_some_and(|key| place.holds(key))) else { return; }; if rest.height > 0 { line(pass, None, &open.within, at, rest, buf); } } /// The band's search box, on the row under the tab line. /// /// Drawn after the places and reached after them, which is the invariant this /// renderer keeps everywhere: the caret walk and the drawing read the same /// order, so the highlighted thing is the thing the reader is looking at. pub(crate) fn draw_search(pass: &mut Pass<'_>, band: &Band, area: Rect, buf: &mut Buffer) { let Some(search) = &band.search else { return; }; if area.height == 0 { return; } let row = Rect { height: 1, ..area }; crate::node::draw(pass, &Node::Field(Box::new(search.clone())), row, buf); } /// The rect below the first `rows` of this one. const fn down(area: Rect, rows: u16) -> Rect { Rect { y: area.y.saturating_add(rows), height: area.height.saturating_sub(rows), ..area } } /// One row of places, separated by spaces, the current one in reverse video. fn line( pass: &mut Pass<'_>, brand: Option<&quasi_router::Brand>, places: &[Place], at: Option<&str>, area: Rect, buf: &mut Buffer, ) { let row = Rect { height: 1, ..area }; // The name first, and bold rather than reversed: reverse video is how this // renderer says "the current place", and spending it on something that is // never current would make the tab line say two things with one signal. // The mark is not drawn differently. A terminal has one typeface and no // way to make a glyph graphic, so the honest drawing is the whole name. let mut spans: Vec> = Vec::new(); if let Some(brand) = brand { spans.push(ratatui::text::Span::styled( format!("{} ", brand.name), ratatui::style::Style::default().add_modifier(ratatui::style::Modifier::BOLD), )); } spans.extend::>>( places .iter() .flat_map(|place| { let style = if at.is_some_and(|key| place.holds(key)) { ratatui::style::Style::default() .add_modifier(ratatui::style::Modifier::REVERSED) } else { ratatui::style::Style::default() }; [ ratatui::text::Span::styled(format!(" {} ", place.label), style), ratatui::text::Span::raw(" "), ] }) .collect(), ); let _ = pass; ratatui::widgets::Widget::render( ratatui::widgets::Paragraph::new(ratatui::text::Line::from(spans)), row, buf, ); } /// Draw it. /// /// Takes the same [`Pass`] the screen and the frame were drawn with, so the /// count of reachable things carries on rather than restarting. A panel's /// controls are reachable, and [`crate::focus::reaches_chromed`] counts them /// last because this draws them last. pub(crate) fn draw(pass: &mut Pass<'_>, chrome: &Chrome, area: Rect, buf: &mut Buffer) { // Activity first and status last, so the app's condition sits closest to // the bottom edge where a terminal's status line goes. Within a role it is // declaration order, which is what the description gave. let ordered = chrome .panels .iter() .filter(|panel| panel.role == Role::Activity) .chain( chrome .panels .iter() .filter(|panel| panel.role == Role::Status), ); let mut top = area.y; for panel in ordered { if top >= area.bottom() { return; } let content: &Node = &panel.content; let height = crate::node::height(pass.tui, content, area.width, &pass.local()) .min(area.bottom() - top); let slice = Rect { x: area.x, y: top, width: area.width, height, }; crate::node::draw(pass, content, slice, buf); top = top.saturating_add(height); } }