Skip to main content

max / quasi

9.0 KB · 231 lines History Blame Raw
1 //! What the app keeps on screen, in cells.
2 //!
3 //! Then `71aa29b4`. [`Chrome`] is host-agnostic and lives in `quasi-router`;
4 //! what is here is the terminal's answer to it — the things that are on the
5 //! screen whatever screen is showing, and the places the app has.
6 //!
7 //! # At the very bottom, below the frame
8 //!
9 //! A terminal has no floating, which is the whole reason the description
10 //! declines to say where a panel sits: "bottom right, over the content" is a
11 //! browser's word. So the renderer decides, and the decision is a band off the
12 //! bottom, under the frame the mount supplied. Under it rather than over it
13 //! because the lifetimes stack that way: the screen is replaced by every
14 //! navigation, the frame outlives the screen inside it, and the panel outlives
15 //! both.
16 //!
17 //! The rows come off the screen's area rather than being painted over it, for
18 //! the reason [`crate::frame`] gives: a band drawn on top would cover whatever
19 //! the last region put there.
20 //!
21 //! Several panels stack in declaration order, which is what the description
22 //! gave and is not this renderer reading anything into it. [`Role`] is read all
23 //! the same: a [`Role::Status`] panel is the app's condition and belongs where a
24 //! status line goes, so it is drawn last, closest to the bottom edge, under any
25 //! [`Role::Activity`] band. That is this host answering the placement question
26 //! its own way, which is the arrangement the vocabulary asks for.
27 //!
28 //! # A band is the tab line with the app's name on it
29 //!
30 //! [`Chrome::band`] says the header is one thing. A browser needs that because
31 //! a stylesheet cannot make a bar out of elements that are not siblings; a
32 //! terminal has no such problem, so what the band buys here is the two members
33 //! the nav never carried. The brand is drawn at the head of the tab line, and
34 //! the search box takes a row under it.
35 //!
36 //! [`Disclose`](quasi_router::Disclose) is ignored, which is what the
37 //! vocabulary says a renderer with no notion of "not enough room" does: a
38 //! terminal draws the width it was given, and hiding the places behind a
39 //! control the user would have to find would be inventing a gesture nothing
40 //! asked for.
41 //!
42 //! # The nav is a tab line across the top
43 //!
44 //! A terminal has no tab bar, so the renderer decides again, and the decision is
45 //! one row at the very top: the places separated by spaces, the current one in
46 //! reverse video. Two levels are drawn as two rows, the second holding the
47 //! places inside the current one only — a terminal is 24 rows tall and every
48 //! sub-place of every tab would spend three of them on navigation.
49
50 use quasi_router::{Band, Chrome, Node, Place, Role};
51 use ratatui::buffer::Buffer;
52 use ratatui::layout::Rect;
53
54 use crate::{Local, Pass, Tui};
55
56 /// The rows the chrome wants at `width`, panels and nav together.
57 pub(crate) fn rows(tui: &Tui, chrome: &Chrome, width: u16, local: &Local<'_>) -> u16 {
58 let panels: u16 = chrome
59 .panels
60 .iter()
61 .map(|panel| crate::node::height(tui, &panel.content, width, local))
62 .sum();
63 panels.saturating_add(nav_rows(chrome))
64 }
65
66 /// The rows the band and the nav want across the top.
67 ///
68 /// None, one, or two when the current place holds more, plus one for a band
69 /// that offers a search box. A band with a brand and no places is still a row:
70 /// the app's name is what is in it.
71 pub(crate) fn nav_rows(chrome: &Chrome) -> u16 {
72 let band = chrome.band.as_ref();
73 let branded = band.is_some_and(|band| band.brand.is_some());
74 if chrome.nav.is_empty() && !branded {
75 return u16::from(band.is_some_and(|band| band.search.is_some()));
76 }
77 let levels = 1 + u16::from(chrome.nav.iter().any(|place| !place.within.is_empty()));
78 levels + u16::from(band.is_some_and(|band| band.search.is_some()))
79 }
80
81 /// Draw the tab line: the places, and the ones inside where the user is.
82 ///
83 /// One row, or two when the current place holds others. The second row is the
84 /// current place's own sub-places and nobody else's: a terminal is 24 rows tall
85 /// and every sub-place of every tab would spend three of them on navigation.
86 ///
87 /// Reverse video for the current one, because a terminal has two ways to say
88 /// "this one" and the other is a colour the theme may have spent already.
89 pub(crate) fn draw_nav(
90 pass: &mut Pass<'_>,
91 chrome: &Chrome,
92 at: Option<&str>,
93 area: Rect,
94 buf: &mut Buffer,
95 ) {
96 let band = chrome.band.as_ref();
97 let branded = band.and_then(|band| band.brand.as_ref());
98 if (chrome.nav.is_empty() && branded.is_none()) || area.height == 0 {
99 return;
100 }
101 let mut rest = area;
102 if !chrome.nav.is_empty() || branded.is_some() {
103 line(pass, branded, &chrome.nav, at, rest, buf);
104 rest = down(rest, 1);
105 }
106 // The places inside where the user is. `holds` is asked rather than the key
107 // compared, so a screen naming a sub-place lights its tab and opens its row.
108 let Some(open) = chrome
109 .nav
110 .iter()
111 .find(|place| at.is_some_and(|key| place.holds(key)))
112 else {
113 return;
114 };
115 if rest.height > 0 {
116 line(pass, None, &open.within, at, rest, buf);
117 }
118 }
119
120 /// The band's search box, on the row under the tab line.
121 ///
122 /// Drawn after the places and reached after them, which is the invariant this
123 /// renderer keeps everywhere: the caret walk and the drawing read the same
124 /// order, so the highlighted thing is the thing the reader is looking at.
125 pub(crate) fn draw_search(pass: &mut Pass<'_>, band: &Band, area: Rect, buf: &mut Buffer) {
126 let Some(search) = &band.search else {
127 return;
128 };
129 if area.height == 0 {
130 return;
131 }
132 let row = Rect { height: 1, ..area };
133 crate::node::draw(pass, &Node::Field(Box::new(search.clone())), row, buf);
134 }
135
136 /// The rect below the first `rows` of this one.
137 const fn down(area: Rect, rows: u16) -> Rect {
138 Rect {
139 y: area.y.saturating_add(rows),
140 height: area.height.saturating_sub(rows),
141 ..area
142 }
143 }
144
145 /// One row of places, separated by spaces, the current one in reverse video.
146 fn line(
147 pass: &mut Pass<'_>,
148 brand: Option<&quasi_router::Brand>,
149 places: &[Place],
150 at: Option<&str>,
151 area: Rect,
152 buf: &mut Buffer,
153 ) {
154 let row = Rect { height: 1, ..area };
155 // The name first, and bold rather than reversed: reverse video is how this
156 // renderer says "the current place", and spending it on something that is
157 // never current would make the tab line say two things with one signal.
158 // The mark is not drawn differently. A terminal has one typeface and no
159 // way to make a glyph graphic, so the honest drawing is the whole name.
160 let mut spans: Vec<ratatui::text::Span<'_>> = Vec::new();
161 if let Some(brand) = brand {
162 spans.push(ratatui::text::Span::styled(
163 format!("{} ", brand.name),
164 ratatui::style::Style::default().add_modifier(ratatui::style::Modifier::BOLD),
165 ));
166 }
167 spans.extend::<Vec<ratatui::text::Span<'_>>>(
168 places
169 .iter()
170 .flat_map(|place| {
171 let style = if at.is_some_and(|key| place.holds(key)) {
172 ratatui::style::Style::default()
173 .add_modifier(ratatui::style::Modifier::REVERSED)
174 } else {
175 ratatui::style::Style::default()
176 };
177 [
178 ratatui::text::Span::styled(format!(" {} ", place.label), style),
179 ratatui::text::Span::raw(" "),
180 ]
181 })
182 .collect(),
183 );
184 let _ = pass;
185 ratatui::widgets::Widget::render(
186 ratatui::widgets::Paragraph::new(ratatui::text::Line::from(spans)),
187 row,
188 buf,
189 );
190 }
191
192 /// Draw it.
193 ///
194 /// Takes the same [`Pass`] the screen and the frame were drawn with, so the
195 /// count of reachable things carries on rather than restarting. A panel's
196 /// controls are reachable, and [`crate::focus::reaches_chromed`] counts them
197 /// last because this draws them last.
198 pub(crate) fn draw(pass: &mut Pass<'_>, chrome: &Chrome, area: Rect, buf: &mut Buffer) {
199 // Activity first and status last, so the app's condition sits closest to
200 // the bottom edge where a terminal's status line goes. Within a role it is
201 // declaration order, which is what the description gave.
202 let ordered = chrome
203 .panels
204 .iter()
205 .filter(|panel| panel.role == Role::Activity)
206 .chain(
207 chrome
208 .panels
209 .iter()
210 .filter(|panel| panel.role == Role::Status),
211 );
212
213 let mut top = area.y;
214 for panel in ordered {
215 if top >= area.bottom() {
216 return;
217 }
218 let content: &Node = &panel.content;
219 let height = crate::node::height(pass.tui, content, area.width, &pass.local())
220 .min(area.bottom() - top);
221 let slice = Rect {
222 x: area.x,
223 y: top,
224 width: area.width,
225 height,
226 };
227 crate::node::draw(pass, content, slice, buf);
228 top = top.saturating_add(height);
229 }
230 }
231