Skip to main content

max / alloy_tui

70.7 KB · 2047 lines History Blame Raw
1 //! Themed ratatui widget wrappers.
2 //!
3 //! v1 target per docs/CONSOLE.md: `AlloyBlock`, `AlloyList`, `AlloyForm`,
4 //! `AlloyTable`, `AlloyStatusBar`, `AlloyLog`, plus form-field widgets driven
5 //! by the config schema. The four the console shell needs to render a screen
6 //! end to end — block, list, log pane, status bar — plus the `Severity` accent
7 //! they all compose with, and, as of 1.2, the form: `AlloyForm`, a single
8 //! `AlloyField` carrying a `FieldKind` value cell, and a display-only
9 //! `AlloyTable`.
10
11 use ratatui::buffer::Buffer;
12 use ratatui::layout::Rect;
13 use ratatui::style::{Color, Style};
14 use ratatui::text::{Line, Span};
15 use ratatui::widgets::{Block, Borders, Paragraph, Shadow, Widget};
16
17 use crate::bevel::{Bevel, Elevation};
18 use crate::input::TextField;
19 use crate::selection::{MARKER, MARKER_BLANK, selected_style, unselected_style};
20 use crate::text;
21 use crate::theme::Theme;
22
23 /// Themed `Block`: default borders + palette chrome. Wraps `ratatui::widgets::Block`
24 /// so downstream code composes with it directly (`AlloyBlock::new(theme).build()`
25 /// returns the inner `Block`, which callers then `.title(..)` and pass to a
26 /// widget).
27 ///
28 /// Per DESIGN-LANGUAGE.md: chrome is tinted-greyscale; borders never carry an
29 /// accent — accents live on text via `Severity`. Focused vs. unfocused chrome
30 /// swaps `border-subtle` (decorative) for `border-strong` (focus/selection),
31 /// matching TOKENS.md's derived-border tiers.
32 pub struct AlloyBlock<'a> {
33 theme: &'a Theme,
34 focused: bool,
35 }
36
37 impl<'a> AlloyBlock<'a> {
38 pub fn new(theme: &'a Theme) -> Self {
39 Self {
40 theme,
41 focused: false,
42 }
43 }
44
45 #[must_use]
46 pub fn focused(mut self, focused: bool) -> Self {
47 self.focused = focused;
48 self
49 }
50
51 pub fn build(self) -> Block<'a> {
52 let border_color = if self.focused {
53 self.theme.border_strong
54 } else {
55 self.theme.border_subtle
56 };
57 Block::default()
58 .borders(Borders::ALL)
59 .border_style(Style::default().fg(border_color))
60 // A container's inner margin is `Gap::Group` by definition, and on
61 // this surface that is one column and no rows. Applied here rather
62 // than at each call site because this is the one place every
63 // bordered box in the console passes through: content used to sit
64 // flush against the frame everywhere, which is the look of a box
65 // drawn around text rather than a box containing it.
66 .padding(crate::geometry::padding(crate::geometry::Gap::Group))
67 .style(
68 Style::default()
69 .bg(self.theme.surface_page)
70 .fg(self.theme.content_primary),
71 )
72 }
73 }
74
75 /// Severity accent — categorical status tag whose color comes from the theme's
76 /// `status.*` intents. Per DESIGN-LANGUAGE.md, this is one of the few places
77 /// color is on-purpose: never on chrome, only on glyphs and text.
78 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
79 pub enum Severity {
80 Info,
81 Healthy,
82 Warn,
83 Error,
84 }
85
86 impl Severity {
87 pub const fn label(self) -> &'static str {
88 match self {
89 Severity::Info => "INFO",
90 Severity::Healthy => "OK",
91 Severity::Warn => "WARN",
92 Severity::Error => "ERROR",
93 }
94 }
95
96 pub fn color(self, theme: &Theme) -> Color {
97 match self {
98 Severity::Info => theme.status_info,
99 Severity::Healthy => theme.status_success,
100 Severity::Warn => theme.status_warning,
101 Severity::Error => theme.status_danger,
102 }
103 }
104
105 pub fn style(self, theme: &Theme) -> Style {
106 Style::default().fg(self.color(theme))
107 }
108 }
109
110 /// A footer key hint: the key, and what it does.
111 pub struct Hint {
112 pub key: &'static str,
113 pub label: &'static str,
114 }
115
116 /// Terse constructor for a [`Hint`], so hint lists read as data at the call
117 /// site: `[hint("Tab", "focus"), hint("q", "quit")]`.
118 pub const fn hint(key: &'static str, label: &'static str) -> Hint {
119 Hint { key, label }
120 }
121
122 /// The one-row footer: key hints on the left, transient status on the right.
123 ///
124 /// This is docs/CONSOLE.md's "common status area" and
125 /// docs/COMPONENT-LIBRARY.md's footer chrome in one widget — they occupy the
126 /// same row, and splitting them into two widgets would mean two things
127 /// competing for it. Descended from sysop-tui's `Footer`, with the status
128 /// slot added.
129 pub struct AlloyStatusBar<'a> {
130 theme: &'a Theme,
131 hints: Vec<Hint>,
132 status: Option<(Severity, String)>,
133 }
134
135 impl<'a> AlloyStatusBar<'a> {
136 pub fn new(theme: &'a Theme, hints: impl IntoIterator<Item = Hint>) -> Self {
137 Self {
138 theme,
139 hints: hints.into_iter().collect(),
140 status: None,
141 }
142 }
143
144 /// Attach a transient status message (busy, error, dirty) to the right end.
145 #[must_use]
146 pub fn status(mut self, severity: Severity, message: impl Into<String>) -> Self {
147 self.status = Some((severity, message.into()));
148 self
149 }
150 }
151
152 impl Widget for AlloyStatusBar<'_> {
153 fn render(self, area: Rect, buf: &mut Buffer) {
154 let base = Style::default().bg(self.theme.surface_sunken);
155 Paragraph::new("").style(base).render(area, buf);
156
157 let mut spans: Vec<Span> = Vec::with_capacity(self.hints.len() * 3);
158 for (i, h) in self.hints.iter().enumerate() {
159 if i > 0 {
160 spans.push(Span::raw(" "));
161 }
162 spans.push(text::action(self.theme, h.key));
163 spans.push(Span::raw(" "));
164 spans.push(text::muted(self.theme, h.label));
165 }
166 Paragraph::new(Line::from(spans))
167 .style(base)
168 .render(area, buf);
169
170 // The status sits on the same row, right-aligned. Rendering it as a
171 // second pass into a right-hand slice means a long hint list is
172 // overwritten by the status rather than pushing it off-screen — the
173 // status is the more urgent of the two.
174 if let Some((severity, message)) = self.status {
175 let text_width = message.chars().count() as u16 + 1;
176 let width = text_width.min(area.width);
177 let slot = Rect {
178 x: area.x + area.width - width,
179 width,
180 ..area
181 };
182 Paragraph::new(Line::from(Span::styled(
183 message,
184 severity.style(self.theme).patch(base),
185 )))
186 .style(base)
187 .right_aligned()
188 .render(slot, buf);
189 }
190 }
191 }
192
193 /// Themed selectable list.
194 ///
195 /// Rows are pre-composed `Line`s so callers keep control of their own content
196 /// styling (a `Severity` span in a row survives selection); this widget owns
197 /// only the gutter marker, the row style, and scrolling.
198 pub struct AlloyList<'a> {
199 theme: &'a Theme,
200 items: Vec<Line<'a>>,
201 selected: Option<usize>,
202 }
203
204 impl<'a> AlloyList<'a> {
205 pub fn new(theme: &'a Theme, items: impl IntoIterator<Item = Line<'a>>) -> Self {
206 Self {
207 theme,
208 items: items.into_iter().collect(),
209 selected: None,
210 }
211 }
212
213 #[must_use]
214 pub fn selected(mut self, selected: Option<usize>) -> Self {
215 self.selected = selected;
216 self
217 }
218
219 /// First visible row for a viewport of `height` rows.
220 fn offset(&self, height: usize) -> usize {
221 list_offset(self.items.len(), height, self.selected)
222 }
223 }
224
225 /// First visible row of a list, given its length, viewport height, and
226 /// selection.
227 ///
228 /// Stateless by design: the offset is derived from the selection each frame
229 /// rather than carried between frames, which is what lets [`AlloyList`] stay
230 /// immediate-mode. The cost is that scrolling centers the selection instead of
231 /// scrolling by the minimum amount; the benefit is that no caller has to own
232 /// and thread a `ListState`.
233 ///
234 /// Public because anything drawing *alongside* a list has to agree with it
235 /// about which rows are on screen and where. [`AlloyConnector`](crate::AlloyConnector)
236 /// needs a row's y position, and computing that from a second, separate copy
237 /// of this rule is how a connector ends up pointing one row off after a scroll.
238 pub fn list_offset(len: usize, height: usize, selected: Option<usize>) -> usize {
239 let (Some(selected), true) = (selected, len > height) else {
240 return 0;
241 };
242 let max_offset = len - height;
243 selected.saturating_sub(height / 2).min(max_offset)
244 }
245
246 /// Screen row for list item `index`, or `None` when it is scrolled out of
247 /// view.
248 ///
249 /// `area` is the list's viewport, already inside any block border.
250 pub fn list_row_y(area: Rect, len: usize, selected: Option<usize>, index: usize) -> Option<u16> {
251 if area.height == 0 || index >= len {
252 return None;
253 }
254 let offset = list_offset(len, area.height as usize, selected);
255 let row = index.checked_sub(offset)?;
256 if row >= area.height as usize {
257 return None;
258 }
259 Some(area.y + row as u16)
260 }
261
262 impl Widget for AlloyList<'_> {
263 fn render(self, area: Rect, buf: &mut Buffer) {
264 if area.height == 0 || area.width == 0 {
265 return;
266 }
267
268 let height = area.height as usize;
269 let offset = self.offset(height);
270
271 for (row, (index, item)) in self
272 .items
273 .iter()
274 .enumerate()
275 .skip(offset)
276 .take(height)
277 .enumerate()
278 {
279 let is_selected = self.selected == Some(index);
280 let style = if is_selected {
281 selected_style(self.theme)
282 } else {
283 unselected_style(self.theme)
284 };
285 let marker = if is_selected { MARKER } else { MARKER_BLANK };
286
287 let mut spans = vec![Span::styled(format!("{marker} "), style)];
288 spans.extend(item.spans.iter().cloned());
289
290 let line_area = Rect {
291 y: area.y + row as u16,
292 height: 1,
293 ..area
294 };
295 Paragraph::new(Line::from(spans))
296 .style(style)
297 .render(line_area, buf);
298 }
299 }
300 }
301
302 /// A one-row tab bar.
303 ///
304 /// Holds no state: the selected index comes from the caller's
305 /// [`FocusRing`](crate::FocusRing), which is already a wrapping cursor over N
306 /// slots with the `focus(slot)` a verb needs to open the view on a given tab.
307 /// Same split as [`AlloyList`] and [`Cursor`](crate::Cursor) — widget shared,
308 /// state owned by the view.
309 ///
310 /// Selection reads as brackets plus weight rather than color. Per
311 /// DESIGN-LANGUAGE.md color stays off chrome, and per the same reasoning as
312 /// [`MARKER`](crate::MARKER) being a plain triangle, a bracket survives a
313 /// console with no theme and no patched font — the TTY before the session
314 /// starts, `alloy` over SSH.
315 pub struct AlloyTabs<'a> {
316 theme: &'a Theme,
317 labels: Vec<String>,
318 selected: usize,
319 }
320
321 impl<'a> AlloyTabs<'a> {
322 pub fn new(theme: &'a Theme, labels: impl IntoIterator<Item = impl Into<String>>) -> Self {
323 Self {
324 theme,
325 labels: labels.into_iter().map(Into::into).collect(),
326 selected: 0,
327 }
328 }
329
330 /// Select a tab. Out-of-range indices select nothing, matching
331 /// [`FocusRing::focus`](crate::FocusRing::focus): landing on a neighbouring
332 /// tab is worse than showing none as current.
333 #[must_use]
334 pub fn selected(mut self, selected: usize) -> Self {
335 self.selected = selected;
336 self
337 }
338 }
339
340 /// Gap between tabs. Wide enough that two short labels do not read as one.
341 const TAB_GAP: &str = " ";
342
343 impl Widget for AlloyTabs<'_> {
344 fn render(self, area: Rect, buf: &mut Buffer) {
345 if area.height == 0 || area.width == 0 {
346 return;
347 }
348
349 let mut spans: Vec<Span> = Vec::with_capacity(self.labels.len() * 2);
350 for (i, label) in self.labels.iter().enumerate() {
351 if i > 0 {
352 spans.push(Span::raw(TAB_GAP));
353 }
354 // Unselected labels carry spaces where the selected one carries
355 // brackets, so a label occupies the same cells either way and the
356 // bar does not shift horizontally as selection moves. Same reason
357 // MARKER_BLANK exists for list rows.
358 let (open, close, style) = if i == self.selected {
359 ("[ ", " ]", selected_style(self.theme))
360 } else {
361 (" ", " ", unselected_style(self.theme))
362 };
363 spans.push(Span::styled(format!("{open}{label}{close}"), style));
364 }
365
366 let row = Rect { height: 1, ..area };
367 Paragraph::new(Line::from(spans))
368 .style(Style::default().bg(self.theme.surface_page))
369 .render(row, buf);
370 }
371 }
372
373 /// A push button: a raised surface with a label on it.
374 ///
375 /// The first widget here whose affordance is physical rather than conventional.
376 /// Everything else in this crate announces interactivity by carrying a border,
377 /// which the reader has to be told about; a beveled surface says it without
378 /// being told, and says the same thing on a widget nobody has seen before.
379 ///
380 /// Pressed is the same button drawn [`Elevation::Sunken`], which is the whole
381 /// economy of the idiom: no `:active` special case, no second set of tones, one
382 /// swap. Disabled keeps its bevel and drops the label to `content_muted`, so an
383 /// unavailable action stays in place and keeps teaching the layout rather than
384 /// vanishing from it.
385 pub struct AlloyButton<'a> {
386 theme: &'a Theme,
387 label: &'a str,
388 pressed: bool,
389 disabled: bool,
390 primary: bool,
391 }
392
393 impl<'a> AlloyButton<'a> {
394 pub fn new(theme: &'a Theme, label: &'a str) -> Self {
395 Self {
396 theme,
397 label,
398 pressed: false,
399 disabled: false,
400 primary: false,
401 }
402 }
403
404 #[must_use]
405 pub fn pressed(mut self, pressed: bool) -> Self {
406 self.pressed = pressed;
407 self
408 }
409
410 #[must_use]
411 pub fn disabled(mut self, disabled: bool) -> Self {
412 self.disabled = disabled;
413 self
414 }
415
416 /// The pane's one primary action, drawn at inverted polarity.
417 ///
418 /// Per docs/DESIGN-LANGUAGE.md this is how weight is added without spending
419 /// an accent on chrome, and a pane never shows more than one. The bevel is
420 /// unchanged: a primary button is lit like every other, it is only filled
421 /// differently.
422 #[must_use]
423 pub fn primary(mut self, primary: bool) -> Self {
424 self.primary = primary;
425 self
426 }
427 }
428
429 impl Widget for AlloyButton<'_> {
430 fn render(self, area: Rect, buf: &mut Buffer) {
431 if area.height == 0 || area.width == 0 {
432 return;
433 }
434
435 let elevation = if self.pressed {
436 Elevation::Sunken
437 } else {
438 Elevation::Raised
439 };
440
441 // A pressed control sits on the recessed surface, so the fill moves with
442 // the light rather than staying put under an inverted bevel.
443 let face = if self.pressed {
444 self.theme.surface_sunken
445 } else {
446 self.theme.surface_raised
447 };
448 let (bg, fg) = if self.primary {
449 (self.theme.content_primary, self.theme.surface_raised)
450 } else {
451 (face, self.theme.content_primary)
452 };
453 let fg = if self.disabled {
454 self.theme.content_muted
455 } else {
456 fg
457 };
458
459 Paragraph::new("")
460 .style(Style::default().bg(bg))
461 .render(area, buf);
462 Bevel::new(self.theme, elevation).render(area, buf);
463
464 // The label goes on the middle row, inside the edge. On a button too
465 // short to have an inside, it takes the whole area and the bevel is
466 // simply what fits around it.
467 let label_area = if area.height >= 3 && area.width >= 3 {
468 Rect {
469 x: area.x + 1,
470 y: area.y + area.height / 2,
471 width: area.width - 2,
472 height: 1,
473 }
474 } else {
475 Rect { height: 1, ..area }
476 };
477 Paragraph::new(Line::from(Span::styled(
478 self.label,
479 Style::default().bg(bg).fg(fg),
480 )))
481 .centered()
482 .render(label_area, buf);
483 }
484 }
485
486 /// A centered confirmation modal, drawn over the view that raised it.
487 ///
488 /// Confirmation is design-system chrome rather than per-view furniture: every
489 /// destructive action in every Alloy TUI asks the same way, with the same
490 /// keys. That is the reason this lives here and the shell owns the state,
491 /// instead of each view drawing its own prompt.
492 ///
493 /// Sits on `surface.overlay`, the one theme surface reserved for content
494 /// floating above the page, and borrows `Severity` for the accent so a
495 /// destructive confirm reads red and a benign one does not.
496 pub struct AlloyModal<'a> {
497 theme: &'a Theme,
498 title: &'a str,
499 message: &'a str,
500 severity: Severity,
501 }
502
503 impl<'a> AlloyModal<'a> {
504 pub fn new(theme: &'a Theme, title: &'a str, message: &'a str) -> Self {
505 Self {
506 theme,
507 title,
508 message,
509 severity: Severity::Warn,
510 }
511 }
512
513 #[must_use]
514 pub fn severity(mut self, severity: Severity) -> Self {
515 self.severity = severity;
516 self
517 }
518 }
519
520 impl Widget for AlloyModal<'_> {
521 fn render(self, area: Rect, buf: &mut Buffer) {
522 if area.height == 0 || area.width == 0 {
523 return;
524 }
525
526 let base = Style::default()
527 .bg(self.theme.surface_overlay)
528 .fg(self.theme.content_primary);
529
530 let block = Block::default()
531 .borders(Borders::ALL)
532 .border_style(Style::default().fg(self.theme.border_strong))
533 .style(base)
534 .shadow(floating_shadow(self.theme))
535 // The same inner margin AlloyBlock gives every other container. A
536 // floating panel is still a container, and it is the one a user
537 // looks straight at.
538 .padding(crate::geometry::padding(crate::geometry::Gap::Group))
539 .title(format!(" {} ", self.title));
540 let inner = block.inner(area);
541 block.render(area, buf);
542
543 if inner.height == 0 {
544 return;
545 }
546
547 // Message on top, keys on the last row. The keys are pinned to the
548 // bottom rather than following the message so their position does not
549 // move with message length: a confirm the user cannot dismiss is the
550 // one failure this widget must not have.
551 let keys = Line::from(vec![
552 text::action(self.theme, "Enter"),
553 Span::styled(" confirm", Style::default().fg(self.theme.content_muted)),
554 Span::raw(" "),
555 text::action(self.theme, "Esc"),
556 Span::styled(" cancel", Style::default().fg(self.theme.content_muted)),
557 ]);
558
559 let message_height = inner.height.saturating_sub(1);
560 if message_height > 0 {
561 Paragraph::new(Line::from(Span::styled(
562 self.message,
563 self.severity.style(self.theme).patch(base),
564 )))
565 .style(base)
566 .wrap(ratatui::widgets::Wrap { trim: true })
567 .render(
568 Rect {
569 height: message_height,
570 ..inner
571 },
572 buf,
573 );
574 }
575
576 Paragraph::new(keys).style(base).render(
577 Rect {
578 y: inner.y + inner.height - 1,
579 height: 1,
580 ..inner
581 },
582 buf,
583 );
584 }
585 }
586
587 /// One line of the command log: the CLI invocation that was run, and how it
588 /// went.
589 ///
590 /// The console fronts CLIs rather than hiding them (docs/CONSOLE.md), so
591 /// `command` holds the actual argv the console executed — verbatim, so a user
592 /// can copy it into a shell and get the same result.
593 #[derive(Debug, Clone)]
594 pub struct LogEntry {
595 pub command: String,
596 pub outcome: Severity,
597 }
598
599 impl LogEntry {
600 pub fn new(command: impl Into<String>, outcome: Severity) -> Self {
601 Self {
602 command: command.into(),
603 outcome,
604 }
605 }
606 }
607
608 /// The always-on command-log pane.
609 ///
610 /// Renders the tail of the log — the most recent invocation on the bottom row,
611 /// terminal-transcript order, so the pane reads the way a shell scrollback
612 /// does.
613 pub struct AlloyLog<'a> {
614 theme: &'a Theme,
615 entries: &'a [LogEntry],
616 }
617
618 impl<'a> AlloyLog<'a> {
619 pub fn new(theme: &'a Theme, entries: &'a [LogEntry]) -> Self {
620 Self { theme, entries }
621 }
622 }
623
624 impl Widget for AlloyLog<'_> {
625 fn render(self, area: Rect, buf: &mut Buffer) {
626 if area.height == 0 || area.width == 0 {
627 return;
628 }
629
630 let block = AlloyBlock::new(self.theme).build().title(" commands ");
631 let inner = block.inner(area);
632 block.render(area, buf);
633
634 if inner.height == 0 {
635 return;
636 }
637
638 let visible = inner.height as usize;
639 let tail = self.entries.len().saturating_sub(visible);
640 let lines: Vec<Line> = self.entries[tail..]
641 .iter()
642 .map(|entry| {
643 Line::from(vec![
644 Span::styled("$ ", entry.outcome.style(self.theme)),
645 text::secondary(self.theme, entry.command.clone()),
646 ])
647 })
648 .collect();
649
650 Paragraph::new(lines)
651 .style(Style::default().bg(self.theme.surface_page))
652 .render(inner, buf);
653 }
654 }
655
656 /// The value half of a form row: what the value cell paints, and nothing else.
657 ///
658 /// One enum rather than five widgets. Which kind a row is comes off a schema
659 /// file read at startup, so it is runtime data and five static types would buy
660 /// no safety at the one call site that builds rows. See
661 /// docs/COMPONENT-LIBRARY.md in the Alloy repo for the full rationale.
662 ///
663 /// Every variant that carries text takes it pre-formatted: rounding a float,
664 /// picking which of an enum's labels is current, and deciding how a path is
665 /// abbreviated are all the caller's business, and a widget that formatted
666 /// values would need to know the schema.
667 pub enum FieldKind<'a> {
668 Toggle(bool),
669 Text(&'a str),
670 Number(&'a str),
671 /// The current selection's label, not its raw value.
672 Enum {
673 label: &'a str,
674 },
675 /// Draws a swatch alongside the text. The one place a widget here paints
676 /// an arbitrary color: DESIGN-LANGUAGE.md keeps color off chrome, and this
677 /// is not chrome, it is the value.
678 Color {
679 hex: &'a str,
680 },
681 }
682
683 /// A single form row: a label, a value cell, and the chrome around focus.
684 ///
685 /// Pure render, like every widget here. It owns no value, does no validation,
686 /// and does not know what a schema is; the console binary holds the document,
687 /// the constraints, and the edit buffer, and rebuilds these each frame.
688 pub struct AlloyField<'a> {
689 theme: &'a Theme,
690 label: &'a str,
691 kind: FieldKind<'a>,
692 focused: bool,
693 indent: bool,
694 unset: bool,
695 label_width: usize,
696 edit: Option<&'a TextField>,
697 diagnostic: Option<(Severity, &'a str)>,
698 help: Option<&'a str>,
699 }
700
701 impl<'a> AlloyField<'a> {
702 pub fn new(theme: &'a Theme, label: &'a str, kind: FieldKind<'a>) -> Self {
703 Self {
704 theme,
705 label,
706 kind,
707 focused: false,
708 indent: false,
709 unset: false,
710 label_width: 0,
711 edit: None,
712 diagnostic: None,
713 help: None,
714 }
715 }
716
717 #[must_use]
718 pub fn focused(mut self, focused: bool) -> Self {
719 self.focused = focused;
720 self
721 }
722
723 /// Sit the row under a section header. [`AlloyForm`] sets this; a field
724 /// rendered on its own is not inside anything.
725 #[must_use]
726 pub fn indent(mut self, indent: bool) -> Self {
727 self.indent = indent;
728 self
729 }
730
731 /// Mark the value as the schema's default rather than something the source
732 /// of truth holds, and paint it muted.
733 ///
734 /// Without this a form cannot show the difference between a key set to 12
735 /// and a key absent from a file that defaults to 12. Keeping the two
736 /// distinguishable is the whole point of the console reading and
737 /// defaulting through separate calls, and this is where that reaches the
738 /// screen.
739 #[must_use]
740 pub fn unset(mut self, unset: bool) -> Self {
741 self.unset = unset;
742 self
743 }
744
745 /// Pad the label column to this width, so value cells line up down the
746 /// form. [`AlloyForm`] sets it from the widest label it holds.
747 #[must_use]
748 pub fn label_width(mut self, width: usize) -> Self {
749 self.label_width = width;
750 self
751 }
752
753 /// Draw the caret buffer in place of the value cell. `Some` means this row
754 /// is being edited.
755 #[must_use]
756 pub fn edit(mut self, edit: Option<&'a TextField>) -> Self {
757 self.edit = edit;
758 self
759 }
760
761 #[must_use]
762 pub fn diagnostic(mut self, diagnostic: Option<(Severity, &'a str)>) -> Self {
763 self.diagnostic = diagnostic;
764 self
765 }
766
767 #[must_use]
768 pub fn help(mut self, help: Option<&'a str>) -> Self {
769 self.help = help;
770 self
771 }
772
773 /// The value cell's spans, under the row's base style.
774 fn value_spans(&self, base: Style) -> Vec<Span<'a>> {
775 if let Some(buffer) = self.edit {
776 return caret_spans(self.theme, buffer, base);
777 }
778
779 let style = if self.unset {
780 base.fg(self.theme.content_muted)
781 } else {
782 base
783 };
784
785 match &self.kind {
786 // Brackets and not a color or a Nerd Font glyph, for the reason
787 // MARKER is a plain triangle: a toggle has to be readable on the
788 // TTY before the session starts, and state must not be carried by
789 // color alone.
790 FieldKind::Toggle(on) => {
791 vec![Span::styled(if *on { "[x]" } else { "[ ]" }, style)]
792 }
793 FieldKind::Text(value) | FieldKind::Number(value) => {
794 vec![Span::styled(*value, style)]
795 }
796 FieldKind::Enum { label } => vec![Span::styled(*label, style)],
797 FieldKind::Color { hex } => {
798 let mut spans = Vec::with_capacity(2);
799 // A hex string the caller could not parse still renders its
800 // text: a swatch is an aid to reading the value, not the value.
801 if let Some(color) = swatch(hex) {
802 spans.push(Span::styled(SWATCH, base.fg(color)));
803 spans.push(Span::styled(" ", base));
804 }
805 spans.push(Span::styled(*hex, style));
806 spans
807 }
808 }
809 }
810 }
811
812 /// The drop shadow under a surface that floats above the page.
813 ///
814 /// The one place this design system draws a shadow, and the exception is
815 /// deliberate: a bevel says *raised by one step*, which is the wrong claim for a
816 /// modal. A shadow says *detached from the page underneath*, and a reader needs
817 /// to know the thing behind is still there and still theirs to return to.
818 ///
819 /// Shade characters rather than a dimmed background, because a terminal has no
820 /// blur and a solid offset block reads as a second window rather than as a
821 /// shadow.
822 pub(crate) fn floating_shadow(theme: &Theme) -> Shadow {
823 Shadow::medium_shade().style(
824 Style::default()
825 .fg(theme.border_strong)
826 .bg(theme.surface_page),
827 )
828 }
829
830 /// The swatch a color field paints beside its hex.
831 const SWATCH: &str = "██";
832
833 /// Parse `#rrggbb` or `#rrggbbaa` into a color. Alpha is dropped: a terminal
834 /// cell has no alpha to blend against.
835 fn swatch(hex: &str) -> Option<Color> {
836 let body = hex.strip_prefix('#')?;
837 if body.len() != 6 && body.len() != 8 {
838 return None;
839 }
840 let channel = |at: usize| u8::from_str_radix(body.get(at..at + 2)?, 16).ok();
841 Some(Color::Rgb(channel(0)?, channel(2)?, channel(4)?))
842 }
843
844 /// A caret buffer as spans: the text either side of the caret, and the cell
845 /// under it drawn as a block.
846 ///
847 /// Colors are named rather than reversed. `Modifier::REVERSED` swaps against
848 /// whatever the row's background happens to be, so a caret on a focused row
849 /// and a caret on an unfocused one would not match.
850 fn caret_spans<'s>(theme: &Theme, buffer: &TextField, base: Style) -> Vec<Span<'s>> {
851 let (before, under, after) = buffer.split();
852 let caret = Style::default()
853 .bg(theme.content_primary)
854 .fg(theme.surface_page);
855 vec![
856 Span::styled(before.to_string(), base),
857 // A caret past the end of the line has no character to sit on, so it
858 // draws on a space. Without this the buffer loses its caret exactly
859 // when the user is appending, which is most of the time.
860 Span::styled(under.map_or(" ".to_string(), String::from), caret),
861 Span::styled(after.to_string(), base),
862 ]
863 }
864
865 impl Widget for AlloyField<'_> {
866 fn render(self, area: Rect, buf: &mut Buffer) {
867 if area.height == 0 || area.width == 0 {
868 return;
869 }
870
871 let base = if self.focused {
872 selected_style(self.theme)
873 } else {
874 unselected_style(self.theme)
875 };
876 let marker = if self.focused { MARKER } else { MARKER_BLANK };
877
878 let mut spans = vec![Span::styled(format!("{marker} "), base)];
879 if self.indent {
880 spans.push(Span::styled(" ", base));
881 }
882
883 let width = self.label_width.max(self.label.chars().count());
884 spans.push(Span::styled(
885 format!("{:width$} ", self.label, width = width),
886 base.fg(self.theme.content_secondary),
887 ));
888 spans.extend(self.value_spans(base));
889
890 Paragraph::new(Line::from(spans))
891 .style(base)
892 .render(Rect { height: 1, ..area }, buf);
893 }
894 }
895
896 /// One row of a form: a section header, or a field.
897 ///
898 /// The binary rebuilds this list every frame from the schema and the fold
899 /// state — headers, plus the fields of open sections — and a `Cursor` rides
900 /// it. Same split as [`AlloyList`]: widget shared, state owned by the view.
901 pub enum FormRow<'a> {
902 Section { label: &'a str, open: bool },
903 Field(AlloyField<'a>),
904 }
905
906 /// The chrome around a sequence of form rows.
907 ///
908 /// Sections are header rows and an indent, not nested boxes. A box inside a
909 /// pane spends two columns a side on every level, and the fold marker already
910 /// says everything a border would; the console's panes are narrow enough that
911 /// the columns matter more than the outline.
912 ///
913 /// Rows are one line each, without exception. That is what lets the form scroll
914 /// through [`list_offset`] rather than carrying its own state, the same trade
915 /// [`AlloyList`] makes, and it is why the focused row's help and diagnostic
916 /// render on a reserved line at the foot of the form instead of under the row.
917 /// The line is reserved whether or not there is anything to put in it, so the
918 /// rows above do not reflow as focus moves.
919 pub struct AlloyForm<'a> {
920 theme: &'a Theme,
921 rows: Vec<FormRow<'a>>,
922 selected: Option<usize>,
923 }
924
925 impl<'a> AlloyForm<'a> {
926 pub fn new(theme: &'a Theme, rows: impl IntoIterator<Item = FormRow<'a>>) -> Self {
927 Self {
928 theme,
929 rows: rows.into_iter().collect(),
930 selected: None,
931 }
932 }
933
934 #[must_use]
935 pub fn selected(mut self, selected: usize) -> Self {
936 self.selected = Some(selected);
937 self
938 }
939
940 /// The label column every field pads to: the widest label in the form.
941 fn label_width(&self) -> usize {
942 self.rows
943 .iter()
944 .filter_map(|row| match row {
945 FormRow::Field(field) => Some(field.label.chars().count()),
946 FormRow::Section { .. } => None,
947 })
948 .max()
949 .unwrap_or(0)
950 }
951
952 /// The footer line for the focused row: its diagnostic if it has one, its
953 /// help otherwise.
954 ///
955 /// A diagnostic wins because it is the reason an edit did not commit, and
956 /// help the user has already read is not worth the row it would cost them.
957 fn footer(&self) -> Option<Line<'a>> {
958 let FormRow::Field(field) = self.rows.get(self.selected?)? else {
959 return None;
960 };
961 if let Some((severity, message)) = field.diagnostic {
962 return Some(Line::from(Span::styled(
963 message,
964 severity.style(self.theme),
965 )));
966 }
967 field
968 .help
969 .map(|help| Line::from(text::muted(self.theme, help)))
970 }
971 }
972
973 /// Fold markers for a section header. Plain geometric shapes rather than Nerd
974 /// Font glyphs, per docs/ICONOGRAPHY.md.
975 const SECTION_OPEN: &str = "";
976 const SECTION_CLOSED: &str = "";
977
978 impl Widget for AlloyForm<'_> {
979 fn render(mut self, area: Rect, buf: &mut Buffer) {
980 if area.height == 0 || area.width == 0 {
981 return;
982 }
983
984 // The footer is computed before the rows are consumed, and its line is
985 // taken off the top of the budget so a form one row tall still shows
986 // the row rather than only its help.
987 let footer = self.footer();
988 let rows_height = if area.height > 1 {
989 area.height - 1
990 } else {
991 area.height
992 };
993
994 let label_width = self.label_width();
995 let len = self.rows.len();
996 let offset = list_offset(len, rows_height as usize, self.selected);
997
998 for (row, (index, item)) in self
999 .rows
1000 .drain(..)
1001 .enumerate()
1002 .skip(offset)
1003 .take(rows_height as usize)
1004 .enumerate()
1005 {
1006 let line_area = Rect {
1007 y: area.y + row as u16,
1008 height: 1,
1009 ..area
1010 };
1011 let focused = self.selected == Some(index);
1012
1013 match item {
1014 FormRow::Section { label, open } => {
1015 let style = if focused {
1016 selected_style(self.theme)
1017 } else {
1018 unselected_style(self.theme)
1019 };
1020 let marker = if focused { MARKER } else { MARKER_BLANK };
1021 let fold = if open { SECTION_OPEN } else { SECTION_CLOSED };
1022 Paragraph::new(Line::from(vec![
1023 Span::styled(format!("{marker} "), style),
1024 Span::styled(format!("{fold} "), style),
1025 Span::styled(
1026 label,
1027 style.patch(Style::default().fg(self.theme.content_primary)),
1028 ),
1029 ]))
1030 .style(style)
1031 .render(line_area, buf);
1032 }
1033 FormRow::Field(field) => field
1034 .focused(focused)
1035 .indent(true)
1036 .label_width(label_width)
1037 .render(line_area, buf),
1038 }
1039 }
1040
1041 if area.height > 1 {
1042 let line_area = Rect {
1043 y: area.y + area.height - 1,
1044 height: 1,
1045 ..area
1046 };
1047 let base = Style::default().bg(self.theme.surface_page);
1048 Paragraph::new(footer.unwrap_or_default())
1049 .style(base)
1050 .render(line_area, buf);
1051 }
1052 }
1053 }
1054
1055 /// One choice in a picker: what it reads as, and what it means.
1056 pub struct PickRow<'a> {
1057 pub label: &'a str,
1058 pub description: Option<&'a str>,
1059 }
1060
1061 impl<'a> PickRow<'a> {
1062 pub fn new(label: &'a str) -> Self {
1063 Self {
1064 label,
1065 description: None,
1066 }
1067 }
1068
1069 #[must_use]
1070 pub fn description(mut self, description: Option<&'a str>) -> Self {
1071 self.description = description;
1072 self
1073 }
1074 }
1075
1076 /// A filterable pick list, drawn over the view that raised it.
1077 ///
1078 /// The overlay an enum field opens: choices do not free-type, so picking one is
1079 /// a different act from editing a value and gets a different surface. Sits on
1080 /// `surface.overlay` alongside [`AlloyModal`], which is the whole reason it
1081 /// lives here rather than being assembled per view — every floating thing in
1082 /// every Alloy TUI should read the same.
1083 ///
1084 /// **The filter is not optional chrome.** The design that specced a pick
1085 /// overlay assumed enums the size of a cursor shape; `timedatectl
1086 /// list-timezones` is about six hundred entries and locales are worse. A plain
1087 /// substring match is deliberate: zone names are terse and hierarchical, and a
1088 /// fuzzy ranker over six hundred strings is a scoring function to tune for no
1089 /// gain a substring does not already give.
1090 ///
1091 /// Like every widget here it owns nothing. The buffer, the filtering, and the
1092 /// selection are the caller's; this paints them.
1093 pub struct AlloyPicker<'a> {
1094 theme: &'a Theme,
1095 title: &'a str,
1096 filter: &'a TextField,
1097 rows: Vec<PickRow<'a>>,
1098 selected: Option<usize>,
1099 empty: &'a str,
1100 }
1101
1102 impl<'a> AlloyPicker<'a> {
1103 pub fn new(
1104 theme: &'a Theme,
1105 title: &'a str,
1106 filter: &'a TextField,
1107 rows: impl IntoIterator<Item = PickRow<'a>>,
1108 ) -> Self {
1109 Self {
1110 theme,
1111 title,
1112 filter,
1113 rows: rows.into_iter().collect(),
1114 selected: None,
1115 empty: "no matches",
1116 }
1117 }
1118
1119 #[must_use]
1120 pub fn selected(mut self, selected: Option<usize>) -> Self {
1121 self.selected = selected;
1122 self
1123 }
1124
1125 /// What to show when the filter matches nothing. A picker that goes blank
1126 /// reads as broken rather than as narrowed.
1127 #[must_use]
1128 pub fn empty(mut self, empty: &'a str) -> Self {
1129 self.empty = empty;
1130 self
1131 }
1132
1133 /// Width the content wants, borders included.
1134 ///
1135 /// Callers size the overlay with this and [`layout::centered`], rather than
1136 /// this widget picking its own area: where an overlay sits is the view's
1137 /// business and how wide its content is, is not.
1138 ///
1139 /// [`layout::centered`]: crate::layout::centered
1140 pub fn width(&self) -> u16 {
1141 let content = self
1142 .rows
1143 .iter()
1144 .map(|row| {
1145 row.label.chars().count()
1146 + row
1147 .description
1148 .map_or(0, |text| text.chars().count() + DESCRIPTION_GAP.len())
1149 })
1150 .chain(std::iter::once(self.title.chars().count()))
1151 .max()
1152 .unwrap_or(0);
1153 // Two for the border, two for the gutter the list draws its marker in.
1154 (content + 4) as u16
1155 }
1156
1157 /// Height for `rows` visible choices, borders included: the filter row and
1158 /// the key row on top of them.
1159 pub fn height(rows: u16) -> u16 {
1160 rows + 4
1161 }
1162 }
1163
1164 /// Separates a choice from what it means.
1165 const DESCRIPTION_GAP: &str = " ";
1166
1167 impl Widget for AlloyPicker<'_> {
1168 fn render(self, area: Rect, buf: &mut Buffer) {
1169 if area.height == 0 || area.width == 0 {
1170 return;
1171 }
1172
1173 let base = Style::default()
1174 .bg(self.theme.surface_overlay)
1175 .fg(self.theme.content_primary);
1176 let block = Block::default()
1177 .borders(Borders::ALL)
1178 .border_style(Style::default().fg(self.theme.border_strong))
1179 .style(base)
1180 .shadow(floating_shadow(self.theme))
1181 // The same inner margin AlloyBlock gives every other container. A
1182 // floating panel is still a container, and it is the one a user
1183 // looks straight at.
1184 .padding(crate::geometry::padding(crate::geometry::Gap::Group))
1185 .title(format!(" {} ", self.title));
1186 let inner = block.inner(area);
1187 block.render(area, buf);
1188
1189 if inner.height == 0 {
1190 return;
1191 }
1192
1193 // Filter on top, keys on the bottom, list between. The keys are pinned
1194 // for the same reason AlloyModal pins its own: a prompt whose dismiss
1195 // keys move with content length is one the user can lose.
1196 let (before, under, after) = self.filter.split();
1197 let caret = Style::default()
1198 .bg(self.theme.content_primary)
1199 .fg(self.theme.surface_overlay);
1200 Paragraph::new(Line::from(vec![
1201 Span::styled("/ ", base.fg(self.theme.content_muted)),
1202 Span::styled(before.to_string(), base),
1203 Span::styled(under.map_or(" ".to_string(), String::from), caret),
1204 Span::styled(after.to_string(), base),
1205 ]))
1206 .style(base)
1207 .render(Rect { height: 1, ..inner }, buf);
1208
1209 let keys = Line::from(vec![
1210 text::action(self.theme, "enter"),
1211 Span::styled(" select", Style::default().fg(self.theme.content_muted)),
1212 Span::raw(" "),
1213 text::action(self.theme, "esc"),
1214 Span::styled(" cancel", Style::default().fg(self.theme.content_muted)),
1215 ]);
1216 if inner.height > 1 {
1217 Paragraph::new(keys).style(base).render(
1218 Rect {
1219 y: inner.y + inner.height - 1,
1220 height: 1,
1221 ..inner
1222 },
1223 buf,
1224 );
1225 }
1226
1227 let list_area = Rect {
1228 y: inner.y + 1,
1229 height: inner.height.saturating_sub(2),
1230 ..inner
1231 };
1232 if list_area.height == 0 {
1233 return;
1234 }
1235
1236 if self.rows.is_empty() {
1237 Paragraph::new(Line::from(Span::styled(
1238 self.empty,
1239 base.fg(self.theme.content_muted),
1240 )))
1241 .style(base)
1242 .render(list_area, buf);
1243 return;
1244 }
1245
1246 // Descriptions line up in a column, so a list of choices reads as two
1247 // columns rather than as ragged sentences.
1248 let label_width = self
1249 .rows
1250 .iter()
1251 .map(|row| row.label.chars().count())
1252 .max()
1253 .unwrap_or(0);
1254
1255 let items: Vec<Line> = self
1256 .rows
1257 .iter()
1258 .map(|row| {
1259 let mut spans = vec![Span::styled(
1260 format!("{:label_width$}", row.label),
1261 Style::default(),
1262 )];
1263 if let Some(description) = row.description {
1264 spans.push(Span::styled(
1265 format!("{DESCRIPTION_GAP}{description}"),
1266 Style::default().fg(self.theme.content_muted),
1267 ));
1268 }
1269 Line::from(spans)
1270 })
1271 .collect();
1272
1273 // Rendered as an ordinary list so selection, the marker, and scrolling
1274 // are the same here as anywhere else in the console.
1275 AlloyList::new(self.theme, items)
1276 .selected(self.selected)
1277 .render(list_area, buf);
1278 }
1279 }
1280
1281 /// A display-only table.
1282 ///
1283 /// v1 renders schema list-of-tables records (rio's `bindings.keys`) read-only;
1284 /// add, remove, and cell edit route to the text-edit fallback until v1.1. It is
1285 /// display-only rather than half-editable on purpose: a table that accepts some
1286 /// edits and silently refuses others is worse than one that clearly accepts
1287 /// none.
1288 pub struct AlloyTable<'a> {
1289 theme: &'a Theme,
1290 headers: Vec<&'a str>,
1291 rows: Vec<Vec<String>>,
1292 }
1293
1294 impl<'a> AlloyTable<'a> {
1295 pub fn new(
1296 theme: &'a Theme,
1297 headers: impl IntoIterator<Item = &'a str>,
1298 rows: impl IntoIterator<Item = Vec<String>>,
1299 ) -> Self {
1300 Self {
1301 theme,
1302 headers: headers.into_iter().collect(),
1303 rows: rows.into_iter().collect(),
1304 }
1305 }
1306
1307 /// Column widths: the widest cell in each column, header included.
1308 fn widths(&self) -> Vec<usize> {
1309 let mut widths: Vec<usize> = self
1310 .headers
1311 .iter()
1312 .map(|header| header.chars().count())
1313 .collect();
1314 for row in &self.rows {
1315 for (column, cell) in row.iter().enumerate() {
1316 let width = cell.chars().count();
1317 match widths.get_mut(column) {
1318 Some(current) => *current = (*current).max(width),
1319 // A row wider than the header list still renders. The
1320 // schema and the file it describes can disagree, and
1321 // dropping a cell would hide exactly that.
1322 None => widths.push(width),
1323 }
1324 }
1325 }
1326 widths
1327 }
1328 }
1329
1330 /// Gap between table columns.
1331 const COLUMN_GAP: &str = " ";
1332
1333 impl Widget for AlloyTable<'_> {
1334 fn render(self, area: Rect, buf: &mut Buffer) {
1335 if area.height == 0 || area.width == 0 {
1336 return;
1337 }
1338
1339 let widths = self.widths();
1340 let pad = |cell: &str, column: usize| {
1341 let width = widths.get(column).copied().unwrap_or(0);
1342 format!("{cell:width$}")
1343 };
1344
1345 let mut lines = Vec::with_capacity(self.rows.len() + 1);
1346 lines.push(Line::from(
1347 self.headers
1348 .iter()
1349 .enumerate()
1350 .map(|(column, header)| {
1351 text::muted(self.theme, format!("{}{COLUMN_GAP}", pad(header, column)))
1352 })
1353 .collect::<Vec<_>>(),
1354 ));
1355 for row in &self.rows {
1356 lines.push(Line::from(
1357 row.iter()
1358 .enumerate()
1359 .map(|(column, cell)| {
1360 text::secondary(self.theme, format!("{}{COLUMN_GAP}", pad(cell, column)))
1361 })
1362 .collect::<Vec<_>>(),
1363 ));
1364 }
1365
1366 Paragraph::new(lines)
1367 .style(Style::default().bg(self.theme.surface_page))
1368 .render(area, buf);
1369 }
1370 }
1371
1372 #[cfg(test)]
1373 mod tests {
1374 use super::*;
1375 use ratatui::style::Color;
1376
1377 fn theme() -> Theme {
1378 Theme {
1379 mode: crate::theme::Mode::Dark,
1380 surface_page: Color::Rgb(0, 0, 0),
1381 surface_raised: Color::Rgb(1, 1, 1),
1382 surface_sunken: Color::Rgb(2, 2, 2),
1383 surface_overlay: Color::Rgb(3, 3, 3),
1384 content_primary: Color::Rgb(4, 4, 4),
1385 content_secondary: Color::Rgb(5, 5, 5),
1386 content_muted: Color::Rgb(6, 6, 6),
1387 action_primary: Color::Rgb(7, 7, 7),
1388 status_danger: Color::Rgb(8, 8, 8),
1389 status_success: Color::Rgb(9, 9, 9),
1390 status_warning: Color::Rgb(10, 10, 10),
1391 status_info: Color::Rgb(11, 11, 11),
1392 line_border: Color::Rgb(12, 12, 12),
1393 border_subtle: Color::Rgb(13, 13, 13),
1394 border_strong: Color::Rgb(14, 14, 14),
1395 bevel_light: Color::Rgb(16, 16, 16),
1396 bevel_dark: Color::Rgb(17, 17, 17),
1397 category: [Color::Rgb(15, 15, 15); 6],
1398 }
1399 }
1400
1401 fn list_of(n: usize, selected: Option<usize>) -> AlloyList<'static> {
1402 // Leaked so the test list can hold a 'static theme reference; the
1403 // widget borrows rather than owns, and these are per-test one-offs.
1404 let theme: &'static Theme = Box::leak(Box::new(theme()));
1405 let items: Vec<Line<'static>> = (0..n).map(|i| Line::from(format!("row {i}"))).collect();
1406 AlloyList::new(theme, items).selected(selected)
1407 }
1408
1409 #[test]
1410 fn short_list_never_scrolls() {
1411 assert_eq!(list_of(3, Some(2)).offset(10), 0);
1412 }
1413
1414 // Selection near the top must not scroll past the start of the list — a
1415 // naive `selected - height/2` underflows or shows blank rows above row 0.
1416 #[test]
1417 fn offset_clamps_at_the_top() {
1418 assert_eq!(list_of(50, Some(0)).offset(10), 0);
1419 assert_eq!(list_of(50, Some(2)).offset(10), 0);
1420 }
1421
1422 // Selection at the end must land the last row on the last visible line,
1423 // not scroll into empty space past the end of the list.
1424 #[test]
1425 fn offset_clamps_at_the_bottom() {
1426 assert_eq!(list_of(50, Some(49)).offset(10), 40);
1427 }
1428
1429 #[test]
1430 fn offset_centers_a_midlist_selection() {
1431 assert_eq!(list_of(50, Some(25)).offset(10), 20);
1432 }
1433
1434 #[test]
1435 fn row_y_maps_visible_items_to_screen_rows() {
1436 let area = Rect::new(0, 5, 20, 10);
1437 assert_eq!(list_row_y(area, 3, Some(0), 0), Some(5));
1438 assert_eq!(list_row_y(area, 3, Some(0), 2), Some(7));
1439 }
1440
1441 // After a scroll the mapping has to follow the offset. A connector using a
1442 // separate copy of the scroll rule is exactly what this prevents.
1443 #[test]
1444 fn row_y_accounts_for_scrolling() {
1445 let area = Rect::new(0, 0, 20, 10);
1446 // 50 items, selection at 25 => offset 20, so item 20 is the top row.
1447 assert_eq!(list_row_y(area, 50, Some(25), 20), Some(0));
1448 assert_eq!(list_row_y(area, 50, Some(25), 25), Some(5));
1449 }
1450
1451 #[test]
1452 fn row_y_is_none_for_rows_scrolled_out_of_view() {
1453 let area = Rect::new(0, 0, 20, 10);
1454 assert_eq!(
1455 list_row_y(area, 50, Some(25), 0),
1456 None,
1457 "above the viewport"
1458 );
1459 assert_eq!(
1460 list_row_y(area, 50, Some(25), 49),
1461 None,
1462 "below the viewport"
1463 );
1464 assert_eq!(
1465 list_row_y(area, 3, Some(0), 9),
1466 None,
1467 "past the end of the list"
1468 );
1469 }
1470
1471 fn render_tabs(selected: usize, width: u16) -> String {
1472 let theme = theme();
1473 let mut buf = Buffer::empty(Rect::new(0, 0, width, 1));
1474 AlloyTabs::new(&theme, ["installed", "boxes", "system"])
1475 .selected(selected)
1476 .render(Rect::new(0, 0, width, 1), &mut buf);
1477 buf.content()
1478 .iter()
1479 .map(ratatui::buffer::Cell::symbol)
1480 .collect()
1481 }
1482
1483 #[test]
1484 fn selected_tab_is_bracketed_and_others_are_not() {
1485 let rendered = render_tabs(0, 60);
1486 assert!(
1487 rendered.contains("[ installed ]"),
1488 "selected tab is bracketed"
1489 );
1490 assert!(!rendered.contains("[ boxes ]"), "unselected tabs are not");
1491 assert!(rendered.contains("boxes"), "unselected labels still render");
1492 }
1493
1494 // The bar must not shift horizontally as selection moves, or every tab
1495 // change reads as the whole row twitching. Unselected labels pad to the
1496 // bracket width for exactly this reason.
1497 #[test]
1498 fn labels_hold_their_columns_across_selections() {
1499 let first = render_tabs(0, 60);
1500 let last = render_tabs(2, 60);
1501 assert_eq!(
1502 first.find("system"),
1503 last.find("system"),
1504 "a label sits in the same columns whichever tab is selected"
1505 );
1506 }
1507
1508 // FocusRing::focus ignores out-of-range slots rather than clamping, and the
1509 // bar has to agree: showing a neighbouring tab as current would misreport
1510 // which screen the user is looking at.
1511 #[test]
1512 fn out_of_range_selection_brackets_nothing() {
1513 let rendered = render_tabs(9, 60);
1514 assert!(!rendered.contains('['), "no tab is marked current");
1515 assert!(rendered.contains("installed"), "labels still render");
1516 }
1517
1518 #[test]
1519 fn zero_height_area_renders_nothing_rather_than_panicking() {
1520 let theme = theme();
1521 let mut buf = Buffer::empty(Rect::new(0, 0, 40, 1));
1522 AlloyTabs::new(&theme, ["installed"]).render(Rect::new(0, 0, 40, 0), &mut buf);
1523 AlloyTabs::new(&theme, ["installed"]).render(Rect::new(0, 0, 0, 1), &mut buf);
1524 }
1525
1526 fn render_modal(area: Rect) -> Vec<String> {
1527 let theme = theme();
1528 let mut buf = Buffer::empty(area);
1529 AlloyModal::new(&theme, "remove", "Remove tailscale?").render(area, &mut buf);
1530 (0..area.height)
1531 .map(|y| {
1532 (0..area.width)
1533 .map(|x| buf[(x, y)].symbol())
1534 .collect::<String>()
1535 })
1536 .collect()
1537 }
1538
1539 #[test]
1540 fn modal_shows_its_message_and_both_keys() {
1541 let rows = render_modal(Rect::new(0, 0, 40, 7)).join("\n");
1542 assert!(rows.contains("Remove tailscale?"), "message renders");
1543 assert!(rows.contains("remove"), "title renders");
1544 assert!(rows.contains("Enter"), "confirm key renders");
1545 assert!(rows.contains("Esc"), "cancel key renders");
1546 }
1547
1548 // The keys are pinned to the last inner row rather than flowing after the
1549 // message. A prompt whose dismiss keys move with message length, or fall
1550 // off a short box, is a modal the user cannot get out of.
1551 #[test]
1552 fn keys_sit_on_the_last_row_whatever_the_message_length() {
1553 for height in [5, 7, 12] {
1554 let rows = render_modal(Rect::new(0, 0, 40, height));
1555 let last_inner = &rows[height as usize - 2];
1556 assert!(
1557 last_inner.contains("Enter") && last_inner.contains("Esc"),
1558 "height {height}: keys belong on the last inner row, got {last_inner:?}"
1559 );
1560 }
1561 }
1562
1563 #[test]
1564 fn modal_survives_an_area_too_small_to_draw_in() {
1565 let theme = theme();
1566 let mut buf = Buffer::empty(Rect::new(0, 0, 40, 7));
1567 AlloyModal::new(&theme, "t", "m").render(Rect::new(0, 0, 40, 0), &mut buf);
1568 AlloyModal::new(&theme, "t", "m").render(Rect::new(0, 0, 0, 7), &mut buf);
1569 AlloyModal::new(&theme, "t", "m").render(Rect::new(0, 0, 2, 2), &mut buf);
1570 }
1571
1572 fn render_to(width: u16, height: u16, draw: impl FnOnce(&mut Buffer, Rect)) -> Vec<String> {
1573 let area = Rect::new(0, 0, width, height);
1574 let mut buf = Buffer::empty(area);
1575 draw(&mut buf, area);
1576 (0..height)
1577 .map(|y| (0..width).map(|x| buf[(x, y)].symbol()).collect::<String>())
1578 .collect()
1579 }
1580
1581 /// Column of `needle` in `row`, counted in characters.
1582 ///
1583 /// `str::find` counts bytes, and the focus marker is three of them, so a
1584 /// byte offset says a focused row's value starts two columns right of an
1585 /// unfocused one when both are in the same column.
1586 fn column(row: &str, needle: &str) -> Option<usize> {
1587 let at = row.find(needle)?;
1588 Some(row[..at].chars().count())
1589 }
1590
1591 fn field_rows(theme: &Theme) -> Vec<FormRow<'_>> {
1592 vec![
1593 FormRow::Section {
1594 label: "cursor",
1595 open: true,
1596 },
1597 FormRow::Field(
1598 AlloyField::new(theme, "shape", FieldKind::Enum { label: "block" })
1599 .help(Some("Cursor shape.")),
1600 ),
1601 FormRow::Field(AlloyField::new(theme, "blinking", FieldKind::Toggle(false))),
1602 FormRow::Section {
1603 label: "colors",
1604 open: false,
1605 },
1606 ]
1607 }
1608
1609 #[test]
1610 fn a_toggle_reads_without_color_or_a_patched_font() {
1611 let rows = render_to(40, 1, |buf, area| {
1612 AlloyField::new(&theme(), "blinking", FieldKind::Toggle(true)).render(area, buf);
1613 });
1614 assert!(rows[0].contains("[x]"), "{rows:?}");
1615
1616 let rows = render_to(40, 1, |buf, area| {
1617 AlloyField::new(&theme(), "blinking", FieldKind::Toggle(false)).render(area, buf);
1618 });
1619 assert!(rows[0].contains("[ ]"), "{rows:?}");
1620 }
1621
1622 #[test]
1623 fn a_color_field_draws_a_swatch_beside_its_hex() {
1624 let rows = render_to(40, 1, |buf, area| {
1625 AlloyField::new(&theme(), "background", FieldKind::Color { hex: "#e4ded6" })
1626 .render(area, buf);
1627 });
1628 assert!(rows[0].contains("██ #e4ded6"), "{rows:?}");
1629 }
1630
1631 // A hex the widget cannot parse still shows its text. The swatch helps read
1632 // a value; it is not the value, and dropping the text would hide the one
1633 // thing the user needs to see to fix it.
1634 #[test]
1635 fn an_unparseable_color_still_renders_its_text() {
1636 let rows = render_to(40, 1, |buf, area| {
1637 AlloyField::new(&theme(), "background", FieldKind::Color { hex: "e4ded6" })
1638 .render(area, buf);
1639 });
1640 assert!(rows[0].contains("e4ded6"), "{rows:?}");
1641 assert!(
1642 !rows[0].contains(''),
1643 "no swatch for a value it cannot parse"
1644 );
1645 }
1646
1647 #[test]
1648 fn swatch_parses_both_hex_lengths_and_rejects_the_rest() {
1649 assert_eq!(swatch("#e4ded6"), Some(Color::Rgb(0xe4, 0xde, 0xd6)));
1650 assert_eq!(
1651 swatch("#e4ded6ff"),
1652 Some(Color::Rgb(0xe4, 0xde, 0xd6)),
1653 "alpha is dropped, not refused",
1654 );
1655 assert_eq!(swatch("e4ded6"), None, "no hash");
1656 assert_eq!(swatch("#e4ded"), None, "wrong length");
1657 assert_eq!(swatch("#gggggg"), None, "not hex");
1658 }
1659
1660 // An edited row shows the buffer and its caret, not the committed value.
1661 #[test]
1662 fn an_edited_field_draws_the_caret_buffer_in_place_of_the_value() {
1663 let mut buffer = TextField::new();
1664 buffer.set("Departure Mono");
1665 buffer.home();
1666 let rows = render_to(60, 1, |buf, area| {
1667 AlloyField::new(&theme(), "family", FieldKind::Text("IosevkaTerm"))
1668 .edit(Some(&buffer))
1669 .render(area, buf);
1670 });
1671 assert!(rows[0].contains("Departure Mono"), "{rows:?}");
1672 assert!(
1673 !rows[0].contains("IosevkaTerm"),
1674 "the committed value is not drawn"
1675 );
1676 }
1677
1678 // The caret has to be visible while appending, which is where it spends
1679 // most of its life. Past the end of the line there is no character under
1680 // it, so it draws on a space.
1681 #[test]
1682 fn a_caret_past_the_end_of_the_line_still_has_a_cell() {
1683 let mut buffer = TextField::new();
1684 buffer.set("alloy");
1685 let (before, under, after) = buffer.split();
1686 assert_eq!((before, under, after), ("alloy", None, ""));
1687
1688 let spans = caret_spans(&theme(), &buffer, Style::default());
1689 assert_eq!(spans[1].content, " ", "the caret sits on a space");
1690 }
1691
1692 #[test]
1693 fn a_form_lines_its_value_column_up_across_rows() {
1694 let theme = theme();
1695 let rows = render_to(50, 6, |buf, area| {
1696 AlloyForm::new(&theme, field_rows(&theme))
1697 .selected(1)
1698 .render(area, buf);
1699 });
1700 // "blinking" is the longest label, so both values start in the same
1701 // column despite "shape" being three characters shorter.
1702 let shape = column(&rows[1], "block").expect("enum label renders");
1703 let blinking = column(&rows[2], "[ ]").expect("toggle renders");
1704 assert_eq!(shape, blinking, "{rows:?}");
1705 }
1706
1707 #[test]
1708 fn a_section_header_shows_whether_it_is_folded() {
1709 let theme = theme();
1710 let rows = render_to(50, 6, |buf, area| {
1711 AlloyForm::new(&theme, field_rows(&theme))
1712 .selected(0)
1713 .render(area, buf);
1714 });
1715 assert!(rows[0].contains("▾ cursor"), "open section: {rows:?}");
1716 assert!(rows[3].contains("▸ colors"), "folded section: {rows:?}");
1717 }
1718
1719 #[test]
1720 fn the_focused_row_carries_the_same_marker_a_list_row_would() {
1721 let theme = theme();
1722 let rows = render_to(50, 6, |buf, area| {
1723 AlloyForm::new(&theme, field_rows(&theme))
1724 .selected(2)
1725 .render(area, buf);
1726 });
1727 assert!(rows[2].starts_with(MARKER), "{rows:?}");
1728 assert!(!rows[1].starts_with(MARKER), "only one row is focused");
1729 }
1730
1731 // The footer belongs to whichever row is focused, and a diagnostic beats
1732 // help: it is the reason an edit did not commit.
1733 #[test]
1734 fn the_footer_shows_the_focused_rows_help_and_a_diagnostic_over_it() {
1735 let theme = theme();
1736 let rows = render_to(50, 6, |buf, area| {
1737 AlloyForm::new(&theme, field_rows(&theme))
1738 .selected(1)
1739 .render(area, buf);
1740 });
1741 assert!(rows[5].contains("Cursor shape."), "help renders: {rows:?}");
1742
1743 let rows = render_to(50, 6, |buf, area| {
1744 let mut rows = field_rows(&theme);
1745 rows[1] = FormRow::Field(
1746 AlloyField::new(&theme, "shape", FieldKind::Enum { label: "bar" })
1747 .help(Some("Cursor shape."))
1748 .diagnostic(Some((Severity::Error, "\"bar\" is not a declared value"))),
1749 );
1750 AlloyForm::new(&theme, rows).selected(1).render(area, buf);
1751 });
1752 assert!(rows[5].contains("not a declared value"), "{rows:?}");
1753 assert!(!rows[5].contains("Cursor shape."), "the diagnostic wins");
1754 }
1755
1756 // The footer line is reserved whether or not it has anything in it. A form
1757 // whose rows reflow as focus moves is one where the row under the cursor
1758 // moves out from under it.
1759 #[test]
1760 fn rows_hold_their_lines_whether_the_footer_has_content_or_not() {
1761 let theme = theme();
1762 let with_help = render_to(50, 6, |buf, area| {
1763 AlloyForm::new(&theme, field_rows(&theme))
1764 .selected(1)
1765 .render(area, buf);
1766 });
1767 let without = render_to(50, 6, |buf, area| {
1768 AlloyForm::new(&theme, field_rows(&theme))
1769 .selected(2)
1770 .render(area, buf);
1771 });
1772 assert_eq!(
1773 with_help[0], without[0],
1774 "the section header sits on the same line either way",
1775 );
1776 assert!(without[5].trim().is_empty(), "no help, an empty footer");
1777 }
1778
1779 // One line per row is what keeps the stateless scroll math valid, so a form
1780 // longer than its area scrolls exactly the way a list does.
1781 #[test]
1782 fn a_form_longer_than_its_area_scrolls_like_a_list() {
1783 let theme = theme();
1784 let many: Vec<FormRow> = (0..50)
1785 .map(|i| {
1786 FormRow::Field(AlloyField::new(
1787 &theme,
1788 "slot",
1789 FieldKind::Number(if i == 25 { "twentyfive" } else { "x" }),
1790 ))
1791 })
1792 .collect();
1793 let rows = render_to(50, 11, |buf, area| {
1794 AlloyForm::new(&theme, many).selected(25).render(area, buf);
1795 });
1796 // 50 rows, 10 row-lines after the footer, selection 25 => offset 20.
1797 assert!(rows[5].contains("twentyfive"), "{rows:?}");
1798 }
1799
1800 #[test]
1801 fn a_form_survives_an_area_too_small_to_draw_in() {
1802 let theme = theme();
1803 let mut buf = Buffer::empty(Rect::new(0, 0, 40, 6));
1804 AlloyForm::new(&theme, field_rows(&theme))
1805 .selected(0)
1806 .render(Rect::new(0, 0, 40, 0), &mut buf);
1807 AlloyForm::new(&theme, field_rows(&theme))
1808 .selected(0)
1809 .render(Rect::new(0, 0, 0, 6), &mut buf);
1810 // One line tall: the row wins over the footer.
1811 let rows = render_to(40, 1, |buf, area| {
1812 AlloyForm::new(&theme, field_rows(&theme))
1813 .selected(0)
1814 .render(area, buf);
1815 });
1816 assert!(rows[0].contains("cursor"), "{rows:?}");
1817 }
1818
1819 fn picker_rows() -> Vec<PickRow<'static>> {
1820 vec![
1821 PickRow::new("Block").description(Some("Solid block.")),
1822 PickRow::new("Beam").description(Some("Thin vertical bar.")),
1823 ]
1824 }
1825
1826 fn render_picker(filter: &TextField, rows: Vec<PickRow<'_>>, height: u16) -> Vec<String> {
1827 let theme = theme();
1828 render_to(50, height, |buf, area| {
1829 AlloyPicker::new(&theme, "cursor.shape", filter, rows)
1830 .selected(Some(0))
1831 .render(area, buf);
1832 })
1833 }
1834
1835 #[test]
1836 fn a_picker_shows_its_choices_with_what_they_mean() {
1837 let rows = render_picker(&TextField::new(), picker_rows(), 8);
1838 let joined = rows.join("\n");
1839 assert!(joined.contains("cursor.shape"), "title: {joined}");
1840 assert!(joined.contains("Block"), "{joined}");
1841 assert!(joined.contains("Solid block."), "{joined}");
1842 assert!(joined.contains("Thin vertical bar."), "{joined}");
1843 }
1844
1845 #[test]
1846 fn the_filter_row_carries_a_caret() {
1847 let mut filter = TextField::new();
1848 filter.set("bl");
1849 let rows = render_picker(&filter, picker_rows(), 8);
1850 assert!(rows[1].contains("/ bl"), "{rows:?}");
1851 }
1852
1853 // A picker that goes blank when the filter matches nothing reads as broken
1854 // rather than as narrowed.
1855 #[test]
1856 fn a_filter_that_matches_nothing_says_so() {
1857 let rows = render_picker(&TextField::new(), Vec::new(), 8);
1858 assert!(rows.join("\n").contains("no matches"), "{rows:?}");
1859 }
1860
1861 // Same rule AlloyModal follows: a floating thing whose dismiss keys move
1862 // with its content is one the user can lose.
1863 #[test]
1864 fn the_keys_sit_on_the_last_row_whatever_the_choice_count() {
1865 for height in [6, 8, 14] {
1866 let rows = render_picker(&TextField::new(), picker_rows(), height);
1867 let last = &rows[height as usize - 2];
1868 assert!(
1869 last.contains("enter") && last.contains("esc"),
1870 "height {height}: {last:?}"
1871 );
1872 }
1873 }
1874
1875 #[test]
1876 fn a_picker_is_wide_enough_for_its_widest_choice() {
1877 let theme = theme();
1878 let filter = TextField::new();
1879 let width = AlloyPicker::new(&theme, "t", &filter, picker_rows()).width();
1880 // "Beam" plus the gap plus "Thin vertical bar." is the longest row.
1881 assert_eq!(width as usize, 4 + 2 + 18 + 4);
1882 assert_eq!(
1883 AlloyPicker::height(2),
1884 6,
1885 "two rows, a filter, keys, borders"
1886 );
1887 }
1888
1889 #[test]
1890 fn a_picker_survives_an_area_too_small_to_draw_in() {
1891 let theme = theme();
1892 let filter = TextField::new();
1893 let mut buf = Buffer::empty(Rect::new(0, 0, 40, 8));
1894 for area in [
1895 Rect::new(0, 0, 40, 0),
1896 Rect::new(0, 0, 0, 8),
1897 Rect::new(0, 0, 4, 2),
1898 Rect::new(0, 0, 40, 3),
1899 ] {
1900 AlloyPicker::new(&theme, "t", &filter, picker_rows()).render(area, &mut buf);
1901 }
1902 }
1903
1904 #[test]
1905 fn a_table_aligns_its_columns_under_its_headers() {
1906 let theme = theme();
1907 let rows = render_to(60, 3, |buf, area| {
1908 AlloyTable::new(
1909 &theme,
1910 ["key", "action"],
1911 [
1912 vec!["ctrl+shift+t".to_string(), "CreateTab".to_string()],
1913 vec!["ctrl+w".to_string(), "CloseTab".to_string()],
1914 ],
1915 )
1916 .render(area, buf);
1917 });
1918 let action = column(&rows[0], "action").expect("header renders");
1919 assert_eq!(column(&rows[1], "CreateTab"), Some(action), "{rows:?}");
1920 assert_eq!(column(&rows[2], "CloseTab"), Some(action), "{rows:?}");
1921 }
1922
1923 // A file can hold a record the schema does not describe. Dropping the extra
1924 // cell would hide exactly the disagreement worth seeing.
1925 #[test]
1926 fn a_row_wider_than_the_headers_still_renders_every_cell() {
1927 let theme = theme();
1928 let rows = render_to(60, 2, |buf, area| {
1929 AlloyTable::new(
1930 &theme,
1931 ["key"],
1932 [vec!["ctrl+w".to_string(), "CloseTab".to_string()]],
1933 )
1934 .render(area, buf);
1935 });
1936 assert!(rows[1].contains("CloseTab"), "{rows:?}");
1937 }
1938
1939 // A log longer than its pane shows the newest entries. Showing the head
1940 // instead would freeze the pane on startup noise and never display the
1941 // command the user just triggered.
1942 #[test]
1943 fn log_renders_the_newest_entries() {
1944 let theme = theme();
1945 let entries: Vec<LogEntry> = (0..10)
1946 .map(|i| LogEntry::new(format!("nmcli run {i}"), Severity::Healthy))
1947 .collect();
1948 let mut buf = Buffer::empty(Rect::new(0, 0, 40, 4));
1949 AlloyLog::new(&theme, &entries).render(Rect::new(0, 0, 40, 4), &mut buf);
1950
1951 let rendered = buf
1952 .content()
1953 .iter()
1954 .map(ratatui::buffer::Cell::symbol)
1955 .collect::<String>();
1956 assert!(
1957 rendered.contains("nmcli run 9"),
1958 "newest entry must be visible"
1959 );
1960 assert!(
1961 !rendered.contains("nmcli run 0"),
1962 "oldest entry must have scrolled off"
1963 );
1964 }
1965
1966 // ---- button ----
1967
1968 fn render_button(button: AlloyButton, w: u16, h: u16) -> (Buffer, Rect) {
1969 let area = Rect::new(0, 0, w, h);
1970 let mut buf = Buffer::empty(area);
1971 button.render(area, &mut buf);
1972 (buf, area)
1973 }
1974
1975 fn rows(buf: &Buffer, area: Rect) -> Vec<String> {
1976 (area.y..area.bottom())
1977 .map(|y| {
1978 (area.x..area.right())
1979 .map(|x| buf[(x, y)].symbol())
1980 .collect::<String>()
1981 })
1982 .collect()
1983 }
1984
1985 #[test]
1986 fn a_button_is_a_beveled_surface_with_a_centered_label() {
1987 let theme = theme();
1988 let (buf, area) = render_button(AlloyButton::new(&theme, "OK"), 8, 3);
1989 assert_eq!(rows(&buf, area), vec!["▛▀▀▀▀▀▀▀", "▌ OK ▐", "▄▄▄▄▄▄▄▟"]);
1990 }
1991
1992 // The pressed state is the same button lit from the other corner. Asserted
1993 // as a relationship rather than against literals, because that is what
1994 // makes it one swap instead of a second widget.
1995 #[test]
1996 fn pressing_a_button_inverts_its_light_and_recesses_its_face() {
1997 let theme = theme();
1998 let (up, area) = render_button(AlloyButton::new(&theme, "OK"), 8, 3);
1999 let (down, _) = render_button(AlloyButton::new(&theme, "OK").pressed(true), 8, 3);
2000
2001 assert_eq!(rows(&up, area), rows(&down, area));
2002 assert_eq!(up[(0u16, 0u16)].fg, theme.bevel_light);
2003 assert_eq!(down[(0u16, 0u16)].fg, theme.bevel_dark);
2004 assert_eq!(up[(3u16, 1u16)].bg, theme.surface_raised);
2005 assert_eq!(down[(3u16, 1u16)].bg, theme.surface_sunken);
2006 }
2007
2008 // Dimmed and still there, so the layout keeps teaching itself.
2009 #[test]
2010 fn a_disabled_button_keeps_its_bevel_and_mutes_only_its_label() {
2011 let theme = theme();
2012 let (buf, area) = render_button(AlloyButton::new(&theme, "OK").disabled(true), 8, 3);
2013 assert_eq!(rows(&buf, area), vec!["▛▀▀▀▀▀▀▀", "▌ OK ▐", "▄▄▄▄▄▄▄▟"]);
2014 assert_eq!(buf[(0u16, 0u16)].fg, theme.bevel_light);
2015 assert_eq!(buf[(3u16, 1u16)].fg, theme.content_muted);
2016 }
2017
2018 #[test]
2019 fn a_primary_button_inverts_polarity_without_touching_the_bevel() {
2020 let theme = theme();
2021 let (buf, _) = render_button(AlloyButton::new(&theme, "OK").primary(true), 8, 3);
2022 assert_eq!(buf[(3u16, 1u16)].bg, theme.content_primary);
2023 assert_eq!(buf[(3u16, 1u16)].fg, theme.surface_raised);
2024 assert_eq!(buf[(0u16, 0u16)].fg, theme.bevel_light);
2025 }
2026
2027 // ---- floating surfaces ----
2028
2029 // The shadow lands outside the modal, one cell down and right, so the page
2030 // has to be bigger than the modal for it to exist at all.
2031 #[test]
2032 fn a_modal_casts_a_shadow_onto_the_page_behind_it() {
2033 let theme = theme();
2034 let page = Rect::new(0, 0, 24, 8);
2035 let modal = Rect::new(2, 1, 18, 5);
2036 let mut buf = Buffer::empty(page);
2037 AlloyModal::new(&theme, "remove", "Remove tailscale?").render(modal, &mut buf);
2038
2039 // Directly under the modal's bottom edge, offset one to the right.
2040 let below = &buf[(3u16, 6u16)];
2041 assert_eq!(below.symbol(), "");
2042 assert_eq!(below.fg, theme.border_strong);
2043 // The page well away from the modal is untouched.
2044 assert_eq!(buf[(23u16, 7u16)].symbol(), " ");
2045 }
2046 }
2047