Skip to main content

max / alloy_tui

24.7 KB · 717 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. This module carries the four the console shell needs
6 //! to render a screen end to end — block, list, log pane, status bar — plus
7 //! the `Severity` accent they all compose with. `AlloyForm`, `AlloyTable`, and
8 //! the schema-driven fields land with `alloy config`.
9
10 use ratatui::buffer::Buffer;
11 use ratatui::layout::Rect;
12 use ratatui::style::{Color, Style};
13 use ratatui::text::{Line, Span};
14 use ratatui::widgets::{Block, Borders, Paragraph, Widget};
15
16 use crate::selection::{MARKER, MARKER_BLANK, selected_style, unselected_style};
17 use crate::text;
18 use crate::theme::Theme;
19
20 /// Themed `Block`: default borders + palette chrome. Wraps `ratatui::widgets::Block`
21 /// so downstream code composes with it directly (`AlloyBlock::new(theme).build()`
22 /// returns the inner `Block`, which callers then `.title(..)` and pass to a
23 /// widget).
24 ///
25 /// Per DESIGN-LANGUAGE.md: chrome is tinted-greyscale; borders never carry an
26 /// accent — accents live on text via `Severity`. Focused vs. unfocused chrome
27 /// swaps `border-subtle` (decorative) for `border-strong` (focus/selection),
28 /// matching TOKENS.md's derived-border tiers.
29 pub struct AlloyBlock<'a> {
30 theme: &'a Theme,
31 focused: bool,
32 }
33
34 impl<'a> AlloyBlock<'a> {
35 pub fn new(theme: &'a Theme) -> Self {
36 Self { theme, focused: false }
37 }
38
39 pub fn focused(mut self, focused: bool) -> Self {
40 self.focused = focused;
41 self
42 }
43
44 pub fn build(self) -> Block<'a> {
45 let border_color = if self.focused {
46 self.theme.border_strong
47 } else {
48 self.theme.border_subtle
49 };
50 Block::default()
51 .borders(Borders::ALL)
52 .border_style(Style::default().fg(border_color))
53 .style(
54 Style::default()
55 .bg(self.theme.surface_page)
56 .fg(self.theme.content_primary),
57 )
58 }
59 }
60
61 /// Severity accent — categorical status tag whose color comes from the theme's
62 /// `status.*` intents. Per DESIGN-LANGUAGE.md, this is one of the few places
63 /// color is on-purpose: never on chrome, only on glyphs and text.
64 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
65 pub enum Severity {
66 Info,
67 Healthy,
68 Warn,
69 Error,
70 }
71
72 impl Severity {
73 pub const fn label(self) -> &'static str {
74 match self {
75 Severity::Info => "INFO",
76 Severity::Healthy => "OK",
77 Severity::Warn => "WARN",
78 Severity::Error => "ERROR",
79 }
80 }
81
82 pub fn color(self, theme: &Theme) -> Color {
83 match self {
84 Severity::Info => theme.status_info,
85 Severity::Healthy => theme.status_success,
86 Severity::Warn => theme.status_warning,
87 Severity::Error => theme.status_danger,
88 }
89 }
90
91 pub fn style(self, theme: &Theme) -> Style {
92 Style::default().fg(self.color(theme))
93 }
94 }
95
96 /// A footer key hint: the key, and what it does.
97 pub struct Hint {
98 pub key: &'static str,
99 pub label: &'static str,
100 }
101
102 /// Terse constructor for a [`Hint`], so hint lists read as data at the call
103 /// site: `[hint("Tab", "focus"), hint("q", "quit")]`.
104 pub const fn hint(key: &'static str, label: &'static str) -> Hint {
105 Hint { key, label }
106 }
107
108 /// The one-row footer: key hints on the left, transient status on the right.
109 ///
110 /// This is docs/CONSOLE.md's "common status area" and
111 /// docs/COMPONENT-LIBRARY.md's footer chrome in one widget — they occupy the
112 /// same row, and splitting them into two widgets would mean two things
113 /// competing for it. Descended from sysop-tui's `Footer`, with the status
114 /// slot added.
115 pub struct AlloyStatusBar<'a> {
116 theme: &'a Theme,
117 hints: Vec<Hint>,
118 status: Option<(Severity, String)>,
119 }
120
121 impl<'a> AlloyStatusBar<'a> {
122 pub fn new(theme: &'a Theme, hints: impl IntoIterator<Item = Hint>) -> Self {
123 Self {
124 theme,
125 hints: hints.into_iter().collect(),
126 status: None,
127 }
128 }
129
130 /// Attach a transient status message (busy, error, dirty) to the right end.
131 pub fn status(mut self, severity: Severity, message: impl Into<String>) -> Self {
132 self.status = Some((severity, message.into()));
133 self
134 }
135 }
136
137 impl Widget for AlloyStatusBar<'_> {
138 fn render(self, area: Rect, buf: &mut Buffer) {
139 let base = Style::default().bg(self.theme.surface_sunken);
140 Paragraph::new("").style(base).render(area, buf);
141
142 let mut spans: Vec<Span> = Vec::with_capacity(self.hints.len() * 3);
143 for (i, h) in self.hints.iter().enumerate() {
144 if i > 0 {
145 spans.push(Span::raw(" "));
146 }
147 spans.push(text::action(self.theme, h.key));
148 spans.push(Span::raw(" "));
149 spans.push(text::muted(self.theme, h.label));
150 }
151 Paragraph::new(Line::from(spans))
152 .style(base)
153 .render(area, buf);
154
155 // The status sits on the same row, right-aligned. Rendering it as a
156 // second pass into a right-hand slice means a long hint list is
157 // overwritten by the status rather than pushing it off-screen — the
158 // status is the more urgent of the two.
159 if let Some((severity, message)) = self.status {
160 let text_width = message.chars().count() as u16 + 1;
161 let width = text_width.min(area.width);
162 let slot = Rect {
163 x: area.x + area.width - width,
164 width,
165 ..area
166 };
167 Paragraph::new(Line::from(Span::styled(
168 message,
169 severity.style(self.theme).patch(base),
170 )))
171 .style(base)
172 .right_aligned()
173 .render(slot, buf);
174 }
175 }
176 }
177
178 /// Themed selectable list.
179 ///
180 /// Rows are pre-composed `Line`s so callers keep control of their own content
181 /// styling (a `Severity` span in a row survives selection); this widget owns
182 /// only the gutter marker, the row style, and scrolling.
183 pub struct AlloyList<'a> {
184 theme: &'a Theme,
185 items: Vec<Line<'a>>,
186 selected: Option<usize>,
187 }
188
189 impl<'a> AlloyList<'a> {
190 pub fn new(theme: &'a Theme, items: impl IntoIterator<Item = Line<'a>>) -> Self {
191 Self {
192 theme,
193 items: items.into_iter().collect(),
194 selected: None,
195 }
196 }
197
198 pub fn selected(mut self, selected: Option<usize>) -> Self {
199 self.selected = selected;
200 self
201 }
202
203 /// First visible row for a viewport of `height` rows.
204 fn offset(&self, height: usize) -> usize {
205 list_offset(self.items.len(), height, self.selected)
206 }
207 }
208
209 /// First visible row of a list, given its length, viewport height, and
210 /// selection.
211 ///
212 /// Stateless by design: the offset is derived from the selection each frame
213 /// rather than carried between frames, which is what lets [`AlloyList`] stay
214 /// immediate-mode. The cost is that scrolling centers the selection instead of
215 /// scrolling by the minimum amount; the benefit is that no caller has to own
216 /// and thread a `ListState`.
217 ///
218 /// Public because anything drawing *alongside* a list has to agree with it
219 /// about which rows are on screen and where. [`AlloyConnector`](crate::AlloyConnector)
220 /// needs a row's y position, and computing that from a second, separate copy
221 /// of this rule is how a connector ends up pointing one row off after a scroll.
222 pub fn list_offset(len: usize, height: usize, selected: Option<usize>) -> usize {
223 let (Some(selected), true) = (selected, len > height) else {
224 return 0;
225 };
226 let max_offset = len - height;
227 selected.saturating_sub(height / 2).min(max_offset)
228 }
229
230 /// Screen row for list item `index`, or `None` when it is scrolled out of
231 /// view.
232 ///
233 /// `area` is the list's viewport, already inside any block border.
234 pub fn list_row_y(area: Rect, len: usize, selected: Option<usize>, index: usize) -> Option<u16> {
235 if area.height == 0 || index >= len {
236 return None;
237 }
238 let offset = list_offset(len, area.height as usize, selected);
239 let row = index.checked_sub(offset)?;
240 if row >= area.height as usize {
241 return None;
242 }
243 Some(area.y + row as u16)
244 }
245
246 impl Widget for AlloyList<'_> {
247 fn render(self, area: Rect, buf: &mut Buffer) {
248 if area.height == 0 || area.width == 0 {
249 return;
250 }
251
252 let height = area.height as usize;
253 let offset = self.offset(height);
254
255 for (row, (index, item)) in self
256 .items
257 .iter()
258 .enumerate()
259 .skip(offset)
260 .take(height)
261 .enumerate()
262 {
263 let is_selected = self.selected == Some(index);
264 let style = if is_selected {
265 selected_style(self.theme)
266 } else {
267 unselected_style(self.theme)
268 };
269 let marker = if is_selected { MARKER } else { MARKER_BLANK };
270
271 let mut spans = vec![Span::styled(format!("{marker} "), style)];
272 spans.extend(item.spans.iter().cloned());
273
274 let line_area = Rect {
275 y: area.y + row as u16,
276 height: 1,
277 ..area
278 };
279 Paragraph::new(Line::from(spans))
280 .style(style)
281 .render(line_area, buf);
282 }
283 }
284 }
285
286 /// A one-row tab bar.
287 ///
288 /// Holds no state: the selected index comes from the caller's
289 /// [`FocusRing`](crate::FocusRing), which is already a wrapping cursor over N
290 /// slots with the `focus(slot)` a verb needs to open the view on a given tab.
291 /// Same split as [`AlloyList`] and [`Cursor`](crate::Cursor) — widget shared,
292 /// state owned by the view.
293 ///
294 /// Selection reads as brackets plus weight rather than color. Per
295 /// DESIGN-LANGUAGE.md color stays off chrome, and per the same reasoning as
296 /// [`MARKER`](crate::MARKER) being a plain triangle, a bracket survives a
297 /// console with no theme and no patched font — the TTY before the session
298 /// starts, `alloy` over SSH.
299 pub struct AlloyTabs<'a> {
300 theme: &'a Theme,
301 labels: Vec<String>,
302 selected: usize,
303 }
304
305 impl<'a> AlloyTabs<'a> {
306 pub fn new(theme: &'a Theme, labels: impl IntoIterator<Item = impl Into<String>>) -> Self {
307 Self {
308 theme,
309 labels: labels.into_iter().map(Into::into).collect(),
310 selected: 0,
311 }
312 }
313
314 /// Select a tab. Out-of-range indices select nothing, matching
315 /// [`FocusRing::focus`](crate::FocusRing::focus): landing on a neighbouring
316 /// tab is worse than showing none as current.
317 pub fn selected(mut self, selected: usize) -> Self {
318 self.selected = selected;
319 self
320 }
321 }
322
323 /// Gap between tabs. Wide enough that two short labels do not read as one.
324 const TAB_GAP: &str = " ";
325
326 impl Widget for AlloyTabs<'_> {
327 fn render(self, area: Rect, buf: &mut Buffer) {
328 if area.height == 0 || area.width == 0 {
329 return;
330 }
331
332 let mut spans: Vec<Span> = Vec::with_capacity(self.labels.len() * 2);
333 for (i, label) in self.labels.iter().enumerate() {
334 if i > 0 {
335 spans.push(Span::raw(TAB_GAP));
336 }
337 // Unselected labels carry spaces where the selected one carries
338 // brackets, so a label occupies the same cells either way and the
339 // bar does not shift horizontally as selection moves. Same reason
340 // MARKER_BLANK exists for list rows.
341 let (open, close, style) = if i == self.selected {
342 ("[ ", " ]", selected_style(self.theme))
343 } else {
344 (" ", " ", unselected_style(self.theme))
345 };
346 spans.push(Span::styled(format!("{open}{label}{close}"), style));
347 }
348
349 let row = Rect { height: 1, ..area };
350 Paragraph::new(Line::from(spans))
351 .style(Style::default().bg(self.theme.surface_page))
352 .render(row, buf);
353 }
354 }
355
356 /// A centered confirmation modal, drawn over the view that raised it.
357 ///
358 /// Confirmation is design-system chrome rather than per-view furniture: every
359 /// destructive action in every Alloy TUI asks the same way, with the same
360 /// keys. That is the reason this lives here and the shell owns the state,
361 /// instead of each view drawing its own prompt.
362 ///
363 /// Sits on `surface.overlay`, the one theme surface reserved for content
364 /// floating above the page, and borrows `Severity` for the accent so a
365 /// destructive confirm reads red and a benign one does not.
366 pub struct AlloyModal<'a> {
367 theme: &'a Theme,
368 title: &'a str,
369 message: &'a str,
370 severity: Severity,
371 }
372
373 impl<'a> AlloyModal<'a> {
374 pub fn new(theme: &'a Theme, title: &'a str, message: &'a str) -> Self {
375 Self {
376 theme,
377 title,
378 message,
379 severity: Severity::Warn,
380 }
381 }
382
383 pub fn severity(mut self, severity: Severity) -> Self {
384 self.severity = severity;
385 self
386 }
387 }
388
389 impl Widget for AlloyModal<'_> {
390 fn render(self, area: Rect, buf: &mut Buffer) {
391 if area.height == 0 || area.width == 0 {
392 return;
393 }
394
395 let base = Style::default()
396 .bg(self.theme.surface_overlay)
397 .fg(self.theme.content_primary);
398
399 let block = Block::default()
400 .borders(Borders::ALL)
401 .border_style(Style::default().fg(self.theme.border_strong))
402 .style(base)
403 .title(format!(" {} ", self.title));
404 let inner = block.inner(area);
405 block.render(area, buf);
406
407 if inner.height == 0 {
408 return;
409 }
410
411 // Message on top, keys on the last row. The keys are pinned to the
412 // bottom rather than following the message so their position does not
413 // move with message length: a confirm the user cannot dismiss is the
414 // one failure this widget must not have.
415 let keys = Line::from(vec![
416 text::action(self.theme, "Enter"),
417 Span::styled(" confirm", Style::default().fg(self.theme.content_muted)),
418 Span::raw(" "),
419 text::action(self.theme, "Esc"),
420 Span::styled(" cancel", Style::default().fg(self.theme.content_muted)),
421 ]);
422
423 let message_height = inner.height.saturating_sub(1);
424 if message_height > 0 {
425 Paragraph::new(Line::from(Span::styled(
426 self.message,
427 self.severity.style(self.theme).patch(base),
428 )))
429 .style(base)
430 .wrap(ratatui::widgets::Wrap { trim: true })
431 .render(Rect { height: message_height, ..inner }, buf);
432 }
433
434 Paragraph::new(keys)
435 .style(base)
436 .render(
437 Rect {
438 y: inner.y + inner.height - 1,
439 height: 1,
440 ..inner
441 },
442 buf,
443 );
444 }
445 }
446
447 /// One line of the command log: the CLI invocation that was run, and how it
448 /// went.
449 ///
450 /// The console fronts CLIs rather than hiding them (docs/CONSOLE.md), so
451 /// `command` holds the actual argv the console executed — verbatim, so a user
452 /// can copy it into a shell and get the same result.
453 #[derive(Debug, Clone)]
454 pub struct LogEntry {
455 pub command: String,
456 pub outcome: Severity,
457 }
458
459 impl LogEntry {
460 pub fn new(command: impl Into<String>, outcome: Severity) -> Self {
461 Self {
462 command: command.into(),
463 outcome,
464 }
465 }
466 }
467
468 /// The always-on command-log pane.
469 ///
470 /// Renders the tail of the log — the most recent invocation on the bottom row,
471 /// terminal-transcript order, so the pane reads the way a shell scrollback
472 /// does.
473 pub struct AlloyLog<'a> {
474 theme: &'a Theme,
475 entries: &'a [LogEntry],
476 }
477
478 impl<'a> AlloyLog<'a> {
479 pub fn new(theme: &'a Theme, entries: &'a [LogEntry]) -> Self {
480 Self { theme, entries }
481 }
482 }
483
484 impl Widget for AlloyLog<'_> {
485 fn render(self, area: Rect, buf: &mut Buffer) {
486 if area.height == 0 || area.width == 0 {
487 return;
488 }
489
490 let block = AlloyBlock::new(self.theme).build().title(" commands ");
491 let inner = block.inner(area);
492 block.render(area, buf);
493
494 if inner.height == 0 {
495 return;
496 }
497
498 let visible = inner.height as usize;
499 let tail = self.entries.len().saturating_sub(visible);
500 let lines: Vec<Line> = self.entries[tail..]
501 .iter()
502 .map(|entry| {
503 Line::from(vec![
504 Span::styled("$ ", entry.outcome.style(self.theme)),
505 text::secondary(self.theme, entry.command.clone()),
506 ])
507 })
508 .collect();
509
510 Paragraph::new(lines)
511 .style(Style::default().bg(self.theme.surface_page))
512 .render(inner, buf);
513 }
514 }
515
516 #[cfg(test)]
517 mod tests {
518 use super::*;
519 use ratatui::style::Color;
520
521 fn theme() -> Theme {
522 Theme {
523 mode: crate::theme::Mode::Dark,
524 surface_page: Color::Rgb(0, 0, 0),
525 surface_raised: Color::Rgb(1, 1, 1),
526 surface_sunken: Color::Rgb(2, 2, 2),
527 surface_overlay: Color::Rgb(3, 3, 3),
528 content_primary: Color::Rgb(4, 4, 4),
529 content_secondary: Color::Rgb(5, 5, 5),
530 content_muted: Color::Rgb(6, 6, 6),
531 action_primary: Color::Rgb(7, 7, 7),
532 status_danger: Color::Rgb(8, 8, 8),
533 status_success: Color::Rgb(9, 9, 9),
534 status_warning: Color::Rgb(10, 10, 10),
535 status_info: Color::Rgb(11, 11, 11),
536 line_border: Color::Rgb(12, 12, 12),
537 border_subtle: Color::Rgb(13, 13, 13),
538 border_strong: Color::Rgb(14, 14, 14),
539 category: [Color::Rgb(15, 15, 15); 6],
540 }
541 }
542
543 fn list_of(n: usize, selected: Option<usize>) -> AlloyList<'static> {
544 // Leaked so the test list can hold a 'static theme reference; the
545 // widget borrows rather than owns, and these are per-test one-offs.
546 let theme: &'static Theme = Box::leak(Box::new(theme()));
547 let items: Vec<Line<'static>> = (0..n).map(|i| Line::from(format!("row {i}"))).collect();
548 AlloyList::new(theme, items).selected(selected)
549 }
550
551 #[test]
552 fn short_list_never_scrolls() {
553 assert_eq!(list_of(3, Some(2)).offset(10), 0);
554 }
555
556 // Selection near the top must not scroll past the start of the list — a
557 // naive `selected - height/2` underflows or shows blank rows above row 0.
558 #[test]
559 fn offset_clamps_at_the_top() {
560 assert_eq!(list_of(50, Some(0)).offset(10), 0);
561 assert_eq!(list_of(50, Some(2)).offset(10), 0);
562 }
563
564 // Selection at the end must land the last row on the last visible line,
565 // not scroll into empty space past the end of the list.
566 #[test]
567 fn offset_clamps_at_the_bottom() {
568 assert_eq!(list_of(50, Some(49)).offset(10), 40);
569 }
570
571 #[test]
572 fn offset_centers_a_midlist_selection() {
573 assert_eq!(list_of(50, Some(25)).offset(10), 20);
574 }
575
576 #[test]
577 fn row_y_maps_visible_items_to_screen_rows() {
578 let area = Rect::new(0, 5, 20, 10);
579 assert_eq!(list_row_y(area, 3, Some(0), 0), Some(5));
580 assert_eq!(list_row_y(area, 3, Some(0), 2), Some(7));
581 }
582
583 // After a scroll the mapping has to follow the offset. A connector using a
584 // separate copy of the scroll rule is exactly what this prevents.
585 #[test]
586 fn row_y_accounts_for_scrolling() {
587 let area = Rect::new(0, 0, 20, 10);
588 // 50 items, selection at 25 => offset 20, so item 20 is the top row.
589 assert_eq!(list_row_y(area, 50, Some(25), 20), Some(0));
590 assert_eq!(list_row_y(area, 50, Some(25), 25), Some(5));
591 }
592
593 #[test]
594 fn row_y_is_none_for_rows_scrolled_out_of_view() {
595 let area = Rect::new(0, 0, 20, 10);
596 assert_eq!(list_row_y(area, 50, Some(25), 0), None, "above the viewport");
597 assert_eq!(list_row_y(area, 50, Some(25), 49), None, "below the viewport");
598 assert_eq!(list_row_y(area, 3, Some(0), 9), None, "past the end of the list");
599 }
600
601 fn render_tabs(selected: usize, width: u16) -> String {
602 let theme = theme();
603 let mut buf = Buffer::empty(Rect::new(0, 0, width, 1));
604 AlloyTabs::new(&theme, ["installed", "boxes", "system"])
605 .selected(selected)
606 .render(Rect::new(0, 0, width, 1), &mut buf);
607 buf.content().iter().map(|cell| cell.symbol()).collect()
608 }
609
610 #[test]
611 fn selected_tab_is_bracketed_and_others_are_not() {
612 let rendered = render_tabs(0, 60);
613 assert!(rendered.contains("[ installed ]"), "selected tab is bracketed");
614 assert!(!rendered.contains("[ boxes ]"), "unselected tabs are not");
615 assert!(rendered.contains("boxes"), "unselected labels still render");
616 }
617
618 // The bar must not shift horizontally as selection moves, or every tab
619 // change reads as the whole row twitching. Unselected labels pad to the
620 // bracket width for exactly this reason.
621 #[test]
622 fn labels_hold_their_columns_across_selections() {
623 let first = render_tabs(0, 60);
624 let last = render_tabs(2, 60);
625 assert_eq!(
626 first.find("system"),
627 last.find("system"),
628 "a label sits in the same columns whichever tab is selected"
629 );
630 }
631
632 // FocusRing::focus ignores out-of-range slots rather than clamping, and the
633 // bar has to agree: showing a neighbouring tab as current would misreport
634 // which screen the user is looking at.
635 #[test]
636 fn out_of_range_selection_brackets_nothing() {
637 let rendered = render_tabs(9, 60);
638 assert!(!rendered.contains('['), "no tab is marked current");
639 assert!(rendered.contains("installed"), "labels still render");
640 }
641
642 #[test]
643 fn zero_height_area_renders_nothing_rather_than_panicking() {
644 let theme = theme();
645 let mut buf = Buffer::empty(Rect::new(0, 0, 40, 1));
646 AlloyTabs::new(&theme, ["installed"]).render(Rect::new(0, 0, 40, 0), &mut buf);
647 AlloyTabs::new(&theme, ["installed"]).render(Rect::new(0, 0, 0, 1), &mut buf);
648 }
649
650 fn render_modal(area: Rect) -> Vec<String> {
651 let theme = theme();
652 let mut buf = Buffer::empty(area);
653 AlloyModal::new(&theme, "remove", "Remove tailscale?").render(area, &mut buf);
654 (0..area.height)
655 .map(|y| {
656 (0..area.width)
657 .map(|x| buf[(x, y)].symbol())
658 .collect::<String>()
659 })
660 .collect()
661 }
662
663 #[test]
664 fn modal_shows_its_message_and_both_keys() {
665 let rows = render_modal(Rect::new(0, 0, 40, 7)).join("\n");
666 assert!(rows.contains("Remove tailscale?"), "message renders");
667 assert!(rows.contains("remove"), "title renders");
668 assert!(rows.contains("Enter"), "confirm key renders");
669 assert!(rows.contains("Esc"), "cancel key renders");
670 }
671
672 // The keys are pinned to the last inner row rather than flowing after the
673 // message. A prompt whose dismiss keys move with message length, or fall
674 // off a short box, is a modal the user cannot get out of.
675 #[test]
676 fn keys_sit_on_the_last_row_whatever_the_message_length() {
677 for height in [5, 7, 12] {
678 let rows = render_modal(Rect::new(0, 0, 40, height));
679 let last_inner = &rows[height as usize - 2];
680 assert!(
681 last_inner.contains("Enter") && last_inner.contains("Esc"),
682 "height {height}: keys belong on the last inner row, got {last_inner:?}"
683 );
684 }
685 }
686
687 #[test]
688 fn modal_survives_an_area_too_small_to_draw_in() {
689 let theme = theme();
690 let mut buf = Buffer::empty(Rect::new(0, 0, 40, 7));
691 AlloyModal::new(&theme, "t", "m").render(Rect::new(0, 0, 40, 0), &mut buf);
692 AlloyModal::new(&theme, "t", "m").render(Rect::new(0, 0, 0, 7), &mut buf);
693 AlloyModal::new(&theme, "t", "m").render(Rect::new(0, 0, 2, 2), &mut buf);
694 }
695
696 // A log longer than its pane shows the newest entries. Showing the head
697 // instead would freeze the pane on startup noise and never display the
698 // command the user just triggered.
699 #[test]
700 fn log_renders_the_newest_entries() {
701 let theme = theme();
702 let entries: Vec<LogEntry> = (0..10)
703 .map(|i| LogEntry::new(format!("nmcli run {i}"), Severity::Healthy))
704 .collect();
705 let mut buf = Buffer::empty(Rect::new(0, 0, 40, 4));
706 AlloyLog::new(&theme, &entries).render(Rect::new(0, 0, 40, 4), &mut buf);
707
708 let rendered = buf
709 .content()
710 .iter()
711 .map(|cell| cell.symbol())
712 .collect::<String>();
713 assert!(rendered.contains("nmcli run 9"), "newest entry must be visible");
714 assert!(!rendered.contains("nmcli run 0"), "oldest entry must have scrolled off");
715 }
716 }
717