Skip to main content

max / alloy_tui

73.9 KB · 2101 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.surface_page)
74 .fg(self.theme.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.status_info,
103 Severity::Healthy => theme.status_success,
104 Severity::Warn => theme.status_warning,
105 Severity::Error => theme.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.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`](crate::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.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.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.content_muted
485 } else if self.primary {
486 self.theme.surface_raised
487 } else {
488 self.theme.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.surface_overlay)
561 .fg(self.theme.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(" confirm", Style::default().fg(self.theme.content_muted)),
587 Span::raw(" "),
588 text::action(self.theme, "Esc"),
589 Span::styled(" cancel", Style::default().fg(self.theme.content_muted)),
590 ]);
591
592 let message_height = inner.height.saturating_sub(1);
593 if message_height > 0 {
594 Paragraph::new(Line::from(Span::styled(
595 self.message,
596 self.severity.style(self.theme).patch(base),
597 )))
598 .style(base)
599 .wrap(ratatui::widgets::Wrap { trim: true })
600 .render(
601 Rect {
602 height: message_height,
603 ..inner
604 },
605 buf,
606 );
607 }
608
609 Paragraph::new(keys).style(base).render(
610 Rect {
611 y: inner.y + inner.height - 1,
612 height: 1,
613 ..inner
614 },
615 buf,
616 );
617 }
618 }
619
620 /// One line of the command log: the CLI invocation that was run, and how it
621 /// went.
622 ///
623 /// The console fronts CLIs rather than hiding them (docs/CONSOLE.md), so
624 /// `command` holds the actual argv the console executed — verbatim, so a user
625 /// can copy it into a shell and get the same result.
626 #[derive(Debug, Clone)]
627 pub struct LogEntry {
628 pub command: String,
629 pub outcome: Severity,
630 }
631
632 impl LogEntry {
633 pub fn new(command: impl Into<String>, outcome: Severity) -> Self {
634 Self {
635 command: command.into(),
636 outcome,
637 }
638 }
639 }
640
641 /// The always-on command-log pane.
642 ///
643 /// Renders the tail of the log — the most recent invocation on the bottom row,
644 /// terminal-transcript order, so the pane reads the way a shell scrollback
645 /// does.
646 pub struct AlloyLog<'a> {
647 theme: &'a Theme,
648 entries: &'a [LogEntry],
649 }
650
651 impl<'a> AlloyLog<'a> {
652 pub fn new(theme: &'a Theme, entries: &'a [LogEntry]) -> Self {
653 Self { theme, entries }
654 }
655 }
656
657 impl Widget for AlloyLog<'_> {
658 fn render(self, area: Rect, buf: &mut Buffer) {
659 if area.height == 0 || area.width == 0 {
660 return;
661 }
662
663 let block = AlloyBlock::new(self.theme).build().title(" commands ");
664 let inner = block.inner(area);
665 block.render(area, buf);
666
667 if inner.height == 0 {
668 return;
669 }
670
671 let visible = inner.height as usize;
672 let tail = self.entries.len().saturating_sub(visible);
673 let lines: Vec<Line> = self.entries[tail..]
674 .iter()
675 .map(|entry| {
676 Line::from(vec![
677 Span::styled("$ ", entry.outcome.style(self.theme)),
678 text::secondary(self.theme, entry.command.clone()),
679 ])
680 })
681 .collect();
682
683 Paragraph::new(lines)
684 .style(Style::default().bg(self.theme.surface_page))
685 .render(inner, buf);
686 }
687 }
688
689 /// The value half of a form row: what the value cell paints, and nothing else.
690 ///
691 /// One enum rather than five widgets. Which kind a row is comes off a schema
692 /// file read at startup, so it is runtime data and five static types would buy
693 /// no safety at the one call site that builds rows. See
694 /// docs/COMPONENT-LIBRARY.md in the Alloy repo for the full rationale.
695 ///
696 /// Every variant that carries text takes it pre-formatted: rounding a float,
697 /// picking which of an enum's labels is current, and deciding how a path is
698 /// abbreviated are all the caller's business, and a widget that formatted
699 /// values would need to know the schema.
700 pub enum FieldKind<'a> {
701 Toggle(bool),
702 Text(&'a str),
703 Number(&'a str),
704 /// The current selection's label, not its raw value.
705 Enum {
706 label: &'a str,
707 },
708 /// Draws a swatch alongside the text. The one place a widget here paints
709 /// an arbitrary color: DESIGN-LANGUAGE.md keeps color off chrome, and this
710 /// is not chrome, it is the value.
711 Color {
712 hex: &'a str,
713 },
714 }
715
716 /// A single form row: a label, a value cell, and the chrome around focus.
717 ///
718 /// Pure render, like every widget here. It owns no value, does no validation,
719 /// and does not know what a schema is; the console binary holds the document,
720 /// the constraints, and the edit buffer, and rebuilds these each frame.
721 pub struct AlloyField<'a> {
722 theme: &'a Theme,
723 label: &'a str,
724 kind: FieldKind<'a>,
725 focused: bool,
726 indent: bool,
727 unset: bool,
728 label_width: usize,
729 edit: Option<&'a TextField>,
730 diagnostic: Option<(Severity, &'a str)>,
731 help: Option<&'a str>,
732 }
733
734 impl<'a> AlloyField<'a> {
735 pub fn new(theme: &'a Theme, label: &'a str, kind: FieldKind<'a>) -> Self {
736 Self {
737 theme,
738 label,
739 kind,
740 focused: false,
741 indent: false,
742 unset: false,
743 label_width: 0,
744 edit: None,
745 diagnostic: None,
746 help: None,
747 }
748 }
749
750 #[must_use]
751 pub fn focused(mut self, focused: bool) -> Self {
752 self.focused = focused;
753 self
754 }
755
756 /// Sit the row under a section header. [`AlloyForm`] sets this; a field
757 /// rendered on its own is not inside anything.
758 #[must_use]
759 pub fn indent(mut self, indent: bool) -> Self {
760 self.indent = indent;
761 self
762 }
763
764 /// Mark the value as the schema's default rather than something the source
765 /// of truth holds, and paint it muted.
766 ///
767 /// Without this a form cannot show the difference between a key set to 12
768 /// and a key absent from a file that defaults to 12. Keeping the two
769 /// distinguishable is the whole point of the console reading and
770 /// defaulting through separate calls, and this is where that reaches the
771 /// screen.
772 #[must_use]
773 pub fn unset(mut self, unset: bool) -> Self {
774 self.unset = unset;
775 self
776 }
777
778 /// Pad the label column to this width, so value cells line up down the
779 /// form. [`AlloyForm`] sets it from the widest label it holds.
780 #[must_use]
781 pub fn label_width(mut self, width: usize) -> Self {
782 self.label_width = width;
783 self
784 }
785
786 /// Draw the caret buffer in place of the value cell. `Some` means this row
787 /// is being edited.
788 #[must_use]
789 pub fn edit(mut self, edit: Option<&'a TextField>) -> Self {
790 self.edit = edit;
791 self
792 }
793
794 #[must_use]
795 pub fn diagnostic(mut self, diagnostic: Option<(Severity, &'a str)>) -> Self {
796 self.diagnostic = diagnostic;
797 self
798 }
799
800 #[must_use]
801 pub fn help(mut self, help: Option<&'a str>) -> Self {
802 self.help = help;
803 self
804 }
805
806 /// The value cell's spans, under the row's base style.
807 fn value_spans(&self, base: Style) -> Vec<Span<'a>> {
808 if let Some(buffer) = self.edit {
809 return caret_spans(self.theme, buffer, base);
810 }
811
812 let style = if self.unset {
813 base.fg(self.theme.content_muted)
814 } else {
815 base
816 };
817
818 match &self.kind {
819 // Brackets and not a color or a Nerd Font glyph, for the reason
820 // MARKER is a plain triangle: a toggle has to be readable on the
821 // TTY before the session starts, and state must not be carried by
822 // color alone.
823 FieldKind::Toggle(on) => {
824 vec![Span::styled(if *on { "[x]" } else { "[ ]" }, style)]
825 }
826 FieldKind::Text(value) | FieldKind::Number(value) => {
827 vec![Span::styled(*value, style)]
828 }
829 FieldKind::Enum { label } => vec![Span::styled(*label, style)],
830 FieldKind::Color { hex } => {
831 let mut spans = Vec::with_capacity(2);
832 // A hex string the caller could not parse still renders its
833 // text: a swatch is an aid to reading the value, not the value.
834 if let Some(color) = swatch(hex) {
835 spans.push(Span::styled(SWATCH, base.fg(color)));
836 spans.push(Span::styled(" ", base));
837 }
838 spans.push(Span::styled(*hex, style));
839 spans
840 }
841 }
842 }
843 }
844
845 /// The drop shadow under a surface that floats above the page.
846 ///
847 /// The one place this design system draws a shadow, and the exception is
848 /// deliberate: a bevel says *raised by one step*, which is the wrong claim for a
849 /// modal. A shadow says *detached from the page underneath*, and a reader needs
850 /// to know the thing behind is still there and still theirs to return to.
851 ///
852 /// Shade characters rather than a dimmed background, because a terminal has no
853 /// blur and a solid offset block reads as a second window rather than as a
854 /// shadow.
855 pub(crate) fn floating_shadow(theme: &Theme) -> Shadow {
856 Shadow::medium_shade().style(
857 Style::default()
858 .fg(theme.border_strong)
859 .bg(theme.surface_page),
860 )
861 }
862
863 /// The swatch a color field paints beside its hex.
864 const SWATCH: &str = "██";
865
866 /// Parse `#rrggbb` or `#rrggbbaa` into a color. Alpha is dropped: a terminal
867 /// cell has no alpha to blend against.
868 fn swatch(hex: &str) -> Option<Color> {
869 let body = hex.strip_prefix('#')?;
870 if body.len() != 6 && body.len() != 8 {
871 return None;
872 }
873 let channel = |at: usize| u8::from_str_radix(body.get(at..at + 2)?, 16).ok();
874 Some(Color::Rgb(channel(0)?, channel(2)?, channel(4)?))
875 }
876
877 /// A caret buffer as spans: the text either side of the caret, and the cell
878 /// under it drawn as a block.
879 ///
880 /// Colors are named rather than reversed. `Modifier::REVERSED` swaps against
881 /// whatever the row's background happens to be, so a caret on a focused row
882 /// and a caret on an unfocused one would not match.
883 fn caret_spans<'s>(theme: &Theme, buffer: &TextField, base: Style) -> Vec<Span<'s>> {
884 let (before, under, after) = buffer.split();
885 let caret = Style::default()
886 .bg(theme.content_primary)
887 .fg(theme.surface_page);
888 vec![
889 Span::styled(before.to_string(), base),
890 // A caret past the end of the line has no character to sit on, so it
891 // draws on a space. Without this the buffer loses its caret exactly
892 // when the user is appending, which is most of the time.
893 Span::styled(under.map_or(" ".to_string(), String::from), caret),
894 Span::styled(after.to_string(), base),
895 ]
896 }
897
898 impl Widget for AlloyField<'_> {
899 fn render(self, area: Rect, buf: &mut Buffer) {
900 if area.height == 0 || area.width == 0 {
901 return;
902 }
903
904 let base = if self.focused {
905 selected_style(self.theme)
906 } else {
907 unselected_style(self.theme)
908 };
909 let marker = if self.focused { MARKER } else { MARKER_BLANK };
910
911 let mut spans = vec![Span::styled(format!("{marker} "), base)];
912 if self.indent {
913 spans.push(Span::styled(" ", base));
914 }
915
916 let width = self.label_width.max(self.label.chars().count());
917 spans.push(Span::styled(
918 format!("{:width$} ", self.label, width = width),
919 base.fg(self.theme.content_secondary),
920 ));
921 spans.extend(self.value_spans(base));
922
923 Paragraph::new(Line::from(spans))
924 .style(base)
925 .render(Rect { height: 1, ..area }, buf);
926 }
927 }
928
929 /// One row of a form: a section header, or a field.
930 ///
931 /// The binary rebuilds this list every frame from the schema and the fold
932 /// state — headers, plus the fields of open sections — and a `Cursor` rides
933 /// it. Same split as [`AlloyList`]: widget shared, state owned by the view.
934 pub enum FormRow<'a> {
935 Section { label: &'a str, open: bool },
936 Field(AlloyField<'a>),
937 }
938
939 /// The chrome around a sequence of form rows.
940 ///
941 /// Sections are header rows and an indent, not nested boxes. A box inside a
942 /// pane spends two columns a side on every level, and the fold marker already
943 /// says everything a border would; the console's panes are narrow enough that
944 /// the columns matter more than the outline.
945 ///
946 /// Rows are one line each, without exception. That is what lets the form scroll
947 /// through [`list_offset`] rather than carrying its own state, the same trade
948 /// [`AlloyList`] makes, and it is why the focused row's help and diagnostic
949 /// render on a reserved line at the foot of the form instead of under the row.
950 /// The line is reserved whether or not there is anything to put in it, so the
951 /// rows above do not reflow as focus moves.
952 pub struct AlloyForm<'a> {
953 theme: &'a Theme,
954 rows: Vec<FormRow<'a>>,
955 selected: Option<usize>,
956 }
957
958 impl<'a> AlloyForm<'a> {
959 pub fn new(theme: &'a Theme, rows: impl IntoIterator<Item = FormRow<'a>>) -> Self {
960 Self {
961 theme,
962 rows: rows.into_iter().collect(),
963 selected: None,
964 }
965 }
966
967 #[must_use]
968 pub fn selected(mut self, selected: usize) -> Self {
969 self.selected = Some(selected);
970 self
971 }
972
973 /// The label column every field pads to: the widest label in the form.
974 fn label_width(&self) -> usize {
975 self.rows
976 .iter()
977 .filter_map(|row| match row {
978 FormRow::Field(field) => Some(field.label.chars().count()),
979 FormRow::Section { .. } => None,
980 })
981 .max()
982 .unwrap_or(0)
983 }
984
985 /// The footer line for the focused row: its diagnostic if it has one, its
986 /// help otherwise.
987 ///
988 /// A diagnostic wins because it is the reason an edit did not commit, and
989 /// help the user has already read is not worth the row it would cost them.
990 fn footer(&self) -> Option<Line<'a>> {
991 let FormRow::Field(field) = self.rows.get(self.selected?)? else {
992 return None;
993 };
994 if let Some((severity, message)) = field.diagnostic {
995 return Some(Line::from(Span::styled(
996 message,
997 severity.style(self.theme),
998 )));
999 }
1000 field
1001 .help
1002 .map(|help| Line::from(text::muted(self.theme, help)))
1003 }
1004 }
1005
1006 /// Fold markers for a section header. Plain geometric shapes rather than Nerd
1007 /// Font glyphs, per docs/ICONOGRAPHY.md.
1008 const SECTION_OPEN: &str = "";
1009 const SECTION_CLOSED: &str = "";
1010
1011 impl Widget for AlloyForm<'_> {
1012 fn render(mut self, area: Rect, buf: &mut Buffer) {
1013 if area.height == 0 || area.width == 0 {
1014 return;
1015 }
1016
1017 // The footer is computed before the rows are consumed, and its line is
1018 // taken off the top of the budget so a form one row tall still shows
1019 // the row rather than only its help.
1020 let footer = self.footer();
1021 let rows_height = if area.height > 1 {
1022 area.height - 1
1023 } else {
1024 area.height
1025 };
1026
1027 let label_width = self.label_width();
1028 let len = self.rows.len();
1029 let offset = list_offset(len, rows_height as usize, self.selected);
1030
1031 for (row, (index, item)) in self
1032 .rows
1033 .drain(..)
1034 .enumerate()
1035 .skip(offset)
1036 .take(rows_height as usize)
1037 .enumerate()
1038 {
1039 let line_area = Rect {
1040 y: area.y + row as u16,
1041 height: 1,
1042 ..area
1043 };
1044 let focused = self.selected == Some(index);
1045
1046 match item {
1047 FormRow::Section { label, open } => {
1048 let style = if focused {
1049 selected_style(self.theme)
1050 } else {
1051 unselected_style(self.theme)
1052 };
1053 let marker = if focused { MARKER } else { MARKER_BLANK };
1054 let fold = if open { SECTION_OPEN } else { SECTION_CLOSED };
1055 Paragraph::new(Line::from(vec![
1056 Span::styled(format!("{marker} "), style),
1057 Span::styled(format!("{fold} "), style),
1058 Span::styled(
1059 label,
1060 style.patch(Style::default().fg(self.theme.content_primary)),
1061 ),
1062 ]))
1063 .style(style)
1064 .render(line_area, buf);
1065 }
1066 FormRow::Field(field) => field
1067 .focused(focused)
1068 .indent(true)
1069 .label_width(label_width)
1070 .render(line_area, buf),
1071 }
1072 }
1073
1074 if area.height > 1 {
1075 let line_area = Rect {
1076 y: area.y + area.height - 1,
1077 height: 1,
1078 ..area
1079 };
1080 let base = Style::default().bg(self.theme.surface_page);
1081 Paragraph::new(footer.unwrap_or_default())
1082 .style(base)
1083 .render(line_area, buf);
1084 }
1085 }
1086 }
1087
1088 /// One choice in a picker: what it reads as, and what it means.
1089 pub struct PickRow<'a> {
1090 pub label: &'a str,
1091 pub description: Option<&'a str>,
1092 }
1093
1094 impl<'a> PickRow<'a> {
1095 pub fn new(label: &'a str) -> Self {
1096 Self {
1097 label,
1098 description: None,
1099 }
1100 }
1101
1102 #[must_use]
1103 pub fn description(mut self, description: Option<&'a str>) -> Self {
1104 self.description = description;
1105 self
1106 }
1107 }
1108
1109 /// A filterable pick list, drawn over the view that raised it.
1110 ///
1111 /// The overlay an enum field opens: choices do not free-type, so picking one is
1112 /// a different act from editing a value and gets a different surface. Sits on
1113 /// `surface.overlay` alongside [`AlloyModal`], which is the whole reason it
1114 /// lives here rather than being assembled per view — every floating thing in
1115 /// every Alloy TUI should read the same.
1116 ///
1117 /// **The filter is not optional chrome.** The design that specced a pick
1118 /// overlay assumed enums the size of a cursor shape; `timedatectl
1119 /// list-timezones` is about six hundred entries and locales are worse. A plain
1120 /// substring match is deliberate: zone names are terse and hierarchical, and a
1121 /// fuzzy ranker over six hundred strings is a scoring function to tune for no
1122 /// gain a substring does not already give.
1123 ///
1124 /// Like every widget here it owns nothing. The buffer, the filtering, and the
1125 /// selection are the caller's; this paints them.
1126 pub struct AlloyPicker<'a> {
1127 theme: &'a Theme,
1128 title: &'a str,
1129 filter: &'a TextField,
1130 rows: Vec<PickRow<'a>>,
1131 selected: Option<usize>,
1132 empty: &'a str,
1133 }
1134
1135 impl<'a> AlloyPicker<'a> {
1136 pub fn new(
1137 theme: &'a Theme,
1138 title: &'a str,
1139 filter: &'a TextField,
1140 rows: impl IntoIterator<Item = PickRow<'a>>,
1141 ) -> Self {
1142 Self {
1143 theme,
1144 title,
1145 filter,
1146 rows: rows.into_iter().collect(),
1147 selected: None,
1148 empty: "no matches",
1149 }
1150 }
1151
1152 #[must_use]
1153 pub fn selected(mut self, selected: Option<usize>) -> Self {
1154 self.selected = selected;
1155 self
1156 }
1157
1158 /// What to show when the filter matches nothing. A picker that goes blank
1159 /// reads as broken rather than as narrowed.
1160 #[must_use]
1161 pub fn empty(mut self, empty: &'a str) -> Self {
1162 self.empty = empty;
1163 self
1164 }
1165
1166 /// Width the content wants, borders included.
1167 ///
1168 /// Callers size the overlay with this and [`layout::centered`], rather than
1169 /// this widget picking its own area: where an overlay sits is the view's
1170 /// business and how wide its content is, is not.
1171 ///
1172 /// [`layout::centered`]: crate::layout::centered
1173 pub fn width(&self) -> u16 {
1174 let content = self
1175 .rows
1176 .iter()
1177 .map(|row| {
1178 row.label.chars().count()
1179 + row
1180 .description
1181 .map_or(0, |text| text.chars().count() + DESCRIPTION_GAP.len())
1182 })
1183 .chain(std::iter::once(self.title.chars().count()))
1184 .max()
1185 .unwrap_or(0);
1186 // Two for the border, two for the gutter the list draws its marker in.
1187 (content + 4) as u16
1188 }
1189
1190 /// Height for `rows` visible choices, borders included: the filter row and
1191 /// the key row on top of them.
1192 pub fn height(rows: u16) -> u16 {
1193 rows + 4
1194 }
1195 }
1196
1197 /// Separates a choice from what it means.
1198 const DESCRIPTION_GAP: &str = " ";
1199
1200 impl Widget for AlloyPicker<'_> {
1201 fn render(self, area: Rect, buf: &mut Buffer) {
1202 if area.height == 0 || area.width == 0 {
1203 return;
1204 }
1205
1206 let base = Style::default()
1207 .bg(self.theme.surface_overlay)
1208 .fg(self.theme.content_primary);
1209 let block = Block::default()
1210 .borders(Borders::ALL)
1211 .border_style(Style::default().fg(self.theme.border_strong))
1212 .style(base)
1213 .shadow(floating_shadow(self.theme))
1214 // The same inner margin AlloyBlock gives every other container. A
1215 // floating panel is still a container, and it is the one a user
1216 // looks straight at.
1217 .padding(crate::geometry::padding(crate::geometry::Gap::Group))
1218 .title(format!(" {} ", self.title));
1219 let inner = block.inner(area);
1220 block.render(area, buf);
1221
1222 if inner.height == 0 {
1223 return;
1224 }
1225
1226 // Filter on top, keys on the bottom, list between. The keys are pinned
1227 // for the same reason AlloyModal pins its own: a prompt whose dismiss
1228 // keys move with content length is one the user can lose.
1229 let (before, under, after) = self.filter.split();
1230 let caret = Style::default()
1231 .bg(self.theme.content_primary)
1232 .fg(self.theme.surface_overlay);
1233 Paragraph::new(Line::from(vec![
1234 Span::styled("/ ", base.fg(self.theme.content_muted)),
1235 Span::styled(before.to_string(), base),
1236 Span::styled(under.map_or(" ".to_string(), String::from), caret),
1237 Span::styled(after.to_string(), base),
1238 ]))
1239 .style(base)
1240 .render(Rect { height: 1, ..inner }, buf);
1241
1242 let keys = Line::from(vec![
1243 text::action(self.theme, "enter"),
1244 Span::styled(" select", Style::default().fg(self.theme.content_muted)),
1245 Span::raw(" "),
1246 text::action(self.theme, "esc"),
1247 Span::styled(" cancel", Style::default().fg(self.theme.content_muted)),
1248 ]);
1249 if inner.height > 1 {
1250 Paragraph::new(keys).style(base).render(
1251 Rect {
1252 y: inner.y + inner.height - 1,
1253 height: 1,
1254 ..inner
1255 },
1256 buf,
1257 );
1258 }
1259
1260 let list_area = Rect {
1261 y: inner.y + 1,
1262 height: inner.height.saturating_sub(2),
1263 ..inner
1264 };
1265 if list_area.height == 0 {
1266 return;
1267 }
1268
1269 if self.rows.is_empty() {
1270 Paragraph::new(Line::from(Span::styled(
1271 self.empty,
1272 base.fg(self.theme.content_muted),
1273 )))
1274 .style(base)
1275 .render(list_area, buf);
1276 return;
1277 }
1278
1279 // Descriptions line up in a column, so a list of choices reads as two
1280 // columns rather than as ragged sentences.
1281 let label_width = self
1282 .rows
1283 .iter()
1284 .map(|row| row.label.chars().count())
1285 .max()
1286 .unwrap_or(0);
1287
1288 let items: Vec<Line> = self
1289 .rows
1290 .iter()
1291 .map(|row| {
1292 let mut spans = vec![Span::styled(
1293 format!("{:label_width$}", row.label),
1294 Style::default(),
1295 )];
1296 if let Some(description) = row.description {
1297 spans.push(Span::styled(
1298 format!("{DESCRIPTION_GAP}{description}"),
1299 Style::default().fg(self.theme.content_muted),
1300 ));
1301 }
1302 Line::from(spans)
1303 })
1304 .collect();
1305
1306 // Rendered as an ordinary list so selection, the marker, and scrolling
1307 // are the same here as anywhere else in the console.
1308 AlloyList::new(self.theme, items)
1309 .selected(self.selected)
1310 .render(list_area, buf);
1311 }
1312 }
1313
1314 /// A display-only table.
1315 ///
1316 /// v1 renders schema list-of-tables records (rio's `bindings.keys`) read-only;
1317 /// add, remove, and cell edit route to the text-edit fallback until v1.1. It is
1318 /// display-only rather than half-editable on purpose: a table that accepts some
1319 /// edits and silently refuses others is worse than one that clearly accepts
1320 /// none.
1321 pub struct AlloyTable<'a> {
1322 theme: &'a Theme,
1323 headers: Vec<&'a str>,
1324 rows: Vec<Vec<String>>,
1325 }
1326
1327 impl<'a> AlloyTable<'a> {
1328 pub fn new(
1329 theme: &'a Theme,
1330 headers: impl IntoIterator<Item = &'a str>,
1331 rows: impl IntoIterator<Item = Vec<String>>,
1332 ) -> Self {
1333 Self {
1334 theme,
1335 headers: headers.into_iter().collect(),
1336 rows: rows.into_iter().collect(),
1337 }
1338 }
1339
1340 /// Column widths: the widest cell in each column, header included.
1341 fn widths(&self) -> Vec<usize> {
1342 let mut widths: Vec<usize> = self
1343 .headers
1344 .iter()
1345 .map(|header| header.chars().count())
1346 .collect();
1347 for row in &self.rows {
1348 for (column, cell) in row.iter().enumerate() {
1349 let width = cell.chars().count();
1350 match widths.get_mut(column) {
1351 Some(current) => *current = (*current).max(width),
1352 // A row wider than the header list still renders. The
1353 // schema and the file it describes can disagree, and
1354 // dropping a cell would hide exactly that.
1355 None => widths.push(width),
1356 }
1357 }
1358 }
1359 widths
1360 }
1361 }
1362
1363 /// Gap between table columns.
1364 const COLUMN_GAP: &str = " ";
1365
1366 impl Widget for AlloyTable<'_> {
1367 fn render(self, area: Rect, buf: &mut Buffer) {
1368 if area.height == 0 || area.width == 0 {
1369 return;
1370 }
1371
1372 let widths = self.widths();
1373 let pad = |cell: &str, column: usize| {
1374 let width = widths.get(column).copied().unwrap_or(0);
1375 format!("{cell:width$}")
1376 };
1377
1378 let mut lines = Vec::with_capacity(self.rows.len() + 1);
1379 lines.push(Line::from(
1380 self.headers
1381 .iter()
1382 .enumerate()
1383 .map(|(column, header)| {
1384 text::muted(self.theme, format!("{}{COLUMN_GAP}", pad(header, column)))
1385 })
1386 .collect::<Vec<_>>(),
1387 ));
1388 for row in &self.rows {
1389 lines.push(Line::from(
1390 row.iter()
1391 .enumerate()
1392 .map(|(column, cell)| {
1393 text::secondary(self.theme, format!("{}{COLUMN_GAP}", pad(cell, column)))
1394 })
1395 .collect::<Vec<_>>(),
1396 ));
1397 }
1398
1399 Paragraph::new(lines)
1400 .style(Style::default().bg(self.theme.surface_page))
1401 .render(area, buf);
1402 }
1403 }
1404
1405 #[cfg(test)]
1406 mod tests {
1407 use super::*;
1408 use ratatui::style::Color;
1409
1410 fn theme() -> Theme {
1411 Theme {
1412 mode: crate::theme::Mode::Dark,
1413 surface_page: Color::Rgb(0, 0, 0),
1414 surface_raised: Color::Rgb(1, 1, 1),
1415 surface_sunken: Color::Rgb(2, 2, 2),
1416 surface_overlay: Color::Rgb(3, 3, 3),
1417 surface_well: Some(Color::Rgb(9, 9, 9)),
1418 content_primary: Color::Rgb(4, 4, 4),
1419 content_secondary: Color::Rgb(5, 5, 5),
1420 content_muted: Color::Rgb(6, 6, 6),
1421 action_primary: Color::Rgb(7, 7, 7),
1422 status_danger: Color::Rgb(8, 8, 8),
1423 status_success: Color::Rgb(9, 9, 9),
1424 status_warning: Color::Rgb(10, 10, 10),
1425 status_info: Color::Rgb(11, 11, 11),
1426 line_border: Color::Rgb(12, 12, 12),
1427 border_subtle: Color::Rgb(13, 13, 13),
1428 border_strong: Color::Rgb(14, 14, 14),
1429 bevel_light: Color::Rgb(16, 16, 16),
1430 bevel_dark: Color::Rgb(17, 17, 17),
1431 category: [Color::Rgb(15, 15, 15); 6],
1432 }
1433 }
1434
1435 fn list_of(n: usize, selected: Option<usize>) -> AlloyList<'static> {
1436 // Leaked so the test list can hold a 'static theme reference; the
1437 // widget borrows rather than owns, and these are per-test one-offs.
1438 let theme: &'static Theme = Box::leak(Box::new(theme()));
1439 let items: Vec<Line<'static>> = (0..n).map(|i| Line::from(format!("row {i}"))).collect();
1440 AlloyList::new(theme, items).selected(selected)
1441 }
1442
1443 #[test]
1444 fn short_list_never_scrolls() {
1445 assert_eq!(list_of(3, Some(2)).offset(10), 0);
1446 }
1447
1448 // Selection near the top must not scroll past the start of the list — a
1449 // naive `selected - height/2` underflows or shows blank rows above row 0.
1450 #[test]
1451 fn offset_clamps_at_the_top() {
1452 assert_eq!(list_of(50, Some(0)).offset(10), 0);
1453 assert_eq!(list_of(50, Some(2)).offset(10), 0);
1454 }
1455
1456 // Selection at the end must land the last row on the last visible line,
1457 // not scroll into empty space past the end of the list.
1458 #[test]
1459 fn offset_clamps_at_the_bottom() {
1460 assert_eq!(list_of(50, Some(49)).offset(10), 40);
1461 }
1462
1463 #[test]
1464 fn offset_centers_a_midlist_selection() {
1465 assert_eq!(list_of(50, Some(25)).offset(10), 20);
1466 }
1467
1468 #[test]
1469 fn row_y_maps_visible_items_to_screen_rows() {
1470 let area = Rect::new(0, 5, 20, 10);
1471 assert_eq!(list_row_y(area, 3, Some(0), 0), Some(5));
1472 assert_eq!(list_row_y(area, 3, Some(0), 2), Some(7));
1473 }
1474
1475 // After a scroll the mapping has to follow the offset. A connector using a
1476 // separate copy of the scroll rule is exactly what this prevents.
1477 #[test]
1478 fn row_y_accounts_for_scrolling() {
1479 let area = Rect::new(0, 0, 20, 10);
1480 // 50 items, selection at 25 => offset 20, so item 20 is the top row.
1481 assert_eq!(list_row_y(area, 50, Some(25), 20), Some(0));
1482 assert_eq!(list_row_y(area, 50, Some(25), 25), Some(5));
1483 }
1484
1485 #[test]
1486 fn row_y_is_none_for_rows_scrolled_out_of_view() {
1487 let area = Rect::new(0, 0, 20, 10);
1488 assert_eq!(
1489 list_row_y(area, 50, Some(25), 0),
1490 None,
1491 "above the viewport"
1492 );
1493 assert_eq!(
1494 list_row_y(area, 50, Some(25), 49),
1495 None,
1496 "below the viewport"
1497 );
1498 assert_eq!(
1499 list_row_y(area, 3, Some(0), 9),
1500 None,
1501 "past the end of the list"
1502 );
1503 }
1504
1505 fn render_tabs(selected: usize, width: u16) -> String {
1506 let theme = theme();
1507 let mut buf = Buffer::empty(Rect::new(0, 0, width, 1));
1508 AlloyTabs::new(&theme, ["installed", "boxes", "system"])
1509 .selected(selected)
1510 .render(Rect::new(0, 0, width, 1), &mut buf);
1511 buf.content()
1512 .iter()
1513 .map(ratatui::buffer::Cell::symbol)
1514 .collect()
1515 }
1516
1517 #[test]
1518 fn selected_tab_is_bracketed_and_others_are_not() {
1519 let rendered = render_tabs(0, 60);
1520 assert!(
1521 rendered.contains("[ installed ]"),
1522 "selected tab is bracketed"
1523 );
1524 assert!(!rendered.contains("[ boxes ]"), "unselected tabs are not");
1525 assert!(rendered.contains("boxes"), "unselected labels still render");
1526 }
1527
1528 // The bar must not shift horizontally as selection moves, or every tab
1529 // change reads as the whole row twitching. Unselected labels pad to the
1530 // bracket width for exactly this reason.
1531 #[test]
1532 fn labels_hold_their_columns_across_selections() {
1533 let first = render_tabs(0, 60);
1534 let last = render_tabs(2, 60);
1535 assert_eq!(
1536 first.find("system"),
1537 last.find("system"),
1538 "a label sits in the same columns whichever tab is selected"
1539 );
1540 }
1541
1542 // FocusRing::focus ignores out-of-range slots rather than clamping, and the
1543 // bar has to agree: showing a neighbouring tab as current would misreport
1544 // which screen the user is looking at.
1545 #[test]
1546 fn out_of_range_selection_brackets_nothing() {
1547 let rendered = render_tabs(9, 60);
1548 assert!(!rendered.contains('['), "no tab is marked current");
1549 assert!(rendered.contains("installed"), "labels still render");
1550 }
1551
1552 #[test]
1553 fn zero_height_area_renders_nothing_rather_than_panicking() {
1554 let theme = theme();
1555 let mut buf = Buffer::empty(Rect::new(0, 0, 40, 1));
1556 AlloyTabs::new(&theme, ["installed"]).render(Rect::new(0, 0, 40, 0), &mut buf);
1557 AlloyTabs::new(&theme, ["installed"]).render(Rect::new(0, 0, 0, 1), &mut buf);
1558 }
1559
1560 fn render_modal(area: Rect) -> Vec<String> {
1561 let theme = theme();
1562 let mut buf = Buffer::empty(area);
1563 AlloyModal::new(&theme, "remove", "Remove tailscale?").render(area, &mut buf);
1564 (0..area.height)
1565 .map(|y| {
1566 (0..area.width)
1567 .map(|x| buf[(x, y)].symbol())
1568 .collect::<String>()
1569 })
1570 .collect()
1571 }
1572
1573 #[test]
1574 fn modal_shows_its_message_and_both_keys() {
1575 let rows = render_modal(Rect::new(0, 0, 40, 7)).join("\n");
1576 assert!(rows.contains("Remove tailscale?"), "message renders");
1577 assert!(rows.contains("remove"), "title renders");
1578 assert!(rows.contains("Enter"), "confirm key renders");
1579 assert!(rows.contains("Esc"), "cancel key renders");
1580 }
1581
1582 // The keys are pinned to the last inner row rather than flowing after the
1583 // message. A prompt whose dismiss keys move with message length, or fall
1584 // off a short box, is a modal the user cannot get out of.
1585 #[test]
1586 fn keys_sit_on_the_last_row_whatever_the_message_length() {
1587 for height in [5, 7, 12] {
1588 let rows = render_modal(Rect::new(0, 0, 40, height));
1589 let last_inner = &rows[height as usize - 2];
1590 assert!(
1591 last_inner.contains("Enter") && last_inner.contains("Esc"),
1592 "height {height}: keys belong on the last inner row, got {last_inner:?}"
1593 );
1594 }
1595 }
1596
1597 #[test]
1598 fn modal_survives_an_area_too_small_to_draw_in() {
1599 let theme = theme();
1600 let mut buf = Buffer::empty(Rect::new(0, 0, 40, 7));
1601 AlloyModal::new(&theme, "t", "m").render(Rect::new(0, 0, 40, 0), &mut buf);
1602 AlloyModal::new(&theme, "t", "m").render(Rect::new(0, 0, 0, 7), &mut buf);
1603 AlloyModal::new(&theme, "t", "m").render(Rect::new(0, 0, 2, 2), &mut buf);
1604 }
1605
1606 fn render_to(width: u16, height: u16, draw: impl FnOnce(&mut Buffer, Rect)) -> Vec<String> {
1607 let area = Rect::new(0, 0, width, height);
1608 let mut buf = Buffer::empty(area);
1609 draw(&mut buf, area);
1610 (0..height)
1611 .map(|y| (0..width).map(|x| buf[(x, y)].symbol()).collect::<String>())
1612 .collect()
1613 }
1614
1615 /// Column of `needle` in `row`, counted in characters.
1616 ///
1617 /// `str::find` counts bytes, and the focus marker is three of them, so a
1618 /// byte offset says a focused row's value starts two columns right of an
1619 /// unfocused one when both are in the same column.
1620 fn column(row: &str, needle: &str) -> Option<usize> {
1621 let at = row.find(needle)?;
1622 Some(row[..at].chars().count())
1623 }
1624
1625 fn field_rows(theme: &Theme) -> Vec<FormRow<'_>> {
1626 vec![
1627 FormRow::Section {
1628 label: "cursor",
1629 open: true,
1630 },
1631 FormRow::Field(
1632 AlloyField::new(theme, "shape", FieldKind::Enum { label: "block" })
1633 .help(Some("Cursor shape.")),
1634 ),
1635 FormRow::Field(AlloyField::new(theme, "blinking", FieldKind::Toggle(false))),
1636 FormRow::Section {
1637 label: "colors",
1638 open: false,
1639 },
1640 ]
1641 }
1642
1643 #[test]
1644 fn a_toggle_reads_without_color_or_a_patched_font() {
1645 let rows = render_to(40, 1, |buf, area| {
1646 AlloyField::new(&theme(), "blinking", FieldKind::Toggle(true)).render(area, buf);
1647 });
1648 assert!(rows[0].contains("[x]"), "{rows:?}");
1649
1650 let rows = render_to(40, 1, |buf, area| {
1651 AlloyField::new(&theme(), "blinking", FieldKind::Toggle(false)).render(area, buf);
1652 });
1653 assert!(rows[0].contains("[ ]"), "{rows:?}");
1654 }
1655
1656 #[test]
1657 fn a_color_field_draws_a_swatch_beside_its_hex() {
1658 let rows = render_to(40, 1, |buf, area| {
1659 AlloyField::new(&theme(), "background", FieldKind::Color { hex: "#e4ded6" })
1660 .render(area, buf);
1661 });
1662 assert!(rows[0].contains("██ #e4ded6"), "{rows:?}");
1663 }
1664
1665 // A hex the widget cannot parse still shows its text. The swatch helps read
1666 // a value; it is not the value, and dropping the text would hide the one
1667 // thing the user needs to see to fix it.
1668 #[test]
1669 fn an_unparseable_color_still_renders_its_text() {
1670 let rows = render_to(40, 1, |buf, area| {
1671 AlloyField::new(&theme(), "background", FieldKind::Color { hex: "e4ded6" })
1672 .render(area, buf);
1673 });
1674 assert!(rows[0].contains("e4ded6"), "{rows:?}");
1675 assert!(
1676 !rows[0].contains(''),
1677 "no swatch for a value it cannot parse"
1678 );
1679 }
1680
1681 #[test]
1682 fn swatch_parses_both_hex_lengths_and_rejects_the_rest() {
1683 assert_eq!(swatch("#e4ded6"), Some(Color::Rgb(0xe4, 0xde, 0xd6)));
1684 assert_eq!(
1685 swatch("#e4ded6ff"),
1686 Some(Color::Rgb(0xe4, 0xde, 0xd6)),
1687 "alpha is dropped, not refused",
1688 );
1689 assert_eq!(swatch("e4ded6"), None, "no hash");
1690 assert_eq!(swatch("#e4ded"), None, "wrong length");
1691 assert_eq!(swatch("#gggggg"), None, "not hex");
1692 }
1693
1694 // An edited row shows the buffer and its caret, not the committed value.
1695 #[test]
1696 fn an_edited_field_draws_the_caret_buffer_in_place_of_the_value() {
1697 let mut buffer = TextField::new();
1698 buffer.set("Departure Mono");
1699 buffer.home();
1700 let rows = render_to(60, 1, |buf, area| {
1701 AlloyField::new(&theme(), "family", FieldKind::Text("IosevkaTerm"))
1702 .edit(Some(&buffer))
1703 .render(area, buf);
1704 });
1705 assert!(rows[0].contains("Departure Mono"), "{rows:?}");
1706 assert!(
1707 !rows[0].contains("IosevkaTerm"),
1708 "the committed value is not drawn"
1709 );
1710 }
1711
1712 // The caret has to be visible while appending, which is where it spends
1713 // most of its life. Past the end of the line there is no character under
1714 // it, so it draws on a space.
1715 #[test]
1716 fn a_caret_past_the_end_of_the_line_still_has_a_cell() {
1717 let mut buffer = TextField::new();
1718 buffer.set("alloy");
1719 let (before, under, after) = buffer.split();
1720 assert_eq!((before, under, after), ("alloy", None, ""));
1721
1722 let spans = caret_spans(&theme(), &buffer, Style::default());
1723 assert_eq!(spans[1].content, " ", "the caret sits on a space");
1724 }
1725
1726 #[test]
1727 fn a_form_lines_its_value_column_up_across_rows() {
1728 let theme = theme();
1729 let rows = render_to(50, 6, |buf, area| {
1730 AlloyForm::new(&theme, field_rows(&theme))
1731 .selected(1)
1732 .render(area, buf);
1733 });
1734 // "blinking" is the longest label, so both values start in the same
1735 // column despite "shape" being three characters shorter.
1736 let shape = column(&rows[1], "block").expect("enum label renders");
1737 let blinking = column(&rows[2], "[ ]").expect("toggle renders");
1738 assert_eq!(shape, blinking, "{rows:?}");
1739 }
1740
1741 #[test]
1742 fn a_section_header_shows_whether_it_is_folded() {
1743 let theme = theme();
1744 let rows = render_to(50, 6, |buf, area| {
1745 AlloyForm::new(&theme, field_rows(&theme))
1746 .selected(0)
1747 .render(area, buf);
1748 });
1749 assert!(rows[0].contains("▾ cursor"), "open section: {rows:?}");
1750 assert!(rows[3].contains("▸ colors"), "folded section: {rows:?}");
1751 }
1752
1753 #[test]
1754 fn the_focused_row_carries_the_same_marker_a_list_row_would() {
1755 let theme = theme();
1756 let rows = render_to(50, 6, |buf, area| {
1757 AlloyForm::new(&theme, field_rows(&theme))
1758 .selected(2)
1759 .render(area, buf);
1760 });
1761 assert!(rows[2].starts_with(MARKER), "{rows:?}");
1762 assert!(!rows[1].starts_with(MARKER), "only one row is focused");
1763 }
1764
1765 // The footer belongs to whichever row is focused, and a diagnostic beats
1766 // help: it is the reason an edit did not commit.
1767 #[test]
1768 fn the_footer_shows_the_focused_rows_help_and_a_diagnostic_over_it() {
1769 let theme = theme();
1770 let rows = render_to(50, 6, |buf, area| {
1771 AlloyForm::new(&theme, field_rows(&theme))
1772 .selected(1)
1773 .render(area, buf);
1774 });
1775 assert!(rows[5].contains("Cursor shape."), "help renders: {rows:?}");
1776
1777 let rows = render_to(50, 6, |buf, area| {
1778 let mut rows = field_rows(&theme);
1779 rows[1] = FormRow::Field(
1780 AlloyField::new(&theme, "shape", FieldKind::Enum { label: "bar" })
1781 .help(Some("Cursor shape."))
1782 .diagnostic(Some((Severity::Error, "\"bar\" is not a declared value"))),
1783 );
1784 AlloyForm::new(&theme, rows).selected(1).render(area, buf);
1785 });
1786 assert!(rows[5].contains("not a declared value"), "{rows:?}");
1787 assert!(!rows[5].contains("Cursor shape."), "the diagnostic wins");
1788 }
1789
1790 // The footer line is reserved whether or not it has anything in it. A form
1791 // whose rows reflow as focus moves is one where the row under the cursor
1792 // moves out from under it.
1793 #[test]
1794 fn rows_hold_their_lines_whether_the_footer_has_content_or_not() {
1795 let theme = theme();
1796 let with_help = render_to(50, 6, |buf, area| {
1797 AlloyForm::new(&theme, field_rows(&theme))
1798 .selected(1)
1799 .render(area, buf);
1800 });
1801 let without = render_to(50, 6, |buf, area| {
1802 AlloyForm::new(&theme, field_rows(&theme))
1803 .selected(2)
1804 .render(area, buf);
1805 });
1806 assert_eq!(
1807 with_help[0], without[0],
1808 "the section header sits on the same line either way",
1809 );
1810 assert!(without[5].trim().is_empty(), "no help, an empty footer");
1811 }
1812
1813 // One line per row is what keeps the stateless scroll math valid, so a form
1814 // longer than its area scrolls exactly the way a list does.
1815 #[test]
1816 fn a_form_longer_than_its_area_scrolls_like_a_list() {
1817 let theme = theme();
1818 let many: Vec<FormRow> = (0..50)
1819 .map(|i| {
1820 FormRow::Field(AlloyField::new(
1821 &theme,
1822 "slot",
1823 FieldKind::Number(if i == 25 { "twentyfive" } else { "x" }),
1824 ))
1825 })
1826 .collect();
1827 let rows = render_to(50, 11, |buf, area| {
1828 AlloyForm::new(&theme, many).selected(25).render(area, buf);
1829 });
1830 // 50 rows, 10 row-lines after the footer, selection 25 => offset 20.
1831 assert!(rows[5].contains("twentyfive"), "{rows:?}");
1832 }
1833
1834 #[test]
1835 fn a_form_survives_an_area_too_small_to_draw_in() {
1836 let theme = theme();
1837 let mut buf = Buffer::empty(Rect::new(0, 0, 40, 6));
1838 AlloyForm::new(&theme, field_rows(&theme))
1839 .selected(0)
1840 .render(Rect::new(0, 0, 40, 0), &mut buf);
1841 AlloyForm::new(&theme, field_rows(&theme))
1842 .selected(0)
1843 .render(Rect::new(0, 0, 0, 6), &mut buf);
1844 // One line tall: the row wins over the footer.
1845 let rows = render_to(40, 1, |buf, area| {
1846 AlloyForm::new(&theme, field_rows(&theme))
1847 .selected(0)
1848 .render(area, buf);
1849 });
1850 assert!(rows[0].contains("cursor"), "{rows:?}");
1851 }
1852
1853 fn picker_rows() -> Vec<PickRow<'static>> {
1854 vec![
1855 PickRow::new("Block").description(Some("Solid block.")),
1856 PickRow::new("Beam").description(Some("Thin vertical bar.")),
1857 ]
1858 }
1859
1860 fn render_picker(filter: &TextField, rows: Vec<PickRow<'_>>, height: u16) -> Vec<String> {
1861 let theme = theme();
1862 render_to(50, height, |buf, area| {
1863 AlloyPicker::new(&theme, "cursor.shape", filter, rows)
1864 .selected(Some(0))
1865 .render(area, buf);
1866 })
1867 }
1868
1869 #[test]
1870 fn a_picker_shows_its_choices_with_what_they_mean() {
1871 let rows = render_picker(&TextField::new(), picker_rows(), 8);
1872 let joined = rows.join("\n");
1873 assert!(joined.contains("cursor.shape"), "title: {joined}");
1874 assert!(joined.contains("Block"), "{joined}");
1875 assert!(joined.contains("Solid block."), "{joined}");
1876 assert!(joined.contains("Thin vertical bar."), "{joined}");
1877 }
1878
1879 #[test]
1880 fn the_filter_row_carries_a_caret() {
1881 let mut filter = TextField::new();
1882 filter.set("bl");
1883 let rows = render_picker(&filter, picker_rows(), 8);
1884 assert!(rows[1].contains("/ bl"), "{rows:?}");
1885 }
1886
1887 // A picker that goes blank when the filter matches nothing reads as broken
1888 // rather than as narrowed.
1889 #[test]
1890 fn a_filter_that_matches_nothing_says_so() {
1891 let rows = render_picker(&TextField::new(), Vec::new(), 8);
1892 assert!(rows.join("\n").contains("no matches"), "{rows:?}");
1893 }
1894
1895 // Same rule AlloyModal follows: a floating thing whose dismiss keys move
1896 // with its content is one the user can lose.
1897 #[test]
1898 fn the_keys_sit_on_the_last_row_whatever_the_choice_count() {
1899 for height in [6, 8, 14] {
1900 let rows = render_picker(&TextField::new(), picker_rows(), height);
1901 let last = &rows[height as usize - 2];
1902 assert!(
1903 last.contains("enter") && last.contains("esc"),
1904 "height {height}: {last:?}"
1905 );
1906 }
1907 }
1908
1909 #[test]
1910 fn a_picker_is_wide_enough_for_its_widest_choice() {
1911 let theme = theme();
1912 let filter = TextField::new();
1913 let width = AlloyPicker::new(&theme, "t", &filter, picker_rows()).width();
1914 // "Beam" plus the gap plus "Thin vertical bar." is the longest row.
1915 assert_eq!(width as usize, 4 + 2 + 18 + 4);
1916 assert_eq!(
1917 AlloyPicker::height(2),
1918 6,
1919 "two rows, a filter, keys, borders"
1920 );
1921 }
1922
1923 #[test]
1924 fn a_picker_survives_an_area_too_small_to_draw_in() {
1925 let theme = theme();
1926 let filter = TextField::new();
1927 let mut buf = Buffer::empty(Rect::new(0, 0, 40, 8));
1928 for area in [
1929 Rect::new(0, 0, 40, 0),
1930 Rect::new(0, 0, 0, 8),
1931 Rect::new(0, 0, 4, 2),
1932 Rect::new(0, 0, 40, 3),
1933 ] {
1934 AlloyPicker::new(&theme, "t", &filter, picker_rows()).render(area, &mut buf);
1935 }
1936 }
1937
1938 #[test]
1939 fn a_table_aligns_its_columns_under_its_headers() {
1940 let theme = theme();
1941 let rows = render_to(60, 3, |buf, area| {
1942 AlloyTable::new(
1943 &theme,
1944 ["key", "action"],
1945 [
1946 vec!["ctrl+shift+t".to_string(), "CreateTab".to_string()],
1947 vec!["ctrl+w".to_string(), "CloseTab".to_string()],
1948 ],
1949 )
1950 .render(area, buf);
1951 });
1952 let action = column(&rows[0], "action").expect("header renders");
1953 assert_eq!(column(&rows[1], "CreateTab"), Some(action), "{rows:?}");
1954 assert_eq!(column(&rows[2], "CloseTab"), Some(action), "{rows:?}");
1955 }
1956
1957 // A file can hold a record the schema does not describe. Dropping the extra
1958 // cell would hide exactly the disagreement worth seeing.
1959 #[test]
1960 fn a_row_wider_than_the_headers_still_renders_every_cell() {
1961 let theme = theme();
1962 let rows = render_to(60, 2, |buf, area| {
1963 AlloyTable::new(
1964 &theme,
1965 ["key"],
1966 [vec!["ctrl+w".to_string(), "CloseTab".to_string()]],
1967 )
1968 .render(area, buf);
1969 });
1970 assert!(rows[1].contains("CloseTab"), "{rows:?}");
1971 }
1972
1973 // A log longer than its pane shows the newest entries. Showing the head
1974 // instead would freeze the pane on startup noise and never display the
1975 // command the user just triggered.
1976 #[test]
1977 fn log_renders_the_newest_entries() {
1978 let theme = theme();
1979 let entries: Vec<LogEntry> = (0..10)
1980 .map(|i| LogEntry::new(format!("nmcli run {i}"), Severity::Healthy))
1981 .collect();
1982 let mut buf = Buffer::empty(Rect::new(0, 0, 40, 4));
1983 AlloyLog::new(&theme, &entries).render(Rect::new(0, 0, 40, 4), &mut buf);
1984
1985 let rendered = buf
1986 .content()
1987 .iter()
1988 .map(ratatui::buffer::Cell::symbol)
1989 .collect::<String>();
1990 assert!(
1991 rendered.contains("nmcli run 9"),
1992 "newest entry must be visible"
1993 );
1994 assert!(
1995 !rendered.contains("nmcli run 0"),
1996 "oldest entry must have scrolled off"
1997 );
1998 }
1999
2000 // ---- button ----
2001
2002 fn render_button(button: AlloyButton, w: u16, h: u16) -> (Buffer, Rect) {
2003 let area = Rect::new(0, 0, w, h);
2004 let mut buf = Buffer::empty(area);
2005 button.render(area, &mut buf);
2006 (buf, area)
2007 }
2008
2009 fn rows(buf: &Buffer, area: Rect) -> Vec<String> {
2010 (area.y..area.bottom())
2011 .map(|y| {
2012 (area.x..area.right())
2013 .map(|x| buf[(x, y)].symbol())
2014 .collect::<String>()
2015 })
2016 .collect()
2017 }
2018
2019 #[test]
2020 fn a_button_is_a_beveled_surface_with_a_centered_label() {
2021 let theme = theme();
2022 let (buf, area) = render_button(AlloyButton::new(&theme, "OK"), 8, 3);
2023 assert_eq!(rows(&buf, area), vec!["▛▀▀▀▀▀▀▀", "▌ OK ▐", "▄▄▄▄▄▄▄▟"]);
2024 }
2025
2026 // The pressed state is the same button lit from the other corner. Asserted
2027 // as a relationship rather than against literals, because that is what
2028 // makes it one swap instead of a second widget.
2029 #[test]
2030 fn pressing_a_button_inverts_its_light_and_recesses_its_face() {
2031 let theme = theme();
2032 let (up, area) = render_button(AlloyButton::new(&theme, "OK"), 8, 3);
2033 let (down, _) = render_button(AlloyButton::new(&theme, "OK").pressed(true), 8, 3);
2034
2035 assert_eq!(rows(&up, area), rows(&down, area));
2036 assert_eq!(up[(0u16, 0u16)].fg, theme.bevel_light);
2037 assert_eq!(down[(0u16, 0u16)].fg, theme.bevel_dark);
2038 assert_eq!(up[(3u16, 1u16)].bg, theme.surface_raised);
2039 // The well, not `surface_sunken`. A theme may author sunken darker than
2040 // raised while a well always inverts away from the text, so the two are
2041 // only interchangeable on themes where the substitution happens not to
2042 // bite. Pinned as the well so it stays that way.
2043 assert_eq!(down[(3u16, 1u16)].bg, theme.surface_well.unwrap());
2044 assert_ne!(down[(3u16, 1u16)].bg, theme.surface_sunken);
2045 }
2046
2047 // A theme that gave makeover nothing to derive a well from still has to
2048 // produce a legible pressed state. No fill is painted and the inverted edge
2049 // carries it alone, which is the whole reason the renderer declines rather
2050 // than substituting some other surface.
2051 #[test]
2052 fn a_button_pressed_on_a_theme_with_no_well_keeps_its_inverted_edge() {
2053 let theme = Theme {
2054 surface_well: None,
2055 ..theme()
2056 };
2057 let (down, area) = render_button(AlloyButton::new(&theme, "OK").pressed(true), 8, 3);
2058 assert_eq!(rows(&down, area), vec!["▛▀▀▀▀▀▀▀", "▌ OK ▐", "▄▄▄▄▄▄▄▟"]);
2059 assert_eq!(down[(0u16, 0u16)].fg, theme.bevel_dark);
2060 }
2061
2062 // Dimmed and still there, so the layout keeps teaching itself.
2063 #[test]
2064 fn a_disabled_button_keeps_its_bevel_and_mutes_only_its_label() {
2065 let theme = theme();
2066 let (buf, area) = render_button(AlloyButton::new(&theme, "OK").disabled(true), 8, 3);
2067 assert_eq!(rows(&buf, area), vec!["▛▀▀▀▀▀▀▀", "▌ OK ▐", "▄▄▄▄▄▄▄▟"]);
2068 assert_eq!(buf[(0u16, 0u16)].fg, theme.bevel_light);
2069 assert_eq!(buf[(3u16, 1u16)].fg, theme.content_muted);
2070 }
2071
2072 #[test]
2073 fn a_primary_button_inverts_polarity_without_touching_the_bevel() {
2074 let theme = theme();
2075 let (buf, _) = render_button(AlloyButton::new(&theme, "OK").primary(true), 8, 3);
2076 assert_eq!(buf[(3u16, 1u16)].bg, theme.content_primary);
2077 assert_eq!(buf[(3u16, 1u16)].fg, theme.surface_raised);
2078 assert_eq!(buf[(0u16, 0u16)].fg, theme.bevel_light);
2079 }
2080
2081 // ---- floating surfaces ----
2082
2083 // The shadow lands outside the modal, one cell down and right, so the page
2084 // has to be bigger than the modal for it to exist at all.
2085 #[test]
2086 fn a_modal_casts_a_shadow_onto_the_page_behind_it() {
2087 let theme = theme();
2088 let page = Rect::new(0, 0, 24, 8);
2089 let modal = Rect::new(2, 1, 18, 5);
2090 let mut buf = Buffer::empty(page);
2091 AlloyModal::new(&theme, "remove", "Remove tailscale?").render(modal, &mut buf);
2092
2093 // Directly under the modal's bottom edge, offset one to the right.
2094 let below = &buf[(3u16, 6u16)];
2095 assert_eq!(below.symbol(), "");
2096 assert_eq!(below.fg, theme.border_strong);
2097 // The page well away from the modal is untouched.
2098 assert_eq!(buf[(23u16, 7u16)].symbol(), " ");
2099 }
2100 }
2101