Skip to main content

max / quasi

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