Skip to main content

max / alloy_tui

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