Skip to main content

max / quasi

12.8 KB · 343 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::{RegionKind, Screen, Slot};
25 use ratatui::buffer::Buffer;
26 use ratatui::layout::Rect;
27 use ratatui::style::{Modifier, Style};
28
29 use crate::{Pass, Tui, below};
30
31 // The sidebar's 24 columns and the list pane's 40% used to be declared here,
32 // as two numbers this renderer chose with nothing behind them. `e0fd485e`:
33 // they are `Arrangement::share` now, so the terminal and the webview honour one
34 // fact and two hosts showing one screen agree about its proportions.
35
36 /// Lay a screen's regions out and draw them.
37 pub(crate) fn screen_regions(pass: &mut Pass<'_>, screen: &Screen, area: Rect, buf: &mut Buffer) {
38 let area = measured(screen.measure, area);
39 let mut rest = area;
40
41 // Bands stack at the top, full width, in the order they were said. A band
42 // is an arrangement rather than a type -- a page header, a toolbar -- so it
43 // takes the rows it needs and gets out of the way.
44 for slot in screen
45 .slots
46 .iter()
47 .filter(|slot| matches!(slot.kind, RegionKind::Band))
48 {
49 let used = draw(pass, slot, rest, buf);
50 rest = below(rest, used);
51 }
52
53 let body = body_slots(screen);
54
55 match screen.arrangement {
56 layout::Arrangement::SidebarContent { share } => {
57 let (left, right) = split(rest, share.of(rest.width));
58 let mut sidebars = 0;
59 let mut content = right;
60 for slot in &body {
61 if matches!(slot.kind, RegionKind::Sidebar) {
62 let used = draw(pass, slot, below(left, sidebars), buf);
63 sidebars += used;
64 } else {
65 let used = draw(pass, slot, content, buf);
66 content = below(content, used);
67 }
68 }
69 }
70 layout::Arrangement::ListDetail { tabbed, share } => {
71 if tabbed {
72 // One at a time, and nothing says which. `body_slots` has
73 // already cut the rest away, so this is the one region there is.
74 if let Some(first) = body.first() {
75 draw(pass, first, rest, buf);
76 }
77 } else {
78 let (left, right) = split(rest, share.of(rest.width));
79 let mut detail = right;
80 for (index, slot) in body.iter().enumerate() {
81 if index == 0 {
82 draw(pass, slot, left, buf);
83 } else {
84 let used = draw(pass, slot, detail, buf);
85 detail = below(detail, used);
86 }
87 }
88 }
89 }
90 }
91
92 // Modals last and over everything, which is what a modal is. Centred in
93 // half the width, because `Depth::Overlay` says it sits above the page and
94 // says nothing about how much of it to cover.
95 for slot in screen
96 .slots
97 .iter()
98 .filter(|slot| matches!(slot.kind, RegionKind::Modal))
99 {
100 draw(pass, slot, centred(area), buf);
101 }
102 }
103
104 /// The screen's area, narrowed to the measure it asked for.
105 ///
106 /// `0eccff0d`, the terminal's half of "every renderer owes an answer". The
107 /// description says how wide the content should run and this says what that is
108 /// in columns, the same division the webview makes: the screen chose one of
109 /// three, and what each one comes to is the renderer's.
110 ///
111 /// The two caps are this renderer's numbers, and only the second has a reason
112 /// outside taste: past roughly 75 characters a line costs the reader the return
113 /// sweep, which is why [`layout::Measure::Reading`] is the narrowest. Centred
114 /// rather than left-aligned, because a narrowed column against the left edge of
115 /// a wide terminal reads as a window that failed to resize.
116 ///
117 /// A terminal narrower than the cap is left alone rather than padded. There is
118 /// no measure to enforce when the window is already tighter than it.
119 fn measured(measure: layout::Measure, area: Rect) -> Rect {
120 let cap = match measure {
121 layout::Measure::Reading => 76,
122 layout::Measure::Contained => 100,
123 // Every column there is, which is what `Wide` means. Also the arm a
124 // member added upstream lands in: a measure this renderer has not
125 // learned should show the whole screen, not hide part of it.
126 _ => return area,
127 };
128 if area.width <= cap {
129 return area;
130 }
131 Rect {
132 x: area.x + (area.width - cap) / 2,
133 width: cap,
134 ..area
135 }
136 }
137
138 /// The regions that fill the body, after the arrangement has had its say.
139 ///
140 /// The tabbed cut lives here and only here. It is the one place a described
141 /// region can be on the screen or not, so the drawing and the focus walk have
142 /// to agree about it, and two copies of "the first one, and nothing says which"
143 /// is two chances to disagree.
144 fn body_slots(screen: &Screen) -> Vec<&Slot> {
145 let body = screen
146 .slots
147 .iter()
148 .filter(|slot| !matches!(slot.kind, RegionKind::Band | RegionKind::Modal));
149
150 match screen.arrangement {
151 layout::Arrangement::ListDetail { tabbed: true, .. } => body.take(1).collect(),
152 _ => body.collect(),
153 }
154 }
155
156 /// Every region the user can see, in the order it is drawn.
157 ///
158 /// What the focus walk reads. A region that is not drawn holds nothing
159 /// reachable, which is why this is a question about slots rather than about
160 /// nodes: a tab that is not showing has controls in it, and stopping on one
161 /// would move focus to a place with nothing on screen.
162 ///
163 /// **A modal takes the whole of it.** A dialog you can tab out of is not a
164 /// dialog, and this is the one place the drawing order and the focus order
165 /// deliberately differ: the screen behind a modal is still painted, because
166 /// covering it costs rows and says nothing, and it is still unreachable.
167 pub(crate) fn reachable(screen: &Screen) -> Vec<&Slot> {
168 let modals: Vec<&Slot> = screen
169 .slots
170 .iter()
171 .filter(|slot| matches!(slot.kind, RegionKind::Modal))
172 .collect();
173 if !modals.is_empty() {
174 return modals;
175 }
176
177 screen
178 .slots
179 .iter()
180 .filter(|slot| matches!(slot.kind, RegionKind::Band))
181 .chain(body_slots(screen))
182 .collect()
183 }
184
185 /// The rows a region wants at `width`.
186 pub(crate) fn height(tui: &Tui, slot: &Slot, width: u16) -> u16 {
187 let inner = width.saturating_sub(2);
188 let body: u16 = slot
189 .body
190 .iter()
191 .map(|node| crate::node::height(tui, node, inner))
192 .sum();
193 // Two rows for the frame, when the region has one.
194 body + if framed(slot.kind.depth()) { 2 } else { 0 }
195 }
196
197 /// Draw one region, and answer the rows it used.
198 pub(crate) fn draw(pass: &mut Pass<'_>, slot: &Slot, area: Rect, buf: &mut Buffer) -> u16 {
199 // A region still loading holds nothing reachable, here and in the focus
200 // walk both: what is on the screen is the word "Loading", and a control
201 // counted under it would be a place the caret could go with nothing to see.
202 let pending = matches!(slot.readiness, layout::Readiness::Pending);
203
204 if area.width == 0 || area.height == 0 {
205 if !pending {
206 for node in &slot.body {
207 crate::node::draw(pass, node, area, buf);
208 }
209 }
210 return 0;
211 }
212
213 let tui = pass.tui;
214 // The frame, from the depth the region's kind implies. This is the whole
215 // reason `makeover-tui` is a dependency rather than a nice-to-have: a
216 // raised region is drawn the same way here as in every other terminal app
217 // in the tree, bevel included, and the depth comes off the vocabulary
218 // rather than off this renderer's taste.
219 let depth = slot.kind.depth();
220 let inner = if framed(depth) {
221 frame(buf, area, depth, tui.palette())
222 } else {
223 area
224 };
225
226 // `Readiness` is the loading axis, and a terminal has no spinner that is
227 // not a clock. It says so in words instead, which loses the motion and
228 // keeps the fact.
229 if pending {
230 let used = text::draw(
231 "Loading",
232 Style::default()
233 .fg(tui.theme().content_muted)
234 .add_modifier(Modifier::ITALIC),
235 inner,
236 buf,
237 );
238 return used + if framed(depth) { 2 } else { 0 };
239 }
240
241 // A bespoke region is the host's. The description named the place and the
242 // blocks it owns above the fill, so those draw; what the host puts under
243 // them is the host's to draw, and this renderer has no fill mechanism to
244 // offer it. That is a gap rather than a decline: `Webview::with_fill` has
245 // no counterpart here.
246 let used = body(pass, slot, inner, buf);
247
248 // `Slot::id` is not drawn anywhere. It is a fragment address, and a
249 // terminal redraws rather than swapping, so it costs nothing and says
250 // nothing here.
251 used + if framed(depth) { 2 } else { 0 }
252 }
253
254 /// A region's contents, at the offset the view is holding it at.
255 ///
256 /// Scrolling is the runtime's and the clipping is the drawing's, and this is
257 /// where the two meet. Flow layout draws from the top of the rect it is given,
258 /// so an offset cannot be honoured by moving the rect: a node starting above
259 /// the window would draw its first row at the window's first row. What works is
260 /// to draw the region at its full height into a buffer of its own and copy the
261 /// window out, which costs an allocation per scrolled region and nothing at all
262 /// for a region sitting at the top, which is every region until someone
263 /// scrolls.
264 ///
265 /// The offset is clamped here rather than in [`crate::View`], because how far a
266 /// region can scroll is how tall it is at the width it was given, and the width
267 /// is not known until this point.
268 fn body(pass: &mut Pass<'_>, slot: &Slot, inner: Rect, buf: &mut Buffer) -> u16 {
269 let offset = pass.view.scroll(&slot.id);
270 if offset == 0 {
271 let mut used = 0;
272 for node in &slot.body {
273 used += crate::node::draw(pass, node, below(inner, used), buf);
274 }
275 return used.min(inner.height);
276 }
277
278 let content: u16 = slot
279 .body
280 .iter()
281 .map(|node| crate::node::height(pass.tui, node, inner.width))
282 .sum();
283 let offset = offset.min(content.saturating_sub(inner.height));
284
285 let tall = Rect {
286 height: content.max(inner.height),
287 ..inner
288 };
289 let mut scratch = Buffer::empty(tall);
290 let mut used = 0;
291 for node in &slot.body {
292 used += crate::node::draw(pass, node, below(tall, used), &mut scratch);
293 }
294
295 let shown = inner.height.min(content.saturating_sub(offset));
296 for row in 0..shown {
297 for column in 0..inner.width {
298 let from = (inner.x + column, inner.y + offset + row);
299 let to = (inner.x + column, inner.y + row);
300 if let Some(cell) = scratch.cell(from).cloned()
301 && let Some(target) = buf.cell_mut(to)
302 {
303 *target = cell;
304 }
305 }
306 }
307 shown
308 }
309
310 /// Whether a depth is drawn with a border.
311 ///
312 /// Flat is not: a band and a plain pane are arrangement, and boxing every one
313 /// of them spends two rows and two columns per region on a screen that is
314 /// mostly regions.
315 fn framed(depth: layout::Depth) -> bool {
316 !matches!(depth, layout::Depth::Flat)
317 }
318
319 /// Split `area` into a left column of `width` and the rest.
320 fn split(area: Rect, width: u16) -> (Rect, Rect) {
321 let width = width.min(area.width);
322 (
323 Rect { width, ..area },
324 Rect {
325 x: area.x + width,
326 width: area.width - width,
327 ..area
328 },
329 )
330 }
331
332 /// Half the width and half the height, in the middle.
333 fn centred(area: Rect) -> Rect {
334 let width = area.width / 2;
335 let height = area.height / 2;
336 Rect {
337 x: area.x + width / 2,
338 y: area.y + height / 2,
339 width,
340 height,
341 }
342 }
343