Skip to main content

max / quasi

85.9 KB · 1960 lines History Blame Raw
1 //! One node into cells.
2 //!
3 //! Every member of [`Node`] is drawn here or declined here in writing, which is
4 //! what `179b088d` asks for. A decline is a comment saying what a terminal has
5 //! no way to honour, and each one is a finding rather than an omission.
6 //!
7 //! [`Node`] is `#[non_exhaustive]`, so "every member" now means every member
8 //! this renderer has learned. The wildcard arm is the gap and [`UNDRAWN`] is
9 //! what it draws; a member that lands there is a member still owed the
10 //! paragraph above, not a member that has been declined.
11
12 use makeover_layout as layout;
13 use makeover_tui::{piece, table, text};
14 use std::time::SystemTime;
15
16 use quasi_router::{
17 Act, Action, Bar, Cell, Chart, Clock, Field, Figure, Image, Meter, Node, Outline, Rest, Row,
18 Tag,
19 };
20 use ratatui::buffer::Buffer;
21 use ratatui::layout::Rect;
22 use ratatui::style::{Modifier, Style};
23 use ratatui::text::{Line, Span};
24
25 use crate::{Local, Pass, Tui, View, below};
26
27 /// The rows `node` wants at `width`.
28 pub(crate) fn height(tui: &Tui, node: &Node, width: u16, local: &Local<'_>) -> u16 {
29 match node {
30 Node::Heading { text: content, .. } | Node::Text { text: content, .. } => {
31 text::height(content, width)
32 }
33 // Neither axis is read here, and both are deliberately ignored rather
34 // than forgotten. `Trust` protects a document from an author -- follow
35 // rules, raw markup, fetchable schemes -- and a terminal has no document
36 // to protect: it paints spans, follows nothing and fetches nothing.
37 // `Richness` would matter for a table, which `rich_spans` does not draw
38 // in any case. Same shape as a renderer ignoring `Discovery::image`.
39 Node::Rich { source, .. } => {
40 text::spans_height(&rich_spans(tui, source, rich_base(tui)), width)
41 }
42 // The questions a control asks stand above it here, so they are rows of
43 // its own height. See the drawing arm for why a terminal shows them
44 // rather than hiding them behind the press.
45 Node::Act(act) => {
46 act.asks
47 .iter()
48 .map(|field| field_height(tui, &field.as_asked(), width, local))
49 .sum::<u16>()
50 + text::line_height(&act_line(tui, act, false), width)
51 + act_note_height(tui, act, width)
52 }
53 Node::Link { text: label, .. } => text::height(label, width),
54 Node::Token(tag) => text::line_height(&Line::from(tag_span(tui, tag, false)), width),
55 Node::Figure(figure) => figure_height(tui, figure, width),
56 // A readout of the clock is one line whatever it says, and it is sized
57 // without asking what the clock says: the widest it ever gets is a
58 // wrapped line, and a height that moved with the seconds would make the
59 // screen jump under the reader once a minute.
60 Node::Since { .. } | Node::Until { .. } | Node::Age { .. } => 1,
61 Node::Image(picture) => image_height(picture, width),
62 Node::Notice { text: content, .. } => text::height(content, width),
63 Node::StandIn { message, act, .. } => {
64 text::height(message, width) + act.as_ref().map_or(0, |_| 1)
65 }
66 Node::Field(field) => field_height(tui, field, width, local),
67 Node::Form { fields, .. } => {
68 fields
69 .iter()
70 .map(|field| field_height(tui, field, width, local))
71 .sum::<u16>()
72 // The submit button, on its own row under the last question.
73 + 1
74 }
75 // A table that declared no columns is a list. One node since the
76 // 2026-09-06 collapse, and the guard is where the two arrangements part
77 // company; the arm below measures the grid.
78 Node::Table {
79 columns,
80 rows,
81 more,
82 ..
83 } if columns.is_empty() => {
84 let gutter = list_gutter(rows);
85 // A row under a shut branch is not on the screen and takes no
86 // lines. The same reading the focus walk and the drawing make, from
87 // the same function: three walks that disagreed about which rows
88 // are there is a caret painted on the wrong line.
89 let branches = rows.iter().any(|row| row.open.is_some());
90 crate::outline::showing(rows, local.view())
91 .map(|(_, row)| {
92 let body = width
93 .saturating_sub(gutter)
94 .saturating_sub(crate::outline::lead(row.depth, branches));
95 let cap = row_lines(row);
96 let kept = fitted(tui, row, &[], body, cap);
97 text::line_height(&row_line_of(tui, row, &[], &kept), body).clamp(1, cap)
98 })
99 .sum::<u16>()
100 + u16::from(more.is_some())
101 }
102 // A terminal cannot place by percentage, and it does not have to. What
103 // the description said is that these things happen at these times; a
104 // clock column and one line each says exactly that, and is what a
105 // terminal is good at. The geometry a webview draws is presentation,
106 // which is the half this renderer is entitled to answer differently.
107 //
108 // What is genuinely lost is duration and overlap as *shapes*: two
109 // things at once are two adjacent lines here rather than two boxes side
110 // by side. The times are on every line, so the fact survives even
111 // though the picture does not. A gantt-style bar column would be this
112 // renderer's own expression and is worth having; it is not a finding
113 // about the description.
114 Node::Timeline { entries, .. } => entries
115 .iter()
116 .map(|entry| {
117 let body = width.saturating_sub(TIMELINE_GUTTER);
118 let cap = row_lines(&entry.row);
119 let kept = fitted(tui, &entry.row, &[], body, cap);
120 text::line_height(&row_line_of(tui, &entry.row, &[], &kept), body).clamp(1, cap)
121 })
122 .sum::<u16>(),
123 Node::Table {
124 columns,
125 rows,
126 more,
127 ..
128 } => {
129 // Only the rows a shut branch is not covering, the same reading the
130 // drawing makes.
131 table_height(columns, crate::outline::showing(rows, local.view()).count())
132 + u16::from(more.is_some())
133 }
134 // Source is drawn as it was written, so its height is its own lines
135 // wrapped. The runs concatenate back to the file exactly, which is what
136 // `Lexeme::text` guarantees, so measuring the joined text and drawing
137 // the spans cannot disagree.
138 Node::Code { runs, .. } => {
139 text::spans_height(&code_spans(tui, runs, rich_base(tui)), width)
140 }
141 Node::Meter(meter) => text::line_height(&meter_line(tui, meter), width),
142 // One line per bar. The lines are built to a fixed width -- the place,
143 // the bar and the reading -- so this measures them rather than assuming
144 // one row each, which stops being true the moment a reading wraps.
145 Node::Chart { axis, bars, .. } => chart_lines(tui, axis, bars)
146 .iter()
147 .map(|line| text::line_height(line, width))
148 .sum(),
149 Node::Stats { figures, .. } => figures
150 .iter()
151 .map(|(figure, _)| figure_height(tui, figure, width))
152 .sum(),
153 Node::Region(slot) => crate::region::height(tui, slot, width, local),
154
155 // A member added since this renderer last learned the vocabulary.
156 _ => text::height(UNDRAWN, width),
157 }
158 }
159
160 /// How much of a row a node wants, in cells.
161 ///
162 /// The counterpart to [`height`] for the one axis a column never had to think
163 /// about. A region that says its members share a row (`Slot::across`) needs to
164 /// know how wide each one is before it can put two of them side by side, and
165 /// every measurement here is derived from what the node holds rather than
166 /// authored: a label's characters, a tag's, a meter's line.
167 ///
168 /// [`Want::Rest`] is the honest answer for two different things, and both of
169 /// them mean "do not try to measure me": a control the description said should
170 /// absorb what is left ([`layout::Width::Fill`]), and a node whose shape is a
171 /// block rather than a line -- a list, a table, a form -- which has no business
172 /// being a member of a row and is given the whole of one if it is.
173 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
174 pub(crate) enum Want {
175 /// This many cells, which is what the node holds.
176 Cells(u16),
177 /// Whatever the line has left.
178 Rest,
179 }
180
181 /// The cells `node` wants as a member of a row.
182 pub(crate) fn want(tui: &Tui, node: &Node) -> Want {
183 /// The cells a line of spans occupies, unwrapped.
184 fn spans_wide(line: &Line<'_>) -> u16 {
185 u16::try_from(
186 line.spans
187 .iter()
188 .map(|span| span.content.chars().count())
189 .sum::<usize>(),
190 )
191 .unwrap_or(u16::MAX)
192 }
193 fn chars_wide(text: &str) -> u16 {
194 u16::try_from(text.chars().count()).unwrap_or(u16::MAX)
195 }
196
197 match node {
198 Node::Heading { text, .. } | Node::Text { text, .. } | Node::Notice { text, .. } => {
199 Want::Cells(chars_wide(text))
200 }
201 Node::Link { text, .. } => Want::Cells(chars_wide(text)),
202 // A control with questions on it is not a line: the questions stand
203 // above it here, which is what `height`'s arm says, so it takes a row
204 // of its own rather than a place in one.
205 Node::Act(act) if act.asks.is_empty() => {
206 Want::Cells(spans_wide(&act_line(tui, act, false)))
207 }
208 Node::Token(tag) => Want::Cells(spans_wide(&Line::from(tag_span(tui, tag, false)))),
209 Node::Figure(figure) => {
210 Want::Cells(chars_wide(&figure.value) + 1 + chars_wide(&figure.caption))
211 }
212 Node::Since { .. } | Node::Until { .. } | Node::Age { .. } => Want::Cells(CLOCK_CELLS),
213 Node::Meter(meter) => Want::Cells(spans_wide(&meter_line(tui, meter))),
214 // The widest bar's line, because they are drawn in a column and the
215 // narrow ones are padded to line up with it.
216 Node::Chart { axis, bars, .. } => Want::Cells(
217 chart_lines(tui, axis, bars)
218 .iter()
219 .map(spans_wide)
220 .max()
221 .unwrap_or(0),
222 ),
223 // What the description said about this one, which is the only place a
224 // width is stated rather than derived. `Fill` is the search box in a
225 // toolbar; `Content` and `Fixed` are a box that should be as wide as
226 // what goes in it, and a terminal has no better number for that than
227 // the label plus room to type.
228 Node::Field(field) => match field.width {
229 layout::Width::Fill => Want::Rest,
230 _ => Want::Cells(chars_wide(&field.label) + FIELD_BOX_CELLS),
231 },
232 _ => Want::Rest,
233 }
234 }
235
236 /// The cells a clock readout is given.
237 ///
238 /// `height`'s arm has the argument: the readout is sized without asking what it
239 /// says, or the row would shuffle under the reader once a minute.
240 const CLOCK_CELLS: u16 = 16;
241
242 /// The cells a box gets beyond its label, when the description did not say the
243 /// box absorbs the line.
244 const FIELD_BOX_CELLS: u16 = 12;
245
246 /// What a node this renderer has not learned yet draws instead of itself.
247 ///
248 /// `Node` is `#[non_exhaustive]` so that a new member is not a lockstep release
249 /// across three renderers, and this is the price of that: one line, muted, in
250 /// the place the thing would have been.
251 ///
252 /// A line rather than nothing, because nothing is a lie by omission. The reader
253 /// would see a screen with a part missing and no way to tell that it was
254 /// missing, which is worse than seeing a part that says so -- and it is exactly
255 /// what [`Node::StandIn`] already exists to say in the description's own voice.
256 /// So this borrows its tone: same situation, said by the renderer instead of by
257 /// the handler.
258 pub(crate) const UNDRAWN: &str = "(not drawn: this terminal does not know this yet)";
259
260 /// What a [`RegionKind::Handover`] with no fill says.
261 ///
262 /// A handover is a fill the app owes every host, so a terminal with none is
263 /// looking at a hole rather than at a region that is finished. Saying so is the
264 /// same bargain [`UNDRAWN`] strikes: a reader who can see that something is
265 /// missing can go and get it elsewhere, and one shown an empty box cannot.
266 ///
267 /// [`RegionKind::Ceded`] deliberately gets no equivalent. Nothing is owed
268 /// there, so silence is the correct drawing and a notice would be this renderer
269 /// inventing a gap the app already ruled on.
270 pub(crate) const UNFILLED: &str = "(not drawn: this host has no fill for this)";
271
272 /// Draw `node` at the top of `area`, and answer the rows it used.
273 ///
274 /// The reachable things are counted as they are passed, in the order
275 /// [`crate::focus::spots`] records them, so that the one whose number matches
276 /// the view's focus can be drawn lit. A node that is not reachable does not
277 /// count, and a node that is drawn but unreachable — a disabled control, a
278 /// hidden field — does not count either.
279 pub(crate) fn draw(pass: &mut Pass<'_>, node: &Node, area: Rect, buf: &mut Buffer) -> u16 {
280 if area.width == 0 || area.height == 0 {
281 // A node with no room still holds its place in the count. The screen is
282 // the same screen whether or not the terminal is tall enough to show
283 // all of it, and a focus order that changed as the window was resized
284 // would move the user's place under them.
285 count(pass, node);
286 return 0;
287 }
288
289 let tui = pass.tui;
290 match node {
291 Node::Heading { level, text: title } => {
292 text::draw(title, tui.style().heading(*level), area, buf)
293 }
294
295 Node::Text {
296 text: content,
297 tone,
298 } => text::draw(content, tui.style().tone(*tone), area, buf),
299
300 // Markdown source, and a terminal has no markup to hand it to. It takes
301 // the runs: the words, each still carrying the marks that were over it,
302 // which is the answer docengine grew for exactly this caller. A webview
303 // draws `**ship it**` bold and so does this.
304 //
305 // What is still lost is block structure. A heading inside a rich node
306 // comes through as its text at the weight of the prose around it,
307 // because `render_runs` carries inline marks and nothing else, and a
308 // terminal has no second type size to spend on the difference anyway.
309 Node::Rich { source, .. } => {
310 text::draw_spans(&rich_spans(tui, source, rich_base(tui)), area, buf)
311 }
312
313 Node::Code { runs, .. } => {
314 text::draw_spans(&code_spans(tui, runs, rich_base(tui)), area, buf)
315 }
316
317 Node::Act(act) => {
318 // What the press asks for first, drawn above the control rather
319 // than behind it. A webview hides these in a `details` the verb
320 // opens; a terminal has no such affordance, and two boxes standing
321 // in the open are worth more here than a disclosure this renderer
322 // would have to invent a key for. The values are the same either
323 // way, which is the half the description states.
324 let mut used = 0;
325 for field in &act.asks {
326 used += draw_field(pass, &field.as_asked(), below(area, used), buf);
327 }
328 let area = below(area, used);
329 let focused = claim_act(pass, act);
330 // The control this screen is waiting on is drawn as the thing it is:
331 // pressed, working, and not answering another press. The runtime
332 // refuses that press whether or not this is drawn, so what is here
333 // is the saying rather than the guard.
334 if pass.view.busy(&act.action) {
335 used += text::draw_line(&busy_line(pass, tui, act, focused), area, buf);
336 return used + draw_act_note(tui, act, below(area, used), buf);
337 }
338 let chosen = commit_count(pass, act.over.as_deref());
339 used += text::draw_line(&commit_line(tui, act, chosen, focused), area, buf);
340 used + draw_act_note(tui, act, below(area, used), buf)
341 }
342
343 // A link is text and an address, and a terminal cannot put the address
344 // under the words the way an anchor does. Underlined, which is the one
345 // affordance a cell has that says "this goes somewhere", and the
346 // address is the runtime's to follow when the link has focus.
347 Node::Link { text: label, .. } => {
348 let focused = pass.claim();
349 text::draw(
350 label,
351 tui.style().focused(focused, link_style(tui)),
352 area,
353 buf,
354 )
355 }
356
357 // The readouts the renderer derives from the current time. The instant
358 // is the description's; the words and the cadence are this crate's, and
359 // `crate::clock` holds both. `Runtime::tick_in` is what tells a host to
360 // draw again before the number goes stale.
361 Node::Since { at } => clock_draw(tui, Clock::Since, *at, area, buf),
362 Node::Until { at } => clock_draw(tui, Clock::Until, *at, area, buf),
363 Node::Age { at } => clock_draw(tui, Clock::Age, *at, area, buf),
364
365 Node::Token(tag) => {
366 let focused = claim_tag(pass, tag);
367 text::draw_line(&Line::from(tag_span(tui, tag, focused)), area, buf)
368 }
369
370 Node::Figure(figure) => draw_figure(tui, figure, area, buf),
371
372 Node::Image(picture) => draw_image(tui, picture, area, buf),
373
374 // A banner and a toast are the same rows here. A toast is a message
375 // that goes away on its own, which is a clock the description does not
376 // carry and the drawing has no way to keep, so the kind is read and
377 // deliberately not honoured. Filed.
378 Node::Notice {
379 tone,
380 text: content,
381 act,
382 ..
383 } => {
384 let style = tui.style().tone(*tone).add_modifier(Modifier::BOLD);
385 let used = text::draw(content, style, area, buf);
386 // The one thing to do about it, under the sentence saying what
387 // happened. `Node::StandIn`'s arm below, verbatim: the two are a
388 // situation and the way out of it, and drawing them two ways would
389 // be this renderer inventing a difference between them.
390 match act {
391 Some(act) => {
392 let focused = claim_act(pass, act);
393 let line = if pass.view.busy(&act.action) {
394 busy_line(pass, tui, act, focused)
395 } else {
396 act_line(tui, act, focused)
397 };
398 used + text::draw_line(&line, below(area, used), buf)
399 }
400 None => used,
401 }
402 }
403
404 Node::StandIn {
405 state,
406 message,
407 act,
408 ..
409 } => {
410 let style = match state {
411 layout::Readiness::Failed => tui.style().tone(layout::Tone::Danger),
412 _ => Style::default().fg(tui.theme().content_muted),
413 };
414 let used = text::draw(message, style, area, buf);
415 match act {
416 Some(act) => {
417 let focused = claim_act(pass, act);
418 let line = if pass.view.busy(&act.action) {
419 busy_line(pass, tui, act, focused)
420 } else {
421 act_line(tui, act, focused)
422 };
423 used + text::draw_line(&line, below(area, used), buf)
424 }
425 None => used,
426 }
427 }
428
429 Node::Field(field) => draw_field(pass, field, area, buf),
430
431 Node::Form { submit, fields, .. } => {
432 let mut used = 0;
433 for field in fields {
434 used += draw_field(pass, field, below(area, used), buf);
435 }
436 // The submit, drawn as the act it is. The form's own action is not
437 // drawn: an address is not a thing a cell can show, and the runtime
438 // is what follows it.
439 let focused = pass.claim();
440 used + text::draw_line(
441 &Line::from(vec![Span::styled(
442 format!("[ {submit} ]"),
443 tui.style().focused(
444 focused,
445 Style::default()
446 .fg(tui.theme().selection_on)
447 .bg(tui.theme().action_primary),
448 ),
449 )]),
450 below(area, used),
451 buf,
452 )
453 }
454
455 // A table that declared no columns is a list, and draws as one: a run
456 // per row rather than a grid. The arm below draws the grid, and both
457 // hold the same `Row`.
458 Node::Table {
459 columns,
460 rows,
461 more,
462 ..
463 } if columns.is_empty() => {
464 // A gutter for the tick and the current marker, and only when some
465 // row in the list has one. Both are facts about the row that a
466 // webview says with a checkbox and an `aria-current`, and neither
467 // is content, so neither belongs in the run. A list where no row is
468 // tickable spends no columns on the possibility.
469 let gutter = list_gutter(rows);
470 // The chevron column, spent on every row of a list that holds a
471 // branch so the labels line up. See `outline::lead`.
472 let branches = rows.iter().any(|row| row.open.is_some());
473 let folded = crate::outline::folds(rows, Some(pass.view));
474 let mut used = 0;
475 for (row, folded) in rows.iter().zip(folded) {
476 // A row a shut branch covers is not drawn, claims nothing and
477 // takes no room. Nothing is counted past for it either: the
478 // focus walk skipped it too, so the two orders agree.
479 if folded {
480 continue;
481 }
482 // Claimed before the room is checked, because the count is a
483 // fact about the description and the room is a fact about the
484 // window.
485 let focused = claim_row(pass, row);
486 let parts = claim_parts(pass, row);
487 let at = below(area, used);
488 if at.height == 0 {
489 continue;
490 }
491 // The row's own indent, after the list's gutter and before its
492 // run. Per row rather than per list, which is what a flat list
493 // of rows carrying their depth means.
494 let lead = crate::outline::lead(row.depth, branches);
495 // What fits is decided at the width the row will actually get,
496 // and the height pass above answers the same question the same
497 // way. A drop the two disagreed about is a row drawing over the
498 // one under it.
499 let cap = row_lines(row);
500 let kept = fitted(
501 tui,
502 row,
503 &parts,
504 at.width.saturating_sub(gutter).saturating_sub(lead),
505 cap,
506 );
507 let line = row_line_of(tui, row, &parts, &kept);
508 draw_gutter(tui, pass.view, row, focused, at, buf);
509 // The chevron, at the end of the row's indent and immediately
510 // before its words. A separate mark from the caret in the
511 // gutter: that one says where the reader is and this one says
512 // what the row holds, and a terminal that drew them in one
513 // column would have to choose between the two facts.
514 if let Some(described) = row.open {
515 let open = pass.view.open(&row.key(), described);
516 buf.set_stringn(
517 at.x + gutter + lead.saturating_sub(crate::outline::STEP),
518 at.y,
519 crate::outline::chevron(open),
520 1,
521 tui.style()
522 .focused(focused, Style::default().fg(tui.theme().content_secondary)),
523 );
524 }
525 // The run is capped here, which is the whole of what `Flow`
526 // buys a terminal: `draw_line` stops at `area.height`, so
527 // giving it the row's own budget is what turns "as many lines
528 // as the words need" into "as many as the description allowed".
529 let body = Rect {
530 x: at.x + gutter + lead,
531 width: at.width.saturating_sub(gutter).saturating_sub(lead),
532 height: at.height.min(cap),
533 ..at
534 };
535 let drew = text::draw_line(&line, body, buf).max(1);
536 ellipsis_if_cut(tui, &line, body, buf);
537 used += drew;
538 }
539 match more {
540 Some(rest) => used + draw_rest(pass, tui, rest, below(area, used), buf),
541 None => used,
542 }
543 }
544
545 // The clock column, then the row. See `height` for what this renderer
546 // keeps and what it gives up.
547 //
548 // Drawn in the order the description gave, not sorted by start. Sorting
549 // is presentation and this renderer could do it, but a caller that
550 // built its entries in a deliberate order would find them silently
551 // rearranged, and the description has no way to say which it meant.
552 // `Node::Timeline`'s doc records that a renderer must not assume the
553 // entries are sorted; quietly sorting them is the same assumption from
554 // the other side.
555 Node::Timeline { track, entries, .. } => {
556 let mut used = 0;
557 for entry in entries {
558 let focused = claim_row(pass, &entry.row);
559 let parts = claim_parts(pass, &entry.row);
560 let entry_cap = row_lines(&entry.row);
561 let kept = fitted(
562 tui,
563 &entry.row,
564 &parts,
565 area.width.saturating_sub(TIMELINE_GUTTER),
566 entry_cap,
567 );
568 let line = row_line_of(tui, &entry.row, &parts, &kept);
569 let at = below(area, used);
570 if at.height == 0 {
571 continue;
572 }
573
574 // Wall clock, wrapped past midnight, matching what the webview
575 // writes into its ruler. A span may legitimately count past
576 // 1440 so that it needs no date.
577 let minute = entry.placement.at();
578 let clock = format!("{:02}:{:02} ", (minute / 60) % 24, minute % 60);
579 let style = if focused {
580 tui.style().muted.add_modifier(Modifier::REVERSED)
581 } else {
582 tui.style().muted
583 };
584 buf.set_span(
585 at.x,
586 at.y,
587 &Span::styled(clock, style),
588 TIMELINE_GUTTER.min(at.width),
589 );
590
591 let body = Rect {
592 x: at.x + TIMELINE_GUTTER,
593 width: at.width.saturating_sub(TIMELINE_GUTTER),
594 height: at.height.min(entry_cap),
595 ..at
596 };
597 let drew = text::draw_line(&line, body, buf).max(1);
598 ellipsis_if_cut(tui, &line, body, buf);
599 used += drew;
600 }
601 // `track` is read for nothing here, and that is the honest state:
602 // the span, the slot and the tick are all questions about where to
603 // put a gridline, and this renderer draws none. Named rather than
604 // ignored with `..` so that a member added to `Track` has to come
605 // past this comment.
606 let _ = track;
607 used
608 }
609 Node::Table {
610 columns,
611 rows,
612 more,
613 ..
614 } => {
615 let used = draw_table(pass, columns, rows, area, buf);
616 // Under the table and belonging to it, which is the adjacency the
617 // task list lost when its paging had to be a separate `Node::Act`.
618 match more {
619 Some(rest) => used + draw_rest(pass, tui, rest, below(area, used), buf),
620 None => used,
621 }
622 }
623
624 Node::Meter(meter) => text::draw_line(&meter_line(tui, meter), area, buf),
625
626 Node::Chart { axis, bars, .. } => {
627 let mut used = 0;
628 for line in chart_lines(tui, axis, bars) {
629 used += text::draw_line(&line, below(area, used), buf);
630 }
631 used
632 }
633
634 Node::Stats { figures, .. } => {
635 // Down and not across. A strip of tiles is a row on a webview
636 // because a webview has room to the right; a terminal that put four
637 // figures on one line would have five cells for each caption.
638 // Stacking is the renderer deciding, and the node still says "these
639 // belong together", which is what it is for.
640 let mut used = 0;
641 for (figure, _) in figures {
642 used += draw_figure(tui, figure, below(area, used), buf);
643 }
644 used
645 }
646
647 Node::Region(slot) => crate::region::draw(pass, slot, area, buf),
648
649 // A member added since this renderer last learned the vocabulary. See
650 // [`UNDRAWN`] for why it says so rather than drawing nothing, and
651 // `focus.rs` for why it is not reachable while it says it.
652 _ => text::draw(
653 UNDRAWN,
654 Style::default().fg(tui.theme().content_muted),
655 area,
656 buf,
657 ),
658 }
659 }
660
661 /// Advance the count past a node that was not drawn, so that a screen too tall
662 /// for its terminal keeps the focus order it had when it fit.
663 fn count(pass: &mut Pass<'_>, node: &Node) {
664 let mut found = Vec::new();
665 // The region's name does not matter here: only how many things were passed.
666 crate::focus::node_spots(node, "", &pass.local(), &mut found);
667 pass.seq += found.len();
668 }
669
670 /// Claim a control, unless it is disabled and therefore unreachable.
671 fn claim_act(pass: &mut Pass<'_>, act: &Act) -> bool {
672 !act.state.is_some_and(layout::State::suppresses_interaction) && pass.claim()
673 }
674
675 /// Claim a chip, which is the only tag that answers anything.
676 fn claim_tag(pass: &mut Pass<'_>, tag: &Tag) -> bool {
677 matches!(tag.kind, layout::Token::Chip { .. }) && tag.action.is_some() && pass.claim()
678 }
679
680 /// Claim a row, when the description gives it something to do.
681 fn claim_row(pass: &mut Pass<'_>, row: &Row) -> bool {
682 let reachable = row.activate.is_some()
683 || row.toggle.is_some()
684 || row.selected.is_some()
685 || !row.menu.is_empty();
686 reachable && pass.claim()
687 }
688
689 /// Claim whatever the row's own run carries, one answer per part.
690 fn claim_parts(pass: &mut Pass<'_>, row: &Row) -> Vec<bool> {
691 row.cells
692 .iter()
693 .map(|cell| {
694 cell.content.iter().any(|node| match node {
695 Node::Act(act) => claim_act(pass, act),
696 Node::Link { .. } => pass.claim(),
697 Node::Token(tag) => claim_tag(pass, tag),
698 _ => false,
699 })
700 })
701 .collect()
702 }
703
704 /// The style text that goes somewhere takes.
705 fn link_style(tui: &Tui) -> Style {
706 Style::default()
707 .fg(tui.theme().action_primary)
708 .add_modifier(Modifier::UNDERLINED)
709 }
710
711 /// The columns a list spends before its rows.
712 ///
713 /// Four for a tick, because `[x] ` is four cells; two for the marker alone;
714 /// none when the list needs neither.
715 ///
716 /// A row that can be reached takes the marker's two columns whether or not it
717 /// is the current one, because focus is drawn there and a gutter of zero would
718 /// put the caret over the first word. That is the drawing paying for an
719 /// interaction, which is what a gutter is: the description says the row can be
720 /// opened, and this is the terminal's way of showing which one is about to be.
721 /// The clock column a timeline draws its rows against.
722 ///
723 /// `HH:MM ` is six cells. Fixed rather than measured, because a ragged clock
724 /// column is worse than a wide one and every entry on an axis has a time by
725 /// construction — there is no "some rows have one" case the way there is for a
726 /// tick.
727 const TIMELINE_GUTTER: u16 = 6;
728
729 /// One piece of the line under a partial set: its words, and what pressing it
730 /// calls.
731 pub(crate) struct RestPiece<'a> {
732 /// What is printed.
733 pub words: String,
734 /// What a caret on it calls, when it is somewhere to go rather than a
735 /// readout.
736 pub action: Option<&'a Action>,
737 }
738
739 /// The one line under a partial list, as the pieces it is made of.
740 ///
741 /// One line whatever the description carries, because a terminal has no room to
742 /// spend on chrome and because a pager that grows a row on page two moves
743 /// everything below it. That is "first paint is final paint" in a renderer that
744 /// measures in cells: [`height`] adds exactly one for this whatever this
745 /// returns, and the drawing truncates rather than wrapping.
746 ///
747 /// # Pieces rather than a string, and why the focus walk reads it too
748 ///
749 /// The drawing and the focus walk both read this, so they cannot count the
750 /// stops on a line differently. A walk that pushes a stop the drawing claims no
751 /// position for puts every caret below it one place early, which is the defect
752 /// `the_drawing_counts_the_same_table_rows_the_walk_stops_on` records.
753 ///
754 /// Prev and Next are printed whether or not they can be pressed, for the reason
755 /// the webview draws them disabled: a pager that gains a word on page two moves
756 /// what is beside it.
757 ///
758 /// A page count is the same "3 / 8" the webview prints, so a bug report reads
759 /// the same from either host -- and where the description offered jumps the
760 /// numbered pages replace it, for the webview's reason: the strip already says
761 /// which page and how many, and printing the position twice is a control
762 /// arguing with itself.
763 pub(crate) fn rest_pieces(rest: &Rest) -> Vec<RestPiece<'_>> {
764 let mut pieces = vec![RestPiece {
765 words: "Prev".to_string(),
766 action: rest.back.as_ref(),
767 }];
768
769 if rest.jumps.is_empty() {
770 let paging = rest.as_layout();
771 pieces.push(RestPiece {
772 words: match (paging.page(), paging.pages_total()) {
773 (Some(page), Some(total)) => format!("{page} / {total}"),
774 _ => match paging.remaining() {
775 Some(0) => "No more".to_string(),
776 Some(remaining) => format!("{remaining} more"),
777 None => "More".to_string(),
778 },
779 },
780 action: None,
781 });
782 } else {
783 for jump in &rest.jumps {
784 pieces.push(RestPiece {
785 words: jump.page.to_string(),
786 // The page the reader is on is a readout and not somewhere to
787 // go. A control that reloads the page it is on is the lying
788 // control the webview refuses for the same reason.
789 action: if jump.here { None } else { Some(&jump.action) },
790 });
791 }
792 }
793
794 pieces.push(RestPiece {
795 words: "Next".to_string(),
796 action: rest.forward.as_ref(),
797 });
798 pieces
799 }
800
801 /// The pager line, claiming one position per piece a caret can land on.
802 ///
803 /// The claim order is [`rest_pieces`]' order, which is the order
804 /// `focus::reaches` pushes them in, which is what keeps the caret and the
805 /// highlight on the same word.
806 fn draw_rest(pass: &mut Pass<'_>, tui: &Tui, rest: &Rest, area: Rect, buf: &mut Buffer) -> u16 {
807 let mut spans = Vec::new();
808 for piece in rest_pieces(rest) {
809 if !spans.is_empty() {
810 spans.push(Span::raw(" "));
811 }
812 let style = match piece.action {
813 Some(_) => {
814 let focused = pass.claim();
815 tui.style()
816 .focused(focused, Style::default().fg(tui.theme().action_primary))
817 }
818 // Not reachable, so it takes no position and never lights: the
819 // readout between the ends, and the page already being read.
820 None => Style::default().fg(tui.theme().content_muted),
821 };
822 spans.push(Span::styled(piece.words, style));
823 }
824 text::draw_line(&Line::from(spans), area, buf)
825 }
826
827 fn list_gutter(rows: &[Row]) -> u16 {
828 if rows.iter().any(|row| row.selected.is_some()) {
829 4
830 } else if rows
831 .iter()
832 .any(|row| row.current || row.activate.is_some() || !row.menu.is_empty())
833 {
834 2
835 } else {
836 0
837 }
838 }
839
840 /// The tick and the current marker, in the columns before a row.
841 ///
842 /// The focus lands here rather than on the row's words. A row is a whole line
843 /// and reversing all of it turns a list into a slab; the gutter is the column
844 /// the affordances already live in, so it is where "you are on this one" can be
845 /// said without repainting the content.
846 fn draw_gutter(tui: &Tui, view: &View, row: &Row, focused: bool, area: Rect, buf: &mut Buffer) {
847 // A tickable row that names a value is drawn from the set the view is
848 // holding, and only an unnamed one falls back to what the description
849 // said. That is the same rule `39057019` settled for a field: the
850 // description says what arrived, the view says what the user has done
851 // since, and a redraw that went back to the description would undo the
852 // tick the moment anything else on the screen changed.
853 let tick = match (row.selected, row.value.as_deref()) {
854 (Some(_), Some(value)) if view.is_ticked(value) => "[x]",
855 (Some(_), Some(_)) => "[ ]",
856 (Some(true), None) => "[x]",
857 (Some(false), None) => "[ ]",
858 (None, _) => "",
859 };
860 if !tick.is_empty() {
861 buf.set_stringn(
862 area.x,
863 area.y,
864 tick,
865 3,
866 tui.style()
867 .focused(focused, Style::default().fg(tui.theme().content_secondary)),
868 );
869 return;
870 }
871 // A row of a live selection, marked in the same gutter and never as a box:
872 // a box is the affordance for staging a set, and this set is already in
873 // force. `1894e95d`. The caret's own `>` still wins where they coincide,
874 // because "you are here" is the more perishable fact of the two.
875 if row.chosen == Some(true) && !(row.current || focused) {
876 buf.set_stringn(
877 area.x,
878 area.y,
879 "*",
880 1,
881 tui.style()
882 .focused(focused, Style::default().fg(tui.theme().action_primary)),
883 );
884 return;
885 }
886 if row.current || focused {
887 buf.set_stringn(
888 area.x,
889 area.y,
890 ">",
891 1,
892 tui.style()
893 .focused(focused, Style::default().fg(tui.theme().action_primary)),
894 );
895 }
896 }
897
898 /// A row's run as one line of spans.
899 ///
900 /// The terminal reads what the description says, in the order it says it, and
901 /// the role picks the style. No fixed sequence of parts is hardcoded here.
902 /// `focus` carries one answer per part, in the run's own order, and is empty
903 /// for the callers that are measuring rather than drawing.
904 /// The run as a line, from the parts a caller decided to keep.
905 ///
906 /// `kept` holds indices into `row.cells`, so focus stays keyed to the part it
907 /// was claimed for even when parts before it were dropped. Claims happen before
908 /// layout and must not be renumbered by it.
909 fn row_line_of(tui: &Tui, row: &Row, focus: &[bool], kept: &[usize]) -> Line<'static> {
910 let mut spans = Vec::new();
911 for &index in kept {
912 let Some(cell) = row.cells.get(index) else {
913 continue;
914 };
915 if !spans.is_empty() {
916 spans.push(Span::raw(" "));
917 }
918 let focused = focus.get(index).copied().unwrap_or(false);
919 // A list draws over the default column set, so every cell in one is
920 // keyed by role. A cell keyed to a declared column has reached a list
921 // row, which is a description error rather than something to style:
922 // the table constructors produce those keys and a list row is built by
923 // `Row::new` and its siblings.
924 //
925 // Loud in debug and benign in release, matching the webview and the two
926 // assertions `Table::row` already carries (`d41d00a`). A panic in a
927 // description is worse than a line drawn under the wrong style.
928 debug_assert!(
929 matches!(cell.key, quasi_router::CellKey::Role(_)),
930 "a cell keyed to a declared column reached a list row, which draws over the \
931 default column set and has no column to style it from. Key: {:?}",
932 cell.key,
933 );
934 let style = match &cell.key {
935 quasi_router::CellKey::Role(role) => part_style(tui, *role),
936 _ => part_style(tui, quasi_router::layout::RowPart::Primary),
937 };
938 for node in &cell.content {
939 spans.extend(inline_spans(tui, node, style, focused));
940 }
941 }
942 // `Row::menu` is not drawn, and that is the description's own instruction:
943 // a menu is reached by right-click on a pointer host, long-press on a touch
944 // one, and a key in a terminal. The key is the runtime's.
945 Line::from(spans)
946 }
947
948 /// Every part, in order: what a row draws when there is room for all of it.
949 fn kept_all(row: &Row) -> Vec<usize> {
950 (0..row.cells.len()).collect()
951 }
952
953 /// The parts that survive a run too tall for its cap.
954 ///
955 /// Drops by [`quasi_router::Cell::worth`], least valuable first, and only when
956 /// dropping earns something: if the run still does not fit with every droppable
957 /// part gone, the whole run comes back and the cap cuts it. That is the rule
958 /// that keeps this honest. Dropping a badge to make room for a title that
959 /// overflows on its own changes nothing a reader can see -- the badge was past
960 /// the cut either way -- so it would be spending the description's words for
961 /// no picture.
962 ///
963 /// `Essential` is never dropped, so a row still identifies itself and still
964 /// offers what it offers however narrow the terminal gets.
965 ///
966 /// **A control is never dropped whatever it is worth.** Focus is claimed per
967 /// part before any of this runs, so removing an `Act` would leave a claim
968 /// pointing at something nobody drew: a key that moves the cursor onto a
969 /// control that is not there. A description marking its own action `Optional`
970 /// is saying something about the picture, and this is the one place where the
971 /// picture is not the whole story.
972 ///
973 /// Whole parts rather than characters. Cutting the tail is what the cap already
974 /// does and it takes whatever happens to be last; this takes what the
975 /// description said it could spare, which is the difference the ladder is for.
976 fn fitted(tui: &Tui, row: &Row, focus: &[bool], width: u16, cap: u16) -> Vec<usize> {
977 let all = kept_all(row);
978 let fits =
979 |kept: &[usize]| text::line_height(&row_line_of(tui, row, focus, kept), width) <= cap;
980 if fits(&all) {
981 return all;
982 }
983
984 let mut kept = all.clone();
985 for tier in [layout::Priority::Optional, layout::Priority::Secondary] {
986 kept.retain(|&index| {
987 row.cells.get(index).is_none_or(|cell| {
988 cell.content.iter().any(|n| matches!(n, Node::Act(_))) || cell.worth() != tier
989 })
990 });
991 if fits(&kept) {
992 return kept;
993 }
994 }
995 all
996 }
997
998 /// Mark a run that the cap cut short.
999 ///
1000 /// A clamp that just stops is a row that looks complete and is not, and the
1001 /// reader has no way to tell the difference. The webview does not have this
1002 /// problem -- `-webkit-line-clamp` writes the ellipsis itself -- so this is the
1003 /// terminal paying for the same honesty by hand.
1004 ///
1005 /// Overwrites the last cell of the last line the cap allowed. That cell already
1006 /// holds content, which is the point: there is no room to append to a full
1007 /// line, and a character of the cut text is the right thing to spend.
1008 fn ellipsis_if_cut(tui: &Tui, line: &Line<'_>, body: Rect, buf: &mut Buffer) {
1009 if body.width == 0 || body.height == 0 {
1010 return;
1011 }
1012 if text::line_height(line, body.width) <= body.height {
1013 return;
1014 }
1015 buf.set_stringn(
1016 body.x + body.width - 1,
1017 body.y + body.height - 1,
1018 "\u{2026}",
1019 1,
1020 tui.style().muted,
1021 );
1022 }
1023
1024 /// How many lines a row may take, from what its parts asked for.
1025 ///
1026 /// The max rather than the sum. A row is an inline run: its parts share one
1027 /// wrapped flow here rather than stacking the way a webview's spans do, so a
1028 /// part asking for two lines is asking *the run* for a second line, and two
1029 /// relaxed parts in one row are still asking for the same second line. Summing
1030 /// would give a row of five tight parts five lines, which is the unbounded
1031 /// behaviour this cap exists to end.
1032 ///
1033 /// `layout::Flow::lines` owns the numbers, so a tier added upstream arrives
1034 /// here without this function being edited.
1035 fn row_lines(row: &Row) -> u16 {
1036 row.cells
1037 .iter()
1038 .map(|cell| u16::from(cell.room().lines()))
1039 .max()
1040 .unwrap_or(1)
1041 .max(1)
1042 }
1043
1044 /// The style a row part takes.
1045 fn part_style(tui: &Tui, role: layout::RowPart) -> Style {
1046 let theme = tui.theme();
1047 match role {
1048 layout::RowPart::Primary => Style::default().fg(theme.content_primary),
1049 layout::RowPart::Secondary => Style::default().fg(theme.content_secondary),
1050 layout::RowPart::Meta => Style::default().fg(theme.content_muted),
1051 // Tokens, actions and a proportion each carry their own tone, so the
1052 // part inherits rather than tinting what sits on it. That is exactly
1053 // what `RowPart::intent` answers for a webview, said in colours.
1054 _ => Style::default().fg(theme.content_primary),
1055 }
1056 }
1057
1058 /// One run of source, as a span under this terminal's palette.
1059 ///
1060 /// `19d7602d`. The classification arrived with the description and this is the
1061 /// half a terminal owes: mapping eight names onto the colours it actually has.
1062 ///
1063 /// # It spends status colours, and that is deliberate
1064 ///
1065 /// This theme has no syntax palette and is not going to grow one: a
1066 /// highlighting palette is held fixed while everything around it changes, which
1067 /// is the opposite of what a theme is for. So the mapping reaches for the
1068 /// status colours, and a red variable does not mean an error here any more than
1069 /// a red variable means one in any editor. The alternative was drawing every
1070 /// file in one colour, which loses the whole of what the description carried.
1071 ///
1072 /// The pairing follows base16 Tomorrow, which is the palette the measured
1073 /// consumer already fixed: comments quiet, strings green, constants amber,
1074 /// definitions blue, uses red.
1075 fn lexeme_span(tui: &Tui, run: &quasi_router::screen::Lexeme, base: Style) -> Span<'static> {
1076 let theme = tui.theme();
1077 let style = match run.syntax {
1078 layout::Syntax::Plain => base,
1079 layout::Syntax::Comment => base.fg(theme.content_muted),
1080 layout::Syntax::String => base.fg(theme.status_success),
1081 layout::Syntax::Keyword => base.fg(theme.action_primary),
1082 layout::Syntax::Constant => base.fg(theme.status_warning),
1083 layout::Syntax::Entity => base.fg(theme.status_info),
1084 layout::Syntax::Variable => base.fg(theme.status_danger),
1085 layout::Syntax::Support => base.fg(theme.content_secondary),
1086 // A class added to this `#[non_exhaustive]` axis since this renderer
1087 // last learned the vocabulary. Ordinary code is the safe reading: the
1088 // text is drawn either way and only the colour is lost.
1089 _ => base,
1090 };
1091 Span::styled(run.text.clone(), style)
1092 }
1093
1094 /// Every run of a code node, as spans.
1095 fn code_spans(tui: &Tui, runs: &[quasi_router::screen::Lexeme], base: Style) -> Vec<Span<'static>> {
1096 runs.iter().map(|run| lexeme_span(tui, run, base)).collect()
1097 }
1098
1099 /// One leaf of a run as spans, under the run's own style.
1100 fn inline_spans(tui: &Tui, node: &Node, inherited: Style, focused: bool) -> Vec<Span<'static>> {
1101 match node {
1102 Node::Text { text, tone } => {
1103 let style = match tone {
1104 layout::Tone::Neutral => inherited,
1105 other => tui.style().tone(*other),
1106 };
1107 vec![Span::styled(text.clone(), style)]
1108 }
1109 Node::Rich { source, .. } => rich_spans(tui, source, inherited),
1110 // An inline literal in a row or a cell: a clone URL, a fingerprint, a
1111 // config line. Every cell is monospace here, so what a webview says
1112 // with a typeface is already true and only the colouring is left.
1113 Node::Code { runs, .. } => code_spans(tui, runs, inherited),
1114 Node::Token(tag) => vec![tag_span(tui, tag, focused)],
1115 Node::Act(act) => act_line(tui, act, focused).spans,
1116 Node::Link { text, .. } => vec![Span::styled(
1117 text.clone(),
1118 tui.style().focused(focused, link_style(tui)),
1119 )],
1120 Node::Meter(meter) => meter_line(tui, meter).spans,
1121 Node::Figure(figure) => vec![Span::styled(
1122 format!("{} {}", figure.value, figure.caption),
1123 inherited,
1124 )],
1125 // In the run rather than on a line of its own, which is where the
1126 // measured one is: goingson's elapsed time sits on the task row beside
1127 // the title. It takes the role's colour like any other part, because
1128 // what it says is a value and not a state.
1129 Node::Since { at } => vec![Span::styled(clock_text(Clock::Since, *at), inherited)],
1130 Node::Until { at } => vec![Span::styled(clock_text(Clock::Until, *at), inherited)],
1131 Node::Age { at } => vec![Span::styled(clock_text(Clock::Age, *at), inherited)],
1132 // Everything else is a block, and the containment bound is what
1133 // guarantees one cannot be here. Drawing the text is the honest answer
1134 // to a case the type system says is unreachable.
1135 other => vec![Span::styled(
1136 format!("{other:?}"),
1137 Style::default().fg(tui.theme().status_danger),
1138 )],
1139 }
1140 }
1141
1142 /// A time-derived readout, drawn on its own line.
1143 fn clock_draw(tui: &Tui, clock: Clock, at: SystemTime, area: Rect, buf: &mut Buffer) -> u16 {
1144 text::draw(
1145 &clock_text(clock, at),
1146 tui.style().tone(layout::Tone::Neutral),
1147 area,
1148 buf,
1149 )
1150 }
1151
1152 /// What a readout of this kind says at this instant.
1153 ///
1154 /// The clock is read here rather than passed in, which is the shape the ruling
1155 /// asks for: the renderer owns it. A test that needs a fixed answer calls
1156 /// [`crate::clock::text`] with a `now` of its own.
1157 fn clock_text(clock: Clock, at: SystemTime) -> String {
1158 crate::clock::text(clock, at, SystemTime::now())
1159 }
1160
1161 /// The style a rich node's unmarked prose takes when it stands on its own,
1162 /// rather than inside a run that has already picked one.
1163 fn rich_base(tui: &Tui) -> Style {
1164 Style::default().fg(tui.theme().content_primary)
1165 }
1166
1167 /// Markdown source as spans: the words, each under the marks that were over it
1168 /// and in the shape of the block it came from.
1169 ///
1170 /// `base` is what the prose takes where the source said nothing, so the same
1171 /// function serves a rich node standing alone and one sitting inside a row's
1172 /// run, where the part's role has already decided the colour.
1173 fn rich_spans(tui: &Tui, source: &str, base: Style) -> Vec<Span<'static>> {
1174 let mut spans = Vec::new();
1175 // A marker belongs at the head of a line and nowhere else, and a run knows
1176 // its block but not its position. The separator runs are what carry the
1177 // breaks, so the run before this one is what says whether a line just
1178 // started.
1179 let mut starting = true;
1180 for run in docengine::render_runs(source) {
1181 if starting && let Some(marker) = marker(run.block) {
1182 spans.push(Span::styled(
1183 marker,
1184 Style::default().fg(tui.theme().content_muted),
1185 ));
1186 }
1187 starting = run.text.ends_with('\n');
1188 let style = style_of(tui, base, &run);
1189 spans.push(Span::styled(run.text, style));
1190 }
1191 spans
1192 }
1193
1194 /// What a block puts in front of its first line, where a webview would have used
1195 /// a bullet glyph or an indent.
1196 ///
1197 /// The description carries no marker of its own, deliberately: what a bullet
1198 /// looks like is the renderer's answer, and this is a terminal's.
1199 fn marker(block: docengine::Block) -> Option<&'static str> {
1200 match block {
1201 docengine::Block::Item => Some("- "),
1202 docengine::Block::Quote => Some("> "),
1203 docengine::Block::Prose | docengine::Block::Heading(_) => None,
1204 }
1205 }
1206
1207 /// One run's block and marks as a style over `base`.
1208 ///
1209 /// The block decides the ground the run is drawn on and the marks are added to
1210 /// it, which is the order a stylesheet uses: a heading with `**bold**` inside it
1211 /// is bold on top of heading weight rather than instead of it.
1212 fn style_of(tui: &Tui, base: Style, run: &docengine::TextRun) -> Style {
1213 let ground = match run.block {
1214 // The three markdown levels a terminal can tell apart, which is as many
1215 // as `layout::Heading` has: a rich node's `######` and its `###` land in
1216 // the same place because a cell has one size and only so much colour.
1217 docengine::Block::Heading(1) => tui.style().heading(layout::Heading::Page),
1218 docengine::Block::Heading(2) => tui.style().heading(layout::Heading::Section),
1219 docengine::Block::Heading(_) => tui.style().heading(layout::Heading::Subsection),
1220 docengine::Block::Quote => Style::default().fg(tui.theme().content_secondary),
1221 docengine::Block::Prose | docengine::Block::Item => base,
1222 };
1223 mark(tui, ground, run.emphasis)
1224 }
1225
1226 /// One run's marks as a style over `base`.
1227 ///
1228 /// Three of the four are the modifier a terminal already has for them. Code is
1229 /// the one with no modifier to take -- every cell is monospace, so the thing a
1230 /// webview says with a typeface cannot be said that way here -- and it takes
1231 /// the sunken surface instead, which is what the theme has for "this is set
1232 /// into the page rather than on it".
1233 fn mark(tui: &Tui, base: Style, emphasis: docengine::Emphasis) -> Style {
1234 if emphasis.is_plain() {
1235 return base;
1236 }
1237 let mut style = base;
1238 if emphasis.strong {
1239 style = style.add_modifier(Modifier::BOLD);
1240 }
1241 if emphasis.italic {
1242 style = style.add_modifier(Modifier::ITALIC);
1243 }
1244 if emphasis.struck {
1245 style = style.add_modifier(Modifier::CROSSED_OUT);
1246 }
1247 if emphasis.code {
1248 style = style.bg(tui.theme().surface_sunken);
1249 }
1250 style
1251 }
1252
1253 /// A tag as one span.
1254 ///
1255 /// The bracket, the latch and the collision between latched and focused are all
1256 /// `makeover-tui`'s answers now. What is left here is the translation: our
1257 /// owned [`Tag`] into the parts the shared drawing takes.
1258 /// # What this renderer does with a hint
1259 ///
1260 /// Drops it.: a terminal has no hover and no second surface to put standing
1261 /// help on, and the alternatives are both worse than nothing -- appending it
1262 /// to the label turns a badge into a sentence and defeats the reason a badge
1263 /// is short, and a status line borrowed for it would be competing with what
1264 /// the runtime already puts there.
1265 ///
1266 /// This is the graceful degradation [`Tag::hint`] describes rather than a gap,
1267 /// and it is why that field says nothing may live only there. Stated here so
1268 /// that a reader comparing the three renderers finds an answer rather than an
1269 /// omission.
1270 fn tag_span(tui: &Tui, tag: &Tag, focused: bool) -> Span<'static> {
1271 piece::token(
1272 tui.style(),
1273 &tag.label,
1274 tag.kind,
1275 tag.tone,
1276 tag.latched,
1277 focused,
1278 )
1279 }
1280
1281 /// A control as a line.
1282 ///
1283 /// `Act::confirm` and `Act::action` do not cross into the description layer's
1284 /// [`layout::Act`] and so are not drawn: an address is not a thing a cell can
1285 /// show, and a confirmation is a question asked after the press, which is the
1286 /// runtime's. [`quasi_router::Act::as_layout`] says the same at the seam.
1287 fn act_line(tui: &Tui, act: &Act, focused: bool) -> Line<'static> {
1288 piece::act(tui.style(), &act.as_layout(), focused)
1289 }
1290
1291 /// A control that has been pressed and has not been answered yet.
1292 ///
1293 /// [`layout::State::Disabled`], and the wait beside it.
1294 ///
1295 /// **The lock is rule 4 and stays.** A control that refuses a second press is
1296 /// already saying something, and on most waits it says the whole of it. What it
1297 /// cannot say is whether the wait has a size, which is what the mark adds.
1298 ///
1299 /// # The reflow this accepts
1300 ///
1301 /// Appending the mark widens the control the moment it is pressed, which is the
1302 /// reflow "first paint is final paint" otherwise forbids. It is drawn anyway,
1303 /// on the standard's own reading: a wait is a state of the control rather than
1304 /// an ornament beside it, and a state that cannot be seen is not being
1305 /// reported. The widening is bounded and it happens on a press the reader just
1306 /// made, which is the one moment they are looking at that control.
1307 fn busy_line(pass: &Pass<'_>, tui: &Tui, act: &Act, focused: bool) -> Line<'static> {
1308 let mut described = act.as_layout();
1309 described.state = Some(layout::State::Disabled);
1310 let mut line = piece::act(tui.style(), &described, focused);
1311 let Some(awaiting) = act.action.awaiting else {
1312 return line;
1313 };
1314 let progress = pass.view.progress_at(std::time::Instant::now());
1315 let lit =
1316 makeover_tui::activity_lit(progress.elapsed.unwrap_or_default(), tui.reduced_motion());
1317 line.spans.push(Span::raw(" "));
1318 line.spans
1319 .extend(piece::awaiting(tui.style(), awaiting, progress, lit).spans);
1320 line
1321 }
1322
1323 /// How many are ticked, for a control that acts on the selection.
1324 ///
1325 /// `None` for a control that does not, which is nearly all of them.
1326 fn commit_count(pass: &Pass<'_>, over: Option<&str>) -> Option<usize> {
1327 over.map(|_| pass.view.ticks().count())
1328 }
1329
1330 /// A control over the screen's selection, drawn with the set it would act on.
1331 ///
1332 /// Two things the description cannot say and this renderer can. **How many are
1333 /// ticked**: the set is the host's until something submits it, so the store has
1334 /// no idea and a description built from the store cannot carry the number. And
1335 /// **that pressing it now would do nothing**: a commit control over an empty
1336 /// selection is offered, pressed, and answers "0 tasks completed", which is a
1337 /// screen letting a user find out by trying.
1338 ///
1339 /// `bulk-actions.js` says both by hiding its bar and writing "3 selected" into
1340 /// it. Hiding is not the move here — a bar that vanishes takes with it the only
1341 /// evidence that bulk actions exist, and the shipped screen can hide it because
1342 /// its rows carry checkboxes that stay put. Disabled says the same thing and
1343 /// keeps the affordance on screen, which is the rule the row actions already
1344 /// follow.
1345 fn commit_line(tui: &Tui, act: &Act, chosen: Option<usize>, focused: bool) -> Line<'static> {
1346 let Some(chosen) = chosen else {
1347 return act_line(tui, act, focused);
1348 };
1349
1350 // The count rides on the label rather than in a status line of its own,
1351 // because a status line needs somewhere to go and nothing in the
1352 // description says where. On the control it is unambiguous besides: it is
1353 // the number this press would act on.
1354 let label = if chosen == 0 {
1355 act.label.clone()
1356 } else {
1357 format!("{} ({chosen})", act.label)
1358 };
1359 let state = if chosen == 0 {
1360 Some(layout::State::Disabled)
1361 } else {
1362 act.state
1363 };
1364
1365 piece::act(
1366 tui.style(),
1367 &layout::Act {
1368 label: &label,
1369 key: act.key.as_deref(),
1370 tone: act.tone,
1371 state,
1372 // Read by `piece::act_note` rather than by `piece::act`: a control
1373 // is one line and its note is another, so the caller places both.
1374 hint: act.hint.as_deref(),
1375 },
1376 focused,
1377 )
1378 }
1379
1380 /// The rows a control's standing help takes under it.
1381 ///
1382 /// Zero for the control that has none, which is nearly all of them, so a
1383 /// screen written before the member existed measures exactly as it did.
1384 fn act_note_height(tui: &Tui, act: &Act, width: u16) -> u16 {
1385 act_note(tui, act).map_or(0, |note| text::line_height(&note, width))
1386 }
1387
1388 /// Standing help, as the muted row under the control.
1389 ///
1390 /// A row rather than a hover, because a terminal has no pointer to hang one on.
1391 /// `makeover_immediate::widget::act` keeps the hover, which is that host reading
1392 /// itself correctly; both draw the sentence the description states.
1393 ///
1394 /// Only from the block arm. A control inside a run is one line by construction
1395 /// and has nowhere to put a second, which is the same reason its
1396 /// [`Act::asks`](quasi_router::Act::asks) are dropped there.
1397 fn draw_act_note(tui: &Tui, act: &Act, area: Rect, buf: &mut Buffer) -> u16 {
1398 act_note(tui, act).map_or(0, |note| text::draw_line(&note, area, buf))
1399 }
1400
1401 /// The muted line a control's [`Act::hint`](quasi_router::Act::hint) draws as.
1402 ///
1403 /// `piece::act_note`'s since makeover-layout 0.40.0 moved the member down and
1404 /// makeover-tui grew somewhere to read it. It was built here for three
1405 /// releases because `makeover_layout::Act` carried no hint, which is the same
1406 /// line drawn in the same style -- what changes is that a makeover host that
1407 /// is not quasi gets it too.
1408 fn act_note(tui: &Tui, act: &Act) -> Option<Line<'static>> {
1409 piece::act_note(tui.style(), &act.as_layout())
1410 }
1411
1412 /// A meter as a line, bar and label.
1413 fn meter_line(tui: &Tui, meter: &Meter) -> Line<'static> {
1414 piece::meter(tui.style(), &meter.as_layout())
1415 }
1416
1417 /// A chart's bars, one line each.
1418 ///
1419 /// The borrow has to be built here rather than passed through, because the
1420 /// description owns its bars and `makeover-tui` draws the borrowed ones. Every
1421 /// other compound member in this renderer does the same.
1422 fn chart_lines(tui: &Tui, axis: &Chart, bars: &[Bar]) -> Vec<Line<'static>> {
1423 let borrowed: Vec<_> = bars.iter().map(Bar::as_layout).collect();
1424 piece::chart(tui.style(), &axis.as_layout(), &borrowed)
1425 }
1426
1427 /// A figure takes two rows: the number, then what it counts.
1428 fn figure_height(_tui: &Tui, figure: &Figure, width: u16) -> u16 {
1429 piece::figure_height(&figure.as_layout(), width)
1430 }
1431
1432 fn draw_figure(tui: &Tui, figure: &Figure, area: Rect, buf: &mut Buffer) -> u16 {
1433 piece::figure(tui.style(), &figure.as_layout(), area, buf)
1434 }
1435
1436 /// A picture is its alt text here, and a decorative one is nothing.
1437 ///
1438 /// The terminal's honest answer, and the reason `layout::Image::alt` is not an
1439 /// `Option`. There is no graphics protocol in this renderer -- ratatui draws
1440 /// cells -- so what a reader gets is the words the picture stands for. An empty
1441 /// alt is the description saying the picture adds nothing to the text around
1442 /// it, and repeating "image" in its place would be worse than the gap.
1443 ///
1444 /// `Fit` is read and deliberately not honoured, the way `Notice`'s kind is:
1445 /// fitting is about a box with proportions, and a run of words has none.
1446 fn image_height(picture: &Image, width: u16) -> u16 {
1447 if !picture.speaks() {
1448 return 0;
1449 }
1450 text::height(&picture.alt, width)
1451 + picture
1452 .caption
1453 .as_ref()
1454 .map_or(0, |c| text::height(c, width))
1455 }
1456
1457 fn draw_image(tui: &Tui, picture: &Image, area: Rect, buf: &mut Buffer) -> u16 {
1458 if !picture.speaks() {
1459 return 0;
1460 }
1461 // Muted, because this is standing in for something rather than being it.
1462 let used = text::draw(&picture.alt, tui.style().muted, area, buf);
1463 let Some(caption) = &picture.caption else {
1464 return used;
1465 };
1466 // A caption is ordinary content that happens to sit under a picture, so it
1467 // is not muted: it reads the same whether or not the picture arrived.
1468 used + text::draw(caption, tui.style().secondary, below(area, used), buf)
1469 }
1470
1471 /// A question takes its label row, its value row, and a row for whatever went
1472 /// wrong.
1473 fn field_height(tui: &Tui, field: &Field, width: u16, local: &Local<'_>) -> u16 {
1474 // Nothing, for a question that does not apply: the drawing leaves it out
1475 // and this walk counts what the drawing paints. `8fdb814c`.
1476 if local.field_out(&field.name) {
1477 return 0;
1478 }
1479 let Some(repeat) = &field.repeats else {
1480 return one_field_height(tui, field, width);
1481 };
1482 // A question answered N times: the message about the set, then a box per
1483 // slot with its remove control under it, then the add control.
1484 // `60d1753c`. Counted here exactly as `draw_field` paints it, because the
1485 // caret is an index into a walk that reads the same numbers.
1486 let standing = local.standing(field);
1487 let slots: u16 = (0..standing)
1488 .map(|at| {
1489 field
1490 .instance_fields(at)
1491 .iter()
1492 .map(|slot| one_field_height(tui, slot, width))
1493 .sum::<u16>()
1494 + repeat_slot_extra(tui, field, at, width)
1495 + u16::from(repeat.fewer(standing))
1496 })
1497 .sum();
1498 field
1499 .error
1500 .as_ref()
1501 .map_or(0, |error| text::height(error, width))
1502 + slots
1503 + u16::from(repeat.add.label().is_some() && repeat.more(standing))
1504 }
1505
1506 fn one_field_height(tui: &Tui, field: &Field, width: u16) -> u16 {
1507 field.with_layout(|field| piece::field_height(tui.style(), &field, width))
1508 }
1509
1510 fn draw_field(pass: &mut Pass<'_>, field: &Field, area: Rect, buf: &mut Buffer) -> u16 {
1511 // Left out rather than dimmed or explained, which is the answer this
1512 // renderer already gives a region that does not apply. `8fdb814c`.
1513 if pass.local().field_out(&field.name) {
1514 return 0;
1515 }
1516 let Some(repeat) = field.repeats.clone() else {
1517 return draw_one_field(pass, field, area, buf);
1518 };
1519 // A question answered N times. `60d1753c`. Each slot is
1520 // `Field::instance`, so a slot is drawn by exactly what this renderer
1521 // already does to a field, and the two controls are stops of their own for
1522 // the reason `Spot::Repeat` gives.
1523 let tui = pass.tui;
1524 let standing = pass.view.standing(field);
1525 // What is wrong with the *set*, which no slot's own message can carry. A
1526 // slot's error is drawn against its own box, by the field drawing.
1527 let mut used = field.error.as_ref().map_or(0, |error| {
1528 text::draw(error, tui.style().tone(layout::Tone::Danger), area, buf)
1529 });
1530 for at in 0..standing {
1531 for slot in field.instance_fields(at) {
1532 used += draw_one_field(pass, &slot, crate::below(area, used), buf);
1533 }
1534 // What is wrong with the slot as a whole, which no part's own message
1535 // carries, and how far the work on it has got. Counted by
1536 // `repeat_slot_extra`, which is the same walk this paints.
1537 if let Some(slot) = repeat.instances.get(at) {
1538 if let Some(error) = &slot.error {
1539 used += text::draw(
1540 error,
1541 tui.style().tone(layout::Tone::Danger),
1542 crate::below(area, used),
1543 buf,
1544 );
1545 }
1546 if let quasi_router::Progress::Working(Some(meter)) = &slot.progress {
1547 used += text::draw_line(&meter_line(tui, meter), crate::below(area, used), buf);
1548 }
1549 }
1550 if repeat.fewer(standing) {
1551 used += draw_repeat_control(pass, &repeat.remove, crate::below(area, used), buf);
1552 }
1553 }
1554 // Nothing for a question whose slots come from another control: this
1555 // renderer may not have that control at all, and inventing one would offer
1556 // a blank the reader cannot fill.
1557 if let Some(label) = repeat.add.label().filter(|_| repeat.more(standing)) {
1558 used += draw_repeat_control(pass, label, crate::below(area, used), buf);
1559 }
1560 used
1561 }
1562
1563 /// The rows one slot takes beyond its own boxes: its message, and how far the
1564 /// work on it has got.
1565 ///
1566 /// Counted apart from the boxes so that `field_height` and `draw_field` cannot
1567 /// disagree about a slot that failed, which is the same reason the two read
1568 /// `instance_fields` rather than looping the parts themselves.
1569 fn repeat_slot_extra(tui: &Tui, field: &Field, at: usize, width: u16) -> u16 {
1570 let Some(slot) = field
1571 .repeats
1572 .as_ref()
1573 .and_then(|repeat| repeat.instances.get(at))
1574 else {
1575 return 0;
1576 };
1577 slot.error
1578 .as_ref()
1579 .map_or(0, |error| text::height(error, width))
1580 + match &slot.progress {
1581 quasi_router::Progress::Working(Some(meter)) => {
1582 text::line_height(&meter_line(tui, meter), width)
1583 }
1584 _ => 0,
1585 }
1586 }
1587
1588 /// One of the two controls a repeating question offers, drawn as the control it
1589 /// is.
1590 ///
1591 /// Named by the description -- "Add reminder", "Remove" -- rather than bound to
1592 /// a chord, because a key this renderer picked is a fact no description could
1593 /// have written down and a reader could not have read off the screen.
1594 fn draw_repeat_control(pass: &mut Pass<'_>, label: &str, area: Rect, buf: &mut Buffer) -> u16 {
1595 let focused = pass.claim();
1596 let tui = pass.tui;
1597 text::draw_line(
1598 &Line::from(vec![Span::styled(
1599 format!("[ {label} ]"),
1600 tui.style()
1601 .focused(focused, Style::default().fg(tui.theme().action_primary)),
1602 )]),
1603 area,
1604 buf,
1605 )
1606 }
1607
1608 fn draw_one_field(pass: &mut Pass<'_>, field: &Field, area: Rect, buf: &mut Buffer) -> u16 {
1609 // Claimed before the room is checked and before the kind is looked at, so
1610 // the count is a fact about the description rather than about the window.
1611 // A hidden field is the one kind that is not reachable at all.
1612 if matches!(field.kind, layout::FieldKind::Hidden) {
1613 return 0;
1614 }
1615 let focused = pass.claim();
1616 let tui = pass.tui;
1617
1618 // What is in the box, which is the view's answer and not the description's,
1619 // and the whole reason drawing takes two arguments. See this crate's
1620 // header, and `39057019`. `Field::value` drops what it is handed when the
1621 // kind is `Secret`, deliberately -- a password that comes back down the
1622 // wire is a password in a page and in a proxy log -- so for that one kind
1623 // the view's buffer is the only source there is.
1624 let held = pass.view.showing(&field.name, field.value.as_deref());
1625 // A checkbox is a bool to the shared drawing rather than a string, because
1626 // `Node::SELECTED` is quasi's submission convention and not a fact about
1627 // what a tick looks like.
1628 // An interval is held as two values, under the two names it submits under.
1629 // Either end may be empty while the other stands, which is an open interval
1630 // rather than a half-filled box.
1631 let upper = field
1632 .upper_name
1633 .as_deref()
1634 .map(|name| pass.view.showing(name, field.upper_value.as_deref()));
1635 let held = match (field.kind, upper) {
1636 (layout::FieldKind::Checkbox, _) => piece::Held::On(held == Node::SELECTED),
1637 (layout::FieldKind::Interval, Some(upper)) => piece::Held::Between { lower: held, upper },
1638 _ => piece::Held::Text(held),
1639 };
1640
1641 let used = field
1642 .with_layout(|described| piece::field(tui.style(), &described, held, focused, area, buf));
1643
1644 // The room under the box, remembered rather than drawn into: the regions
1645 // after this one would paint over a list drawn here. See `Pass::suggesting`.
1646 // Only for the field the list belongs to, which is the field being typed
1647 // into, so a second field's stale candidates cannot appear under a box
1648 // nobody is in.
1649 if pass.view.suggesting(&field.name).is_some() {
1650 pass.suggesting = Some(crate::below(area, used));
1651 }
1652
1653 used
1654 }
1655
1656 /// The open suggestion list, drawn over whatever is under the box.
1657 ///
1658 /// One row per candidate, in the order the route offered them, cut off at the
1659 /// bottom of the room there is — which is what a terminal does with
1660 /// everything, and is why the highlight is not scrolled to: a list long enough
1661 /// to need scrolling is a route answering with more than a reader can take in,
1662 /// and the floor and the wait are what the description has to say about that.
1663 ///
1664 /// A candidate that cannot be picked is drawn muted with its reason beside it,
1665 /// which is the same pair the webview draws and the same pair
1666 /// [`Choice::unavailable`] carries.
1667 pub(crate) fn draw_suggestions(tui: &Tui, view: &View, area: Rect, buf: &mut Buffer) {
1668 let Some(open) = view.suggesting_here() else {
1669 return;
1670 };
1671 for (at, choice) in open.options.iter().enumerate() {
1672 let Ok(row) = u16::try_from(at) else {
1673 return;
1674 };
1675 if row >= area.height {
1676 return;
1677 }
1678 let here = Rect {
1679 height: 1,
1680 ..crate::below(area, row)
1681 };
1682 // Painted before the label, because the list sits over content that is
1683 // already drawn and a row of it that is shorter than the list's width
1684 // would leave the old screen showing through.
1685 buf.set_style(here, tui.style().sunken);
1686 let style = tui
1687 .style()
1688 .focused(open.at == Some(at), tui.style().content);
1689 let mut spans = vec![Span::styled(choice.label.clone(), style)];
1690 // The second line, which in a terminal is the rest of the row rather
1691 // than a second line. `1fcf2e9b`: this is what tells two candidates
1692 // apart when their labels read alike, and it is why a suggestion is not
1693 // a `Choice`. Muted, because it orients rather than answers.
1694 if let Some(detail) = &choice.detail {
1695 spans.push(Span::styled(format!(" {detail}"), tui.style().muted));
1696 }
1697 text::draw_line(&Line::from(spans), here, buf);
1698 }
1699 }
1700
1701 /// A header row plus one row per row of cells.
1702 /// What the tick column calls itself.
1703 ///
1704 /// A name rather than an empty string because `makeover_tui::table` addresses
1705 /// cells by their column's name, so two unnamed columns would be one column
1706 /// twice. It is never shown: the heading a user reads is blank, the same way
1707 /// the webview's is.
1708 const TICK_COLUMN: &str = "select";
1709
1710 fn table_height(_columns: &[quasi_router::Column], shown: usize) -> u16 {
1711 1 + u16::try_from(shown).unwrap_or(u16::MAX)
1712 }
1713
1714 /// A table, through makeover-tui's own table.
1715 ///
1716 /// The one node this crate does not draw itself, and the reason the shared
1717 /// crate has a table at all: column sizing, the priority cutoff that drops
1718 /// columns a narrow terminal has no room for, and the sort marker are all
1719 /// decided there, so a described table narrows the same way an undescribed one
1720 /// does.
1721 fn draw_table(
1722 pass: &mut Pass<'_>,
1723 columns: &[quasi_router::Column],
1724 rows: &[Row],
1725 area: Rect,
1726 buf: &mut Buffer,
1727 ) -> u16 {
1728 use ratatui::widgets::{StatefulWidget, TableState};
1729
1730 // A row is one stop and a cell inside one is not, which `focus.rs`
1731 // explains: the table is laid out by `makeover_tui::table`, which answers no
1732 // coordinates back, so there is nothing here that could say where in a row a
1733 // control ended up. A control in a cell is reached by stepping into the row,
1734 // and `lit` below is where that shows.
1735 // Every reachable row is claimed, not just the ones before the focused one,
1736 // or the count would end early and every control below the table would be
1737 // off by the difference.
1738 //
1739 // The test has to be the one `focus.rs` pushes a `Spot::Row` on, exactly:
1740 // that walk decides what the caret can land on and this one decides what the
1741 // drawing counts, so a row counted in one and not the other shifts every
1742 // stop below the table. It read `activate.is_some()` alone until 2026-08-17,
1743 // which was already wrong for a table whose rows are tickable but do not
1744 // open — goingson's task list is one — and adding `menu` to the walk without
1745 // this would have widened the same gap to a third case. It is one function
1746 // in `focus.rs` now, so the two cannot drift again.
1747 // The rows a shut branch is not covering, which is what the table has for
1748 // this frame. Taken before anything is counted: the focus walk skipped the
1749 // folded ones too, so an index here is an index there.
1750 let shown: Vec<&Row> = crate::outline::showing(rows, Some(pass.view))
1751 .map(|(_, cells)| cells)
1752 .collect();
1753 let rows = shown.as_slice();
1754
1755 let mut focused = None;
1756 for (index, cells) in rows.iter().enumerate() {
1757 if crate::focus::row_reachable(cells) && pass.claim() {
1758 focused = Some(index);
1759 }
1760 }
1761
1762 // Which part of which cell the caret has stepped onto, when it has stepped
1763 // into a row at all. The same `(column, part)` list the reach walk built its
1764 // `inside` from, so the ring lands on the control Enter would press.
1765 let lit = focused.and_then(|index| {
1766 let at = pass.view.inside()?;
1767 let cells = rows.get(index)?;
1768 crate::focus::inside(cells).get(at).copied()
1769 });
1770
1771 let tui = pass.tui;
1772
1773 // A tick takes no column in the description, and a terminal table has no
1774 // gutter to put one in, so it is drawn as a leading column this renderer
1775 // adds. Essential, because a column that can drop is one a narrow terminal
1776 // silently makes unselectable; three cells wide, matching the `[x]` a list
1777 // row draws in its own gutter, so the two read alike on one screen.
1778 // Whether any row in the table is a branch, which is what buys the leading
1779 // column its chevron and every row its indent. `outline::lead`'s rule, in
1780 // the one place a table can spend the room.
1781 let branches = rows.iter().any(|row| row.open.is_some());
1782 let ticks = rows.iter().any(|row| row.selected.is_some());
1783 // The same narrow column serves a live selection, because it is the same
1784 // question in the same place: what is this row's standing in the set. Only
1785 // the mark differs -- a box for a tick, which is an affordance to press, and
1786 // a bare mark for a choice, which is a state already in force. `1894e95d`.
1787 let chooses = rows.iter().any(|row| row.chosen.is_some());
1788 let gutter = ticks || chooses;
1789 let mut named: Vec<layout::Column<'_>> =
1790 Vec::with_capacity(columns.len() + usize::from(gutter));
1791 if gutter {
1792 named.push(layout::Column {
1793 width: layout::Width::Fixed,
1794 priority: layout::Priority::Essential,
1795 ..layout::Column::new(TICK_COLUMN)
1796 });
1797 }
1798 named.extend(columns.iter().map(quasi_router::Column::as_layout));
1799 // No authored track lengths. `Width::Fixed` is the description's way of
1800 // saying a column has one, and it names no number, so the fallback is this
1801 // renderer's guess and the sizing table stays empty until the vocabulary
1802 // carries a measure.
1803 let sizing = table::Sizing {
1804 lengths: &[],
1805 fallback: 12,
1806 };
1807
1808 let body: Vec<Vec<table::Cell<'_>>> =
1809 rows.iter()
1810 .enumerate()
1811 .map(|(index, cells)| {
1812 let mut row: Vec<table::Cell<'_>> =
1813 Vec::with_capacity(columns.len() + usize::from(gutter));
1814 if gutter {
1815 // The set the view is holding decides it, not the description,
1816 // which is the rule `39057019` settled for a field and
1817 // `draw_gutter` follows for a list row: the description says
1818 // what arrived and the view says what the user has done since.
1819 // A redraw reading the description would undo the tick the
1820 // moment anything else on the screen changed.
1821 let drawn = match (cells.selected, cells.value.as_deref()) {
1822 (Some(_), Some(value)) if pass.view.is_ticked(value) => "[x]",
1823 (Some(_), _) => "[ ]",
1824 // A live selection is the description's own answer and not
1825 // the view's, which is the whole difference from the two
1826 // above: the app holds the set, so a redraw reading the
1827 // description is reading the truth rather than undoing it.
1828 (None, _) if cells.chosen == Some(true) => " * ",
1829 (None, _) => "",
1830 };
1831 row.push(table::Cell::new(TICK_COLUMN, Line::from(drawn)));
1832 }
1833 row.extend(columns.iter().zip(&cells.cells).enumerate().map(
1834 |(at, (column, cell))| {
1835 // The lit part, and only in the row the caret is on:
1836 // `lit` is already `None` unless this row is focused, so
1837 // the column test is all that is left to do here.
1838 let within = lit.filter(|(col, _)| Some(index) == focused && *col == at);
1839 let mut line = cell_line(tui, cell, within.map(|(_, part)| part));
1840 // The outline, in the first column and nowhere else. A
1841 // table has no gutter to indent in and the indent is not a
1842 // value in the grid, so it rides in front of the row's
1843 // leading text -- which is the cell the eye reads the
1844 // hierarchy from anyway.
1845 if at == 0 && branches {
1846 let mark = match cells.open {
1847 Some(described) => {
1848 let open = pass.view.open(&cells.key(), described);
1849 format!("{} ", crate::outline::chevron(open))
1850 }
1851 None => " ".to_string(),
1852 };
1853 let indent = " ".repeat(
1854 usize::from(cells.depth.level) * usize::from(crate::outline::STEP),
1855 );
1856 line.spans.insert(0, Span::raw(format!("{indent}{mark}")));
1857 }
1858 // Which side of a change this line is on, when the table is
1859 // a diff. `19d7602d`. A marker in front of the row rather
1860 // than a tint behind it, which is what every terminal diff
1861 // has always done and what a reader already reads: the sign
1862 // survives a monochrome terminal, a colour does not, and
1863 // this renderer has no per-row background to spend anyway.
1864 //
1865 // The colour goes on beside it, off `Change`'s own intent,
1866 // so a terminal with status colours gets both.
1867 if at == 0
1868 && let Some(change) = cells.change
1869 {
1870 let (sign, style) = match change {
1871 layout::Change::Added => {
1872 ("+", Style::default().fg(tui.theme().status_success))
1873 }
1874 layout::Change::Removed => {
1875 ("-", Style::default().fg(tui.theme().status_danger))
1876 }
1877 // Including a kind this renderer has not learned:
1878 // an unchanged line, which is the reading that
1879 // draws the text and loses only the sign.
1880 _ => (" ", Style::default()),
1881 };
1882 line.spans.insert(0, Span::styled(sign, style));
1883 }
1884 table::Cell::new(column.name.as_str(), line).part(cell_part(cell))
1885 },
1886 ));
1887 row
1888 })
1889 .collect();
1890
1891 let widget = table::table(&named, &body, &sizing, &tui.table, area.width);
1892 let height = table_height(columns, rows.len()).min(area.height);
1893 let within = Rect { height, ..area };
1894
1895 // `Row::current` through ratatui's own selection, so the row takes the
1896 // highlight style `TableStyle` already carries rather than a second
1897 // emphasis invented here. It is the one place a drawing needs a widget's
1898 // state, and the state is read straight off the description.
1899 //
1900 // Focus wins over current when they disagree. Both end up in the same
1901 // one-row selection because a table has one highlight to give, and of the
1902 // two facts the one the user is steering is the one they need to see.
1903 let mut state = TableState::default();
1904 if let Some(index) = focused.or_else(|| rows.iter().position(|cells| cells.current)) {
1905 state.select(Some(index));
1906 }
1907 StatefulWidget::render(widget, within, buf, &mut state);
1908 height
1909 }
1910
1911 /// A cell's run as one line, which is what makeover-tui's table takes.
1912 ///
1913 /// `lit` is the part the caret has stepped onto, as an index into the cell's
1914 /// run, and `None` on every cell of every row but the one it is in. It is what
1915 /// the two-step focus order has instead of a ring around a rect: the row takes
1916 /// the table's own row highlight and the control inside it takes the focus
1917 /// style, so the reader can see which of a row's buttons Enter would press.
1918 fn cell_line(tui: &Tui, cell: &Cell, lit: Option<usize>) -> Line<'static> {
1919 let mut spans = Vec::new();
1920 for (at, part) in cell.content.iter().enumerate() {
1921 if !spans.is_empty() {
1922 spans.push(Span::raw(" "));
1923 }
1924 spans.extend(inline_spans(
1925 tui,
1926 part,
1927 Style::default().fg(tui.theme().content_primary),
1928 lit == Some(at),
1929 ));
1930 }
1931 Line::from(spans)
1932 }
1933
1934 /// Which `CellPart` a cell's run reads as.
1935 ///
1936 /// The table style wants one part for the whole cell where the run has one per
1937 /// entry, so a mixed cell has to answer with the part that decides its colour.
1938 /// A control wins, then a link, then a chip, then the value: a cell whose last
1939 /// word is a button should not be painted as prose.
1940 fn cell_part(cell: &Cell) -> layout::CellPart {
1941 if cell.content.iter().any(|part| matches!(part, Node::Act(_))) {
1942 return layout::CellPart::Actions;
1943 }
1944 if cell
1945 .content
1946 .iter()
1947 .any(|part| matches!(part, Node::Link { .. }))
1948 {
1949 return layout::CellPart::Link;
1950 }
1951 if cell
1952 .content
1953 .iter()
1954 .any(|part| matches!(part, Node::Token(_)))
1955 {
1956 return layout::CellPart::Tokens;
1957 }
1958 layout::CellPart::Value
1959 }
1960