//! Themed ratatui widget wrappers. //! //! v1 target per docs/CONSOLE.md: `AlloyBlock`, `AlloyList`, `AlloyForm`, //! `AlloyTable`, `AlloyStatusBar`, `AlloyLog`, plus form-field widgets driven //! by the config schema. The four the console shell needs to render a screen //! end to end — block, list, log pane, status bar — plus the `Severity` accent //! they all compose with, and, as of 1.2, the form: `AlloyForm`, a single //! `AlloyField` carrying a `FieldKind` value cell, and a display-only //! `AlloyTable`. use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::style::{Color, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Paragraph, Widget}; use crate::input::TextField; use crate::selection::{MARKER, MARKER_BLANK, selected_style, unselected_style}; use crate::text; use crate::theme::Theme; /// Themed `Block`: default borders + palette chrome. Wraps `ratatui::widgets::Block` /// so downstream code composes with it directly (`AlloyBlock::new(theme).build()` /// returns the inner `Block`, which callers then `.title(..)` and pass to a /// widget). /// /// Per DESIGN-LANGUAGE.md: chrome is tinted-greyscale; borders never carry an /// accent — accents live on text via `Severity`. Focused vs. unfocused chrome /// swaps `border-subtle` (decorative) for `border-strong` (focus/selection), /// matching TOKENS.md's derived-border tiers. pub struct AlloyBlock<'a> { theme: &'a Theme, focused: bool, } impl<'a> AlloyBlock<'a> { pub fn new(theme: &'a Theme) -> Self { Self { theme, focused: false, } } #[must_use] pub fn focused(mut self, focused: bool) -> Self { self.focused = focused; self } pub fn build(self) -> Block<'a> { let border_color = if self.focused { self.theme.border_strong } else { self.theme.border_subtle }; Block::default() .borders(Borders::ALL) .border_style(Style::default().fg(border_color)) .style( Style::default() .bg(self.theme.surface_page) .fg(self.theme.content_primary), ) } } /// Severity accent — categorical status tag whose color comes from the theme's /// `status.*` intents. Per DESIGN-LANGUAGE.md, this is one of the few places /// color is on-purpose: never on chrome, only on glyphs and text. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Severity { Info, Healthy, Warn, Error, } impl Severity { pub const fn label(self) -> &'static str { match self { Severity::Info => "INFO", Severity::Healthy => "OK", Severity::Warn => "WARN", Severity::Error => "ERROR", } } pub fn color(self, theme: &Theme) -> Color { match self { Severity::Info => theme.status_info, Severity::Healthy => theme.status_success, Severity::Warn => theme.status_warning, Severity::Error => theme.status_danger, } } pub fn style(self, theme: &Theme) -> Style { Style::default().fg(self.color(theme)) } } /// A footer key hint: the key, and what it does. pub struct Hint { pub key: &'static str, pub label: &'static str, } /// Terse constructor for a [`Hint`], so hint lists read as data at the call /// site: `[hint("Tab", "focus"), hint("q", "quit")]`. pub const fn hint(key: &'static str, label: &'static str) -> Hint { Hint { key, label } } /// The one-row footer: key hints on the left, transient status on the right. /// /// This is docs/CONSOLE.md's "common status area" and /// docs/COMPONENT-LIBRARY.md's footer chrome in one widget — they occupy the /// same row, and splitting them into two widgets would mean two things /// competing for it. Descended from sysop-tui's `Footer`, with the status /// slot added. pub struct AlloyStatusBar<'a> { theme: &'a Theme, hints: Vec, status: Option<(Severity, String)>, } impl<'a> AlloyStatusBar<'a> { pub fn new(theme: &'a Theme, hints: impl IntoIterator) -> Self { Self { theme, hints: hints.into_iter().collect(), status: None, } } /// Attach a transient status message (busy, error, dirty) to the right end. #[must_use] pub fn status(mut self, severity: Severity, message: impl Into) -> Self { self.status = Some((severity, message.into())); self } } impl Widget for AlloyStatusBar<'_> { fn render(self, area: Rect, buf: &mut Buffer) { let base = Style::default().bg(self.theme.surface_sunken); Paragraph::new("").style(base).render(area, buf); let mut spans: Vec = Vec::with_capacity(self.hints.len() * 3); for (i, h) in self.hints.iter().enumerate() { if i > 0 { spans.push(Span::raw(" ")); } spans.push(text::action(self.theme, h.key)); spans.push(Span::raw(" ")); spans.push(text::muted(self.theme, h.label)); } Paragraph::new(Line::from(spans)) .style(base) .render(area, buf); // The status sits on the same row, right-aligned. Rendering it as a // second pass into a right-hand slice means a long hint list is // overwritten by the status rather than pushing it off-screen — the // status is the more urgent of the two. if let Some((severity, message)) = self.status { let text_width = message.chars().count() as u16 + 1; let width = text_width.min(area.width); let slot = Rect { x: area.x + area.width - width, width, ..area }; Paragraph::new(Line::from(Span::styled( message, severity.style(self.theme).patch(base), ))) .style(base) .right_aligned() .render(slot, buf); } } } /// Themed selectable list. /// /// Rows are pre-composed `Line`s so callers keep control of their own content /// styling (a `Severity` span in a row survives selection); this widget owns /// only the gutter marker, the row style, and scrolling. pub struct AlloyList<'a> { theme: &'a Theme, items: Vec>, selected: Option, } impl<'a> AlloyList<'a> { pub fn new(theme: &'a Theme, items: impl IntoIterator>) -> Self { Self { theme, items: items.into_iter().collect(), selected: None, } } #[must_use] pub fn selected(mut self, selected: Option) -> Self { self.selected = selected; self } /// First visible row for a viewport of `height` rows. fn offset(&self, height: usize) -> usize { list_offset(self.items.len(), height, self.selected) } } /// First visible row of a list, given its length, viewport height, and /// selection. /// /// Stateless by design: the offset is derived from the selection each frame /// rather than carried between frames, which is what lets [`AlloyList`] stay /// immediate-mode. The cost is that scrolling centers the selection instead of /// scrolling by the minimum amount; the benefit is that no caller has to own /// and thread a `ListState`. /// /// Public because anything drawing *alongside* a list has to agree with it /// about which rows are on screen and where. [`AlloyConnector`](crate::AlloyConnector) /// needs a row's y position, and computing that from a second, separate copy /// of this rule is how a connector ends up pointing one row off after a scroll. pub fn list_offset(len: usize, height: usize, selected: Option) -> usize { let (Some(selected), true) = (selected, len > height) else { return 0; }; let max_offset = len - height; selected.saturating_sub(height / 2).min(max_offset) } /// Screen row for list item `index`, or `None` when it is scrolled out of /// view. /// /// `area` is the list's viewport, already inside any block border. pub fn list_row_y(area: Rect, len: usize, selected: Option, index: usize) -> Option { if area.height == 0 || index >= len { return None; } let offset = list_offset(len, area.height as usize, selected); let row = index.checked_sub(offset)?; if row >= area.height as usize { return None; } Some(area.y + row as u16) } impl Widget for AlloyList<'_> { fn render(self, area: Rect, buf: &mut Buffer) { if area.height == 0 || area.width == 0 { return; } let height = area.height as usize; let offset = self.offset(height); for (row, (index, item)) in self .items .iter() .enumerate() .skip(offset) .take(height) .enumerate() { let is_selected = self.selected == Some(index); let style = if is_selected { selected_style(self.theme) } else { unselected_style(self.theme) }; let marker = if is_selected { MARKER } else { MARKER_BLANK }; let mut spans = vec![Span::styled(format!("{marker} "), style)]; spans.extend(item.spans.iter().cloned()); let line_area = Rect { y: area.y + row as u16, height: 1, ..area }; Paragraph::new(Line::from(spans)) .style(style) .render(line_area, buf); } } } /// A one-row tab bar. /// /// Holds no state: the selected index comes from the caller's /// [`FocusRing`](crate::FocusRing), which is already a wrapping cursor over N /// slots with the `focus(slot)` a verb needs to open the view on a given tab. /// Same split as [`AlloyList`] and [`Cursor`](crate::Cursor) — widget shared, /// state owned by the view. /// /// Selection reads as brackets plus weight rather than color. Per /// DESIGN-LANGUAGE.md color stays off chrome, and per the same reasoning as /// [`MARKER`](crate::MARKER) being a plain triangle, a bracket survives a /// console with no theme and no patched font — the TTY before the session /// starts, `alloy` over SSH. pub struct AlloyTabs<'a> { theme: &'a Theme, labels: Vec, selected: usize, } impl<'a> AlloyTabs<'a> { pub fn new(theme: &'a Theme, labels: impl IntoIterator>) -> Self { Self { theme, labels: labels.into_iter().map(Into::into).collect(), selected: 0, } } /// Select a tab. Out-of-range indices select nothing, matching /// [`FocusRing::focus`](crate::FocusRing::focus): landing on a neighbouring /// tab is worse than showing none as current. #[must_use] pub fn selected(mut self, selected: usize) -> Self { self.selected = selected; self } } /// Gap between tabs. Wide enough that two short labels do not read as one. const TAB_GAP: &str = " "; impl Widget for AlloyTabs<'_> { fn render(self, area: Rect, buf: &mut Buffer) { if area.height == 0 || area.width == 0 { return; } let mut spans: Vec = Vec::with_capacity(self.labels.len() * 2); for (i, label) in self.labels.iter().enumerate() { if i > 0 { spans.push(Span::raw(TAB_GAP)); } // Unselected labels carry spaces where the selected one carries // brackets, so a label occupies the same cells either way and the // bar does not shift horizontally as selection moves. Same reason // MARKER_BLANK exists for list rows. let (open, close, style) = if i == self.selected { ("[ ", " ]", selected_style(self.theme)) } else { (" ", " ", unselected_style(self.theme)) }; spans.push(Span::styled(format!("{open}{label}{close}"), style)); } let row = Rect { height: 1, ..area }; Paragraph::new(Line::from(spans)) .style(Style::default().bg(self.theme.surface_page)) .render(row, buf); } } /// A centered confirmation modal, drawn over the view that raised it. /// /// Confirmation is design-system chrome rather than per-view furniture: every /// destructive action in every Alloy TUI asks the same way, with the same /// keys. That is the reason this lives here and the shell owns the state, /// instead of each view drawing its own prompt. /// /// Sits on `surface.overlay`, the one theme surface reserved for content /// floating above the page, and borrows `Severity` for the accent so a /// destructive confirm reads red and a benign one does not. pub struct AlloyModal<'a> { theme: &'a Theme, title: &'a str, message: &'a str, severity: Severity, } impl<'a> AlloyModal<'a> { pub fn new(theme: &'a Theme, title: &'a str, message: &'a str) -> Self { Self { theme, title, message, severity: Severity::Warn, } } #[must_use] pub fn severity(mut self, severity: Severity) -> Self { self.severity = severity; self } } impl Widget for AlloyModal<'_> { fn render(self, area: Rect, buf: &mut Buffer) { if area.height == 0 || area.width == 0 { return; } let base = Style::default() .bg(self.theme.surface_overlay) .fg(self.theme.content_primary); let block = Block::default() .borders(Borders::ALL) .border_style(Style::default().fg(self.theme.border_strong)) .style(base) .title(format!(" {} ", self.title)); let inner = block.inner(area); block.render(area, buf); if inner.height == 0 { return; } // Message on top, keys on the last row. The keys are pinned to the // bottom rather than following the message so their position does not // move with message length: a confirm the user cannot dismiss is the // one failure this widget must not have. let keys = Line::from(vec![ text::action(self.theme, "Enter"), Span::styled(" confirm", Style::default().fg(self.theme.content_muted)), Span::raw(" "), text::action(self.theme, "Esc"), Span::styled(" cancel", Style::default().fg(self.theme.content_muted)), ]); let message_height = inner.height.saturating_sub(1); if message_height > 0 { Paragraph::new(Line::from(Span::styled( self.message, self.severity.style(self.theme).patch(base), ))) .style(base) .wrap(ratatui::widgets::Wrap { trim: true }) .render( Rect { height: message_height, ..inner }, buf, ); } Paragraph::new(keys).style(base).render( Rect { y: inner.y + inner.height - 1, height: 1, ..inner }, buf, ); } } /// One line of the command log: the CLI invocation that was run, and how it /// went. /// /// The console fronts CLIs rather than hiding them (docs/CONSOLE.md), so /// `command` holds the actual argv the console executed — verbatim, so a user /// can copy it into a shell and get the same result. #[derive(Debug, Clone)] pub struct LogEntry { pub command: String, pub outcome: Severity, } impl LogEntry { pub fn new(command: impl Into, outcome: Severity) -> Self { Self { command: command.into(), outcome, } } } /// The always-on command-log pane. /// /// Renders the tail of the log — the most recent invocation on the bottom row, /// terminal-transcript order, so the pane reads the way a shell scrollback /// does. pub struct AlloyLog<'a> { theme: &'a Theme, entries: &'a [LogEntry], } impl<'a> AlloyLog<'a> { pub fn new(theme: &'a Theme, entries: &'a [LogEntry]) -> Self { Self { theme, entries } } } impl Widget for AlloyLog<'_> { fn render(self, area: Rect, buf: &mut Buffer) { if area.height == 0 || area.width == 0 { return; } let block = AlloyBlock::new(self.theme).build().title(" commands "); let inner = block.inner(area); block.render(area, buf); if inner.height == 0 { return; } let visible = inner.height as usize; let tail = self.entries.len().saturating_sub(visible); let lines: Vec = self.entries[tail..] .iter() .map(|entry| { Line::from(vec![ Span::styled("$ ", entry.outcome.style(self.theme)), text::secondary(self.theme, entry.command.clone()), ]) }) .collect(); Paragraph::new(lines) .style(Style::default().bg(self.theme.surface_page)) .render(inner, buf); } } /// The value half of a form row: what the value cell paints, and nothing else. /// /// One enum rather than five widgets. Which kind a row is comes off a schema /// file read at startup, so it is runtime data and five static types would buy /// no safety at the one call site that builds rows. See /// docs/COMPONENT-LIBRARY.md in the Alloy repo for the full rationale. /// /// Every variant that carries text takes it pre-formatted: rounding a float, /// picking which of an enum's labels is current, and deciding how a path is /// abbreviated are all the caller's business, and a widget that formatted /// values would need to know the schema. pub enum FieldKind<'a> { Toggle(bool), Text(&'a str), Number(&'a str), /// The current selection's label, not its raw value. Enum { label: &'a str, }, /// Draws a swatch alongside the text. The one place a widget here paints /// an arbitrary color: DESIGN-LANGUAGE.md keeps color off chrome, and this /// is not chrome, it is the value. Color { hex: &'a str, }, } /// A single form row: a label, a value cell, and the chrome around focus. /// /// Pure render, like every widget here. It owns no value, does no validation, /// and does not know what a schema is; the console binary holds the document, /// the constraints, and the edit buffer, and rebuilds these each frame. pub struct AlloyField<'a> { theme: &'a Theme, label: &'a str, kind: FieldKind<'a>, focused: bool, indent: bool, unset: bool, label_width: usize, edit: Option<&'a TextField>, diagnostic: Option<(Severity, &'a str)>, help: Option<&'a str>, } impl<'a> AlloyField<'a> { pub fn new(theme: &'a Theme, label: &'a str, kind: FieldKind<'a>) -> Self { Self { theme, label, kind, focused: false, indent: false, unset: false, label_width: 0, edit: None, diagnostic: None, help: None, } } #[must_use] pub fn focused(mut self, focused: bool) -> Self { self.focused = focused; self } /// Sit the row under a section header. [`AlloyForm`] sets this; a field /// rendered on its own is not inside anything. #[must_use] pub fn indent(mut self, indent: bool) -> Self { self.indent = indent; self } /// Mark the value as the schema's default rather than something the source /// of truth holds, and paint it muted. /// /// Without this a form cannot show the difference between a key set to 12 /// and a key absent from a file that defaults to 12. Keeping the two /// distinguishable is the whole point of the console reading and /// defaulting through separate calls, and this is where that reaches the /// screen. #[must_use] pub fn unset(mut self, unset: bool) -> Self { self.unset = unset; self } /// Pad the label column to this width, so value cells line up down the /// form. [`AlloyForm`] sets it from the widest label it holds. #[must_use] pub fn label_width(mut self, width: usize) -> Self { self.label_width = width; self } /// Draw the caret buffer in place of the value cell. `Some` means this row /// is being edited. #[must_use] pub fn edit(mut self, edit: Option<&'a TextField>) -> Self { self.edit = edit; self } #[must_use] pub fn diagnostic(mut self, diagnostic: Option<(Severity, &'a str)>) -> Self { self.diagnostic = diagnostic; self } #[must_use] pub fn help(mut self, help: Option<&'a str>) -> Self { self.help = help; self } /// The value cell's spans, under the row's base style. fn value_spans(&self, base: Style) -> Vec> { if let Some(buffer) = self.edit { return caret_spans(self.theme, buffer, base); } let style = if self.unset { base.fg(self.theme.content_muted) } else { base }; match &self.kind { // Brackets and not a color or a Nerd Font glyph, for the reason // MARKER is a plain triangle: a toggle has to be readable on the // TTY before the session starts, and state must not be carried by // color alone. FieldKind::Toggle(on) => { vec![Span::styled(if *on { "[x]" } else { "[ ]" }, style)] } FieldKind::Text(value) | FieldKind::Number(value) => { vec![Span::styled(*value, style)] } FieldKind::Enum { label } => vec![Span::styled(*label, style)], FieldKind::Color { hex } => { let mut spans = Vec::with_capacity(2); // A hex string the caller could not parse still renders its // text: a swatch is an aid to reading the value, not the value. if let Some(color) = swatch(hex) { spans.push(Span::styled(SWATCH, base.fg(color))); spans.push(Span::styled(" ", base)); } spans.push(Span::styled(*hex, style)); spans } } } } /// The swatch a color field paints beside its hex. const SWATCH: &str = "██"; /// Parse `#rrggbb` or `#rrggbbaa` into a color. Alpha is dropped: a terminal /// cell has no alpha to blend against. fn swatch(hex: &str) -> Option { let body = hex.strip_prefix('#')?; if body.len() != 6 && body.len() != 8 { return None; } let channel = |at: usize| u8::from_str_radix(body.get(at..at + 2)?, 16).ok(); Some(Color::Rgb(channel(0)?, channel(2)?, channel(4)?)) } /// A caret buffer as spans: the text either side of the caret, and the cell /// under it drawn as a block. /// /// Colors are named rather than reversed. `Modifier::REVERSED` swaps against /// whatever the row's background happens to be, so a caret on a focused row /// and a caret on an unfocused one would not match. fn caret_spans<'s>(theme: &Theme, buffer: &TextField, base: Style) -> Vec> { let (before, under, after) = buffer.split(); let caret = Style::default() .bg(theme.content_primary) .fg(theme.surface_page); vec![ Span::styled(before.to_string(), base), // A caret past the end of the line has no character to sit on, so it // draws on a space. Without this the buffer loses its caret exactly // when the user is appending, which is most of the time. Span::styled(under.map_or(" ".to_string(), String::from), caret), Span::styled(after.to_string(), base), ] } impl Widget for AlloyField<'_> { fn render(self, area: Rect, buf: &mut Buffer) { if area.height == 0 || area.width == 0 { return; } let base = if self.focused { selected_style(self.theme) } else { unselected_style(self.theme) }; let marker = if self.focused { MARKER } else { MARKER_BLANK }; let mut spans = vec![Span::styled(format!("{marker} "), base)]; if self.indent { spans.push(Span::styled(" ", base)); } let width = self.label_width.max(self.label.chars().count()); spans.push(Span::styled( format!("{:width$} ", self.label, width = width), base.fg(self.theme.content_secondary), )); spans.extend(self.value_spans(base)); Paragraph::new(Line::from(spans)) .style(base) .render(Rect { height: 1, ..area }, buf); } } /// One row of a form: a section header, or a field. /// /// The binary rebuilds this list every frame from the schema and the fold /// state — headers, plus the fields of open sections — and a `Cursor` rides /// it. Same split as [`AlloyList`]: widget shared, state owned by the view. pub enum FormRow<'a> { Section { label: &'a str, open: bool }, Field(AlloyField<'a>), } /// The chrome around a sequence of form rows. /// /// Sections are header rows and an indent, not nested boxes. A box inside a /// pane spends two columns a side on every level, and the fold marker already /// says everything a border would; the console's panes are narrow enough that /// the columns matter more than the outline. /// /// Rows are one line each, without exception. That is what lets the form scroll /// through [`list_offset`] rather than carrying its own state, the same trade /// [`AlloyList`] makes, and it is why the focused row's help and diagnostic /// render on a reserved line at the foot of the form instead of under the row. /// The line is reserved whether or not there is anything to put in it, so the /// rows above do not reflow as focus moves. pub struct AlloyForm<'a> { theme: &'a Theme, rows: Vec>, selected: Option, } impl<'a> AlloyForm<'a> { pub fn new(theme: &'a Theme, rows: impl IntoIterator>) -> Self { Self { theme, rows: rows.into_iter().collect(), selected: None, } } #[must_use] pub fn selected(mut self, selected: usize) -> Self { self.selected = Some(selected); self } /// The label column every field pads to: the widest label in the form. fn label_width(&self) -> usize { self.rows .iter() .filter_map(|row| match row { FormRow::Field(field) => Some(field.label.chars().count()), FormRow::Section { .. } => None, }) .max() .unwrap_or(0) } /// The footer line for the focused row: its diagnostic if it has one, its /// help otherwise. /// /// A diagnostic wins because it is the reason an edit did not commit, and /// help the user has already read is not worth the row it would cost them. fn footer(&self) -> Option> { let FormRow::Field(field) = self.rows.get(self.selected?)? else { return None; }; if let Some((severity, message)) = field.diagnostic { return Some(Line::from(Span::styled( message, severity.style(self.theme), ))); } field .help .map(|help| Line::from(text::muted(self.theme, help))) } } /// Fold markers for a section header. Plain geometric shapes rather than Nerd /// Font glyphs, per docs/ICONOGRAPHY.md. const SECTION_OPEN: &str = "▾"; const SECTION_CLOSED: &str = "▸"; impl Widget for AlloyForm<'_> { fn render(mut self, area: Rect, buf: &mut Buffer) { if area.height == 0 || area.width == 0 { return; } // The footer is computed before the rows are consumed, and its line is // taken off the top of the budget so a form one row tall still shows // the row rather than only its help. let footer = self.footer(); let rows_height = if area.height > 1 { area.height - 1 } else { area.height }; let label_width = self.label_width(); let len = self.rows.len(); let offset = list_offset(len, rows_height as usize, self.selected); for (row, (index, item)) in self .rows .drain(..) .enumerate() .skip(offset) .take(rows_height as usize) .enumerate() { let line_area = Rect { y: area.y + row as u16, height: 1, ..area }; let focused = self.selected == Some(index); match item { FormRow::Section { label, open } => { let style = if focused { selected_style(self.theme) } else { unselected_style(self.theme) }; let marker = if focused { MARKER } else { MARKER_BLANK }; let fold = if open { SECTION_OPEN } else { SECTION_CLOSED }; Paragraph::new(Line::from(vec![ Span::styled(format!("{marker} "), style), Span::styled(format!("{fold} "), style), Span::styled( label, style.patch(Style::default().fg(self.theme.content_primary)), ), ])) .style(style) .render(line_area, buf); } FormRow::Field(field) => field .focused(focused) .indent(true) .label_width(label_width) .render(line_area, buf), } } if area.height > 1 { let line_area = Rect { y: area.y + area.height - 1, height: 1, ..area }; let base = Style::default().bg(self.theme.surface_page); Paragraph::new(footer.unwrap_or_default()) .style(base) .render(line_area, buf); } } } /// One choice in a picker: what it reads as, and what it means. pub struct PickRow<'a> { pub label: &'a str, pub description: Option<&'a str>, } impl<'a> PickRow<'a> { pub fn new(label: &'a str) -> Self { Self { label, description: None, } } #[must_use] pub fn description(mut self, description: Option<&'a str>) -> Self { self.description = description; self } } /// A filterable pick list, drawn over the view that raised it. /// /// The overlay an enum field opens: choices do not free-type, so picking one is /// a different act from editing a value and gets a different surface. Sits on /// `surface.overlay` alongside [`AlloyModal`], which is the whole reason it /// lives here rather than being assembled per view — every floating thing in /// every Alloy TUI should read the same. /// /// **The filter is not optional chrome.** The design that specced a pick /// overlay assumed enums the size of a cursor shape; `timedatectl /// list-timezones` is about six hundred entries and locales are worse. A plain /// substring match is deliberate: zone names are terse and hierarchical, and a /// fuzzy ranker over six hundred strings is a scoring function to tune for no /// gain a substring does not already give. /// /// Like every widget here it owns nothing. The buffer, the filtering, and the /// selection are the caller's; this paints them. pub struct AlloyPicker<'a> { theme: &'a Theme, title: &'a str, filter: &'a TextField, rows: Vec>, selected: Option, empty: &'a str, } impl<'a> AlloyPicker<'a> { pub fn new( theme: &'a Theme, title: &'a str, filter: &'a TextField, rows: impl IntoIterator>, ) -> Self { Self { theme, title, filter, rows: rows.into_iter().collect(), selected: None, empty: "no matches", } } #[must_use] pub fn selected(mut self, selected: Option) -> Self { self.selected = selected; self } /// What to show when the filter matches nothing. A picker that goes blank /// reads as broken rather than as narrowed. #[must_use] pub fn empty(mut self, empty: &'a str) -> Self { self.empty = empty; self } /// Width the content wants, borders included. /// /// Callers size the overlay with this and [`layout::centered`], rather than /// this widget picking its own area: where an overlay sits is the view's /// business and how wide its content is, is not. /// /// [`layout::centered`]: crate::layout::centered pub fn width(&self) -> u16 { let content = self .rows .iter() .map(|row| { row.label.chars().count() + row .description .map_or(0, |text| text.chars().count() + DESCRIPTION_GAP.len()) }) .chain(std::iter::once(self.title.chars().count())) .max() .unwrap_or(0); // Two for the border, two for the gutter the list draws its marker in. (content + 4) as u16 } /// Height for `rows` visible choices, borders included: the filter row and /// the key row on top of them. pub fn height(rows: u16) -> u16 { rows + 4 } } /// Separates a choice from what it means. const DESCRIPTION_GAP: &str = " "; impl Widget for AlloyPicker<'_> { fn render(self, area: Rect, buf: &mut Buffer) { if area.height == 0 || area.width == 0 { return; } let base = Style::default() .bg(self.theme.surface_overlay) .fg(self.theme.content_primary); let block = Block::default() .borders(Borders::ALL) .border_style(Style::default().fg(self.theme.border_strong)) .style(base) .title(format!(" {} ", self.title)); let inner = block.inner(area); block.render(area, buf); if inner.height == 0 { return; } // Filter on top, keys on the bottom, list between. The keys are pinned // for the same reason AlloyModal pins its own: a prompt whose dismiss // keys move with content length is one the user can lose. let (before, under, after) = self.filter.split(); let caret = Style::default() .bg(self.theme.content_primary) .fg(self.theme.surface_overlay); Paragraph::new(Line::from(vec![ Span::styled("/ ", base.fg(self.theme.content_muted)), Span::styled(before.to_string(), base), Span::styled(under.map_or(" ".to_string(), String::from), caret), Span::styled(after.to_string(), base), ])) .style(base) .render(Rect { height: 1, ..inner }, buf); let keys = Line::from(vec![ text::action(self.theme, "enter"), Span::styled(" select", Style::default().fg(self.theme.content_muted)), Span::raw(" "), text::action(self.theme, "esc"), Span::styled(" cancel", Style::default().fg(self.theme.content_muted)), ]); if inner.height > 1 { Paragraph::new(keys).style(base).render( Rect { y: inner.y + inner.height - 1, height: 1, ..inner }, buf, ); } let list_area = Rect { y: inner.y + 1, height: inner.height.saturating_sub(2), ..inner }; if list_area.height == 0 { return; } if self.rows.is_empty() { Paragraph::new(Line::from(Span::styled( self.empty, base.fg(self.theme.content_muted), ))) .style(base) .render(list_area, buf); return; } // Descriptions line up in a column, so a list of choices reads as two // columns rather than as ragged sentences. let label_width = self .rows .iter() .map(|row| row.label.chars().count()) .max() .unwrap_or(0); let items: Vec = self .rows .iter() .map(|row| { let mut spans = vec![Span::styled( format!("{:label_width$}", row.label), Style::default(), )]; if let Some(description) = row.description { spans.push(Span::styled( format!("{DESCRIPTION_GAP}{description}"), Style::default().fg(self.theme.content_muted), )); } Line::from(spans) }) .collect(); // Rendered as an ordinary list so selection, the marker, and scrolling // are the same here as anywhere else in the console. AlloyList::new(self.theme, items) .selected(self.selected) .render(list_area, buf); } } /// A display-only table. /// /// v1 renders schema list-of-tables records (rio's `bindings.keys`) read-only; /// add, remove, and cell edit route to the text-edit fallback until v1.1. It is /// display-only rather than half-editable on purpose: a table that accepts some /// edits and silently refuses others is worse than one that clearly accepts /// none. pub struct AlloyTable<'a> { theme: &'a Theme, headers: Vec<&'a str>, rows: Vec>, } impl<'a> AlloyTable<'a> { pub fn new( theme: &'a Theme, headers: impl IntoIterator, rows: impl IntoIterator>, ) -> Self { Self { theme, headers: headers.into_iter().collect(), rows: rows.into_iter().collect(), } } /// Column widths: the widest cell in each column, header included. fn widths(&self) -> Vec { let mut widths: Vec = self .headers .iter() .map(|header| header.chars().count()) .collect(); for row in &self.rows { for (column, cell) in row.iter().enumerate() { let width = cell.chars().count(); match widths.get_mut(column) { Some(current) => *current = (*current).max(width), // A row wider than the header list still renders. The // schema and the file it describes can disagree, and // dropping a cell would hide exactly that. None => widths.push(width), } } } widths } } /// Gap between table columns. const COLUMN_GAP: &str = " "; impl Widget for AlloyTable<'_> { fn render(self, area: Rect, buf: &mut Buffer) { if area.height == 0 || area.width == 0 { return; } let widths = self.widths(); let pad = |cell: &str, column: usize| { let width = widths.get(column).copied().unwrap_or(0); format!("{cell:width$}") }; let mut lines = Vec::with_capacity(self.rows.len() + 1); lines.push(Line::from( self.headers .iter() .enumerate() .map(|(column, header)| { text::muted(self.theme, format!("{}{COLUMN_GAP}", pad(header, column))) }) .collect::>(), )); for row in &self.rows { lines.push(Line::from( row.iter() .enumerate() .map(|(column, cell)| { text::secondary(self.theme, format!("{}{COLUMN_GAP}", pad(cell, column))) }) .collect::>(), )); } Paragraph::new(lines) .style(Style::default().bg(self.theme.surface_page)) .render(area, buf); } } #[cfg(test)] mod tests { use super::*; use ratatui::style::Color; fn theme() -> Theme { Theme { mode: crate::theme::Mode::Dark, surface_page: Color::Rgb(0, 0, 0), surface_raised: Color::Rgb(1, 1, 1), surface_sunken: Color::Rgb(2, 2, 2), surface_overlay: Color::Rgb(3, 3, 3), content_primary: Color::Rgb(4, 4, 4), content_secondary: Color::Rgb(5, 5, 5), content_muted: Color::Rgb(6, 6, 6), action_primary: Color::Rgb(7, 7, 7), status_danger: Color::Rgb(8, 8, 8), status_success: Color::Rgb(9, 9, 9), status_warning: Color::Rgb(10, 10, 10), status_info: Color::Rgb(11, 11, 11), line_border: Color::Rgb(12, 12, 12), border_subtle: Color::Rgb(13, 13, 13), border_strong: Color::Rgb(14, 14, 14), category: [Color::Rgb(15, 15, 15); 6], } } fn list_of(n: usize, selected: Option) -> AlloyList<'static> { // Leaked so the test list can hold a 'static theme reference; the // widget borrows rather than owns, and these are per-test one-offs. let theme: &'static Theme = Box::leak(Box::new(theme())); let items: Vec> = (0..n).map(|i| Line::from(format!("row {i}"))).collect(); AlloyList::new(theme, items).selected(selected) } #[test] fn short_list_never_scrolls() { assert_eq!(list_of(3, Some(2)).offset(10), 0); } // Selection near the top must not scroll past the start of the list — a // naive `selected - height/2` underflows or shows blank rows above row 0. #[test] fn offset_clamps_at_the_top() { assert_eq!(list_of(50, Some(0)).offset(10), 0); assert_eq!(list_of(50, Some(2)).offset(10), 0); } // Selection at the end must land the last row on the last visible line, // not scroll into empty space past the end of the list. #[test] fn offset_clamps_at_the_bottom() { assert_eq!(list_of(50, Some(49)).offset(10), 40); } #[test] fn offset_centers_a_midlist_selection() { assert_eq!(list_of(50, Some(25)).offset(10), 20); } #[test] fn row_y_maps_visible_items_to_screen_rows() { let area = Rect::new(0, 5, 20, 10); assert_eq!(list_row_y(area, 3, Some(0), 0), Some(5)); assert_eq!(list_row_y(area, 3, Some(0), 2), Some(7)); } // After a scroll the mapping has to follow the offset. A connector using a // separate copy of the scroll rule is exactly what this prevents. #[test] fn row_y_accounts_for_scrolling() { let area = Rect::new(0, 0, 20, 10); // 50 items, selection at 25 => offset 20, so item 20 is the top row. assert_eq!(list_row_y(area, 50, Some(25), 20), Some(0)); assert_eq!(list_row_y(area, 50, Some(25), 25), Some(5)); } #[test] fn row_y_is_none_for_rows_scrolled_out_of_view() { let area = Rect::new(0, 0, 20, 10); assert_eq!( list_row_y(area, 50, Some(25), 0), None, "above the viewport" ); assert_eq!( list_row_y(area, 50, Some(25), 49), None, "below the viewport" ); assert_eq!( list_row_y(area, 3, Some(0), 9), None, "past the end of the list" ); } fn render_tabs(selected: usize, width: u16) -> String { let theme = theme(); let mut buf = Buffer::empty(Rect::new(0, 0, width, 1)); AlloyTabs::new(&theme, ["installed", "boxes", "system"]) .selected(selected) .render(Rect::new(0, 0, width, 1), &mut buf); buf.content() .iter() .map(ratatui::buffer::Cell::symbol) .collect() } #[test] fn selected_tab_is_bracketed_and_others_are_not() { let rendered = render_tabs(0, 60); assert!( rendered.contains("[ installed ]"), "selected tab is bracketed" ); assert!(!rendered.contains("[ boxes ]"), "unselected tabs are not"); assert!(rendered.contains("boxes"), "unselected labels still render"); } // The bar must not shift horizontally as selection moves, or every tab // change reads as the whole row twitching. Unselected labels pad to the // bracket width for exactly this reason. #[test] fn labels_hold_their_columns_across_selections() { let first = render_tabs(0, 60); let last = render_tabs(2, 60); assert_eq!( first.find("system"), last.find("system"), "a label sits in the same columns whichever tab is selected" ); } // FocusRing::focus ignores out-of-range slots rather than clamping, and the // bar has to agree: showing a neighbouring tab as current would misreport // which screen the user is looking at. #[test] fn out_of_range_selection_brackets_nothing() { let rendered = render_tabs(9, 60); assert!(!rendered.contains('['), "no tab is marked current"); assert!(rendered.contains("installed"), "labels still render"); } #[test] fn zero_height_area_renders_nothing_rather_than_panicking() { let theme = theme(); let mut buf = Buffer::empty(Rect::new(0, 0, 40, 1)); AlloyTabs::new(&theme, ["installed"]).render(Rect::new(0, 0, 40, 0), &mut buf); AlloyTabs::new(&theme, ["installed"]).render(Rect::new(0, 0, 0, 1), &mut buf); } fn render_modal(area: Rect) -> Vec { let theme = theme(); let mut buf = Buffer::empty(area); AlloyModal::new(&theme, "remove", "Remove tailscale?").render(area, &mut buf); (0..area.height) .map(|y| { (0..area.width) .map(|x| buf[(x, y)].symbol()) .collect::() }) .collect() } #[test] fn modal_shows_its_message_and_both_keys() { let rows = render_modal(Rect::new(0, 0, 40, 7)).join("\n"); assert!(rows.contains("Remove tailscale?"), "message renders"); assert!(rows.contains("remove"), "title renders"); assert!(rows.contains("Enter"), "confirm key renders"); assert!(rows.contains("Esc"), "cancel key renders"); } // The keys are pinned to the last inner row rather than flowing after the // message. A prompt whose dismiss keys move with message length, or fall // off a short box, is a modal the user cannot get out of. #[test] fn keys_sit_on_the_last_row_whatever_the_message_length() { for height in [5, 7, 12] { let rows = render_modal(Rect::new(0, 0, 40, height)); let last_inner = &rows[height as usize - 2]; assert!( last_inner.contains("Enter") && last_inner.contains("Esc"), "height {height}: keys belong on the last inner row, got {last_inner:?}" ); } } #[test] fn modal_survives_an_area_too_small_to_draw_in() { let theme = theme(); let mut buf = Buffer::empty(Rect::new(0, 0, 40, 7)); AlloyModal::new(&theme, "t", "m").render(Rect::new(0, 0, 40, 0), &mut buf); AlloyModal::new(&theme, "t", "m").render(Rect::new(0, 0, 0, 7), &mut buf); AlloyModal::new(&theme, "t", "m").render(Rect::new(0, 0, 2, 2), &mut buf); } fn render_to(width: u16, height: u16, draw: impl FnOnce(&mut Buffer, Rect)) -> Vec { let area = Rect::new(0, 0, width, height); let mut buf = Buffer::empty(area); draw(&mut buf, area); (0..height) .map(|y| (0..width).map(|x| buf[(x, y)].symbol()).collect::()) .collect() } /// Column of `needle` in `row`, counted in characters. /// /// `str::find` counts bytes, and the focus marker is three of them, so a /// byte offset says a focused row's value starts two columns right of an /// unfocused one when both are in the same column. fn column(row: &str, needle: &str) -> Option { let at = row.find(needle)?; Some(row[..at].chars().count()) } fn field_rows(theme: &Theme) -> Vec> { vec![ FormRow::Section { label: "cursor", open: true, }, FormRow::Field( AlloyField::new(theme, "shape", FieldKind::Enum { label: "block" }) .help(Some("Cursor shape.")), ), FormRow::Field(AlloyField::new(theme, "blinking", FieldKind::Toggle(false))), FormRow::Section { label: "colors", open: false, }, ] } #[test] fn a_toggle_reads_without_color_or_a_patched_font() { let rows = render_to(40, 1, |buf, area| { AlloyField::new(&theme(), "blinking", FieldKind::Toggle(true)).render(area, buf); }); assert!(rows[0].contains("[x]"), "{rows:?}"); let rows = render_to(40, 1, |buf, area| { AlloyField::new(&theme(), "blinking", FieldKind::Toggle(false)).render(area, buf); }); assert!(rows[0].contains("[ ]"), "{rows:?}"); } #[test] fn a_color_field_draws_a_swatch_beside_its_hex() { let rows = render_to(40, 1, |buf, area| { AlloyField::new(&theme(), "background", FieldKind::Color { hex: "#e4ded6" }) .render(area, buf); }); assert!(rows[0].contains("██ #e4ded6"), "{rows:?}"); } // A hex the widget cannot parse still shows its text. The swatch helps read // a value; it is not the value, and dropping the text would hide the one // thing the user needs to see to fix it. #[test] fn an_unparseable_color_still_renders_its_text() { let rows = render_to(40, 1, |buf, area| { AlloyField::new(&theme(), "background", FieldKind::Color { hex: "e4ded6" }) .render(area, buf); }); assert!(rows[0].contains("e4ded6"), "{rows:?}"); assert!( !rows[0].contains('█'), "no swatch for a value it cannot parse" ); } #[test] fn swatch_parses_both_hex_lengths_and_rejects_the_rest() { assert_eq!(swatch("#e4ded6"), Some(Color::Rgb(0xe4, 0xde, 0xd6))); assert_eq!( swatch("#e4ded6ff"), Some(Color::Rgb(0xe4, 0xde, 0xd6)), "alpha is dropped, not refused", ); assert_eq!(swatch("e4ded6"), None, "no hash"); assert_eq!(swatch("#e4ded"), None, "wrong length"); assert_eq!(swatch("#gggggg"), None, "not hex"); } // An edited row shows the buffer and its caret, not the committed value. #[test] fn an_edited_field_draws_the_caret_buffer_in_place_of_the_value() { let mut buffer = TextField::new(); buffer.set("Departure Mono"); buffer.home(); let rows = render_to(60, 1, |buf, area| { AlloyField::new(&theme(), "family", FieldKind::Text("IosevkaTerm")) .edit(Some(&buffer)) .render(area, buf); }); assert!(rows[0].contains("Departure Mono"), "{rows:?}"); assert!( !rows[0].contains("IosevkaTerm"), "the committed value is not drawn" ); } // The caret has to be visible while appending, which is where it spends // most of its life. Past the end of the line there is no character under // it, so it draws on a space. #[test] fn a_caret_past_the_end_of_the_line_still_has_a_cell() { let mut buffer = TextField::new(); buffer.set("alloy"); let (before, under, after) = buffer.split(); assert_eq!((before, under, after), ("alloy", None, "")); let spans = caret_spans(&theme(), &buffer, Style::default()); assert_eq!(spans[1].content, " ", "the caret sits on a space"); } #[test] fn a_form_lines_its_value_column_up_across_rows() { let theme = theme(); let rows = render_to(50, 6, |buf, area| { AlloyForm::new(&theme, field_rows(&theme)) .selected(1) .render(area, buf); }); // "blinking" is the longest label, so both values start in the same // column despite "shape" being three characters shorter. let shape = column(&rows[1], "block").expect("enum label renders"); let blinking = column(&rows[2], "[ ]").expect("toggle renders"); assert_eq!(shape, blinking, "{rows:?}"); } #[test] fn a_section_header_shows_whether_it_is_folded() { let theme = theme(); let rows = render_to(50, 6, |buf, area| { AlloyForm::new(&theme, field_rows(&theme)) .selected(0) .render(area, buf); }); assert!(rows[0].contains("▾ cursor"), "open section: {rows:?}"); assert!(rows[3].contains("▸ colors"), "folded section: {rows:?}"); } #[test] fn the_focused_row_carries_the_same_marker_a_list_row_would() { let theme = theme(); let rows = render_to(50, 6, |buf, area| { AlloyForm::new(&theme, field_rows(&theme)) .selected(2) .render(area, buf); }); assert!(rows[2].starts_with(MARKER), "{rows:?}"); assert!(!rows[1].starts_with(MARKER), "only one row is focused"); } // The footer belongs to whichever row is focused, and a diagnostic beats // help: it is the reason an edit did not commit. #[test] fn the_footer_shows_the_focused_rows_help_and_a_diagnostic_over_it() { let theme = theme(); let rows = render_to(50, 6, |buf, area| { AlloyForm::new(&theme, field_rows(&theme)) .selected(1) .render(area, buf); }); assert!(rows[5].contains("Cursor shape."), "help renders: {rows:?}"); let rows = render_to(50, 6, |buf, area| { let mut rows = field_rows(&theme); rows[1] = FormRow::Field( AlloyField::new(&theme, "shape", FieldKind::Enum { label: "bar" }) .help(Some("Cursor shape.")) .diagnostic(Some((Severity::Error, "\"bar\" is not a declared value"))), ); AlloyForm::new(&theme, rows).selected(1).render(area, buf); }); assert!(rows[5].contains("not a declared value"), "{rows:?}"); assert!(!rows[5].contains("Cursor shape."), "the diagnostic wins"); } // The footer line is reserved whether or not it has anything in it. A form // whose rows reflow as focus moves is one where the row under the cursor // moves out from under it. #[test] fn rows_hold_their_lines_whether_the_footer_has_content_or_not() { let theme = theme(); let with_help = render_to(50, 6, |buf, area| { AlloyForm::new(&theme, field_rows(&theme)) .selected(1) .render(area, buf); }); let without = render_to(50, 6, |buf, area| { AlloyForm::new(&theme, field_rows(&theme)) .selected(2) .render(area, buf); }); assert_eq!( with_help[0], without[0], "the section header sits on the same line either way", ); assert!(without[5].trim().is_empty(), "no help, an empty footer"); } // One line per row is what keeps the stateless scroll math valid, so a form // longer than its area scrolls exactly the way a list does. #[test] fn a_form_longer_than_its_area_scrolls_like_a_list() { let theme = theme(); let many: Vec = (0..50) .map(|i| { FormRow::Field(AlloyField::new( &theme, "slot", FieldKind::Number(if i == 25 { "twentyfive" } else { "x" }), )) }) .collect(); let rows = render_to(50, 11, |buf, area| { AlloyForm::new(&theme, many).selected(25).render(area, buf); }); // 50 rows, 10 row-lines after the footer, selection 25 => offset 20. assert!(rows[5].contains("twentyfive"), "{rows:?}"); } #[test] fn a_form_survives_an_area_too_small_to_draw_in() { let theme = theme(); let mut buf = Buffer::empty(Rect::new(0, 0, 40, 6)); AlloyForm::new(&theme, field_rows(&theme)) .selected(0) .render(Rect::new(0, 0, 40, 0), &mut buf); AlloyForm::new(&theme, field_rows(&theme)) .selected(0) .render(Rect::new(0, 0, 0, 6), &mut buf); // One line tall: the row wins over the footer. let rows = render_to(40, 1, |buf, area| { AlloyForm::new(&theme, field_rows(&theme)) .selected(0) .render(area, buf); }); assert!(rows[0].contains("cursor"), "{rows:?}"); } fn picker_rows() -> Vec> { vec![ PickRow::new("Block").description(Some("Solid block.")), PickRow::new("Beam").description(Some("Thin vertical bar.")), ] } fn render_picker(filter: &TextField, rows: Vec>, height: u16) -> Vec { let theme = theme(); render_to(50, height, |buf, area| { AlloyPicker::new(&theme, "cursor.shape", filter, rows) .selected(Some(0)) .render(area, buf); }) } #[test] fn a_picker_shows_its_choices_with_what_they_mean() { let rows = render_picker(&TextField::new(), picker_rows(), 8); let joined = rows.join("\n"); assert!(joined.contains("cursor.shape"), "title: {joined}"); assert!(joined.contains("Block"), "{joined}"); assert!(joined.contains("Solid block."), "{joined}"); assert!(joined.contains("Thin vertical bar."), "{joined}"); } #[test] fn the_filter_row_carries_a_caret() { let mut filter = TextField::new(); filter.set("bl"); let rows = render_picker(&filter, picker_rows(), 8); assert!(rows[1].contains("/ bl"), "{rows:?}"); } // A picker that goes blank when the filter matches nothing reads as broken // rather than as narrowed. #[test] fn a_filter_that_matches_nothing_says_so() { let rows = render_picker(&TextField::new(), Vec::new(), 8); assert!(rows.join("\n").contains("no matches"), "{rows:?}"); } // Same rule AlloyModal follows: a floating thing whose dismiss keys move // with its content is one the user can lose. #[test] fn the_keys_sit_on_the_last_row_whatever_the_choice_count() { for height in [6, 8, 14] { let rows = render_picker(&TextField::new(), picker_rows(), height); let last = &rows[height as usize - 2]; assert!( last.contains("enter") && last.contains("esc"), "height {height}: {last:?}" ); } } #[test] fn a_picker_is_wide_enough_for_its_widest_choice() { let theme = theme(); let filter = TextField::new(); let width = AlloyPicker::new(&theme, "t", &filter, picker_rows()).width(); // "Beam" plus the gap plus "Thin vertical bar." is the longest row. assert_eq!(width as usize, 4 + 2 + 18 + 4); assert_eq!( AlloyPicker::height(2), 6, "two rows, a filter, keys, borders" ); } #[test] fn a_picker_survives_an_area_too_small_to_draw_in() { let theme = theme(); let filter = TextField::new(); let mut buf = Buffer::empty(Rect::new(0, 0, 40, 8)); for area in [ Rect::new(0, 0, 40, 0), Rect::new(0, 0, 0, 8), Rect::new(0, 0, 4, 2), Rect::new(0, 0, 40, 3), ] { AlloyPicker::new(&theme, "t", &filter, picker_rows()).render(area, &mut buf); } } #[test] fn a_table_aligns_its_columns_under_its_headers() { let theme = theme(); let rows = render_to(60, 3, |buf, area| { AlloyTable::new( &theme, ["key", "action"], [ vec!["ctrl+shift+t".to_string(), "CreateTab".to_string()], vec!["ctrl+w".to_string(), "CloseTab".to_string()], ], ) .render(area, buf); }); let action = column(&rows[0], "action").expect("header renders"); assert_eq!(column(&rows[1], "CreateTab"), Some(action), "{rows:?}"); assert_eq!(column(&rows[2], "CloseTab"), Some(action), "{rows:?}"); } // A file can hold a record the schema does not describe. Dropping the extra // cell would hide exactly the disagreement worth seeing. #[test] fn a_row_wider_than_the_headers_still_renders_every_cell() { let theme = theme(); let rows = render_to(60, 2, |buf, area| { AlloyTable::new( &theme, ["key"], [vec!["ctrl+w".to_string(), "CloseTab".to_string()]], ) .render(area, buf); }); assert!(rows[1].contains("CloseTab"), "{rows:?}"); } // A log longer than its pane shows the newest entries. Showing the head // instead would freeze the pane on startup noise and never display the // command the user just triggered. #[test] fn log_renders_the_newest_entries() { let theme = theme(); let entries: Vec = (0..10) .map(|i| LogEntry::new(format!("nmcli run {i}"), Severity::Healthy)) .collect(); let mut buf = Buffer::empty(Rect::new(0, 0, 40, 4)); AlloyLog::new(&theme, &entries).render(Rect::new(0, 0, 40, 4), &mut buf); let rendered = buf .content() .iter() .map(ratatui::buffer::Cell::symbol) .collect::(); assert!( rendered.contains("nmcli run 9"), "newest entry must be visible" ); assert!( !rendered.contains("nmcli run 0"), "oldest entry must have scrolled off" ); } }