Skip to main content

max / quasi

34.4 KB · 868 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 were recorded on `179b088d`, and **all three have since been
9 //! answered by the vocabulary rather than worked around here.** Kept as a
10 //! record of what a description had to grow, since each was found by trying to
11 //! draw a screen in a terminal and finding nothing to draw it from:
12 //!
13 //! - **A tabbed arrangement did not say which tab is showing**, so this drew
14 //! the first and called it a guess. [`layout::Showing`] says it now, and
15 //! [`Slot::current`] reads it; see [`showing_body`].
16 //! - **A tab had no label**, so a heading here would have been [`Slot::id`],
17 //! an address chosen for fragment targeting. [`Slot::label`] says it now,
18 //! gathered by [`Slot::labels`].
19 //! - **Nothing said a region's share**, so a sidebar was 24 columns and a list
20 //! pane 40% because this renderer picked those numbers. `Arrangement::share`
21 //! says it now, `e0fd485e`.
22
23 use makeover_layout as layout;
24 use makeover_tui::{frame, text};
25 use quasi_router::{Node, Ranked, RegionKind, Run, Screen, Slot};
26 use ratatui::buffer::Buffer;
27 use ratatui::layout::Rect;
28 use ratatui::style::{Modifier, Style};
29 use ratatui::text::Span;
30
31 use crate::{Local, Pass, Tui, below};
32
33 // The sidebar's 24 columns and the list pane's 40% used to be declared here,
34 // as two numbers this renderer chose with nothing behind them. `e0fd485e`:
35 // they are `Arrangement::share` now, so the terminal and the webview honour one
36 // fact and two hosts showing one screen agree about its proportions.
37
38 /// The cutoff a region narrows to at this width, in cells.
39 ///
40 /// The terminal's answer to the question `@media` answers in a browser and
41 /// `makeover-geometry`'s [`SizeClass`] boundaries answer in points. The three
42 /// tiers are the same three; the numbers cannot be, because a size class is
43 /// quoted in CSS pixels and a terminal has cells. Material's 600 and 840
44 /// divided by a nominal 10px cell is where these come from, so a terminal
45 /// window and a browser window showing one screen narrow at roughly the same
46 /// physical width rather than at unrelated points.
47 ///
48 /// A guess in the same sense `makeover-tui`'s table sizing fallback is one: the
49 /// description carries no magnitude and one has to be supplied here or the
50 /// member cannot be honoured at all. What is *not* a guess is the order, which
51 /// is [`quasi_router::CUTOFFS`], and the fact that nothing counts -- the cutoff
52 /// comes off this width alone, so the same width is the same answer whatever
53 /// widths came before it.
54 ///
55 /// [`SizeClass`]: https://docs.rs/makeover-geometry
56 fn cutoff(width: u16) -> layout::Priority {
57 match width {
58 0..60 => layout::Priority::Essential,
59 60..84 => layout::Priority::Secondary,
60 _ => layout::Priority::Optional,
61 }
62 }
63
64 /// The members a region shows at this width.
65 fn kept<'a>(body: &[&'a Ranked], width: u16) -> impl Iterator<Item = &'a Node> {
66 let cutoff = cutoff(width);
67 body.iter()
68 .copied()
69 .filter(move |placed| placed.kept_at(cutoff))
70 .map(|placed| &placed.node)
71 }
72
73 /// Lay a screen's regions out and draw them.
74 pub(crate) fn screen_regions(pass: &mut Pass<'_>, screen: &Screen, area: Rect, buf: &mut Buffer) {
75 let area = measured(screen.measure, area);
76 let mut rest = area;
77
78 // Bands take the rows they need and get out of the way; which way they get
79 // out of is where they were said. Ruled by Max 2026-08-23 (quasicoherent
80 // `3725bacf`), picking (b) of two: a band before the body sits above it and
81 // a band after it sits below, so a footer is a band at the end rather than
82 // a member the vocabulary had to grow. quasi-webview has emitted slots in
83 // declaration order all along; this renderer and quasi-immediate hoisted,
84 // so the ruling settled a disagreement rather than a silence.
85 let (leading, trailing) = bands(screen);
86 for slot in &leading {
87 let used = draw(pass, slot, rest, buf);
88 rest = below(rest, used);
89 }
90
91 // The bottom is reserved before the body is laid out, or a pane that fills
92 // its area would leave the footer nowhere to go. Reserved by asking each
93 // trailing band how tall it is, which this renderer can do exactly --
94 // `height` is the same arithmetic the scroll already trusts -- so nothing
95 // here is a guess and no number is authored.
96 let reserved = trailing
97 .iter()
98 .map(|slot| height(pass.tui, slot, rest.width, &pass.local()))
99 .sum::<u16>()
100 .min(rest.height);
101 let mut feet = below(rest, rest.height - reserved);
102 rest = Rect {
103 height: rest.height - reserved,
104 ..rest
105 };
106
107 let body = body_slots(screen);
108
109 match screen.arrangement {
110 layout::Arrangement::SidebarContent { share } => {
111 let (left, right) = split(rest, share.of(rest.width));
112 let mut sidebars = 0;
113 let mut content = right;
114 for slot in &body {
115 if matches!(slot.kind, RegionKind::Sidebar) {
116 let used = draw(pass, slot, below(left, sidebars), buf);
117 sidebars += used;
118 } else {
119 let used = draw(pass, slot, content, buf);
120 content = below(content, used);
121 }
122 }
123 }
124 layout::Arrangement::ListDetail { tabbed, share } => {
125 if tabbed {
126 // One at a time, and nothing says which. `body_slots` has
127 // already cut the rest away, so this is the one region there is.
128 if let Some(first) = body.first() {
129 draw(pass, first, rest, buf);
130 }
131 } else {
132 let (left, right) = split(rest, share.of(rest.width));
133 let mut detail = right;
134 for (index, slot) in body.iter().enumerate() {
135 if index == 0 {
136 draw(pass, slot, left, buf);
137 } else {
138 let used = draw(pass, slot, detail, buf);
139 detail = below(detail, used);
140 }
141 }
142 }
143 }
144 // One region filling the width. Every body slot stacks down the whole
145 // of `rest`, because there is no division to put anything beside.
146 layout::Arrangement::Single => {
147 let mut content = rest;
148 for slot in &body {
149 let used = draw(pass, slot, content, buf);
150 content = below(content, used);
151 }
152 }
153 }
154
155 // The trailing bands, in the room kept for them, in the order they were
156 // said: the band said last is the one at the bottom.
157 for slot in &trailing {
158 let used = draw(pass, slot, feet, buf);
159 feet = below(feet, used);
160 }
161
162 // Modals last and over everything, which is what a modal is. Centred in
163 // half the width, because `Depth::Overlay` says it sits above the page and
164 // says nothing about how much of it to cover.
165 for slot in screen
166 .slots
167 .iter()
168 .filter(|slot| matches!(slot.kind, RegionKind::Modal))
169 {
170 draw(pass, slot, centred(area), buf);
171 }
172 }
173
174 /// The screen's area, narrowed to the measure it asked for.
175 ///
176 /// The terminal's half of "every renderer owes an answer". The description
177 /// says how wide the content should run and this says what that is in columns,
178 /// the same division the webview makes: the screen chose one of three, and
179 /// what each one comes to is the renderer's.
180 ///
181 /// The two caps are this renderer's numbers, and only the second has a reason
182 /// outside taste: past roughly 75 characters a line costs the reader the return
183 /// sweep, which is why [`layout::Measure::Reading`] is the narrowest. Centred
184 /// rather than left-aligned, because a narrowed column against the left edge of
185 /// a wide terminal reads as a window that failed to resize.
186 ///
187 /// A terminal narrower than the cap is left alone rather than padded. There is
188 /// no measure to enforce when the window is already tighter than it.
189 fn measured(measure: layout::Measure, area: Rect) -> Rect {
190 let cap = match measure {
191 layout::Measure::Reading => 76,
192 layout::Measure::Contained => 100,
193 // Every column there is, which is what `Wide` means. Also the arm a
194 // member added upstream lands in: a measure this renderer has not
195 // learned should show the whole screen, not hide part of it.
196 _ => return area,
197 };
198 if area.width <= cap {
199 return area;
200 }
201 Rect {
202 x: area.x + (area.width - cap) / 2,
203 width: cap,
204 ..area
205 }
206 }
207
208 /// The regions that fill the body, after the arrangement has had its say.
209 ///
210 /// The tabbed cut lives here and only here. It is the one place a described
211 /// region can be on the screen or not, so the drawing and the focus walk have
212 /// to agree about it, and two copies of "the first one, and nothing says which"
213 /// is two chances to disagree.
214 fn body_slots(screen: &Screen) -> Vec<&Slot> {
215 let body = screen
216 .slots
217 .iter()
218 .filter(|slot| !matches!(slot.kind, RegionKind::Band | RegionKind::Modal));
219
220 match screen.arrangement {
221 layout::Arrangement::ListDetail { tabbed: true, .. } => body.take(1).collect(),
222 _ => body.collect(),
223 }
224 }
225
226 /// A screen's bands, as the ones above the body and the ones below it.
227 ///
228 /// The split is at the first region that is not a band or a modal. A screen of
229 /// nothing but bands is all leading, which is the shape every description
230 /// written before the ruling already had.
231 fn bands(screen: &Screen) -> (Vec<&Slot>, Vec<&Slot>) {
232 let is_band = |slot: &&Slot| matches!(slot.kind, RegionKind::Band);
233 let first_body = screen
234 .slots
235 .iter()
236 .position(|slot| !matches!(slot.kind, RegionKind::Band | RegionKind::Modal));
237 let Some(at) = first_body else {
238 return (screen.slots.iter().filter(is_band).collect(), Vec::new());
239 };
240 (
241 screen.slots[..at].iter().filter(is_band).collect(),
242 screen.slots[at..].iter().filter(is_band).collect(),
243 )
244 }
245
246 /// Every region the user can see, in the order it is drawn.
247 ///
248 /// What the focus walk reads. A region that is not drawn holds nothing
249 /// reachable, which is why this is a question about slots rather than about
250 /// nodes: a tab that is not showing has controls in it, and stopping on one
251 /// would move focus to a place with nothing on screen.
252 ///
253 /// **A modal takes the whole of it.** A dialog you can tab out of is not a
254 /// dialog, and this is the one place the drawing order and the focus order
255 /// deliberately differ: the screen behind a modal is still painted, because
256 /// covering it costs rows and says nothing, and it is still unreachable.
257 pub(crate) fn reachable(screen: &Screen) -> Vec<&Slot> {
258 let modals: Vec<&Slot> = screen
259 .slots
260 .iter()
261 .filter(|slot| matches!(slot.kind, RegionKind::Modal))
262 .collect();
263 if !modals.is_empty() {
264 return modals;
265 }
266
267 let (leading, trailing) = bands(screen);
268 leading
269 .into_iter()
270 .chain(body_slots(screen))
271 .chain(trailing)
272 .collect()
273 }
274
275 /// How a region's row packs at this width: the members on each line, and how
276 /// tall the line is.
277 ///
278 /// Ruling: wiki `layout-room-and-fallback`.
279 ///
280 /// Packing is left to right, one space between members, wrapping to a new line
281 /// when what is left of this one cannot hold the next member. Every width is
282 /// derived -- [`crate::node::want`] measures a member from what it holds -- so
283 /// nothing here is authored and there is no breakpoint.
284 ///
285 /// # What each fallback gets
286 ///
287 /// [`Wrap`](layout::Fallback::Wrap) and [`Stack`](layout::Fallback::Stack) keep
288 /// every member and wrap. They differ in a webview by whether a wrapped member
289 /// fills its line; a terminal has no such distinction to draw, so both answer
290 /// alike rather than this renderer inventing one.
291 ///
292 /// [`Shed`](layout::Fallback::Shed) drops members by [`layout::Priority`] at
293 /// the cutoff the region's body already reads, which is the half a webview
294 /// cannot do at all.
295 ///
296 /// [`Menu`](layout::Fallback::Menu) **wraps rather than shedding**, and that is
297 /// this renderer's answer rather than a shortfall. Menu says the shed members
298 /// stay reachable behind one control; a terminal has no anchored menu to put
299 /// them behind -- `quasi-tui`'s own header says a menu here is a key -- and a
300 /// marker that shows a count nobody can open is the `by_host` failure, a thing
301 /// drawn, reachable and doing nothing. Keeping every member on a second line
302 /// honours the half that matters and states the half it cannot.
303 fn packed<'a>(tui: &Tui, run: &'a Run, width: u16) -> Vec<Vec<(&'a Node, u16)>> {
304 let kept: Vec<&Ranked> = match run.fallback {
305 layout::Fallback::Shed => run.kept_at(cutoff(width)),
306 _ => run.members.iter().collect(),
307 };
308
309 let mut lines: Vec<Vec<(&Ranked, u16)>> = Vec::new();
310 let mut line: Vec<(&Ranked, u16)> = Vec::new();
311 let mut left = width;
312 for placed in kept {
313 let wants = match crate::node::want(tui, &placed.node) {
314 crate::node::Want::Cells(cells) => cells.min(width),
315 crate::node::Want::Rest => left.max(MEMBER_FLOOR).min(width),
316 };
317 let gap = u16::from(!line.is_empty());
318 if !line.is_empty() && wants + gap > left {
319 lines.push(std::mem::take(&mut line));
320 left = width;
321 }
322 let gap = u16::from(!line.is_empty());
323 let given = wants.min(left.saturating_sub(gap));
324 line.push((placed, given));
325 left = left.saturating_sub(given + gap);
326 }
327 if !line.is_empty() {
328 lines.push(line);
329 }
330
331 lines.into_iter().map(|line| filled(line, width)).collect()
332 }
333
334 /// One packed line with whatever is left over handed to the members that asked
335 /// for it.
336 ///
337 /// The description's half of a width, which a terminal can answer exactly:
338 /// [`layout::Width::Fill`] members share what the content-sized ones did not
339 /// take, equally, which is that member's own stated rule and the same answer a
340 /// webview's `flex: 1 1 0` gives.
341 ///
342 /// Done after packing rather than during it, because how much is left over is
343 /// not known until the line is known: the wrap point is decided by what each
344 /// member wants from its contents, and a member that asked to fill has no
345 /// opinion about where the line ends. So the line is composed the way it always
346 /// was and only the leftover changes hands.
347 ///
348 /// [`layout::Width::Fixed`] takes nothing extra. A run carries no size, so
349 /// there is no share to fix a member at, and content is what it drew before.
350 fn filled(line: Vec<(&Ranked, u16)>, width: u16) -> Vec<(&Node, u16)> {
351 let gaps = u16::try_from(line.len().saturating_sub(1)).unwrap_or(u16::MAX);
352 let taken: u16 = line.iter().map(|(_, given)| *given).sum::<u16>() + gaps;
353 let left = width.saturating_sub(taken);
354 let fills = u16::try_from(
355 line.iter()
356 .filter(|(placed, _)| matches!(placed.width, layout::Width::Fill))
357 .count(),
358 )
359 .unwrap_or(u16::MAX);
360 if left == 0 || fills == 0 {
361 return line
362 .into_iter()
363 .map(|(placed, given)| (&placed.node, given))
364 .collect();
365 }
366
367 // The remainder goes to the leading fills, one cell each, because a
368 // terminal cannot divide a cell and dropping it would leave the row short
369 // of the width it was given.
370 let share = left / fills;
371 let mut over = left % fills;
372 line.into_iter()
373 .map(|(placed, given)| {
374 if !matches!(placed.width, layout::Width::Fill) {
375 return (&placed.node, given);
376 }
377 let extra = share + u16::from(over > 0);
378 over = over.saturating_sub(1);
379 (&placed.node, given + extra)
380 })
381 .collect()
382 }
383
384 /// The narrowest a member is given before the row wraps instead.
385 ///
386 /// Only reached by a member that asked for what is left of the line and found
387 /// almost nothing there. Below this a control is not readable, so it takes the
388 /// next line whole rather than a sliver of this one.
389 const MEMBER_FLOOR: u16 = 8;
390
391 /// The rows a region's leading row takes at this width.
392 fn run_height(tui: &Tui, slot: &Slot, width: u16, local: &Local<'_>) -> u16 {
393 let Some(run) = slot.run.as_ref() else {
394 return 0;
395 };
396 packed(tui, run, width)
397 .iter()
398 .map(|line| {
399 line.iter()
400 .map(|(node, cells)| crate::node::height(tui, node, *cells, local))
401 .max()
402 .unwrap_or(0)
403 })
404 .sum()
405 }
406
407 /// Draw a region's leading row, and answer the rows it used.
408 fn run_body(pass: &mut Pass<'_>, slot: &Slot, inner: Rect, buf: &mut Buffer) -> u16 {
409 let Some(run) = slot.run.as_ref() else {
410 return 0;
411 };
412 let lines = packed(pass.tui, run, inner.width);
413 let mut used = 0;
414 for line in lines {
415 let area = below(inner, used);
416 if area.height == 0 {
417 break;
418 }
419 let mut column = 0;
420 let mut tall = 0;
421 for (node, cells) in line {
422 let cell = Rect {
423 x: area.x + column,
424 width: cells.min(area.width.saturating_sub(column)),
425 ..area
426 };
427 if cell.width == 0 {
428 break;
429 }
430 tall = tall.max(crate::node::draw(pass, node, cell, buf));
431 // One space between members, which is the only separator a
432 // terminal row needs and the same one a strip of labels uses.
433 column += cell.width + 1;
434 }
435 used += tall.max(1);
436 }
437 used.min(inner.height)
438 }
439
440 /// The rows a region wants at `width`.
441 pub(crate) fn height(tui: &Tui, slot: &Slot, width: u16, local: &Local<'_>) -> u16 {
442 // A region that does not apply right now takes no rows: this renderer
443 // leaves it out rather than dimming it, and the measurement and the drawing
444 // have to say so together or a footer band is reserved room nothing paints
445 // into. `079a011e`.
446 if local.out(&slot.id) {
447 return 0;
448 }
449 let inner = width.saturating_sub(2);
450 // A region showing one child at a time is as tall as its tallest child plus
451 // the row that moves between them. Tallest rather than current, because this
452 // has no `View` and so cannot know which child is up -- an over-estimate,
453 // which for the one caller (scroll arithmetic) errs toward letting a region
454 // scroll slightly further than it needs to rather than cutting it off.
455 let body: u16 = if slot.showing().selective() {
456 // Every child, not only the ones kept at this width: a region showing
457 // one child at a time can be moved onto any of them, so the tallest is
458 // the height it has to be able to be.
459 slot.body
460 .iter()
461 .map(|placed| crate::node::height(tui, &placed.node, inner, local))
462 .max()
463 .unwrap_or(0)
464 + 1
465 } else if slot.repeating.is_some() {
466 // The slots, plus a caption row over each and the two controls. Counted
467 // the way `repeating_body` draws it, because a measurement that
468 // disagreed with the drawing is a footer with rows nothing paints into.
469 slot.body
470 .iter()
471 .map(|placed| {
472 1 + crate::node::height(tui, &placed.node, inner, local)
473 + u16::from(removes_of(&placed.node).is_some())
474 })
475 .sum::<u16>()
476 + 1
477 } else {
478 // At the same cutoff the drawing uses, or the scroll arithmetic and
479 // the picture disagree about how much there is.
480 kept(&slot.body.members().collect::<Vec<_>>(), inner)
481 .map(|node| crate::node::height(tui, node, inner, local))
482 .sum()
483 };
484 // The host's rows under the described ones, and only for a bespoke region:
485 // a fill named against a pane is a host reaching into a region the
486 // description already owns, which is the rule the drawing keeps too.
487 let filled = match (&slot.kind, tui.fill(&slot.id)) {
488 (RegionKind::Handover { .. } | RegionKind::Ceded { .. }, Some(fill)) => {
489 fill.rows(tui, inner)
490 }
491 // A handover with no fill says so and spends the rows to do it. A ceded
492 // region says nothing, because nothing is owed. See `node::UNFILLED`.
493 (RegionKind::Handover { .. }, None) => text::height(crate::node::UNFILLED, inner),
494 _ => 0,
495 };
496
497 // Two rows for the frame, when the region has one.
498 run_height(tui, slot, inner, local)
499 + body
500 + filled
501 + if framed(slot.kind.depth()) { 2 } else { 0 }
502 }
503
504 /// Draw one region, and answer the rows it used.
505 pub(crate) fn draw(pass: &mut Pass<'_>, slot: &Slot, area: Rect, buf: &mut Buffer) -> u16 {
506 // A region that does not apply right now is not on the screen at all, and
507 // nothing in it is reachable. `079a011e`: the region names the control and
508 // the value that bring it out, and this renderer answers it by leaving the
509 // region out -- one of the three the ruling names, and the one a reader
510 // does not have to skip past.
511 //
512 // No count is advanced with it, and the focus walk skips the same region
513 // from the same list, so the caret and the drawing still agree about how
514 // many stops there are.
515 if pass.local().out(&slot.id) {
516 return 0;
517 }
518
519 // A region still loading holds nothing reachable, here and in the focus
520 // walk both: what is on the screen is the word "Loading", and a control
521 // counted under it would be a place the caret could go with nothing to see.
522 let pending = matches!(slot.readiness, layout::Readiness::Pending);
523
524 if area.width == 0 || area.height == 0 {
525 if !pending {
526 for placed in slot
527 .run
528 .iter()
529 .flat_map(|run| run.members.iter())
530 .chain(slot.body.iter())
531 {
532 crate::node::draw(pass, &placed.node, area, buf);
533 }
534 }
535 return 0;
536 }
537
538 let tui = pass.tui;
539 // The frame, from the depth the region's kind implies. This is the whole
540 // reason `makeover-tui` is a dependency rather than a nice-to-have: a
541 // raised region is drawn the same way here as in every other terminal app
542 // in the tree, bevel included, and the depth comes off the vocabulary
543 // rather than off this renderer's taste.
544 let depth = slot.kind.depth();
545 let inner = if framed(depth) {
546 frame(buf, area, depth, tui.palette())
547 } else {
548 area
549 };
550
551 // `Readiness` is the loading axis, and a terminal has no spinner that is
552 // not a clock. It says so in words instead, which loses the motion and
553 // keeps the fact.
554 if pending {
555 let used = text::draw(
556 "Loading",
557 Style::default()
558 .fg(tui.theme().content_muted)
559 .add_modifier(Modifier::ITALIC),
560 inner,
561 buf,
562 );
563 return used + if framed(depth) { 2 } else { 0 };
564 }
565
566 // A bespoke region is the host's. The description named the place and the
567 // blocks it owns above the fill, so those draw first; what goes under them
568 // is the host's own drawing, handed the rows that are left.
569 //
570 // `Tui::with_fill` is the counterpart to `Webview::with_fill`, and until
571 // `d86122cf` this renderer had none: a bespoke region drew its described
572 // blocks and then stopped, whatever the host had to put in it. The
573 // ordering is the arrangement `Containment::Opaque` describes -- a heading
574 // the description owns above a canvas it does not.
575 // The row the description said its members share, above the body. Above,
576 // because that is the order the webview emits them in and the order a
577 // toolbar over a list reads in.
578 let row = run_body(pass, slot, inner, buf);
579 let rest = below(inner, row);
580 let mut used = row
581 + if slot.showing().selective() {
582 showing_body(pass, slot, rest, buf)
583 } else if let Some(repeating) = slot.repeating.as_deref() {
584 repeating_body(pass, slot, repeating, rest, buf)
585 } else {
586 body(
587 pass,
588 slot,
589 &slot.body.members().collect::<Vec<_>>(),
590 rest,
591 buf,
592 )
593 };
594
595 if let RegionKind::Handover { .. } | RegionKind::Ceded { .. } = slot.kind {
596 if let Some(fill) = tui.fill(&slot.id) {
597 used = (used + fill.draw(tui, below(inner, used), buf)).min(inner.height);
598 } else if slot.kind.as_layout().owed() {
599 // The half the split exists for. Before it, a region the app had
600 // ruled undescribable and one nobody had filled yet were the same
601 // value here, and both drew as an empty box.
602 let drawn = text::draw(
603 crate::node::UNFILLED,
604 tui.style().muted,
605 below(inner, used),
606 buf,
607 );
608 used = (used + drawn).min(inner.height);
609 }
610 }
611
612 // `Slot::id` is not drawn anywhere. It is a fragment address, and a
613 // terminal redraws rather than swapping, so it costs nothing and says
614 // nothing here.
615 used + if framed(depth) { 2 } else { 0 }
616 }
617
618 /// What taking one slot away calls, when this node is a slot that can go.
619 ///
620 /// Read here rather than matched at each call site because the drawing, the
621 /// measurement and the focus walk all ask it, and three spellings of "is this a
622 /// region with a remove on it" is how three walks come to disagree.
623 pub(crate) fn removes_of(node: &Node) -> Option<&quasi_router::Act> {
624 match node {
625 Node::Region(child) => child.removes.as_deref(),
626 _ => None,
627 }
628 }
629
630 /// One control, as the act it is, at whatever the floor or the ceiling says.
631 ///
632 /// The boundary is drawn rather than hidden: audiofiles' rule editor already
633 /// made that call for its last condition, and a control that vanishes at a
634 /// boundary is one the reader has to discover twice. What changed is that
635 /// `Repeating` says it once instead of each app disabling its own button.
636 pub(crate) fn bounded(act: &quasi_router::Act, allowed: bool) -> quasi_router::Act {
637 if allowed {
638 act.clone()
639 } else {
640 act.clone().disabled()
641 }
642 }
643
644 /// A region whose children are answers to one question.
645 ///
646 /// Each slot under its number, with the control that takes it away, and the
647 /// control that adds one under the lot.
648 ///
649 /// Everything is derived as nodes and drawn through [`crate::node::draw`], so
650 /// this invents no styling: a slot's number is the same heading a described one
651 /// gets, and the controls take everything `act_line` knows about focus, tone
652 /// and waiting. The three walks -- this, [`height`] and `crate::focus` -- read
653 /// the same two helpers above so they cannot come apart.
654 fn repeating_body(
655 pass: &mut Pass<'_>,
656 slot: &Slot,
657 repeating: &quasi_router::Repeating,
658 inner: Rect,
659 buf: &mut Buffer,
660 ) -> u16 {
661 let standing = slot.body.len();
662 let mut used = 0;
663 for (at, placed) in slot.body.iter().enumerate() {
664 // One-based, because it is read by a person.
665 used += crate::node::draw(
666 pass,
667 &Node::section(format!("{} {}", repeating.one, at + 1)),
668 below(inner, used),
669 buf,
670 );
671 used += crate::node::draw(pass, &placed.node, below(inner, used), buf);
672 if let Some(removes) = removes_of(&placed.node) {
673 let act = Node::Act(bounded(removes, repeating.may_remove(standing)));
674 used += crate::node::draw(pass, &act, below(inner, used), buf);
675 }
676 }
677 let add = Node::Act(bounded(&repeating.add, repeating.may_add(standing)));
678 used += crate::node::draw(pass, &add, below(inner, used), buf);
679 used.min(inner.height)
680 }
681
682 /// A region showing one child at a time, and the chrome that moves between them.
683 ///
684 /// The same derivation quasi-webview makes and for the same reason: nothing here
685 /// reads [`RegionKind::Widget`]'s name. A carousel, a tab group and a disclosure
686 /// are one region that shows some of its children, and which idiom comes out
687 /// falls out of what the children carry.
688 ///
689 /// This is what `c0b63ea9`'s terminal half was waiting for.
690 ///
691 /// # The chrome is one row, and it is in flow
692 ///
693 /// `< Prev > 2 / 3 < Next >` under the content, or a strip of labels above it.
694 /// The row rather than overlaid arrows or a dot strip: a terminal cannot
695 /// honestly overlay anything, and a dot strip has no form here at all.
696 fn showing_body(pass: &mut Pass<'_>, slot: &Slot, inner: Rect, buf: &mut Buffer) -> u16 {
697 let at = pass.view.shown(slot);
698 let labels = slot.labels();
699 let mut used = 0;
700
701 // A strip sits above the panes it opens; a counter row sits under the
702 // content it counts. The folder semantic, and the same placement the
703 // webview derives.
704 if !labels.is_empty() {
705 used += text::draw_spans(
706 &showing_spans(pass.tui, &labels, at),
707 below(inner, used),
708 buf,
709 );
710 }
711
712 // One child, or none at all: `Showing::AtMostOne` closed is the only way to
713 // reach `None` here, and drawing nothing is what closed means.
714 if let Some(index) = at
715 && index < slot.body.len()
716 {
717 // The one frame that is up, and nothing else: a selective region draws
718 // its members one at a time, which is what selective means.
719 if let Some(member) = slot.body.get(index) {
720 used += body(pass, slot, &[member], below(inner, used), buf);
721 }
722 }
723
724 if labels.is_empty() {
725 used += text::draw_spans(
726 &counter_spans(pass.tui, at, slot.body.len()),
727 below(inner, used),
728 buf,
729 );
730 }
731
732 used.min(inner.height)
733 }
734
735 /// A strip of labels, the current one lit.
736 ///
737 /// The one tab strip this renderer draws.
738 fn showing_spans(tui: &Tui, labels: &[&str], at: Option<usize>) -> Vec<Span<'static>> {
739 let mut spans = Vec::new();
740 for (index, label) in labels.iter().enumerate() {
741 if !spans.is_empty() {
742 spans.push(Span::raw(" "));
743 }
744 let picked = at == Some(index);
745 let style = if picked {
746 Style::default()
747 .fg(tui.theme().selection_on)
748 .bg(tui.theme().action_primary)
749 } else {
750 Style::default().fg(tui.theme().content_secondary)
751 };
752 spans.push(Span::styled(format!(" {label} "), style));
753 }
754 spans
755 }
756
757 /// Previous, where you are, next.
758 ///
759 /// The position reads back one step, which is `picture-caption`'s claim in the
760 /// other renderer: it says where you are among the children and it is not one
761 /// of them. Zero when a dismissible region is closed, which is a true statement
762 /// about how many of its children are showing.
763 fn counter_spans(tui: &Tui, at: Option<usize>, total: usize) -> Vec<Span<'static>> {
764 let control = Style::default().fg(tui.theme().content_secondary);
765 vec![
766 Span::styled("< Prev >", control),
767 Span::styled(
768 format!(" {} / {total} ", at.map_or(0, |index| index + 1)),
769 Style::default().fg(tui.theme().content_muted),
770 ),
771 Span::styled("< Next >", control),
772 ]
773 }
774
775 /// A region's contents, at the offset the view is holding it at.
776 ///
777 /// Scrolling is the runtime's and the clipping is the drawing's, and this is
778 /// where the two meet. Flow layout draws from the top of the rect it is given,
779 /// so an offset cannot be honoured by moving the rect: a node starting above
780 /// the window would draw its first row at the window's first row. What works is
781 /// to draw the region at its full height into a buffer of its own and copy the
782 /// window out, which costs an allocation per scrolled region and nothing at all
783 /// for a region sitting at the top, which is every region until someone
784 /// scrolls.
785 ///
786 /// The offset is clamped here rather than in [`crate::View`], because how far a
787 /// region can scroll is how tall it is at the width it was given, and the width
788 /// is not known until this point.
789 /// # What narrowing does here
790 ///
791 /// A member ranked below [`layout::Priority::Essential`] is not drawn once
792 /// [`cutoff`] has risen past it. The cutoff is read off `inner.width` and
793 /// nothing else, which is the whole of "Any width, one answer" in this
794 /// renderer: no count of what fitted, no measurement kept from the last frame.
795 fn body(pass: &mut Pass<'_>, slot: &Slot, nodes: &[&Ranked], inner: Rect, buf: &mut Buffer) -> u16 {
796 let offset = pass.view.scroll(&slot.id);
797 if offset == 0 {
798 let mut used = 0;
799 for node in kept(nodes, inner.width) {
800 used += crate::node::draw(pass, node, below(inner, used), buf);
801 }
802 return used.min(inner.height);
803 }
804
805 let content: u16 = kept(nodes, inner.width)
806 .map(|node| crate::node::height(pass.tui, node, inner.width, &pass.local()))
807 .sum();
808 let offset = offset.min(content.saturating_sub(inner.height));
809
810 let tall = Rect {
811 height: content.max(inner.height),
812 ..inner
813 };
814 let mut scratch = Buffer::empty(tall);
815 let mut used = 0;
816 for node in kept(nodes, inner.width) {
817 used += crate::node::draw(pass, node, below(tall, used), &mut scratch);
818 }
819
820 let shown = inner.height.min(content.saturating_sub(offset));
821 for row in 0..shown {
822 for column in 0..inner.width {
823 let from = (inner.x + column, inner.y + offset + row);
824 let to = (inner.x + column, inner.y + row);
825 if let Some(cell) = scratch.cell(from).cloned()
826 && let Some(target) = buf.cell_mut(to)
827 {
828 *target = cell;
829 }
830 }
831 }
832 shown
833 }
834
835 /// Whether a depth is drawn with a border.
836 ///
837 /// Flat is not: a band and a plain pane are arrangement, and boxing every one
838 /// of them spends two rows and two columns per region on a screen that is
839 /// mostly regions.
840 fn framed(depth: layout::Depth) -> bool {
841 !matches!(depth, layout::Depth::Flat)
842 }
843
844 /// Split `area` into a left column of `width` and the rest.
845 fn split(area: Rect, width: u16) -> (Rect, Rect) {
846 let width = width.min(area.width);
847 (
848 Rect { width, ..area },
849 Rect {
850 x: area.x + width,
851 width: area.width - width,
852 ..area
853 },
854 )
855 }
856
857 /// Half the width and half the height, in the middle.
858 fn centred(area: Rect) -> Rect {
859 let width = area.width / 2;
860 let height = area.height / 2;
861 Rect {
862 x: area.x + width / 2,
863 y: area.y + height / 2,
864 width,
865 height,
866 }
867 }
868