Skip to main content

max / quasi

17.3 KB · 461 lines History Blame Raw
1 //! Regions into rects.
2 //!
3 //! The half of the drawing a webview never has to do. A stylesheet turns
4 //! `list-detail` into two columns and the browser does the arithmetic; here the
5 //! arithmetic is the renderer's, and every place the description does not say
6 //! enough to do it is a finding.
7 //!
8 //! Three of them, and all three are recorded on `179b088d`:
9 //!
10 //! - **A tabbed arrangement does not say which tab is showing.** `Arrangement::
11 //! ListDetail { tabbed: true }` says the two regions share the space and only
12 //! one is visible; nothing says which. A webview never asked, because a
13 //! stylesheet with `:target` or a class answers it. This draws the first,
14 //! which is a guess.
15 //! - **A tab has no label.** [`Slot::id`] is an address, chosen to be stable
16 //! for fragment targeting, and using it as a heading puts `contacts-detail`
17 //! on screen.
18 //! - **Nothing says a region's share.** A sidebar is 24 columns here and a list
19 //! pane 40% because this renderer picked those numbers. `makeover-geometry`
20 //! has size classes and the description reaches none of them.
21
22 use makeover_layout as layout;
23 use makeover_tui::{frame, text};
24 use quasi_router::{Node, RegionKind, Screen, Slot};
25 use ratatui::buffer::Buffer;
26 use ratatui::layout::Rect;
27 use ratatui::style::{Modifier, Style};
28 use ratatui::text::Span;
29
30 use crate::{Pass, Tui, below};
31
32 // The sidebar's 24 columns and the list pane's 40% used to be declared here,
33 // as two numbers this renderer chose with nothing behind them. `e0fd485e`:
34 // they are `Arrangement::share` now, so the terminal and the webview honour one
35 // fact and two hosts showing one screen agree about its proportions.
36
37 /// Lay a screen's regions out and draw them.
38 pub(crate) fn screen_regions(pass: &mut Pass<'_>, screen: &Screen, area: Rect, buf: &mut Buffer) {
39 let area = measured(screen.measure, area);
40 let mut rest = area;
41
42 // Bands stack at the top, full width, in the order they were said. A band
43 // is an arrangement rather than a type -- a page header, a toolbar -- so it
44 // takes the rows it needs and gets out of the way.
45 for slot in screen
46 .slots
47 .iter()
48 .filter(|slot| matches!(slot.kind, RegionKind::Band))
49 {
50 let used = draw(pass, slot, rest, buf);
51 rest = below(rest, used);
52 }
53
54 let body = body_slots(screen);
55
56 match screen.arrangement {
57 layout::Arrangement::SidebarContent { share } => {
58 let (left, right) = split(rest, share.of(rest.width));
59 let mut sidebars = 0;
60 let mut content = right;
61 for slot in &body {
62 if matches!(slot.kind, RegionKind::Sidebar) {
63 let used = draw(pass, slot, below(left, sidebars), buf);
64 sidebars += used;
65 } else {
66 let used = draw(pass, slot, content, buf);
67 content = below(content, used);
68 }
69 }
70 }
71 layout::Arrangement::ListDetail { tabbed, share } => {
72 if tabbed {
73 // One at a time, and nothing says which. `body_slots` has
74 // already cut the rest away, so this is the one region there is.
75 if let Some(first) = body.first() {
76 draw(pass, first, rest, buf);
77 }
78 } else {
79 let (left, right) = split(rest, share.of(rest.width));
80 let mut detail = right;
81 for (index, slot) in body.iter().enumerate() {
82 if index == 0 {
83 draw(pass, slot, left, buf);
84 } else {
85 let used = draw(pass, slot, detail, buf);
86 detail = below(detail, used);
87 }
88 }
89 }
90 }
91 }
92
93 // Modals last and over everything, which is what a modal is. Centred in
94 // half the width, because `Depth::Overlay` says it sits above the page and
95 // says nothing about how much of it to cover.
96 for slot in screen
97 .slots
98 .iter()
99 .filter(|slot| matches!(slot.kind, RegionKind::Modal))
100 {
101 draw(pass, slot, centred(area), buf);
102 }
103 }
104
105 /// The screen's area, narrowed to the measure it asked for.
106 ///
107 /// `0eccff0d`, the terminal's half of "every renderer owes an answer". The
108 /// description says how wide the content should run and this says what that is
109 /// in columns, the same division the webview makes: the screen chose one of
110 /// three, and what each one comes to is the renderer's.
111 ///
112 /// The two caps are this renderer's numbers, and only the second has a reason
113 /// outside taste: past roughly 75 characters a line costs the reader the return
114 /// sweep, which is why [`layout::Measure::Reading`] is the narrowest. Centred
115 /// rather than left-aligned, because a narrowed column against the left edge of
116 /// a wide terminal reads as a window that failed to resize.
117 ///
118 /// A terminal narrower than the cap is left alone rather than padded. There is
119 /// no measure to enforce when the window is already tighter than it.
120 fn measured(measure: layout::Measure, area: Rect) -> Rect {
121 let cap = match measure {
122 layout::Measure::Reading => 76,
123 layout::Measure::Contained => 100,
124 // Every column there is, which is what `Wide` means. Also the arm a
125 // member added upstream lands in: a measure this renderer has not
126 // learned should show the whole screen, not hide part of it.
127 _ => return area,
128 };
129 if area.width <= cap {
130 return area;
131 }
132 Rect {
133 x: area.x + (area.width - cap) / 2,
134 width: cap,
135 ..area
136 }
137 }
138
139 /// The regions that fill the body, after the arrangement has had its say.
140 ///
141 /// The tabbed cut lives here and only here. It is the one place a described
142 /// region can be on the screen or not, so the drawing and the focus walk have
143 /// to agree about it, and two copies of "the first one, and nothing says which"
144 /// is two chances to disagree.
145 fn body_slots(screen: &Screen) -> Vec<&Slot> {
146 let body = screen
147 .slots
148 .iter()
149 .filter(|slot| !matches!(slot.kind, RegionKind::Band | RegionKind::Modal));
150
151 match screen.arrangement {
152 layout::Arrangement::ListDetail { tabbed: true, .. } => body.take(1).collect(),
153 _ => body.collect(),
154 }
155 }
156
157 /// Every region the user can see, in the order it is drawn.
158 ///
159 /// What the focus walk reads. A region that is not drawn holds nothing
160 /// reachable, which is why this is a question about slots rather than about
161 /// nodes: a tab that is not showing has controls in it, and stopping on one
162 /// would move focus to a place with nothing on screen.
163 ///
164 /// **A modal takes the whole of it.** A dialog you can tab out of is not a
165 /// dialog, and this is the one place the drawing order and the focus order
166 /// deliberately differ: the screen behind a modal is still painted, because
167 /// covering it costs rows and says nothing, and it is still unreachable.
168 pub(crate) fn reachable(screen: &Screen) -> Vec<&Slot> {
169 let modals: Vec<&Slot> = screen
170 .slots
171 .iter()
172 .filter(|slot| matches!(slot.kind, RegionKind::Modal))
173 .collect();
174 if !modals.is_empty() {
175 return modals;
176 }
177
178 screen
179 .slots
180 .iter()
181 .filter(|slot| matches!(slot.kind, RegionKind::Band))
182 .chain(body_slots(screen))
183 .collect()
184 }
185
186 /// The rows a region wants at `width`.
187 pub(crate) fn height(tui: &Tui, slot: &Slot, width: u16) -> u16 {
188 let inner = width.saturating_sub(2);
189 // A region showing one child at a time is as tall as its tallest child plus
190 // the row that moves between them. Tallest rather than current, because this
191 // has no `View` and so cannot know which child is up -- an over-estimate,
192 // which for the one caller (scroll arithmetic) errs toward letting a region
193 // scroll slightly further than it needs to rather than cutting it off.
194 let body: u16 = if slot.showing.selective() {
195 slot.body
196 .iter()
197 .map(|node| crate::node::height(tui, node, inner))
198 .max()
199 .unwrap_or(0)
200 + 1
201 } else {
202 slot.body
203 .iter()
204 .map(|node| crate::node::height(tui, node, inner))
205 .sum()
206 };
207 // Two rows for the frame, when the region has one.
208 body + if framed(slot.kind.depth()) { 2 } else { 0 }
209 }
210
211 /// Draw one region, and answer the rows it used.
212 pub(crate) fn draw(pass: &mut Pass<'_>, slot: &Slot, area: Rect, buf: &mut Buffer) -> u16 {
213 // A region still loading holds nothing reachable, here and in the focus
214 // walk both: what is on the screen is the word "Loading", and a control
215 // counted under it would be a place the caret could go with nothing to see.
216 let pending = matches!(slot.readiness, layout::Readiness::Pending);
217
218 if area.width == 0 || area.height == 0 {
219 if !pending {
220 for node in &slot.body {
221 crate::node::draw(pass, node, area, buf);
222 }
223 }
224 return 0;
225 }
226
227 let tui = pass.tui;
228 // The frame, from the depth the region's kind implies. This is the whole
229 // reason `makeover-tui` is a dependency rather than a nice-to-have: a
230 // raised region is drawn the same way here as in every other terminal app
231 // in the tree, bevel included, and the depth comes off the vocabulary
232 // rather than off this renderer's taste.
233 let depth = slot.kind.depth();
234 let inner = if framed(depth) {
235 frame(buf, area, depth, tui.palette())
236 } else {
237 area
238 };
239
240 // `Readiness` is the loading axis, and a terminal has no spinner that is
241 // not a clock. It says so in words instead, which loses the motion and
242 // keeps the fact.
243 if pending {
244 let used = text::draw(
245 "Loading",
246 Style::default()
247 .fg(tui.theme().content_muted)
248 .add_modifier(Modifier::ITALIC),
249 inner,
250 buf,
251 );
252 return used + if framed(depth) { 2 } else { 0 };
253 }
254
255 // A bespoke region is the host's. The description named the place and the
256 // blocks it owns above the fill, so those draw; what the host puts under
257 // them is the host's to draw, and this renderer has no fill mechanism to
258 // offer it. That is a gap rather than a decline: `Webview::with_fill` has
259 // no counterpart here.
260 let used = if slot.showing.selective() {
261 showing_body(pass, slot, inner, buf)
262 } else {
263 body(pass, slot, &slot.body, inner, buf)
264 };
265
266 // `Slot::id` is not drawn anywhere. It is a fragment address, and a
267 // terminal redraws rather than swapping, so it costs nothing and says
268 // nothing here.
269 used + if framed(depth) { 2 } else { 0 }
270 }
271
272 /// A region showing one child at a time, and the chrome that moves between them.
273 ///
274 /// The same derivation quasi-webview makes and for the same reason: nothing here
275 /// reads [`RegionKind::Widget`]'s name. A carousel, a tab group and a disclosure
276 /// are one region that shows some of its children, and which idiom comes out
277 /// falls out of what the children carry.
278 ///
279 /// This is what `c0b63ea9`'s terminal half was waiting for. It was filed as a
280 /// drawing change and it never was one -- until [`layout::Showing`] existed a
281 /// terminal had no way to learn that a stack of pictures was meant to be one
282 /// picture, so it honestly drew the stack.
283 ///
284 /// # The chrome is one row, and it is in flow
285 ///
286 /// `< Prev > 2 / 3 < Next >` under the content, or a strip of labels above it.
287 /// Max chose the row over the overlaid arrows a browser had been drawing,
288 /// 2026-08-14, and the reason it ports is the reason it was chosen: a terminal
289 /// cannot honestly overlay anything, and a dot strip has no form here at all.
290 fn showing_body(pass: &mut Pass<'_>, slot: &Slot, inner: Rect, buf: &mut Buffer) -> u16 {
291 let at = pass.view.shown(slot);
292 let labels = slot.labels();
293 let mut used = 0;
294
295 // A strip sits above the panes it opens; a counter row sits under the
296 // content it counts. The folder semantic, and the same placement the
297 // webview derives.
298 if !labels.is_empty() {
299 used += text::draw_spans(
300 &showing_spans(pass.tui, &labels, at),
301 below(inner, used),
302 buf,
303 );
304 }
305
306 // One child, or none at all: `Showing::AtMostOne` closed is the only way to
307 // reach `None` here, and drawing nothing is what closed means.
308 if let Some(index) = at
309 && index < slot.body.len()
310 {
311 used += body(
312 pass,
313 slot,
314 &slot.body[index..=index],
315 below(inner, used),
316 buf,
317 );
318 }
319
320 if labels.is_empty() {
321 used += text::draw_spans(
322 &counter_spans(pass.tui, at, slot.body.len()),
323 below(inner, used),
324 buf,
325 );
326 }
327
328 used.min(inner.height)
329 }
330
331 /// A strip of labels, the current one lit.
332 ///
333 /// `select_spans`' styling, deliberately: a derived tab strip and a described
334 /// one are the same thing on the screen, and two spellings of it would drift
335 /// the way `tabs` and `tab` did in the webview.
336 fn showing_spans(tui: &Tui, labels: &[&str], at: Option<usize>) -> Vec<Span<'static>> {
337 let mut spans = Vec::new();
338 for (index, label) in labels.iter().enumerate() {
339 if !spans.is_empty() {
340 spans.push(Span::raw(" "));
341 }
342 let picked = at == Some(index);
343 let style = if picked {
344 Style::default()
345 .fg(tui.theme().selection_on)
346 .bg(tui.theme().action_primary)
347 } else {
348 Style::default().fg(tui.theme().content_secondary)
349 };
350 spans.push(Span::styled(format!(" {label} "), style));
351 }
352 spans
353 }
354
355 /// Previous, where you are, next.
356 ///
357 /// The position reads back one step, which is `picture-caption`'s claim in the
358 /// other renderer: it says where you are among the children and it is not one
359 /// of them. Zero when a dismissible region is closed, which is a true statement
360 /// about how many of its children are showing.
361 fn counter_spans(tui: &Tui, at: Option<usize>, total: usize) -> Vec<Span<'static>> {
362 let control = Style::default().fg(tui.theme().content_secondary);
363 vec![
364 Span::styled("< Prev >", control),
365 Span::styled(
366 format!(" {} / {total} ", at.map_or(0, |index| index + 1)),
367 Style::default().fg(tui.theme().content_muted),
368 ),
369 Span::styled("< Next >", control),
370 ]
371 }
372
373 /// A region's contents, at the offset the view is holding it at.
374 ///
375 /// Scrolling is the runtime's and the clipping is the drawing's, and this is
376 /// where the two meet. Flow layout draws from the top of the rect it is given,
377 /// so an offset cannot be honoured by moving the rect: a node starting above
378 /// the window would draw its first row at the window's first row. What works is
379 /// to draw the region at its full height into a buffer of its own and copy the
380 /// window out, which costs an allocation per scrolled region and nothing at all
381 /// for a region sitting at the top, which is every region until someone
382 /// scrolls.
383 ///
384 /// The offset is clamped here rather than in [`crate::View`], because how far a
385 /// region can scroll is how tall it is at the width it was given, and the width
386 /// is not known until this point.
387 fn body(pass: &mut Pass<'_>, slot: &Slot, nodes: &[Node], inner: Rect, buf: &mut Buffer) -> u16 {
388 let offset = pass.view.scroll(&slot.id);
389 if offset == 0 {
390 let mut used = 0;
391 for node in nodes {
392 used += crate::node::draw(pass, node, below(inner, used), buf);
393 }
394 return used.min(inner.height);
395 }
396
397 let content: u16 = nodes
398 .iter()
399 .map(|node| crate::node::height(pass.tui, node, inner.width))
400 .sum();
401 let offset = offset.min(content.saturating_sub(inner.height));
402
403 let tall = Rect {
404 height: content.max(inner.height),
405 ..inner
406 };
407 let mut scratch = Buffer::empty(tall);
408 let mut used = 0;
409 for node in nodes {
410 used += crate::node::draw(pass, node, below(tall, used), &mut scratch);
411 }
412
413 let shown = inner.height.min(content.saturating_sub(offset));
414 for row in 0..shown {
415 for column in 0..inner.width {
416 let from = (inner.x + column, inner.y + offset + row);
417 let to = (inner.x + column, inner.y + row);
418 if let Some(cell) = scratch.cell(from).cloned()
419 && let Some(target) = buf.cell_mut(to)
420 {
421 *target = cell;
422 }
423 }
424 }
425 shown
426 }
427
428 /// Whether a depth is drawn with a border.
429 ///
430 /// Flat is not: a band and a plain pane are arrangement, and boxing every one
431 /// of them spends two rows and two columns per region on a screen that is
432 /// mostly regions.
433 fn framed(depth: layout::Depth) -> bool {
434 !matches!(depth, layout::Depth::Flat)
435 }
436
437 /// Split `area` into a left column of `width` and the rest.
438 fn split(area: Rect, width: u16) -> (Rect, Rect) {
439 let width = width.min(area.width);
440 (
441 Rect { width, ..area },
442 Rect {
443 x: area.x + width,
444 width: area.width - width,
445 ..area
446 },
447 )
448 }
449
450 /// Half the width and half the height, in the middle.
451 fn centred(area: Rect) -> Rect {
452 let width = area.width / 2;
453 let height = area.height / 2;
454 Rect {
455 x: area.x + width / 2,
456 y: area.y + height / 2,
457 width,
458 height,
459 }
460 }
461