Skip to main content

max / alloy_tui

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