Skip to main content

max / quasi

36.4 KB · 909 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 use makeover_layout as layout;
8 use makeover_tui::{piece, table, text};
9 use quasi_router::{Act, Cell, Cells, Field, Figure, Meter, Node, Part, Picture, Row, Tag};
10 use ratatui::buffer::Buffer;
11 use ratatui::layout::Rect;
12 use ratatui::style::{Modifier, Style};
13 use ratatui::text::{Line, Span};
14
15 use crate::{Pass, Tui, View, below};
16
17 /// The rows `node` wants at `width`.
18 pub(crate) fn height(tui: &Tui, node: &Node, width: u16) -> u16 {
19 match node {
20 Node::Heading { text: content, .. } | Node::Text { text: content, .. } => {
21 text::height(content, width)
22 }
23 Node::Rich { source } => {
24 text::spans_height(&rich_spans(tui, source, rich_base(tui)), width)
25 }
26 Node::Act(act) => text::line_height(&act_line(tui, act, false), width),
27 Node::Link { text: label, .. } => text::height(label, width),
28 Node::Token(tag) => text::line_height(&Line::from(tag_span(tui, tag, false)), width),
29 Node::Figure(figure) => figure_height(tui, figure, width),
30 Node::Image(picture) => image_height(picture, width),
31 Node::Notice { text: content, .. } => text::height(content, width),
32 Node::StandIn { message, act, .. } => {
33 text::height(message, width) + act.as_ref().map_or(0, |_| 1)
34 }
35 Node::Field(field) => field_height(tui, field, width),
36 Node::Form { fields, .. } => {
37 fields
38 .iter()
39 .map(|field| field_height(tui, field, width))
40 .sum::<u16>()
41 // The submit button, on its own row under the last question.
42 + 1
43 }
44 Node::List { rows, more } => {
45 let gutter = list_gutter(rows);
46 rows.iter()
47 .map(|row| {
48 text::line_height(&row_line(tui, row, &[]), width.saturating_sub(gutter)).max(1)
49 })
50 .sum::<u16>()
51 + u16::from(more.is_some())
52 }
53 // A terminal cannot place by percentage, and it does not have to. What
54 // the description said is that these things happen at these times; a
55 // clock column and one line each says exactly that, and is what a
56 // terminal is good at. The geometry a webview draws is presentation,
57 // which is the half this renderer is entitled to answer differently.
58 //
59 // What is genuinely lost is duration and overlap as *shapes*: two
60 // things at once are two adjacent lines here rather than two boxes side
61 // by side. The times are on every line, so the fact survives even
62 // though the picture does not. A gantt-style bar column would be this
63 // renderer's own expression and is worth having; it is not a finding
64 // about the description.
65 Node::Timeline { entries, .. } => entries
66 .iter()
67 .map(|entry| {
68 text::line_height(
69 &row_line(tui, &entry.row, &[]),
70 width.saturating_sub(TIMELINE_GUTTER),
71 )
72 .max(1)
73 })
74 .sum::<u16>(),
75 Node::Table { columns, rows } => table_height(columns, rows),
76 Node::Select { options, .. } => {
77 text::line_height(&Line::from(select_spans(tui, options, None, &[])), width)
78 }
79 Node::Meter(meter) => text::line_height(&meter_line(tui, meter), width),
80 Node::Stats { figures } => figures
81 .iter()
82 .map(|(figure, _)| figure_height(tui, figure, width))
83 .sum(),
84 Node::Region(slot) => crate::region::height(tui, slot, width),
85 }
86 }
87
88 /// Draw `node` at the top of `area`, and answer the rows it used.
89 ///
90 /// The reachable things are counted as they are passed, in the order
91 /// [`crate::focus::spots`] records them, so that the one whose number matches
92 /// the view's focus can be drawn lit. A node that is not reachable does not
93 /// count, and a node that is drawn but unreachable — a disabled control, a
94 /// hidden field — does not count either.
95 pub(crate) fn draw(pass: &mut Pass<'_>, node: &Node, area: Rect, buf: &mut Buffer) -> u16 {
96 if area.width == 0 || area.height == 0 {
97 // A node with no room still holds its place in the count. The screen is
98 // the same screen whether or not the terminal is tall enough to show
99 // all of it, and a focus order that changed as the window was resized
100 // would move the user's place under them.
101 count(pass, node);
102 return 0;
103 }
104
105 let tui = pass.tui;
106 match node {
107 Node::Heading { level, text: title } => {
108 text::draw(title, tui.style().heading(*level), area, buf)
109 }
110
111 Node::Text {
112 text: content,
113 tone,
114 } => text::draw(content, tui.style().tone(*tone), area, buf),
115
116 // Markdown source, and a terminal has no markup to hand it to. It takes
117 // the runs: the words, each still carrying the marks that were over it,
118 // which is the answer docengine grew for exactly this caller. A webview
119 // draws `**ship it**` bold and so does this.
120 //
121 // What is still lost is block structure. A heading inside a rich node
122 // comes through as its text at the weight of the prose around it,
123 // because `render_runs` carries inline marks and nothing else, and a
124 // terminal has no second type size to spend on the difference anyway.
125 Node::Rich { source } => {
126 text::draw_spans(&rich_spans(tui, source, rich_base(tui)), area, buf)
127 }
128
129 Node::Act(act) => {
130 let focused = claim_act(pass, act);
131 text::draw_line(&act_line(tui, act, focused), area, buf)
132 }
133
134 // A link is text and an address, and a terminal cannot put the address
135 // under the words the way an anchor does. Underlined, which is the one
136 // affordance a cell has that says "this goes somewhere", and the
137 // address is the runtime's to follow when the link has focus.
138 Node::Link { text: label, .. } => {
139 let focused = pass.claim();
140 text::draw(
141 label,
142 tui.style().focused(focused, link_style(tui)),
143 area,
144 buf,
145 )
146 }
147
148 Node::Token(tag) => {
149 let focused = claim_tag(pass, tag);
150 text::draw_line(&Line::from(tag_span(tui, tag, focused)), area, buf)
151 }
152
153 Node::Figure(figure) => draw_figure(tui, figure, area, buf),
154
155 Node::Image(picture) => draw_image(tui, picture, area, buf),
156
157 // A banner and a toast are the same rows here. A toast is a message
158 // that goes away on its own, which is a clock the description does not
159 // carry and the drawing has no way to keep, so the kind is read and
160 // deliberately not honoured. Filed.
161 Node::Notice {
162 tone,
163 text: content,
164 ..
165 } => {
166 let style = tui.style().tone(*tone).add_modifier(Modifier::BOLD);
167 text::draw(content, style, area, buf)
168 }
169
170 Node::StandIn {
171 state,
172 message,
173 act,
174 } => {
175 let style = match state {
176 layout::Readiness::Failed => tui.style().tone(layout::Tone::Danger),
177 _ => Style::default().fg(tui.theme().content_muted),
178 };
179 let used = text::draw(message, style, area, buf);
180 match act {
181 Some(act) => {
182 let focused = claim_act(pass, act);
183 used + text::draw_line(&act_line(tui, act, focused), below(area, used), buf)
184 }
185 None => used,
186 }
187 }
188
189 Node::Field(field) => draw_field(pass, field, area, buf),
190
191 Node::Form { submit, fields, .. } => {
192 let mut used = 0;
193 for field in fields {
194 used += draw_field(pass, field, below(area, used), buf);
195 }
196 // The submit, drawn as the act it is. The form's own action is not
197 // drawn: an address is not a thing a cell can show, and the runtime
198 // is what follows it.
199 let focused = pass.claim();
200 used + text::draw_line(
201 &Line::from(vec![Span::styled(
202 format!("[ {submit} ]"),
203 tui.style().focused(
204 focused,
205 Style::default()
206 .fg(tui.theme().selection_on)
207 .bg(tui.theme().action_primary),
208 ),
209 )]),
210 below(area, used),
211 buf,
212 )
213 }
214
215 Node::List { rows, more } => {
216 // A gutter for the tick and the current marker, and only when some
217 // row in the list has one. Both are facts about the row that a
218 // webview says with a checkbox and an `aria-current`, and neither
219 // is content, so neither belongs in the run. A list where no row is
220 // tickable spends no columns on the possibility.
221 let gutter = list_gutter(rows);
222 let mut used = 0;
223 for row in rows {
224 // Claimed before the room is checked, because the count is a
225 // fact about the description and the room is a fact about the
226 // window.
227 let focused = claim_row(pass, row);
228 let parts = claim_parts(pass, row);
229 let line = row_line(tui, row, &parts);
230 let at = below(area, used);
231 if at.height == 0 {
232 continue;
233 }
234 draw_gutter(tui, pass.view, row, focused, at, buf);
235 let body = Rect {
236 x: at.x + gutter,
237 width: at.width.saturating_sub(gutter),
238 ..at
239 };
240 used += text::draw_line(&line, body, buf).max(1);
241 }
242 match more {
243 Some(rest) => {
244 let focused = pass.claim();
245 let label = rest.remaining.map_or_else(
246 || "More".to_string(),
247 |remaining| format!("{remaining} more"),
248 );
249 used + text::draw(
250 &label,
251 tui.style()
252 .focused(focused, Style::default().fg(tui.theme().action_primary)),
253 below(area, used),
254 buf,
255 )
256 }
257 None => used,
258 }
259 }
260
261 // The clock column, then the row. See `height` for what this renderer
262 // keeps and what it gives up.
263 //
264 // Drawn in the order the description gave, not sorted by start. Sorting
265 // is presentation and this renderer could do it, but a caller that
266 // built its entries in a deliberate order would find them silently
267 // rearranged, and the description has no way to say which it meant.
268 // `Node::Timeline`'s doc records that a renderer must not assume the
269 // entries are sorted; quietly sorting them is the same assumption from
270 // the other side.
271 Node::Timeline { track, entries, .. } => {
272 let mut used = 0;
273 for entry in entries {
274 let focused = claim_row(pass, &entry.row);
275 let parts = claim_parts(pass, &entry.row);
276 let line = row_line(tui, &entry.row, &parts);
277 let at = below(area, used);
278 if at.height == 0 {
279 continue;
280 }
281
282 // Wall clock, wrapped past midnight, matching what the webview
283 // writes into its ruler. A span may legitimately count past
284 // 1440 so that it needs no date.
285 let minute = entry.placement.at();
286 let clock = format!("{:02}:{:02} ", (minute / 60) % 24, minute % 60);
287 let style = if focused {
288 tui.style().muted.add_modifier(Modifier::REVERSED)
289 } else {
290 tui.style().muted
291 };
292 buf.set_span(
293 at.x,
294 at.y,
295 &Span::styled(clock, style),
296 TIMELINE_GUTTER.min(at.width),
297 );
298
299 let body = Rect {
300 x: at.x + TIMELINE_GUTTER,
301 width: at.width.saturating_sub(TIMELINE_GUTTER),
302 ..at
303 };
304 used += text::draw_line(&line, body, buf).max(1);
305 }
306 // `track` is read for nothing here, and that is the honest state:
307 // the span, the slot and the tick are all questions about where to
308 // put a gridline, and this renderer draws none. Named rather than
309 // ignored with `..` so that a member added to `Track` has to come
310 // past this comment.
311 let _ = track;
312 used
313 }
314 Node::Table { columns, rows } => draw_table(pass, columns, rows, area, buf),
315
316 Node::Select {
317 options,
318 chosen,
319 action,
320 ..
321 } => {
322 // Segmented, toggle and tabs draw the same here: a row of labels
323 // with the chosen one lit. The three differ in how much room they
324 // claim and how they are grouped, which is a geometry question, and
325 // a terminal has one cell size and no groups.
326 let reachable: Vec<bool> = options
327 .iter()
328 .map(|(_, own)| {
329 let calls = own.is_some() || action.is_some();
330 calls && pass.claim()
331 })
332 .collect();
333 text::draw_line(
334 &Line::from(select_spans(tui, options, chosen.as_deref(), &reachable)),
335 area,
336 buf,
337 )
338 }
339
340 Node::Meter(meter) => text::draw_line(&meter_line(tui, meter), area, buf),
341
342 Node::Stats { figures } => {
343 // Down and not across. A strip of tiles is a row on a webview
344 // because a webview has room to the right; a terminal that put four
345 // figures on one line would have five cells for each caption.
346 // Stacking is the renderer deciding, and the node still says "these
347 // belong together", which is what it is for.
348 let mut used = 0;
349 for (figure, _) in figures {
350 used += draw_figure(tui, figure, below(area, used), buf);
351 }
352 used
353 }
354
355 Node::Region(slot) => crate::region::draw(pass, slot, area, buf),
356 }
357 }
358
359 /// Advance the count past a node that was not drawn, so that a screen too tall
360 /// for its terminal keeps the focus order it had when it fit.
361 fn count(pass: &mut Pass<'_>, node: &Node) {
362 let mut found = Vec::new();
363 // The region's name does not matter here: only how many things were passed.
364 crate::focus::node_spots(node, "", &mut found);
365 pass.seq += found.len();
366 }
367
368 /// Claim a control, unless it is disabled and therefore unreachable.
369 fn claim_act(pass: &mut Pass<'_>, act: &Act) -> bool {
370 !act.state.is_some_and(layout::State::suppresses_interaction) && pass.claim()
371 }
372
373 /// Claim a chip, which is the only tag that answers anything.
374 fn claim_tag(pass: &mut Pass<'_>, tag: &Tag) -> bool {
375 matches!(tag.kind, layout::Token::Chip { .. }) && tag.action.is_some() && pass.claim()
376 }
377
378 /// Claim a row, when the description gives it something to do.
379 fn claim_row(pass: &mut Pass<'_>, row: &Row) -> bool {
380 let reachable = row.activate.is_some()
381 || row.toggle.is_some()
382 || row.selected.is_some()
383 || !row.menu.is_empty();
384 reachable && pass.claim()
385 }
386
387 /// Claim whatever the row's own run carries, one answer per part.
388 fn claim_parts(pass: &mut Pass<'_>, row: &Row) -> Vec<bool> {
389 row.parts
390 .iter()
391 .map(|Part { node, .. }| match node {
392 Node::Act(act) => claim_act(pass, act),
393 Node::Link { .. } => pass.claim(),
394 Node::Token(tag) => claim_tag(pass, tag),
395 _ => false,
396 })
397 .collect()
398 }
399
400 /// The style text that goes somewhere takes.
401 fn link_style(tui: &Tui) -> Style {
402 Style::default()
403 .fg(tui.theme().action_primary)
404 .add_modifier(Modifier::UNDERLINED)
405 }
406
407 /// The columns a list spends before its rows.
408 ///
409 /// Four for a tick, because `[x] ` is four cells; two for the marker alone;
410 /// none when the list needs neither.
411 ///
412 /// A row that can be reached takes the marker's two columns whether or not it
413 /// is the current one, because focus is drawn there and a gutter of zero would
414 /// put the caret over the first word. That is the drawing paying for an
415 /// interaction, which is what a gutter is: the description says the row can be
416 /// opened, and this is the terminal's way of showing which one is about to be.
417 /// The clock column a timeline draws its rows against.
418 ///
419 /// `HH:MM ` is six cells. Fixed rather than measured, because a ragged clock
420 /// column is worse than a wide one and every entry on an axis has a time by
421 /// construction — there is no "some rows have one" case the way there is for a
422 /// tick.
423 const TIMELINE_GUTTER: u16 = 6;
424
425 fn list_gutter(rows: &[Row]) -> u16 {
426 if rows.iter().any(|row| row.selected.is_some()) {
427 4
428 } else if rows
429 .iter()
430 .any(|row| row.current || row.activate.is_some() || !row.menu.is_empty())
431 {
432 2
433 } else {
434 0
435 }
436 }
437
438 /// The tick and the current marker, in the columns before a row.
439 ///
440 /// The focus lands here rather than on the row's words. A row is a whole line
441 /// and reversing all of it turns a list into a slab; the gutter is the column
442 /// the affordances already live in, so it is where "you are on this one" can be
443 /// said without repainting the content.
444 fn draw_gutter(tui: &Tui, view: &View, row: &Row, focused: bool, area: Rect, buf: &mut Buffer) {
445 // A tickable row that names a value is drawn from the set the view is
446 // holding, and only an unnamed one falls back to what the description
447 // said. That is the same rule `39057019` settled for a field: the
448 // description says what arrived, the view says what the user has done
449 // since, and a redraw that went back to the description would undo the
450 // tick the moment anything else on the screen changed.
451 let tick = match (row.selected, row.value.as_deref()) {
452 (Some(_), Some(value)) if view.is_ticked(value) => "[x]",
453 (Some(_), Some(_)) => "[ ]",
454 (Some(true), None) => "[x]",
455 (Some(false), None) => "[ ]",
456 (None, _) => "",
457 };
458 if !tick.is_empty() {
459 buf.set_stringn(
460 area.x,
461 area.y,
462 tick,
463 3,
464 tui.style()
465 .focused(focused, Style::default().fg(tui.theme().content_secondary)),
466 );
467 return;
468 }
469 if row.current || focused {
470 buf.set_stringn(
471 area.x,
472 area.y,
473 ">",
474 1,
475 tui.style()
476 .focused(focused, Style::default().fg(tui.theme().action_primary)),
477 );
478 }
479 }
480
481 /// A row's run as one line of spans.
482 ///
483 /// The run is what made this possible to write at all. Under the old members a
484 /// terminal had to know the fixed sequence -- primary, secondary, meta, bar,
485 /// tokens, actions -- and hardcode it; here it reads what the description says,
486 /// in the order it says it, and the role picks the style.
487 /// `focus` carries one answer per part, in the run's own order, and is empty
488 /// for the callers that are measuring rather than drawing.
489 fn row_line(tui: &Tui, row: &Row, focus: &[bool]) -> Line<'static> {
490 let mut spans = Vec::new();
491 for (index, Part { role, node }) in row.parts.iter().enumerate() {
492 if !spans.is_empty() {
493 spans.push(Span::raw(" "));
494 }
495 let focused = focus.get(index).copied().unwrap_or(false);
496 spans.extend(inline_spans(tui, node, part_style(tui, *role), focused));
497 }
498 // `Row::menu` is not drawn, and that is the description's own instruction:
499 // a menu is reached by right-click on a pointer host, long-press on a touch
500 // one, and a key in a terminal. The key is the runtime's.
501 Line::from(spans)
502 }
503
504 /// The style a row part takes.
505 fn part_style(tui: &Tui, role: layout::RowPart) -> Style {
506 let theme = tui.theme();
507 match role {
508 layout::RowPart::Primary => Style::default().fg(theme.content_primary),
509 layout::RowPart::Secondary => Style::default().fg(theme.content_secondary),
510 layout::RowPart::Meta => Style::default().fg(theme.content_muted),
511 // Tokens, actions and a proportion each carry their own tone, so the
512 // part inherits rather than tinting what sits on it. That is exactly
513 // what `RowPart::intent` answers for a webview, said in colours.
514 _ => Style::default().fg(theme.content_primary),
515 }
516 }
517
518 /// One leaf of a run as spans, under the run's own style.
519 fn inline_spans(tui: &Tui, node: &Node, inherited: Style, focused: bool) -> Vec<Span<'static>> {
520 match node {
521 Node::Text { text, tone } => {
522 let style = match tone {
523 layout::Tone::Neutral => inherited,
524 other => tui.style().tone(*other),
525 };
526 vec![Span::styled(text.clone(), style)]
527 }
528 Node::Rich { source } => rich_spans(tui, source, inherited),
529 Node::Token(tag) => vec![tag_span(tui, tag, focused)],
530 Node::Act(act) => act_line(tui, act, focused).spans,
531 Node::Link { text, .. } => vec![Span::styled(
532 text.clone(),
533 tui.style().focused(focused, link_style(tui)),
534 )],
535 Node::Meter(meter) => meter_line(tui, meter).spans,
536 Node::Figure(figure) => vec![Span::styled(
537 format!("{} {}", figure.value, figure.caption),
538 inherited,
539 )],
540 // Everything else is a block, and the containment bound is what
541 // guarantees one cannot be here. Drawing the text is the honest answer
542 // to a case the type system says is unreachable.
543 other => vec![Span::styled(
544 format!("{other:?}"),
545 Style::default().fg(tui.theme().status_danger),
546 )],
547 }
548 }
549
550 /// The style a rich node's unmarked prose takes when it stands on its own,
551 /// rather than inside a run that has already picked one.
552 fn rich_base(tui: &Tui) -> Style {
553 Style::default().fg(tui.theme().content_primary)
554 }
555
556 /// Markdown source as spans: the words, each under the marks that were over it
557 /// and in the shape of the block it came from.
558 ///
559 /// `base` is what the prose takes where the source said nothing, so the same
560 /// function serves a rich node standing alone and one sitting inside a row's
561 /// run, where the part's role has already decided the colour.
562 fn rich_spans(tui: &Tui, source: &str, base: Style) -> Vec<Span<'static>> {
563 let mut spans = Vec::new();
564 // A marker belongs at the head of a line and nowhere else, and a run knows
565 // its block but not its position. The separator runs are what carry the
566 // breaks, so the run before this one is what says whether a line just
567 // started.
568 let mut starting = true;
569 for run in docengine::render_runs(source) {
570 if starting && let Some(marker) = marker(run.block) {
571 spans.push(Span::styled(
572 marker,
573 Style::default().fg(tui.theme().content_muted),
574 ));
575 }
576 starting = run.text.ends_with('\n');
577 let style = style_of(tui, base, &run);
578 spans.push(Span::styled(run.text, style));
579 }
580 spans
581 }
582
583 /// What a block puts in front of its first line, where a webview would have used
584 /// a bullet glyph or an indent.
585 ///
586 /// The description carries no marker of its own, deliberately: what a bullet
587 /// looks like is the renderer's answer, and this is a terminal's.
588 fn marker(block: docengine::Block) -> Option<&'static str> {
589 match block {
590 docengine::Block::Item => Some("- "),
591 docengine::Block::Quote => Some("> "),
592 docengine::Block::Prose | docengine::Block::Heading(_) => None,
593 }
594 }
595
596 /// One run's block and marks as a style over `base`.
597 ///
598 /// The block decides the ground the run is drawn on and the marks are added to
599 /// it, which is the order a stylesheet uses: a heading with `**bold**` inside it
600 /// is bold on top of heading weight rather than instead of it.
601 fn style_of(tui: &Tui, base: Style, run: &docengine::TextRun) -> Style {
602 let ground = match run.block {
603 // The three markdown levels a terminal can tell apart, which is as many
604 // as `layout::Heading` has: a rich node's `######` and its `###` land in
605 // the same place because a cell has one size and only so much colour.
606 docengine::Block::Heading(1) => tui.style().heading(layout::Heading::Page),
607 docengine::Block::Heading(2) => tui.style().heading(layout::Heading::Section),
608 docengine::Block::Heading(_) => tui.style().heading(layout::Heading::Subsection),
609 docengine::Block::Quote => Style::default().fg(tui.theme().content_secondary),
610 docengine::Block::Prose | docengine::Block::Item => base,
611 };
612 mark(tui, ground, run.emphasis)
613 }
614
615 /// One run's marks as a style over `base`.
616 ///
617 /// Three of the four are the modifier a terminal already has for them. Code is
618 /// the one with no modifier to take -- every cell is monospace, so the thing a
619 /// webview says with a typeface cannot be said that way here -- and it takes
620 /// the sunken surface instead, which is what the theme has for "this is set
621 /// into the page rather than on it".
622 fn mark(tui: &Tui, base: Style, emphasis: docengine::Emphasis) -> Style {
623 if emphasis.is_plain() {
624 return base;
625 }
626 let mut style = base;
627 if emphasis.strong {
628 style = style.add_modifier(Modifier::BOLD);
629 }
630 if emphasis.italic {
631 style = style.add_modifier(Modifier::ITALIC);
632 }
633 if emphasis.struck {
634 style = style.add_modifier(Modifier::CROSSED_OUT);
635 }
636 if emphasis.code {
637 style = style.bg(tui.theme().surface_sunken);
638 }
639 style
640 }
641
642 /// A tag as one span.
643 ///
644 /// The bracket, the latch and the collision between latched and focused are all
645 /// `makeover-tui`'s answers now. What is left here is the translation: our
646 /// owned [`Tag`] into the parts the shared drawing takes.
647 fn tag_span(tui: &Tui, tag: &Tag, focused: bool) -> Span<'static> {
648 piece::token(
649 tui.style(),
650 &tag.label,
651 tag.kind,
652 tag.tone,
653 tag.latched,
654 focused,
655 )
656 }
657
658 /// A control as a line.
659 ///
660 /// `Act::confirm` and `Act::action` do not cross into the description layer's
661 /// [`layout::Act`] and so are not drawn: an address is not a thing a cell can
662 /// show, and a confirmation is a question asked after the press, which is the
663 /// runtime's. [`quasi_router::Act::as_layout`] says the same at the seam.
664 fn act_line(tui: &Tui, act: &Act, focused: bool) -> Line<'static> {
665 piece::act(tui.style(), &act.as_layout(), focused)
666 }
667
668 /// A meter as a line, bar and label.
669 fn meter_line(tui: &Tui, meter: &Meter) -> Line<'static> {
670 piece::meter(tui.style(), &meter.as_layout())
671 }
672
673 /// A figure takes two rows: the number, then what it counts.
674 fn figure_height(_tui: &Tui, figure: &Figure, width: u16) -> u16 {
675 piece::figure_height(&figure.as_layout(), width)
676 }
677
678 fn draw_figure(tui: &Tui, figure: &Figure, area: Rect, buf: &mut Buffer) -> u16 {
679 piece::figure(tui.style(), &figure.as_layout(), area, buf)
680 }
681
682 /// A picture is its alt text here, and a decorative one is nothing.
683 ///
684 /// The terminal's honest answer, and the reason `layout::Image::alt` is not an
685 /// `Option`. There is no graphics protocol in this renderer -- ratatui draws
686 /// cells -- so what a reader gets is the words the picture stands for. An empty
687 /// alt is the description saying the picture adds nothing to the text around
688 /// it, and repeating "image" in its place would be worse than the gap.
689 ///
690 /// `Fit` is read and deliberately not honoured, the way `Notice`'s kind is:
691 /// fitting is about a box with proportions, and a run of words has none.
692 fn image_height(picture: &Picture, width: u16) -> u16 {
693 if !picture.as_layout().speaks() {
694 return 0;
695 }
696 text::height(&picture.alt, width)
697 + picture
698 .caption
699 .as_ref()
700 .map_or(0, |c| text::height(c, width))
701 }
702
703 fn draw_image(tui: &Tui, picture: &Picture, area: Rect, buf: &mut Buffer) -> u16 {
704 if !picture.as_layout().speaks() {
705 return 0;
706 }
707 // Muted, because this is standing in for something rather than being it.
708 let used = text::draw(&picture.alt, tui.style().muted, area, buf);
709 let Some(caption) = &picture.caption else {
710 return used;
711 };
712 // A caption is ordinary content that happens to sit under a picture, so it
713 // is not muted: it reads the same whether or not the picture arrived.
714 used + text::draw(caption, tui.style().secondary, below(area, used), buf)
715 }
716
717 /// A question takes its label row, its value row, and a row for whatever went
718 /// wrong.
719 fn field_height(tui: &Tui, field: &Field, width: u16) -> u16 {
720 field.with_layout(|field| piece::field_height(tui.style(), &field, width))
721 }
722
723 fn draw_field(pass: &mut Pass<'_>, field: &Field, area: Rect, buf: &mut Buffer) -> u16 {
724 // Claimed before the room is checked and before the kind is looked at, so
725 // the count is a fact about the description rather than about the window.
726 // A hidden field is the one kind that is not reachable at all.
727 if matches!(field.kind, layout::FieldKind::Hidden) {
728 return 0;
729 }
730 let focused = pass.claim();
731 let tui = pass.tui;
732
733 // What is in the box, which is the view's answer and not the description's,
734 // and the whole reason drawing takes two arguments. See this crate's
735 // header, and `39057019`. `Field::value` drops what it is handed when the
736 // kind is `Secret`, deliberately -- a password that comes back down the
737 // wire is a password in a page and in a proxy log -- so for that one kind
738 // the view's buffer is the only source there is.
739 let held = pass.view.showing(&field.name, field.value.as_deref());
740 // A checkbox is a bool to the shared drawing rather than a string, because
741 // `Node::SELECTED` is quasi's submission convention and not a fact about
742 // what a tick looks like.
743 let held = if matches!(field.kind, layout::FieldKind::Checkbox) {
744 piece::Held::On(held == Node::SELECTED)
745 } else {
746 piece::Held::Text(held)
747 };
748
749 field.with_layout(|described| piece::field(tui.style(), &described, held, focused, area, buf))
750 }
751
752 /// A tabs strip, a segmented control and a toggle, all as one row of labels.
753 fn select_spans(
754 tui: &Tui,
755 options: &[(quasi_router::Choice, Option<quasi_router::Action>)],
756 chosen: Option<&str>,
757 focus: &[bool],
758 ) -> Vec<Span<'static>> {
759 let mut spans = Vec::new();
760 for (index, (choice, _)) in options.iter().enumerate() {
761 if !spans.is_empty() {
762 spans.push(Span::raw(" "));
763 }
764 let picked = chosen == Some(choice.value.as_str());
765 let style = if picked {
766 Style::default()
767 .fg(tui.theme().selection_on)
768 .bg(tui.theme().action_primary)
769 } else {
770 Style::default().fg(tui.theme().content_secondary)
771 };
772 // The chosen option and the focused one are different facts and both
773 // have to show: which tab you are reading, and which one Enter would
774 // open. The chosen one takes the filled label and focus adds the
775 // brackets around whichever the caret is on.
776 let focused = focus.get(index).copied().unwrap_or(false);
777 let label = if focused {
778 format!("[{}]", choice.label)
779 } else {
780 format!(" {} ", choice.label)
781 };
782 spans.push(Span::styled(label, tui.style().focused(focused, style)));
783 }
784 spans
785 }
786
787 /// A header row plus one row per row of cells.
788 fn table_height(_columns: &[quasi_router::Column], rows: &[Cells]) -> u16 {
789 1 + u16::try_from(rows.len()).unwrap_or(u16::MAX)
790 }
791
792 /// A table, through makeover-tui's own table.
793 ///
794 /// The one node this crate does not draw itself, and the reason the shared
795 /// crate has a table at all: column sizing, the priority cutoff that drops
796 /// columns a narrow terminal has no room for, and the sort marker are all
797 /// decided there, so a described table narrows the same way an undescribed one
798 /// does.
799 fn draw_table(
800 pass: &mut Pass<'_>,
801 columns: &[quasi_router::Column],
802 rows: &[Cells],
803 area: Rect,
804 buf: &mut Buffer,
805 ) -> u16 {
806 use ratatui::widgets::{StatefulWidget, TableState};
807
808 // A row that opens is reachable and a cell inside one is not, which
809 // `focus.rs` explains: the table is laid out by `makeover_tui::table`, which
810 // answers no coordinates back, so there is nothing here that could say where
811 // in a row a control ended up.
812 // Every openable row is claimed, not just the ones before the focused one,
813 // or the count would end early and every control below the table would be
814 // off by the difference.
815 let mut focused = None;
816 for (index, cells) in rows.iter().enumerate() {
817 if cells.activate.is_some() && pass.claim() {
818 focused = Some(index);
819 }
820 }
821
822 let tui = pass.tui;
823 let named: Vec<layout::Column<'_>> = columns
824 .iter()
825 .map(quasi_router::Column::as_layout)
826 .collect();
827 // No authored track lengths. `Width::Fixed` is the description's way of
828 // saying a column has one, and it names no number, so the fallback is this
829 // renderer's guess and the sizing table stays empty until the vocabulary
830 // carries a measure.
831 let sizing = table::Sizing {
832 lengths: &[],
833 fallback: 12,
834 };
835
836 let body: Vec<Vec<table::Cell<'_>>> = rows
837 .iter()
838 .map(|cells| {
839 columns
840 .iter()
841 .zip(&cells.values)
842 .map(|(column, cell)| {
843 table::Cell::new(column.name.as_str(), cell_line(tui, cell))
844 .part(cell_part(cell))
845 })
846 .collect()
847 })
848 .collect();
849
850 let widget = table::table(&named, &body, &sizing, &tui.table, area.width);
851 let height = table_height(columns, rows).min(area.height);
852 let within = Rect { height, ..area };
853
854 // `Cells::current` through ratatui's own selection, so the row takes the
855 // highlight style `TableStyle` already carries rather than a second
856 // emphasis invented here. It is the one place a drawing needs a widget's
857 // state, and the state is read straight off the description.
858 //
859 // Focus wins over current when they disagree. Both end up in the same
860 // one-row selection because a table has one highlight to give, and of the
861 // two facts the one the user is steering is the one they need to see.
862 let mut state = TableState::default();
863 if let Some(index) = focused.or_else(|| rows.iter().position(|cells| cells.current)) {
864 state.select(Some(index));
865 }
866 StatefulWidget::render(widget, within, buf, &mut state);
867 height
868 }
869
870 /// A cell's run as one line, which is what makeover-tui's table takes.
871 fn cell_line(tui: &Tui, cell: &Cell) -> Line<'static> {
872 let mut spans = Vec::new();
873 for part in &cell.parts {
874 if !spans.is_empty() {
875 spans.push(Span::raw(" "));
876 }
877 spans.extend(inline_spans(
878 tui,
879 part,
880 Style::default().fg(tui.theme().content_primary),
881 false,
882 ));
883 }
884 Line::from(spans)
885 }
886
887 /// Which `CellPart` a cell's run reads as.
888 ///
889 /// The table style wants one part for the whole cell where the run has one per
890 /// entry, so a mixed cell has to answer with the part that decides its colour.
891 /// A control wins, then a link, then a chip, then the value: a cell whose last
892 /// word is a button should not be painted as prose.
893 fn cell_part(cell: &Cell) -> layout::CellPart {
894 if cell.parts.iter().any(|part| matches!(part, Node::Act(_))) {
895 return layout::CellPart::Actions;
896 }
897 if cell
898 .parts
899 .iter()
900 .any(|part| matches!(part, Node::Link { .. }))
901 {
902 return layout::CellPart::Link;
903 }
904 if cell.parts.iter().any(|part| matches!(part, Node::Token(_))) {
905 return layout::CellPart::Tokens;
906 }
907 layout::CellPart::Value
908 }
909