Skip to main content

purple_ssh/ui/
design.rs

1//! Design system tokens and reusable component builders.
2//!
3//! This module centralizes spacing, overlay sizing, toast, timeout, icon and
4//! list rendering constants that are shared across UI modules. It also exposes
5//! block component builders, layout helpers, a `Footer` builder and a small
6//! set of render helpers so individual screens can stay short and consistent.
7//!
8//! The goal is to keep design intent in one place and have screens reference
9//! these helpers instead of duplicating border, title or footer wiring.
10//!
11use ratatui::Frame;
12use ratatui::layout::Rect;
13use ratatui::text::{Line, Span};
14use ratatui::widgets::{Block, BorderType, Borders, Clear, Paragraph};
15
16use super::theme;
17use crate::app::App;
18
19// ---------------------------------------------------------------------------
20// Spacing tokens
21// ---------------------------------------------------------------------------
22
23/// Two-space gap used between footer action entries.
24pub const FOOTER_GAP: &str = "  ";
25/// Gap between columns in list rows.
26pub const COL_GAP: u16 = 2;
27
28/// Lowercase "purple." wordmark in Unicode box-drawing, 5 rows × 20 cols.
29/// Trailing `▪` on row 3 renders in `theme::logo_dot` (cyan).
30pub const LOGO: [&str; 5] = [
31    "             ╮      ",
32    "╭─╮╷ ╷╭─ ╭─╮ │ ╭─╮  ",
33    "│ ││ ││  │ │ │ ├─╯  ",
34    "├─╯╰─╯╵  ├─╯╶┴╴╰─╴ ▪",
35    "╵        ╵          ",
36];
37
38/// Column range of the trailing dot glyph. `logo_line` slices on this
39/// range to recolour the dot independently of the word body.
40pub const LOGO_DOT_COL_START: usize = 19;
41pub const LOGO_DOT_COL_END: usize = 20;
42
43/// Build logo row `i` as three spans (word / dot / padding) so callers
44/// can keep their existing alignment logic.
45pub fn logo_line(
46    i: usize,
47    word_style: ratatui::style::Style,
48    dot_style: ratatui::style::Style,
49) -> ratatui::text::Line<'static> {
50    use ratatui::text::Span;
51    let chars: Vec<char> = LOGO[i].chars().collect();
52    let before: String = chars
53        .get(..LOGO_DOT_COL_START)
54        .unwrap_or(&[])
55        .iter()
56        .collect();
57    let dot: String = chars
58        .get(LOGO_DOT_COL_START..LOGO_DOT_COL_END.min(chars.len()))
59        .unwrap_or(&[])
60        .iter()
61        .collect();
62    let after: String = chars
63        .get(LOGO_DOT_COL_END..)
64        .unwrap_or(&[])
65        .iter()
66        .collect();
67    ratatui::text::Line::from(vec![
68        Span::styled(before, word_style),
69        Span::styled(dot, dot_style),
70        Span::styled(after, word_style),
71    ])
72}
73
74// ---------------------------------------------------------------------------
75// Overlay sizing tokens
76// ---------------------------------------------------------------------------
77
78/// Default overlay width percentage.
79pub const OVERLAY_W: u16 = 70;
80/// Default overlay height percentage.
81pub const OVERLAY_H: u16 = 80;
82/// Minimum width for picker overlays. All pickers (Password Source,
83/// Select Key, Vault SSH Role, ProxyJump, tag picker, theme picker, etc.)
84/// share this single sizing range so they look identical regardless of
85/// which form field opened them.
86pub const PICKER_MIN_W: u16 = 60;
87/// Maximum width for picker overlays.
88pub const PICKER_MAX_W: u16 = 72;
89/// Maximum height (incl. borders) for picker overlays. Pickers grow with
90/// item count up to this cap, then scroll.
91pub const PICKER_MAX_H: u16 = 18;
92
93// ---------------------------------------------------------------------------
94// Toast tokens
95// ---------------------------------------------------------------------------
96
97/// Toast horizontal inset from the right edge.
98pub const TOAST_INSET_X: u16 = 2;
99/// Toast vertical inset from the bottom edge.
100pub const TOAST_INSET_Y: u16 = 2;
101
102// ---------------------------------------------------------------------------
103// Timeout tokens (millisecond-based, tick-rate-independent)
104// ---------------------------------------------------------------------------
105
106/// Minimum milliseconds before a Success or Info message clears (2.5s).
107/// Effective timeout is `max(TIMEOUT_MIN_MS, words * MS_PER_WORD)`.
108pub const TIMEOUT_MIN_MS: u64 = 2500;
109/// Minimum milliseconds before a Warning message clears (4s).
110pub const TIMEOUT_MIN_WARNING_MS: u64 = 4000;
111/// Per-word reading-time budget in milliseconds (750ms/word, matching
112/// peripheral reading speed for short status strings competing with the
113/// primary task).
114pub const MS_PER_WORD: u64 = 750;
115/// Cap on word count for length-proportional timeout. 30 words at
116/// 750ms/word = 22.5s maximum for any non-sticky toast.
117pub const WORD_CAP: usize = 30;
118/// Maximum number of queued toast messages. Three matches Linear/Stripe
119/// toast stack patterns; more than 3 stacked toasts is itself a UX signal
120/// of a system problem and dropping older ones is preferable to clutter.
121pub const TOAST_QUEUE_MAX: usize = 3;
122
123// ---------------------------------------------------------------------------
124// Status indicator tokens
125// ---------------------------------------------------------------------------
126
127/// Online status glyph (U+25CF, filled circle).
128pub const ICON_ONLINE: &str = "\u{25CF}";
129/// Success glyph (U+2713, check mark). Also used as the toast success glyph.
130pub const ICON_SUCCESS: &str = "\u{2713}";
131/// Warning glyph (U+26A0, warning sign). Also used as the toast warning glyph.
132pub const ICON_WARNING: &str = "\u{26A0}";
133/// Error glyph (U+2716, heavy multiplication X). Distinct from the
134/// warning sign so the user can tell at a glance whether something is
135/// recoverable (warning) or has gone wrong (error).
136pub const ICON_ERROR: &str = "\u{2716}";
137/// Paused / restarting container glyph (U+25D0, left half-filled circle).
138/// Used for transitional states where the container is neither cleanly
139/// running nor cleanly stopped.
140pub const ICON_PAUSED: &str = "\u{25D0}";
141/// Stopped / inactive container glyph (U+25CB, empty circle). Used for
142/// "exited cleanly" and for unknown / not-yet-seen states.
143pub const ICON_STOPPED: &str = "\u{25CB}";
144/// Slow ping glyph (U+25B2, up-pointing triangle). Used in the Linked
145/// Hosts list to mark hosts that responded but exceeded the slow-ping
146/// threshold.
147pub const ICON_SLOW: &str = "\u{25B2}";
148/// Pending / unknown status glyph (U+00B7, middle dot). Used for
149/// "checking now" and "not yet probed" rows where neither online nor
150/// offline applies yet.
151pub const ICON_PENDING: &str = "\u{00B7}";
152/// Target / destination glyph (U+25C9, fisheye). Marks the final hop in a
153/// ProxyJump ladder and the container/host that is currently selected as
154/// the navigation target in a tree view.
155pub const ICON_TARGET: &str = "\u{25C9}";
156
157// ---------------------------------------------------------------------------
158// Route / tree glyphs
159// ---------------------------------------------------------------------------
160
161/// Dotted vertical glyph (U+250A) for the connecting line between hops in a
162/// ProxyJump ladder rendered in detail panels.
163pub const ROUTE_BRANCH: &str = "\u{250A}";
164
165// ---------------------------------------------------------------------------
166// Container state mapping (single source of truth)
167// ---------------------------------------------------------------------------
168
169/// True when the container `state` field reports a running container.
170/// Single source of truth shared by detail panels, overlays and the
171/// containers overview so all surfaces classify the same state identically.
172pub fn is_container_running(state: &str) -> bool {
173    state.eq_ignore_ascii_case("running")
174}
175
176/// Extract the integer in `Exited (N)` from a docker `Status` string.
177/// Returns `None` when the prefix is absent or the captured slice does not
178/// parse as an integer. Podman emits an empty status; callers should fall
179/// back to inspect-cache exit code in that case.
180pub fn parse_container_exit_code(status: &str) -> Option<i32> {
181    let prefix = "Exited (";
182    let start = status.find(prefix)?;
183    let after = &status[start + prefix.len()..];
184    let end = after.find(')')?;
185    after[..end].parse().ok()
186}
187
188/// Canonical mapping from container state to (icon, style).
189///
190/// Every consumer of container state (host detail panel, per-host overlay,
191/// containers overview tab) must route through this helper so a single
192/// container shows the same glyph and colour everywhere. Pre-rules audit
193/// found 3 distinct ad-hoc mappings producing divergent visuals across the
194/// same container; this helper closes that gap.
195///
196/// Arguments:
197/// - `state`: docker/podman `state` field (`running`, `exited`, `dead`, etc.)
198/// - `health`: docker health (`healthy`, `unhealthy`, `starting`) when known
199/// - `status`: docker `Status` string (e.g. `Exited (137) 2 minutes ago`)
200/// - `inspect_exit_code`: fallback exit code from inspect cache (podman path)
201/// - `spinner_tick`: spinner counter for the pulsing running-dot
202///
203/// Pass `None`/empty/0 for fields the caller does not have; the helper
204/// degrades gracefully (e.g. running container without spinner_tick still
205/// renders, just without pulsing animation contribution).
206pub fn container_state_style(
207    state: &str,
208    health: Option<&str>,
209    status: &str,
210    inspect_exit_code: Option<i32>,
211    spinner_tick: u64,
212) -> (&'static str, ratatui::style::Style) {
213    if is_container_running(state) {
214        return match health {
215            Some("unhealthy") => (ICON_ONLINE, theme::error()),
216            Some("starting") => (ICON_ONLINE, theme::warning()),
217            _ => (ICON_ONLINE, theme::online_dot_pulsing(spinner_tick)),
218        };
219    }
220    match state {
221        "dead" => (ICON_ERROR, theme::error()),
222        "exited" | "stopped" => {
223            let exit_code = parse_container_exit_code(status).or(inspect_exit_code);
224            match exit_code {
225                Some(code) if code != 0 => (ICON_ERROR, theme::warning()),
226                _ => (ICON_STOPPED, theme::muted()),
227            }
228        }
229        "paused" | "restarting" => (ICON_PAUSED, theme::warning()),
230        _ => (ICON_STOPPED, theme::muted()),
231    }
232}
233
234// ---------------------------------------------------------------------------
235// List rendering tokens
236// ---------------------------------------------------------------------------
237
238/// Default list-row highlight prefix (two spaces).
239pub const LIST_HIGHLIGHT: &str = "  ";
240/// Host list highlight prefix (U+258C, left half block).
241pub const HOST_HIGHLIGHT: &str = "\u{258C}";
242
243// ---------------------------------------------------------------------------
244// Detail panel tokens
245// ---------------------------------------------------------------------------
246
247/// Detail panel section label column width.
248pub const SECTION_LABEL_W: u16 = 14;
249
250// ---------------------------------------------------------------------------
251// Dim background tokens
252// ---------------------------------------------------------------------------
253
254/// RGB triple used for dim-background text.
255pub const DIM_FG_RGB: (u8, u8, u8) = (70, 70, 70);
256
257// ---------------------------------------------------------------------------
258// Block component builders
259// ---------------------------------------------------------------------------
260
261/// Standard overlay block: rounded border, brand title, accent border.
262pub fn overlay_block(title: &str) -> Block<'static> {
263    overlay_block_line(Line::from(Span::styled(
264        format!(" {title} "),
265        theme::brand(),
266    )))
267}
268
269/// Overlay block variant accepting a pre-built compound title `Line`.
270/// Use when the caller needs multi-span titles that `overlay_block(&str)`
271/// cannot express. Border style, border type and borders match `overlay_block`.
272pub fn overlay_block_line(title: Line<'static>) -> Block<'static> {
273    Block::default()
274        .borders(Borders::ALL)
275        .border_type(BorderType::Rounded)
276        .border_style(theme::border_dim())
277        .title(title)
278}
279
280/// Overlay block with the search-active purple border. Mirrors
281/// `search_block_line` for overlays — use on overlays whose body
282/// hosts a `/` search so the border switches to purple while the
283/// search is open (same affordance as the host list border switch).
284pub fn search_overlay_block_line(title: Line<'static>) -> Block<'static> {
285    Block::default()
286        .borders(Borders::ALL)
287        .border_type(BorderType::Rounded)
288        .border_style(theme::border_search())
289        .title(title)
290}
291
292/// Plain overlay block: rounded border, dim border, NO title. Use for
293/// unique dialogs (e.g. welcome screen) where the block carries no title
294/// and the content itself supplies visual hierarchy.
295pub fn plain_overlay_block() -> Block<'static> {
296    Block::default()
297        .borders(Borders::ALL)
298        .border_type(BorderType::Rounded)
299        .border_style(theme::border_dim())
300}
301
302/// Danger overlay block: rounded border, danger title, danger border.
303/// Use for destructive confirmations (delete, purge).
304pub fn danger_block(title: &str) -> Block<'static> {
305    danger_block_line(Line::from(Span::styled(
306        format!(" {title} "),
307        theme::danger(),
308    )))
309}
310
311/// Danger block variant accepting a pre-built compound title `Line`.
312pub fn danger_block_line(title: Line<'static>) -> Block<'static> {
313    Block::default()
314        .borders(Borders::ALL)
315        .border_type(BorderType::Rounded)
316        .border_style(theme::border_danger())
317        .title(title)
318}
319
320/// Main block accepting a pre-built compound title `Line`.
321/// All main-screen blocks (host list, top navigation bar) compose their
322/// title spans manually and pass them in here, so a string-only convenience
323/// constructor is intentionally absent.
324pub fn main_block_line(title: Line<'static>) -> Block<'static> {
325    Block::default()
326        .borders(Borders::ALL)
327        .border_type(BorderType::Rounded)
328        .border_style(theme::border())
329        .title(title)
330}
331
332/// Search-active block accepting a pre-built compound title `Line`.
333/// Mirrors `main_block_line` but with the search border style.
334pub fn search_block_line(title: Line<'static>) -> Block<'static> {
335    Block::default()
336        .borders(Borders::ALL)
337        .border_type(BorderType::Rounded)
338        .border_style(theme::border_search())
339        .title(title)
340}
341
342// ---------------------------------------------------------------------------
343// Layout helpers
344// ---------------------------------------------------------------------------
345
346/// Overlay area: percentage width with a fixed height clamped to terminal.
347pub fn overlay_area(frame: &Frame, w_pct: u16, h_pct: u16, height: u16) -> Rect {
348    let area = frame.area();
349    // Start from a percentage-based rectangle, then clamp the vertical extent
350    // to the caller-requested height so narrow terminals still show a usable
351    // overlay without stretching vertically.
352    let pct_area = super::centered_rect(w_pct, h_pct, area);
353    super::centered_rect_fixed(pct_area.width, height.min(pct_area.height), area)
354}
355
356/// Form footer positioned directly below the block border.
357///
358/// All overlays use this — there is no longer an "inside the block + spacer"
359/// alternative. Form screens, list/picker overlays and detail overlays
360/// alike render their action footer at this fixed external position so the
361/// keycaps strip lines up consistently across every screen.
362///
363/// **Note:** Prefer `render_overlay_footer` over this helper. `form_footer`
364/// only computes the Rect; `render_overlay_footer` also renders a `Clear`
365/// widget over the footer row so it does not show through to the screen
366/// behind the overlay (e.g. the host list when a picker is open).
367pub fn form_footer(block_area: Rect, block_height: u16) -> Rect {
368    Rect::new(
369        block_area.x,
370        block_area.y + block_height,
371        block_area.width,
372        1,
373    )
374}
375
376/// Compute the external footer Rect for an overlay block, render `Clear`
377/// over it so the row underneath the overlay does not bleed through, and
378/// return the footer Rect for the caller to render the footer spans into.
379pub fn render_overlay_footer(frame: &mut Frame, block_area: Rect) -> Rect {
380    let footer_area = form_footer(block_area, block_area.height);
381    frame.render_widget(Clear, footer_area);
382    footer_area
383}
384
385/// Form divider Y position for the given index.
386pub fn form_divider_y(inner: Rect, index: usize) -> u16 {
387    inner.y + (index as u16) * 2
388}
389
390/// Picker overlay width clamped to `[PICKER_MIN_W, PICKER_MAX_W]`.
391///
392/// Canonical formula used by all picker overlays (ProxyJump, Vault role,
393/// Password source). `super::picker_overlay_width` delegates here.
394pub fn picker_width(frame: &Frame) -> u16 {
395    frame.area().width.clamp(PICKER_MIN_W, PICKER_MAX_W)
396}
397
398// ---------------------------------------------------------------------------
399// Footer builder
400// ---------------------------------------------------------------------------
401
402/// Builder for action footers. Inserts `FOOTER_GAP` between entries only.
403pub struct Footer {
404    spans: Vec<Span<'static>>,
405}
406
407impl Footer {
408    /// Create an empty footer.
409    pub fn new() -> Self {
410        Self { spans: Vec::new() }
411    }
412
413    /// Add a primary action (semantic marker for the default action).
414    #[allow(deprecated)]
415    pub fn primary(mut self, key: &str, label: &str) -> Self {
416        if !self.spans.is_empty() {
417            self.spans.push(Span::raw(FOOTER_GAP));
418        }
419        let [k, l] = super::footer_primary(key, label);
420        self.spans.push(k);
421        self.spans.push(l);
422        self
423    }
424
425    /// Add a secondary action.
426    pub fn action(mut self, key: &str, label: &str) -> Self {
427        if !self.spans.is_empty() {
428            self.spans.push(Span::raw(FOOTER_GAP));
429        }
430        let [k, l] = super::footer_action(key, label);
431        self.spans.push(k);
432        self.spans.push(l);
433        self
434    }
435
436    /// Render in an overlay footer (status right-aligned if present).
437    pub fn render_with_status(self, frame: &mut Frame, area: Rect, app: &App) {
438        super::render_footer_with_status(frame, area, self.spans, app);
439    }
440
441    /// Convert the accumulated spans into a single `Line`.
442    #[allow(clippy::wrong_self_convention)]
443    pub fn to_line(self) -> Line<'static> {
444        Line::from(self.spans)
445    }
446
447    /// Raw spans for screens with custom footer rendering.
448    pub fn into_spans(self) -> Vec<Span<'static>> {
449        self.spans
450    }
451}
452
453impl Default for Footer {
454    fn default() -> Self {
455        Self::new()
456    }
457}
458
459// ---------------------------------------------------------------------------
460// Render helpers
461// ---------------------------------------------------------------------------
462
463/// 2-space-indented muted line. Single source of truth for the
464/// indent + muted style pattern shared by `render_empty`, `render_loading`
465/// and `empty_line`.
466fn muted_line(message: &str) -> Line<'static> {
467    Line::from(vec![
468        Span::raw("  "),
469        Span::styled(message.to_string(), theme::muted()),
470    ])
471}
472
473/// Render a 2-space-indented message with the muted style.
474fn render_muted_message(frame: &mut Frame, area: Rect, message: &str) {
475    frame.render_widget(Paragraph::new(muted_line(message)), area);
476}
477
478/// Render an empty-state message with 2-space indent and muted style.
479pub fn render_empty(frame: &mut Frame, area: Rect, message: &str) {
480    render_muted_message(frame, area, message);
481}
482
483/// Render a loading message with 2-space indent and muted style.
484pub fn render_loading(frame: &mut Frame, area: Rect, message: &str) {
485    render_muted_message(frame, area, message);
486}
487
488/// Render an error message with 2-space indent and error style.
489pub fn render_error(frame: &mut Frame, area: Rect, message: &str) {
490    let line = Line::from(vec![
491        Span::raw("  "),
492        Span::styled(message.to_string(), theme::error()),
493    ]);
494    frame.render_widget(Paragraph::new(line), area);
495}
496
497/// Inline section divider below section headers.
498/// Renders as indented dashes in muted style.
499pub fn section_divider() -> Line<'static> {
500    Line::from(Span::styled("  ────────────────────────", theme::muted()))
501}
502
503// ---------------------------------------------------------------------------
504// Content-level helpers
505// ---------------------------------------------------------------------------
506
507/// Column-width padding formula (usize variant for list screens).
508pub fn padded_usize(w: usize) -> usize {
509    if w == 0 { 0 } else { w + w / 10 + 1 }
510}
511
512/// 3-space prefix for column headers (aligns with highlight_symbol + leading space).
513pub const COLUMN_HEADER_PREFIX: &str = "   ";
514
515/// Inter-column gap as string.
516pub const COL_GAP_STR: &str = "  ";
517
518/// Key-value line: muted label (left-padded to width) + bold value.
519pub fn kv_line(label: &str, value: &str, label_width: usize) -> Line<'static> {
520    Line::from(vec![
521        Span::styled(
522            format!("  {:<width$}", label, width = label_width),
523            theme::muted(),
524        ),
525        Span::styled(value.to_string(), theme::bold()),
526    ])
527}
528
529/// Key-value label width for overlay detail screens (host_detail, key_detail).
530pub const KV_LABEL_WIDE: usize = 22;
531
532/// Content section header + divider pair.
533pub fn content_section(label: &str) -> [Line<'static>; 2] {
534    [
535        Line::from(vec![
536            Span::raw("  "),
537            Span::styled(label.to_string(), theme::section_header()),
538        ]),
539        section_divider(),
540    ]
541}
542
543/// Empty state with action hint: `"  message  \[key\]  action"`
544pub fn render_empty_with_hint(
545    frame: &mut Frame,
546    area: Rect,
547    message: &str,
548    key: &str,
549    action: &str,
550) {
551    let line = Line::from(vec![
552        Span::raw("  "),
553        Span::styled(message.to_string(), theme::muted()),
554        Span::raw("  "),
555        Span::styled(format!(" {} ", key), theme::footer_key()),
556        Span::styled(format!(" {}", action), theme::muted()),
557    ]);
558    frame.render_widget(Paragraph::new(line), area);
559}
560
561/// Body-content area inside an overlay block. Inset 1 char left so
562/// paragraphs align with field-content rows (which sit at `inner.x + 1`)
563/// and the divider labels (which start with a leading space). Use this
564/// instead of `Rect::new(inner.x, ...)` for any prose inside an overlay.
565///
566/// Currently no overlay renders body prose alongside form dividers — the
567/// label-migration form proved the field labels are enough by themselves —
568/// but keep this helper available so the next screen that needs body text
569/// gets the inset for free.
570#[allow(dead_code)]
571pub fn body_text_area(inner: Rect, y: u16, height: u16) -> Rect {
572    Rect::new(
573        inner.x.saturating_add(1),
574        y,
575        inner.width.saturating_sub(1),
576        height,
577    )
578}
579
580// ---------------------------------------------------------------------------
581// Body breathing room (prevents text from touching the inner-right border)
582// ---------------------------------------------------------------------------
583
584/// Right-side breathing room inside any block that hosts text content.
585/// Without this margin `ratatui` writes the last glyph flush against
586/// the right `│`, which reads as a layout bug even when the text fits.
587/// Left-side padding is provided by the existing per-line convention
588/// (lines start with `Span::raw("  ")` or `format!("  ...")`); this
589/// helper only fixes the asymmetric right edge.
590pub const BODY_RIGHT_PAD: u16 = 2;
591
592/// Safe content area inside an overlay block. Insets `block_area` by the
593/// 1-char block border on every side plus `BODY_RIGHT_PAD` on the right
594/// only, so wrapped paragraphs and ASCII art rendered into the returned
595/// `Rect` never touch the inner-right border.
596///
597/// Use whenever you would otherwise pass `Block::inner(area)` to
598/// `frame.render_widget(Paragraph::new(...), inner)`. The
599/// [`render_body`] and [`render_body_wrapped`] helpers do this for you;
600/// reach for `body_area` directly only when the caller needs the rect
601/// for custom rendering (e.g. List + Paragraph in the same block).
602pub fn body_area(block_area: Rect) -> Rect {
603    let inner_x = block_area.x.saturating_add(1);
604    let inner_y = block_area.y.saturating_add(1);
605    let inner_w = block_area.width.saturating_sub(2);
606    let inner_h = block_area.height.saturating_sub(2);
607    let pad_x = BODY_RIGHT_PAD.min(inner_w);
608    Rect::new(inner_x, inner_y, inner_w.saturating_sub(pad_x), inner_h)
609}
610
611/// Render the block, then render `lines` into [`body_area`] of that
612/// block. Lines render verbatim (no wrap) so long text is hard-truncated
613/// by ratatui. Use for dialogs whose content is composed of pre-formatted
614/// single-line rows (key-value lists, identity rows, glyph rows).
615///
616/// Caller is responsible for keeping individual lines within
617/// `body_area(block_area).width`; reach for [`render_body_wrapped`] when
618/// long prose may overflow and should wrap, or [`ellipsize`] when a
619/// single value (alias, image, path) must clip with `…`.
620#[allow(dead_code)]
621pub fn render_body<'a>(
622    frame: &mut Frame,
623    block_area: Rect,
624    block: Block<'a>,
625    lines: Vec<Line<'a>>,
626) {
627    frame.render_widget(block, block_area);
628    frame.render_widget(Paragraph::new(lines), body_area(block_area));
629}
630
631/// Render the block, then render `lines` into [`body_area`] of that
632/// block with word-wrapping enabled. Long prose wraps to additional
633/// rows.
634///
635/// Hanging indent: when a single-span input line starts with ASCII
636/// spaces, those spaces are reused as the indent on every continuation
637/// row produced by the wrap. Ratatui's `Wrap { trim: false }` only
638/// preserves trailing whitespace on wrapped rows, not leading indent
639/// on continuations, so we pre-wrap via [`wrap_indented`] instead. The
640/// difference is visible whenever a confirm dialog's body sentence is
641/// long enough to wrap (e.g. provider remove labelled detail) and the
642/// second row would otherwise collapse to column 0.
643///
644/// Use for confirm dialogs, help body text, and any block whose content
645/// includes full sentences. The 2-char right margin guarantees wrapped
646/// continuation lines never touch the inner border.
647pub fn render_body_wrapped<'a>(
648    frame: &mut Frame,
649    block_area: Rect,
650    block: Block<'a>,
651    lines: Vec<Line<'a>>,
652) {
653    use ratatui::widgets::Wrap;
654    frame.render_widget(block, block_area);
655    let body = body_area(block_area);
656    let max_w = body.width as usize;
657    let out = wrap_block_lines(lines, max_w);
658    frame.render_widget(Paragraph::new(out).wrap(Wrap { trim: false }), body);
659}
660
661/// Pre-wrap dialog body lines for a fixed inner width, preserving the
662/// hanging indent of every continuation row.
663///
664/// The function recognises three input shapes:
665/// - `Line::from(Span::styled("  text", style))` (single span with
666///   leading whitespace). Wrapped with the leading spaces as hanging
667///   indent on every continuation row.
668/// - `Line::from(vec![Span::raw("  "), Span::styled(text, style)])`
669///   (whitespace-only prefix spans followed by exactly one styled
670///   body span). Same hanging-indent treatment.
671/// - Any other line shape (blank, aligned, or composite multi-span).
672///   Emitted verbatim, with `Span` content moved to `'static`.
673///
674/// Lines that already fit in `max_w` are emitted verbatim so trailing
675/// spaces used for manual centering (welcome banners) survive. Lines
676/// with an explicit `alignment` bypass indent detection entirely so the
677/// caller's `Alignment::Center` / `Alignment::Right` keeps working.
678pub fn wrap_block_lines<'a>(lines: Vec<Line<'a>>, max_w: usize) -> Vec<Line<'static>> {
679    use unicode_width::UnicodeWidthStr;
680    let mut out: Vec<Line<'static>> = Vec::with_capacity(lines.len());
681    for line in lines {
682        // Lines with an explicit alignment (Center/Right for banners,
683        // logo rows, typewriter subtitles) bypass indent detection so
684        // the caller's alignment keeps applying.
685        if line.alignment.is_some() {
686            let alignment = line.alignment;
687            let owned: Vec<Span<'static>> = line
688                .spans
689                .into_iter()
690                .map(|s| Span::styled(s.content.into_owned(), s.style))
691                .collect();
692            let mut new_line = Line::from(owned);
693            if let Some(a) = alignment {
694                new_line = new_line.alignment(a);
695            }
696            out.push(new_line);
697            continue;
698        }
699
700        // Detect "whitespace-only prefix spans + one styled body span".
701        let mut indent_w = 0usize;
702        let mut body_span: Option<Span<'a>> = None;
703        let mut leading_only = true;
704        let total_spans = line.spans.len();
705        for (i, span) in line.spans.iter().enumerate() {
706            let content: &str = span.content.as_ref();
707            if content.chars().all(|c| c == ' ') {
708                indent_w += content.len();
709                continue;
710            }
711            if i == total_spans - 1 {
712                body_span = Some(span.clone());
713            } else {
714                leading_only = false;
715            }
716            break;
717        }
718
719        if leading_only {
720            if let Some(span) = body_span {
721                let content = span.content.into_owned();
722                let trimmed = content.trim_start_matches(' ');
723                let extra_indent = content.len() - trimmed.len();
724                let total_indent = indent_w + extra_indent;
725                let full_width = indent_w + content.width();
726                let needs_wrap = full_width > max_w;
727                if total_indent > 0 && !trimmed.is_empty() && needs_wrap {
728                    let indent = " ".repeat(total_indent);
729                    let body_text = trimmed.to_string();
730                    for wrapped in wrap_indented(&body_text, &indent, max_w) {
731                        out.push(Line::from(Span::styled(wrapped, span.style)));
732                    }
733                    continue;
734                }
735                let mut spans: Vec<Span<'static>> = Vec::new();
736                if indent_w > 0 {
737                    spans.push(Span::raw(" ".repeat(indent_w)));
738                }
739                spans.push(Span::styled(content, span.style));
740                out.push(Line::from(spans));
741                continue;
742            }
743            out.push(Line::from(""));
744            continue;
745        }
746
747        // Composite multi-span line (header rows, glyph rows). Keep as-is;
748        // ratatui's Wrap { trim: false } below handles overflow without
749        // losing the span styles. Indent on continuation is best-effort.
750        let owned: Vec<Span<'static>> = line
751            .spans
752            .into_iter()
753            .map(|s| Span::styled(s.content.into_owned(), s.style))
754            .collect();
755        out.push(Line::from(owned));
756    }
757    out
758}
759
760// ---------------------------------------------------------------------------
761// Tab-level empty state (one card, no duplicate messages across panels)
762// ---------------------------------------------------------------------------
763
764/// Copy bundle for a tab's empty state. Each tab (Containers, Tunnels,
765/// Keys, and any future Hosts/empty surface) constructs one of these
766/// and hands it to [`render_tab_empty`], which composes the bordered
767/// card inside the existing outer block.
768///
769/// - `card_title` appears in the inner card's top border (e.g. "Containers").
770/// - `headline` is the one-line bold statement of what is missing.
771/// - `explainer` is a one or two sentence muted paragraph that names
772///   the cause (cache not yet populated, no key files, etc.). Wrap is
773///   handled internally; pass the unwrapped text.
774/// - `hints` is a list of `(key, action)` pairs rendered as keycap rows
775///   below the explainer. Empty slice renders no hints.
776pub struct TabEmpty<'a> {
777    pub card_title: &'a str,
778    pub headline: &'a str,
779    pub explainer: &'a str,
780    pub hints: &'a [(&'a str, &'a str)],
781}
782
783/// Render an inner empty-state card centred horizontally inside `area`.
784/// Caller is responsible for the outer block (e.g. the existing
785/// `main_block_line` with the row-count title) — that outer frame is
786/// preserved so the empty state reads as a state OF the tab, not as a
787/// replacement screen.
788///
789/// Width: clamped to `[40, 78]` columns so the card never hugs the
790/// outer border on a 200-col terminal and never overflows on a narrow
791/// one. When `area.width` is below 44 the card collapses to a single
792/// 2-space-indented line via [`render_empty_with_hint`] using the first
793/// hint as the affordance — graceful degradation without a new code
794/// path on the caller side.
795pub fn render_tab_empty(frame: &mut Frame, area: Rect, e: &TabEmpty) {
796    use unicode_width::UnicodeWidthStr;
797
798    // Graceful degradation on narrow terminals: drop the card chrome,
799    // render the headline as a single muted-with-hint line.
800    if area.width < 44 || area.height < 8 {
801        if let Some((key, action)) = e.hints.first() {
802            render_empty_with_hint(frame, area, e.headline, key, action);
803        } else {
804            render_empty(frame, area, e.headline);
805        }
806        return;
807    }
808
809    let body = body_area(area);
810    let card_w_max = 78u16.min(body.width.saturating_sub(2));
811    let card_w_min = 40u16;
812    let card_w = card_w_max.max(card_w_min).min(body.width);
813    let card_x = body.x + (body.width.saturating_sub(card_w)) / 2;
814
815    // Compose the card contents: blank, headline, blank, explainer
816    // (wrapped), blank, hints. Compute the row count so the card
817    // height matches the content exactly — no trailing whitespace.
818    let inner_card_w = card_w as usize;
819    let prose_w = inner_card_w.saturating_sub(4); // border (2) + 2-col padding
820    let mut card_lines: Vec<Line<'static>> = Vec::new();
821    section_open(&mut card_lines, e.card_title, inner_card_w);
822
823    // Open the body of the card with a blank inner row so the headline
824    // does not crowd the top border.
825    section_line(&mut card_lines, vec![Span::raw("")], inner_card_w);
826    section_line(
827        &mut card_lines,
828        vec![
829            Span::raw("  "),
830            Span::styled(e.headline.to_string(), theme::bold()),
831        ],
832        inner_card_w,
833    );
834
835    // Explainer: wrap with 2-space indent on every continuation row.
836    if !e.explainer.is_empty() {
837        section_line(&mut card_lines, vec![Span::raw("")], inner_card_w);
838        for row in wrap_indented(e.explainer, "  ", prose_w) {
839            section_line(
840                &mut card_lines,
841                vec![Span::styled(row, theme::muted())],
842                inner_card_w,
843            );
844        }
845    }
846
847    if !e.hints.is_empty() {
848        section_line(&mut card_lines, vec![Span::raw("")], inner_card_w);
849        // Right-align the keycap glyphs in a small column so every hint
850        // line reads as a single visual list, regardless of key length.
851        let key_w = e.hints.iter().map(|(k, _)| k.width()).max().unwrap_or(1);
852        for (key, action) in e.hints {
853            let key_pad = format!("  {:>width$}  ", key, width = key_w);
854            section_line(
855                &mut card_lines,
856                vec![
857                    Span::styled(key_pad, theme::accent_bold()),
858                    Span::styled(action.to_string(), theme::muted()),
859                ],
860                inner_card_w,
861            );
862        }
863    }
864
865    // Close the card with a blank inner row + the bottom border.
866    section_line(&mut card_lines, vec![Span::raw("")], inner_card_w);
867    section_close(&mut card_lines, inner_card_w);
868
869    // Centre vertically: leave equal blank rows above and below within
870    // the body area (≥ 0).
871    let card_h = card_lines.len() as u16;
872    let top_pad = body.height.saturating_sub(card_h) / 2;
873    let card_y = body.y + top_pad;
874    let card_rect = Rect::new(card_x, card_y, card_w, card_h.min(body.height));
875    frame.render_widget(Paragraph::new(card_lines), card_rect);
876}
877
878/// Render a bordered placeholder for the detail panel that sits next to
879/// an empty tab. Draws only the block frame — no text — so the caller
880/// can keep both panels visible without re-introducing the
881/// double-message bug. Use when the layout reserves a detail panel
882/// area but there are no rows to populate it.
883pub fn render_tab_empty_detail(frame: &mut Frame, detail_area: Rect) {
884    frame.render_widget(main_block_line(Line::default()), detail_area);
885}
886
887/// Word-wrap `text` to lines whose display width is at most
888/// `max_width`, prepending `indent` to **every** output line — the
889/// first and every continuation. Ratatui's `Wrap { trim: false }`
890/// preserves leading whitespace on the source line but emits
891/// continuation rows flush-left, which breaks the visual indent of a
892/// multi-line body paragraph (e.g. a long host-list preview wraps to a
893/// second row with no indent and no longer reads as a single block).
894///
895/// Words longer than `max_width - indent.width()` are hard-broken at
896/// the column boundary so the wrapper never loops. Empty input returns
897/// an empty vec; zero `max_width` returns an empty vec rather than
898/// looping. The breakable character is the ASCII space; tabs and
899/// non-breaking spaces are treated as part of a word.
900pub fn wrap_indented(text: &str, indent: &str, max_width: usize) -> Vec<String> {
901    use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
902    if text.is_empty() || max_width == 0 {
903        return Vec::new();
904    }
905    let indent_w = indent.width();
906    if indent_w >= max_width {
907        // Indent eats the entire width — fall back to no indent so the
908        // caller still gets readable output rather than infinite recursion.
909        return wrap_indented(text, "", max_width);
910    }
911    let content_max = max_width - indent_w;
912    let mut out: Vec<String> = Vec::new();
913    let mut current = String::new();
914    let mut current_w = 0usize;
915    let push_current = |out: &mut Vec<String>, current: &mut String, current_w: &mut usize| {
916        if !current.is_empty() {
917            out.push(format!("{}{}", indent, current));
918            current.clear();
919            *current_w = 0;
920        }
921    };
922    for word in text.split(' ') {
923        let word_w = word.width();
924        if word_w == 0 {
925            // empty word from a double space — emit a single space if room
926            if current_w < content_max {
927                current.push(' ');
928                current_w += 1;
929            }
930            continue;
931        }
932        // Word doesn't fit on the current line.
933        if current_w > 0 && current_w + 1 + word_w > content_max {
934            push_current(&mut out, &mut current, &mut current_w);
935        }
936        // Word longer than the available content width: hard-break.
937        if word_w > content_max {
938            push_current(&mut out, &mut current, &mut current_w);
939            let mut chunk = String::new();
940            let mut chunk_w = 0usize;
941            for ch in word.chars() {
942                let cw = UnicodeWidthChar::width(ch).unwrap_or(0);
943                if chunk_w + cw > content_max {
944                    out.push(format!("{}{}", indent, chunk));
945                    chunk.clear();
946                    chunk_w = 0;
947                }
948                chunk.push(ch);
949                chunk_w += cw;
950            }
951            if !chunk.is_empty() {
952                current = chunk;
953                current_w = chunk_w;
954            }
955            continue;
956        }
957        if current_w > 0 {
958            current.push(' ');
959            current_w += 1;
960        }
961        current.push_str(word);
962        current_w += word_w;
963    }
964    push_current(&mut out, &mut current, &mut current_w);
965    out
966}
967
968/// Truncate `text` to at most `max_width` display columns. If the text
969/// is longer, the last column is replaced with `…` so the reader can
970/// tell that data was cut. `max_width` must be ≥ 1; the helper is a
971/// no-op when `text` already fits.
972///
973/// Use for single-line cells where wrapping is not an option (host
974/// alias columns, image names, path fragments). The single-character
975/// ellipsis `…` matches the convention used by `ssh -G` output and
976/// most modern terminal lists; do not introduce ASCII `...` for the
977/// same purpose — it consumes 3 display columns where `…` consumes 1.
978#[allow(dead_code)]
979pub fn ellipsize(text: &str, max_width: usize) -> String {
980    use unicode_width::UnicodeWidthStr;
981    if max_width == 0 {
982        return String::new();
983    }
984    if text.width() <= max_width {
985        return text.to_string();
986    }
987    if max_width == 1 {
988        return "…".to_string();
989    }
990    let mut out = String::new();
991    let mut width = 0usize;
992    for ch in text.chars() {
993        let cw = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
994        if width + cw + 1 > max_width {
995            break;
996        }
997        width += cw;
998        out.push(ch);
999    }
1000    out.push('…');
1001    out
1002}
1003
1004/// Right-arrow glyph for picker fields.
1005pub const PICKER_ARROW: &str = "\u{25B8}";
1006
1007/// Space-bar glyph for toggle fields.
1008pub const TOGGLE_HINT: &str = "\u{2423}";
1009
1010/// Down-pointing triangle for an expanded tree node (multi-config provider).
1011pub const TREE_EXPANDED: &str = "\u{25BE}";
1012
1013/// Down-pointing triangle reused as a column-header descending-sort
1014/// indicator (e.g. "LAST ▾"). Same glyph as `TREE_EXPANDED` but exposed
1015/// under a sort-specific name so column headers don't grep as tree code.
1016pub const SORT_DESC: &str = "\u{25BE}";
1017
1018/// Right-pointing triangle for a collapsed tree node (multi-config provider).
1019pub const TREE_COLLAPSED: &str = "\u{25B8}";
1020
1021/// L-shaped branch glyph for the last-child leaf row under an expanded tree node.
1022pub const TREE_BRANCH: &str = "\u{2514}";
1023
1024/// Empty-state line for embedding in Paragraphs that render inside a block.
1025/// Same visual output as `render_empty()` but returns a composable `Line`.
1026pub fn empty_line(message: &str) -> Line<'static> {
1027    muted_line(message)
1028}
1029
1030// ---------------------------------------------------------------------------
1031// Keyboard interaction primitives
1032// ---------------------------------------------------------------------------
1033//
1034// These helpers are the single source of truth for keyboard interaction
1035// patterns in purple. The CI script `scripts/check-keybindings.sh` enforces
1036// that handler and screen code uses these helpers instead of building footers
1037// or routing keys ad hoc.
1038
1039/// Field kind for dynamic form footer hints.
1040///
1041/// Drives the `Space` action label in [`form_save_footer`]:
1042/// - `Text`: Space inserts a literal space character. No hint shown.
1043/// - `Toggle`: Space flips a boolean. Footer shows "Space toggle".
1044/// - `Picker`: Space opens a selection picker. Footer shows "Space pick".
1045///
1046/// **Invariant**: Enter ALWAYS submits the form regardless of `FieldKind`.
1047/// Pickers and toggles are reached via Space only, never via Enter.
1048/// `scripts/check-keybindings.sh` enforces this.
1049#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1050pub enum FieldKind {
1051    /// Text input field. Space inserts a literal character.
1052    Text,
1053    /// Boolean toggle (e.g. VerifyTls, AutoSync). Space flips the value.
1054    Toggle,
1055    /// Picker field (e.g. IdentityFile, ProxyJump). Space opens the picker.
1056    Picker,
1057}
1058
1059/// Form mode for dynamic footer rendering.
1060///
1061/// Forms with progressive disclosure (host form, provider form) start
1062/// `Collapsed` showing only required fields. The footer hints `\u{2193} more
1063/// options` so the user can expand. After expansion the footer flips to
1064/// `Expanded(kind)` and shows the appropriate per-field hint.
1065#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1066pub enum FormFooterMode {
1067    /// Required fields only. Down arrow expands to optional fields.
1068    Collapsed,
1069    /// All fields visible. Field kind determines the Space hint.
1070    Expanded(FieldKind),
1071}
1072
1073/// Standard form save footer with dynamic hints based on focused field.
1074///
1075/// Renders one of:
1076/// - Collapsed:                `Enter save | \u{2193} more options | Esc cancel`
1077/// - Expanded + Text field:    `Enter save | Tab next | Esc cancel`
1078/// - Expanded + Toggle field:  `Enter save | Space toggle | Tab next | Esc cancel`
1079/// - Expanded + Picker field:  `Enter save | Space pick | Tab next | Esc cancel`
1080///
1081/// **Why this helper exists**: it codifies the rule that Enter is always the
1082/// save action, and that Space is the universal field-action key. Screens
1083/// must call this instead of building form footers ad hoc.
1084pub fn form_save_footer(mode: FormFooterMode) -> Footer {
1085    use crate::messages::footer as f;
1086    let mut footer = Footer::new().primary("Enter", f::ENTER_SAVE);
1087    match mode {
1088        FormFooterMode::Collapsed => {
1089            footer = footer.action("\u{2193}", " more options ");
1090        }
1091        FormFooterMode::Expanded(FieldKind::Text) => {
1092            footer = footer.action("Tab", f::TAB_NEXT);
1093        }
1094        FormFooterMode::Expanded(FieldKind::Toggle) => {
1095            footer = footer
1096                .action("Space", f::SPACE_TOGGLE)
1097                .action("Tab", f::TAB_NEXT);
1098        }
1099        FormFooterMode::Expanded(FieldKind::Picker) => {
1100            footer = footer
1101                .action("Space", f::SPACE_PICK)
1102                .action("Tab", f::TAB_NEXT);
1103        }
1104    }
1105    footer.action("Esc", f::ESC_CANCEL)
1106}
1107
1108/// Footer for a destructive confirmation. Action-specific verbs both sides.
1109///
1110/// Stakes test: if cancelling by mistake loses irrecoverable work, use
1111/// action verbs (e.g. `delete/keep`, `sign/skip`, `purge/keep`). The
1112/// asymmetry helps users read the dialog as a choice between two outcomes,
1113/// not "did I press the right key?".
1114///
1115/// Both `n` and `Esc` cancel (the contract enforced by
1116/// `handler::route_confirm_key`); the footer advertises them as `n/Esc` so
1117/// the visible UI matches the actual key set.
1118///
1119/// Examples:
1120/// - `confirm_footer_destructive("delete", "keep")` for delete confirms
1121/// - `confirm_footer_destructive("sign", "skip")` for vault sign
1122/// - `confirm_footer_destructive("purge", "keep")` for purge stale
1123pub fn confirm_footer_destructive(yes_verb: &str, no_verb: &str) -> Footer {
1124    Footer::new()
1125        .primary("y", &format!(" {} ", yes_verb))
1126        .action("n/Esc", &format!(" {}", no_verb))
1127}
1128
1129/// Footer for the standard discard-changes confirmation in any form.
1130///
1131/// Discarding form changes is a benign confirmation: users can re-enter the
1132/// data. We still use action verbs (`discard`/`keep`) instead of `yes/no`
1133/// because the noun-verb pairing is more informative than a bare affirmative.
1134pub fn discard_footer() -> Footer {
1135    confirm_footer_destructive("discard", "keep")
1136}
1137
1138/// Kind of confirm popup. Selects block styling (destructive = red
1139/// border, neutral = muted border) and lets the caller communicate
1140/// intent without having to construct the block themselves.
1141#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1142pub enum PopupKind {
1143    /// Red danger border. Used for delete/discard/sign/purge confirms.
1144    Destructive,
1145    /// Muted overlay border. Used for non-destructive confirms (import,
1146    /// info dialogs, neutral yes/no).
1147    Neutral,
1148}
1149
1150/// Render a centred confirm popup over whatever is on screen.
1151///
1152/// Single source of truth for every confirm-style modal in the TUI:
1153/// destructive deletes, sign confirmations, import dialogs, container
1154/// action prompts, tunnel removal, provider remove, etc. Replaces ad
1155/// hoc combinations of `centered_rect_fixed` + `Clear` + `block` +
1156/// `render_body_wrapped` + `render_overlay_footer` that were drifting
1157/// per caller.
1158///
1159/// Guarantees the caller does NOT have to think about:
1160/// - hanging indent on wrapped continuation rows (via [`wrap_block_lines`])
1161/// - the trailing blank row between the last content row and the
1162///   bottom border (height is computed from the wrapped row count so
1163///   long prose never pushes the last content line against the border)
1164/// - footer placement (rendered below the bottom border via
1165///   [`render_overlay_footer`], not inside the block)
1166///
1167/// The caller supplies the body as a `Vec<Line>` of content rows. The
1168/// helper adds the top blank and trailing blank itself; embed any
1169/// inter-section blanks (e.g. between a question and its detail
1170/// paragraph) directly in `content_lines`.
1171///
1172/// Height is clamped to the frame so very tall content does not exceed
1173/// the available screen; in that case ratatui truncates from the
1174/// bottom. Pick a `popup_w` that fits the longest unwrapped word; the
1175/// wrap-with-indent helper handles the rest.
1176pub fn render_confirm_popup<'a>(
1177    frame: &mut Frame,
1178    popup_w: u16,
1179    kind: PopupKind,
1180    title: &str,
1181    content_lines: Vec<Line<'a>>,
1182    footer_spans: Vec<Span<'static>>,
1183    app: &App,
1184) {
1185    // Probe rect with a baseline height to derive inner width. Inner
1186    // width is independent of height for centered_rect_fixed.
1187    let probe = super::centered_rect_fixed(popup_w, 7, frame.area());
1188    let inner_w = body_area(probe).width as usize;
1189
1190    let wrapped = wrap_block_lines(content_lines, inner_w);
1191    let body_rows = wrapped.len() as u16;
1192
1193    // borders (2) + top blank (1) + body (N) + trailing blank (1)
1194    let frame_h = frame.area().height;
1195    let max_h = frame_h.saturating_sub(2); // leave room for footer + status bar
1196    let height = (2 + 1 + body_rows + 1).min(max_h);
1197
1198    let area = super::centered_rect_fixed(popup_w, height, frame.area());
1199    frame.render_widget(Clear, area);
1200
1201    let block = match kind {
1202        PopupKind::Destructive => danger_block(title),
1203        PopupKind::Neutral => overlay_block(title),
1204    };
1205
1206    let mut text: Vec<Line<'static>> = Vec::with_capacity(wrapped.len() + 1);
1207    text.push(Line::from(""));
1208    text.extend(wrapped);
1209    // No trailing blank line here: the `+ 1` in the height formula leaves
1210    // an empty row in the body_area that ratatui paints blank by default,
1211    // which is the trailing blank we want.
1212    render_body(frame, area, block, text);
1213
1214    let footer_area = render_overlay_footer(frame, area);
1215    super::render_footer_with_status(frame, footer_area, footer_spans, app);
1216}
1217
1218/// Render a centred destructive-confirm popup. Thin wrapper around
1219/// [`render_confirm_popup`] for callers that have the simple
1220/// "question + optional detail + yes/no verbs" shape.
1221///
1222/// `title` appears in the danger block's top border.
1223/// `body_question` is the bold first row.
1224/// `body_detail` is a muted second-row sentence; pass `""` to skip.
1225pub fn render_destructive_popup(
1226    frame: &mut Frame,
1227    title: &str,
1228    body_question: &str,
1229    body_detail: &str,
1230    yes_verb: &str,
1231    no_verb: &str,
1232    app: &App,
1233) {
1234    let mut content: Vec<Line<'static>> = vec![Line::from(Span::styled(
1235        format!("  {}", body_question),
1236        theme::bold(),
1237    ))];
1238    if !body_detail.is_empty() {
1239        content.push(Line::from(""));
1240        content.push(Line::from(Span::styled(
1241            format!("  {}", body_detail),
1242            theme::muted(),
1243        )));
1244    }
1245    let footer_spans = confirm_footer_destructive(yes_verb, no_verb)
1246        .to_line()
1247        .spans;
1248    render_confirm_popup(
1249        frame,
1250        56,
1251        PopupKind::Destructive,
1252        title,
1253        content,
1254        footer_spans,
1255        app,
1256    );
1257}
1258
1259/// Render the standard "Discard changes?" footer with prompt prefix.
1260///
1261/// Single source of truth for the discard prompt across every editable
1262/// surface (host form, tunnel form, snippet form, provider form, snippet
1263/// param form, bulk tag editor). Renders below the block via
1264/// `render_overlay_footer`. Callers must compute `footer_area` first via
1265/// [`render_overlay_footer`] and pass it in.
1266pub fn render_discard_prompt(frame: &mut Frame, footer_area: Rect, app: &App) {
1267    let mut spans = vec![Span::styled(" Discard changes? ", theme::error())];
1268    spans.extend(discard_footer().into_spans());
1269    super::render_footer_with_status(frame, footer_area, spans, app);
1270}
1271
1272// ---------------------------------------------------------------------------
1273// Section card primitives
1274// ---------------------------------------------------------------------------
1275//
1276// Bordered "section cards" with title in the top border. Shared by host
1277// detail and container detail so both panels read as siblings.
1278
1279/// Box-drawing characters for section cards. Match the rounded-border look
1280/// used everywhere else in the TUI.
1281pub const BOX_TL: &str = "\u{256D}";
1282pub const BOX_TR: &str = "\u{256E}";
1283pub const BOX_BL: &str = "\u{2570}";
1284pub const BOX_BR: &str = "\u{256F}";
1285pub const BOX_H: &str = "\u{2500}";
1286pub const BOX_V: &str = "\u{2502}";
1287
1288/// Push the opening line of a section card: ╭─ TITLE ───╮
1289pub fn section_open(lines: &mut Vec<Line<'static>>, title: &str, width: usize) {
1290    use unicode_width::UnicodeWidthStr;
1291    let border_prefix = format!("{}{} ", BOX_TL, BOX_H);
1292    let title_suffix = " ";
1293    let prefix_width = border_prefix.width() + title.width() + title_suffix.width();
1294    let fill = width.saturating_sub(prefix_width).saturating_sub(1);
1295    lines.push(Line::from(vec![
1296        Span::styled(border_prefix, theme::border()),
1297        Span::styled(title.to_string(), theme::bold()),
1298        Span::styled(title_suffix, theme::border()),
1299        Span::styled(BOX_H.repeat(fill), theme::border()),
1300        Span::styled(BOX_TR, theme::border()),
1301    ]));
1302}
1303
1304/// Push the opening line of a section card without a title: ╭───────╮
1305pub fn section_open_notitle(lines: &mut Vec<Line<'static>>, width: usize) {
1306    let fill = width.saturating_sub(2);
1307    lines.push(Line::from(vec![
1308        Span::styled(BOX_TL, theme::border()),
1309        Span::styled(BOX_H.repeat(fill), theme::border()),
1310        Span::styled(BOX_TR, theme::border()),
1311    ]));
1312}
1313
1314/// Push a content row wrapped in box side characters: │ <spans...> │
1315pub fn section_line(lines: &mut Vec<Line<'static>>, spans: Vec<Span<'static>>, width: usize) {
1316    use unicode_width::UnicodeWidthStr;
1317    let mut full_spans: Vec<Span<'static>> =
1318        vec![Span::styled(format!("{} ", BOX_V), theme::border())];
1319    let content_width: usize = full_spans.iter().map(|s| s.content.width()).sum::<usize>()
1320        + spans.iter().map(|s| s.content.width()).sum::<usize>();
1321    full_spans.extend(spans);
1322    let closing_offset = 1;
1323    let padding = width
1324        .saturating_sub(content_width)
1325        .saturating_sub(closing_offset);
1326    if padding > 0 {
1327        full_spans.push(Span::raw(" ".repeat(padding)));
1328    }
1329    full_spans.push(Span::styled(BOX_V, theme::border()));
1330    lines.push(Line::from(full_spans));
1331}
1332
1333/// Push the closing line of a section card: ╰───────╯
1334pub fn section_close(lines: &mut Vec<Line<'static>>, width: usize) {
1335    let fill = width.saturating_sub(2);
1336    lines.push(Line::from(vec![
1337        Span::styled(BOX_BL, theme::border()),
1338        Span::styled(BOX_H.repeat(fill), theme::border()),
1339        Span::styled(BOX_BR, theme::border()),
1340    ]));
1341}
1342
1343/// Empty bordered line used to stretch the last card on a panel. Renders
1344/// as `│              │` so the close-border stays anchored to the panel
1345/// bottom regardless of how much content the cards above produced.
1346pub fn section_empty_line(width: usize) -> Line<'static> {
1347    let fill = width.saturating_sub(2);
1348    Line::from(vec![
1349        Span::styled(BOX_V, theme::border()),
1350        Span::raw(" ".repeat(fill)),
1351        Span::styled(BOX_V, theme::border()),
1352    ])
1353}
1354
1355/// Pad the line list so the LAST `section_close` row sits at row index
1356/// `available_rows - 1`. Inserts empty bordered lines just before that
1357/// close so the trailing card stretches without breaking its frame.
1358/// No-op when the lines already fill or exceed `available_rows`.
1359pub fn stretch_last_card(lines: &mut Vec<Line<'static>>, available_rows: usize, box_width: usize) {
1360    if lines.len() >= available_rows {
1361        return;
1362    }
1363    let extra = available_rows - lines.len();
1364    let last_close = lines.iter().rposition(|line| {
1365        line.spans
1366            .first()
1367            .map(|s| s.content.starts_with(BOX_BL))
1368            .unwrap_or(false)
1369    });
1370    let Some(idx) = last_close else {
1371        return;
1372    };
1373    for _ in 0..extra {
1374        lines.insert(idx, section_empty_line(box_width));
1375    }
1376}
1377
1378/// Push a label+value field row inside a section card. Truncates the value
1379/// when it exceeds `max_value_width`. Pass `0` to disable truncation.
1380pub fn section_field(
1381    lines: &mut Vec<Line<'static>>,
1382    label: &str,
1383    value: &str,
1384    max_value_width: usize,
1385    box_width: usize,
1386) {
1387    use unicode_width::UnicodeWidthStr;
1388    let display = if max_value_width > 0 && value.width() > max_value_width {
1389        super::truncate(value, max_value_width)
1390    } else {
1391        value.to_string()
1392    };
1393    let spans = vec![
1394        Span::styled(
1395            format!("{:<width$}", label, width = SECTION_LABEL_W as usize),
1396            theme::muted(),
1397        ),
1398        Span::styled(display, theme::bold()),
1399    ];
1400    section_line(lines, spans, box_width);
1401}
1402
1403/// Push a label+value field row with a custom value style (e.g. warning,
1404/// error, online dot). Otherwise identical to `section_field`.
1405pub fn section_field_styled(
1406    lines: &mut Vec<Line<'static>>,
1407    label: &str,
1408    value: &str,
1409    value_style: ratatui::style::Style,
1410    max_value_width: usize,
1411    box_width: usize,
1412) {
1413    use unicode_width::UnicodeWidthStr;
1414    let display = if max_value_width > 0 && value.width() > max_value_width {
1415        super::truncate(value, max_value_width)
1416    } else {
1417        value.to_string()
1418    };
1419    let spans = vec![
1420        Span::styled(
1421            format!("{:<width$}", label, width = SECTION_LABEL_W as usize),
1422            theme::muted(),
1423        ),
1424        Span::styled(display, value_style),
1425    ];
1426    section_line(lines, spans, box_width);
1427}
1428
1429// ---------------------------------------------------------------------------
1430// Tests
1431// ---------------------------------------------------------------------------
1432
1433#[cfg(test)]
1434mod tests {
1435    use super::*;
1436    use ratatui::Terminal;
1437    use ratatui::backend::TestBackend;
1438    use ratatui::buffer::Buffer;
1439    use ratatui::widgets::Widget;
1440
1441    fn make_app() -> (App, tempfile::TempDir) {
1442        let dir = tempfile::tempdir().unwrap();
1443        let config = crate::ssh_config::model::SshConfigFile {
1444            elements: crate::ssh_config::model::SshConfigFile::parse_content(""),
1445            path: dir.path().join("test_design"),
1446            crlf: false,
1447            bom: false,
1448        };
1449        (App::new(config), dir)
1450    }
1451
1452    fn buffer_contains(buf: &Buffer, needle: &str) -> bool {
1453        for y in 0..buf.area.height {
1454            let mut row = String::new();
1455            for x in 0..buf.area.width {
1456                row.push_str(buf[(x, y)].symbol());
1457            }
1458            if row.contains(needle) {
1459                return true;
1460            }
1461        }
1462        false
1463    }
1464
1465    fn render_block_title(block: Block<'static>, title: &str) -> bool {
1466        let area = Rect::new(0, 0, 30, 5);
1467        let mut buf = Buffer::empty(area);
1468        block.render(area, &mut buf);
1469        buffer_contains(&buf, title)
1470    }
1471
1472    #[test]
1473    fn overlay_block_title_is_padded() {
1474        assert!(render_block_title(overlay_block("Hello"), " Hello "));
1475    }
1476
1477    #[test]
1478    fn danger_block_title_is_padded() {
1479        assert!(render_block_title(danger_block("Delete"), " Delete "));
1480    }
1481
1482    #[test]
1483    fn overlay_area_stays_within_frame() {
1484        let backend = TestBackend::new(100, 40);
1485        let mut terminal = Terminal::new(backend).unwrap();
1486        terminal
1487            .draw(|frame| {
1488                let rect = overlay_area(frame, 70, 80, 20);
1489                let area = frame.area();
1490                assert!(rect.x >= area.x);
1491                assert!(rect.y >= area.y);
1492                assert!(rect.x + rect.width <= area.x + area.width);
1493                assert!(rect.y + rect.height <= area.y + area.height);
1494                assert!(rect.height <= 20);
1495            })
1496            .unwrap();
1497    }
1498
1499    #[test]
1500    fn form_footer_sits_directly_below_block() {
1501        let block_area = Rect::new(5, 2, 30, 8);
1502        let rect = form_footer(block_area, 8);
1503        assert_eq!(rect.x, 5);
1504        assert_eq!(rect.y, 10);
1505        assert_eq!(rect.width, 30);
1506        assert_eq!(rect.height, 1);
1507    }
1508
1509    #[test]
1510    fn form_divider_y_steps_by_two() {
1511        let inner = Rect::new(2, 3, 20, 10);
1512        assert_eq!(form_divider_y(inner, 0), 3);
1513        assert_eq!(form_divider_y(inner, 1), 5);
1514        assert_eq!(form_divider_y(inner, 2), 7);
1515    }
1516
1517    #[test]
1518    fn footer_builder_inserts_gaps_between_entries_only() {
1519        let spans = Footer::new()
1520            .primary("Enter", "save")
1521            .action("Esc", "cancel")
1522            .action("Tab", "next")
1523            .into_spans();
1524        // primary (2) + gap (1) + action (2) + gap (1) + action (2) = 8
1525        assert_eq!(spans.len(), 8);
1526        assert_eq!(spans[2].content, FOOTER_GAP);
1527        assert_eq!(spans[5].content, FOOTER_GAP);
1528    }
1529
1530    #[test]
1531    fn empty_footer_has_no_spans() {
1532        assert!(Footer::new().into_spans().is_empty());
1533    }
1534
1535    #[test]
1536    fn footer_to_line_preserves_span_count() {
1537        let footer = Footer::new()
1538            .primary("Enter", "save")
1539            .action("Esc", "cancel");
1540        let spans_len = {
1541            let clone = Footer::new()
1542                .primary("Enter", "save")
1543                .action("Esc", "cancel");
1544            clone.into_spans().len()
1545        };
1546        let line = footer.to_line();
1547        assert_eq!(line.spans.len(), spans_len);
1548    }
1549
1550    #[test]
1551    fn picker_width_is_clamped() {
1552        let backend = TestBackend::new(100, 40);
1553        let mut terminal = Terminal::new(backend).unwrap();
1554        terminal
1555            .draw(|frame| {
1556                let w = picker_width(frame);
1557                assert!(w >= PICKER_MIN_W);
1558                assert!(w <= PICKER_MAX_W);
1559            })
1560            .unwrap();
1561    }
1562
1563    #[test]
1564    fn picker_width_clamps_narrow_terminal_to_min() {
1565        let backend = TestBackend::new(30, 20);
1566        let mut terminal = Terminal::new(backend).unwrap();
1567        terminal
1568            .draw(|frame| {
1569                assert_eq!(picker_width(frame), PICKER_MIN_W);
1570            })
1571            .unwrap();
1572    }
1573
1574    #[test]
1575    fn picker_width_clamps_wide_terminal_to_max() {
1576        let backend = TestBackend::new(200, 20);
1577        let mut terminal = Terminal::new(backend).unwrap();
1578        terminal
1579            .draw(|frame| {
1580                assert_eq!(picker_width(frame), PICKER_MAX_W);
1581            })
1582            .unwrap();
1583    }
1584
1585    #[test]
1586    fn picker_width_passes_midrange_through() {
1587        // PICKER_MIN_W (60) < 66 < PICKER_MAX_W (72), so passes through unclamped.
1588        let backend = TestBackend::new(66, 20);
1589        let mut terminal = Terminal::new(backend).unwrap();
1590        terminal
1591            .draw(|frame| {
1592                assert_eq!(picker_width(frame), 66);
1593            })
1594            .unwrap();
1595    }
1596
1597    #[test]
1598    fn plain_overlay_block_has_no_title() {
1599        // Render the block into a small buffer and verify the top border row
1600        // contains only rounded glyphs and horizontal lines (no injected title
1601        // characters from a helper).
1602        let area = Rect::new(0, 0, 20, 3);
1603        let mut buf = Buffer::empty(area);
1604        plain_overlay_block().render(area, &mut buf);
1605        let mut top = String::new();
1606        for x in 0..area.width {
1607            top.push_str(buf[(x, 0)].symbol());
1608        }
1609        assert!(top.starts_with('\u{256D}'));
1610        assert!(top.ends_with('\u{256E}'));
1611        // All inner chars should be box-drawing horizontals.
1612        for ch in top.chars().skip(1).take((area.width as usize) - 2) {
1613            assert_eq!(ch, '\u{2500}');
1614        }
1615    }
1616
1617    #[test]
1618    fn section_divider_contains_dashes() {
1619        let line = section_divider();
1620        let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
1621        assert!(
1622            text.contains("────"),
1623            "section divider should contain dash characters"
1624        );
1625    }
1626
1627    #[test]
1628    fn padded_usize_matches_expected_values() {
1629        assert_eq!(padded_usize(0), 0);
1630        assert_eq!(padded_usize(10), 12);
1631        assert_eq!(padded_usize(20), 23);
1632    }
1633
1634    #[test]
1635    fn kv_line_format_has_two_spans() {
1636        let line = kv_line("Label", "Value", KV_LABEL_WIDE);
1637        assert_eq!(line.spans.len(), 2);
1638        let label_text = &line.spans[0].content;
1639        assert!(
1640            label_text.starts_with("  "),
1641            "label should be 2-space indented"
1642        );
1643        assert!(label_text.contains("Label"));
1644        assert_eq!(line.spans[1].content.as_ref(), "Value");
1645    }
1646
1647    #[test]
1648    fn kv_line_label_is_padded_to_width() {
1649        let line = kv_line("X", "Y", 22);
1650        let label = &line.spans[0].content;
1651        // 2-space indent + 22-char padded label = 24 total
1652        assert_eq!(label.len(), 24);
1653    }
1654
1655    #[test]
1656    fn content_section_returns_header_and_divider() {
1657        let [header, divider] = content_section("Directives");
1658        let h_text: String = header.spans.iter().map(|s| s.content.as_ref()).collect();
1659        assert!(h_text.contains("Directives"));
1660        let d_text: String = divider.spans.iter().map(|s| s.content.as_ref()).collect();
1661        assert!(d_text.contains("────"));
1662    }
1663
1664    #[test]
1665    fn render_empty_with_hint_does_not_panic() {
1666        let backend = TestBackend::new(60, 3);
1667        let mut terminal = Terminal::new(backend).unwrap();
1668        terminal
1669            .draw(|frame| {
1670                let area = Rect::new(0, 0, 60, 1);
1671                render_empty_with_hint(frame, area, "No tags yet.", "+", "add");
1672            })
1673            .unwrap();
1674    }
1675
1676    #[test]
1677    fn column_header_prefix_is_three_spaces() {
1678        assert_eq!(COLUMN_HEADER_PREFIX, "   ");
1679        assert_eq!(COLUMN_HEADER_PREFIX.len(), 3);
1680    }
1681
1682    #[test]
1683    fn col_gap_str_is_two_spaces() {
1684        assert_eq!(COL_GAP_STR, "  ");
1685        assert_eq!(COL_GAP_STR.len(), 2);
1686    }
1687
1688    #[test]
1689    fn picker_arrow_renders_as_single_glyph() {
1690        // The grep check in scripts/check-design-system.sh enforces that the
1691        // literal "\u{25B8}" only appears in design.rs. The test here
1692        // guards a different invariant: PICKER_ARROW must be a single
1693        // non-whitespace grapheme so it lines up in form fields.
1694        assert_eq!(PICKER_ARROW.chars().count(), 1);
1695        assert!(!PICKER_ARROW.starts_with(char::is_whitespace));
1696    }
1697
1698    #[test]
1699    fn toggle_hint_renders_as_single_glyph() {
1700        assert_eq!(TOGGLE_HINT.chars().count(), 1);
1701        assert!(!TOGGLE_HINT.starts_with(char::is_whitespace));
1702    }
1703
1704    #[test]
1705    fn empty_line_has_indent_and_muted_style() {
1706        let line = empty_line("No results.");
1707        assert_eq!(line.spans.len(), 2);
1708        assert_eq!(line.spans[0].content.as_ref(), "  ");
1709        assert_eq!(line.spans[1].content.as_ref(), "No results.");
1710    }
1711
1712    #[test]
1713    fn render_empty_loading_error_do_not_panic() {
1714        let backend = TestBackend::new(40, 3);
1715        let mut terminal = Terminal::new(backend).unwrap();
1716        terminal
1717            .draw(|frame| {
1718                let area = Rect::new(0, 0, 40, 1);
1719                render_empty(frame, area, "no hosts");
1720                render_loading(frame, area, "loading...");
1721                render_error(frame, area, "something broke");
1722            })
1723            .unwrap();
1724    }
1725
1726    #[test]
1727    fn footer_render_with_status_does_not_panic() {
1728        let (app, _dir) = make_app();
1729        let backend = TestBackend::new(60, 3);
1730        let mut terminal = Terminal::new(backend).unwrap();
1731        terminal
1732            .draw(|frame| {
1733                let area = Rect::new(0, 0, 60, 1);
1734                Footer::new()
1735                    .primary("Enter", "save")
1736                    .action("Esc", "cancel")
1737                    .render_with_status(frame, area, &app);
1738            })
1739            .unwrap();
1740    }
1741
1742    fn footer_text(footer: Footer) -> String {
1743        footer
1744            .into_spans()
1745            .iter()
1746            .map(|s| s.content.as_ref())
1747            .collect()
1748    }
1749
1750    #[test]
1751    fn form_save_footer_collapsed_shows_more_options() {
1752        let text = footer_text(form_save_footer(FormFooterMode::Collapsed));
1753        assert!(text.contains("Enter"));
1754        assert!(text.contains("save"));
1755        assert!(text.contains("more options"));
1756        assert!(text.contains("Esc"));
1757        assert!(text.contains("cancel"));
1758        // Collapsed mode never advertises Space.
1759        assert!(!text.contains("Space"));
1760    }
1761
1762    #[test]
1763    fn form_save_footer_expanded_text_omits_space_hint() {
1764        let text = footer_text(form_save_footer(FormFooterMode::Expanded(FieldKind::Text)));
1765        assert!(text.contains("Enter"));
1766        assert!(text.contains("save"));
1767        assert!(text.contains("Tab"));
1768        assert!(text.contains("Esc"));
1769        // Text fields: Space is a literal character, not a hint.
1770        assert!(!text.contains("Space"));
1771    }
1772
1773    #[test]
1774    fn form_save_footer_expanded_toggle_shows_space_toggle() {
1775        let text = footer_text(form_save_footer(FormFooterMode::Expanded(
1776            FieldKind::Toggle,
1777        )));
1778        assert!(text.contains("Space"));
1779        assert!(text.contains("toggle"));
1780        // Should not advertise picker on a toggle field.
1781        assert!(!text.contains("pick"));
1782    }
1783
1784    #[test]
1785    fn form_save_footer_expanded_picker_shows_space_pick() {
1786        let text = footer_text(form_save_footer(FormFooterMode::Expanded(
1787            FieldKind::Picker,
1788        )));
1789        assert!(text.contains("Space"));
1790        assert!(text.contains("pick"));
1791        // Should not advertise toggle on a picker field.
1792        assert!(!text.contains("toggle"));
1793    }
1794
1795    #[test]
1796    fn confirm_footer_destructive_uses_action_verbs() {
1797        let text = footer_text(confirm_footer_destructive("delete", "keep"));
1798        assert!(text.contains("y"));
1799        assert!(text.contains("delete"));
1800        assert!(text.contains("n/Esc"));
1801        assert!(text.contains("keep"));
1802        // Destructive footer must not contain generic yes/no labels.
1803        assert!(!text.contains("yes"));
1804        assert!(!text.contains(" no"));
1805    }
1806
1807    #[test]
1808    fn confirm_footers_advertise_n_alongside_esc() {
1809        // route_confirm_key accepts y/Y, n/N, Esc. The footer must advertise
1810        // both n and Esc to keep the visible UI in sync with the key contract.
1811        for footer_text_str in [
1812            footer_text(confirm_footer_destructive("delete", "keep")),
1813            footer_text(discard_footer()),
1814        ] {
1815            assert!(
1816                footer_text_str.contains("n/Esc"),
1817                "footer must show both n and Esc as cancel keys: {}",
1818                footer_text_str
1819            );
1820        }
1821    }
1822
1823    #[test]
1824    fn discard_footer_uses_discard_keep_verbs() {
1825        let text = footer_text(discard_footer());
1826        assert!(text.contains("discard"));
1827        assert!(text.contains("keep"));
1828    }
1829
1830    #[test]
1831    fn is_container_running_is_case_insensitive() {
1832        assert!(is_container_running("running"));
1833        assert!(is_container_running("Running"));
1834        assert!(is_container_running("RUNNING"));
1835        assert!(!is_container_running("exited"));
1836        assert!(!is_container_running("paused"));
1837        assert!(!is_container_running(""));
1838    }
1839
1840    #[test]
1841    fn parse_container_exit_code_extracts_docker_format() {
1842        assert_eq!(
1843            parse_container_exit_code("Exited (0) 2 minutes ago"),
1844            Some(0)
1845        );
1846        assert_eq!(
1847            parse_container_exit_code("Exited (137) just now"),
1848            Some(137)
1849        );
1850        assert_eq!(parse_container_exit_code("Up 5 minutes"), None);
1851        assert_eq!(parse_container_exit_code(""), None);
1852        assert_eq!(parse_container_exit_code("Exited (abc) bad"), None);
1853    }
1854
1855    #[test]
1856    fn container_state_style_running_uses_online_icon() {
1857        let (icon, _) = container_state_style("running", None, "", None, 0);
1858        assert_eq!(icon, ICON_ONLINE);
1859    }
1860
1861    #[test]
1862    fn container_state_style_dead_uses_error_icon() {
1863        let (icon, _) = container_state_style("dead", None, "", None, 0);
1864        assert_eq!(icon, ICON_ERROR);
1865    }
1866
1867    #[test]
1868    fn container_state_style_paused_uses_paused_icon() {
1869        let (icon, _) = container_state_style("paused", None, "", None, 0);
1870        assert_eq!(icon, ICON_PAUSED);
1871        let (icon, _) = container_state_style("restarting", None, "", None, 0);
1872        assert_eq!(icon, ICON_PAUSED);
1873    }
1874
1875    #[test]
1876    fn container_state_style_clean_exit_uses_stopped_icon() {
1877        let (icon, _) = container_state_style("exited", None, "Exited (0) ago", None, 0);
1878        assert_eq!(icon, ICON_STOPPED);
1879        // No exit code at all also reads as clean.
1880        let (icon, _) = container_state_style("exited", None, "", None, 0);
1881        assert_eq!(icon, ICON_STOPPED);
1882    }
1883
1884    #[test]
1885    fn container_state_style_nonzero_exit_uses_error_icon() {
1886        let (icon, _) = container_state_style("exited", None, "Exited (137) ago", None, 0);
1887        assert_eq!(icon, ICON_ERROR);
1888        // Podman fallback path via inspect cache.
1889        let (icon, _) = container_state_style("stopped", None, "", Some(1), 0);
1890        assert_eq!(icon, ICON_ERROR);
1891    }
1892
1893    #[test]
1894    fn container_state_style_unknown_state_falls_back_to_stopped() {
1895        let (icon, _) = container_state_style("created", None, "", None, 0);
1896        assert_eq!(icon, ICON_STOPPED);
1897        let (icon, _) = container_state_style("removing", None, "", None, 0);
1898        assert_eq!(icon, ICON_STOPPED);
1899    }
1900
1901    #[test]
1902    fn container_state_style_running_with_unhealthy_uses_error_style() {
1903        let (_, style) = container_state_style("running", Some("unhealthy"), "", None, 0);
1904        // theme::error() in ANSI 16 mode is Red foreground.
1905        assert!(style.fg.is_some());
1906    }
1907
1908    #[test]
1909    fn body_area_insets_block_border_plus_right_margin() {
1910        let block_area = Rect::new(10, 5, 40, 12);
1911        let body = body_area(block_area);
1912        // Border (1) only on left, border (1) + BODY_RIGHT_PAD (2) on right.
1913        assert_eq!(body.x, 11);
1914        assert_eq!(body.width, 40 - 2 - BODY_RIGHT_PAD);
1915        // Vertical: border only, no padding (block.inner equivalent).
1916        assert_eq!(body.y, 6);
1917        assert_eq!(body.height, 10);
1918    }
1919
1920    #[test]
1921    fn body_area_collapses_safely_in_tiny_blocks() {
1922        // A 1x1 block has no room for margins; body_area must not panic
1923        // and must return a zero-sized rect inside the bounds.
1924        let body = body_area(Rect::new(0, 0, 1, 1));
1925        assert_eq!(body.width, 0);
1926        assert_eq!(body.height, 0);
1927    }
1928
1929    #[test]
1930    fn ellipsize_returns_text_unchanged_when_it_fits() {
1931        assert_eq!(ellipsize("hello", 10), "hello");
1932        assert_eq!(ellipsize("hello", 5), "hello");
1933    }
1934
1935    #[test]
1936    fn ellipsize_appends_single_glyph_when_text_overflows() {
1937        assert_eq!(ellipsize("hello world", 8), "hello w…");
1938    }
1939
1940    #[test]
1941    fn ellipsize_handles_extreme_widths() {
1942        assert_eq!(ellipsize("hello", 0), "");
1943        assert_eq!(ellipsize("hello", 1), "…");
1944        assert_eq!(ellipsize("", 5), "");
1945    }
1946
1947    #[test]
1948    fn wrap_indented_keeps_prefix_on_continuation_rows() {
1949        let text = "alpha beta gamma delta epsilon zeta eta theta iota kappa";
1950        let rows = wrap_indented(text, "  ", 18);
1951        assert!(rows.len() > 1, "long text must wrap");
1952        for row in &rows {
1953            assert!(row.starts_with("  "), "every row keeps indent: {row:?}");
1954            assert!(row.len() <= 18 + 2, "row exceeds budget: {row:?}");
1955        }
1956    }
1957
1958    #[test]
1959    fn wrap_indented_hard_breaks_oversized_words() {
1960        let text = "ohabsurdlylongwordthatdoesnotfit ok";
1961        let rows = wrap_indented(text, "  ", 10);
1962        assert!(rows.len() >= 2);
1963        // Every row still carries the indent and stays within budget.
1964        for row in &rows {
1965            assert!(row.starts_with("  "));
1966        }
1967    }
1968
1969    #[test]
1970    fn wrap_indented_returns_empty_for_zero_inputs() {
1971        assert!(wrap_indented("", "  ", 10).is_empty());
1972        assert!(wrap_indented("hi", "  ", 0).is_empty());
1973    }
1974
1975    #[test]
1976    fn render_body_wrapped_preserves_hanging_indent_on_continuation() {
1977        // Regression: confirm-dialog body text wrapped without keeping the
1978        // "  " prefix on the continuation row. Ratatui's Wrap { trim: false }
1979        // does not preserve leading indent, so the helper pre-wraps with a
1980        // hanging indent. The Linode provider-remove confirm screenshot
1981        // surfaced this; every long-prose dialog body needs the same
1982        // alignment.
1983        let backend = TestBackend::new(20, 6);
1984        let mut terminal = Terminal::new(backend).unwrap();
1985        terminal
1986            .draw(|frame| {
1987                let area = Rect::new(0, 0, 20, 6);
1988                let block = Block::default().borders(Borders::ALL);
1989                let text = vec![
1990                    Line::from(""),
1991                    Line::from(Span::styled(
1992                        "  alpha beta gamma delta epsilon".to_string(),
1993                        theme::muted(),
1994                    )),
1995                ];
1996                render_body_wrapped(frame, area, block, text);
1997            })
1998            .unwrap();
1999        let buf = terminal.backend().buffer().clone();
2000        // Collect every non-blank row inside the inner area.
2001        let mut content_rows: Vec<String> = Vec::new();
2002        for y in 1..(buf.area.height - 1) {
2003            let mut row = String::new();
2004            for x in 1..(buf.area.width - 1) {
2005                row.push_str(buf[(x, y)].symbol());
2006            }
2007            if !row.trim().is_empty() {
2008                content_rows.push(row);
2009            }
2010        }
2011        assert!(
2012            content_rows.len() >= 2,
2013            "the body must wrap to at least two rows: {content_rows:?}"
2014        );
2015        for row in &content_rows {
2016            assert!(
2017                row.starts_with("  "),
2018                "every wrapped row keeps the 2-space hanging indent: {row:?}"
2019            );
2020        }
2021    }
2022
2023    /// Helper: locate the inner row directly above the bottom border and
2024    /// return its content (excluding the side borders). Returns None when
2025    /// the popup has no body rows or no detectable borders.
2026    fn trailing_inner_row(buf: &ratatui::buffer::Buffer) -> Option<String> {
2027        let mut top_y: Option<u16> = None;
2028        let mut bottom_y: Option<u16> = None;
2029        for y in 0..buf.area.height {
2030            let mut row = String::new();
2031            for x in 0..buf.area.width {
2032                row.push_str(buf[(x, y)].symbol());
2033            }
2034            if top_y.is_none() && row.contains('\u{256D}') {
2035                top_y = Some(y);
2036            }
2037            if row.contains('\u{2570}') {
2038                bottom_y = Some(y);
2039            }
2040        }
2041        let (top, bottom) = (top_y?, bottom_y?);
2042        if bottom <= top + 1 {
2043            return None;
2044        }
2045        let trailing_y = bottom - 1;
2046        let mut left_border_x: Option<u16> = None;
2047        for x in 0..buf.area.width {
2048            if buf[(x, trailing_y)].symbol() == "\u{2502}" {
2049                left_border_x = Some(x);
2050                break;
2051            }
2052        }
2053        let left = left_border_x?;
2054        let mut row = String::new();
2055        for x in (left + 1)..buf.area.width {
2056            let sym = buf[(x, trailing_y)].symbol();
2057            if sym == "\u{2502}" {
2058                break;
2059            }
2060            row.push_str(sym);
2061        }
2062        Some(row)
2063    }
2064
2065    #[test]
2066    fn render_confirm_popup_keeps_trailing_blank_when_body_wraps() {
2067        // Design system invariant: a confirm popup's last inner row is
2068        // always blank, regardless of how many wrapped rows the body
2069        // produces. Regression for the Linode "Remove provider?" dialog
2070        // where a long detail sentence used to push its second wrap row
2071        // up against the bottom border because the popup height was
2072        // hard-coded and the wrap continuation overwrote the trailing
2073        // blank.
2074        let backend = TestBackend::new(70, 14);
2075        let mut terminal = Terminal::new(backend).unwrap();
2076        let (app, _dir) = make_app();
2077        terminal
2078            .draw(|frame| {
2079                render_destructive_popup(
2080                    frame,
2081                    "Remove provider?",
2082                    "Remove the \"Linode\" config labelled \"default\"?",
2083                    "Synced hosts stay in ~/.ssh/config. The integration is gone after save.",
2084                    "remove",
2085                    "keep",
2086                    &app,
2087                );
2088            })
2089            .unwrap();
2090        let buf = terminal.backend().buffer().clone();
2091
2092        // Scan rows for the popup's top and bottom borders.
2093        let mut top_y: Option<u16> = None;
2094        let mut bottom_y: Option<u16> = None;
2095        for y in 0..buf.area.height {
2096            let mut row = String::new();
2097            for x in 0..buf.area.width {
2098                row.push_str(buf[(x, y)].symbol());
2099            }
2100            if top_y.is_none() && row.contains('\u{256D}') {
2101                top_y = Some(y);
2102            }
2103            if row.contains('\u{2570}') {
2104                bottom_y = Some(y);
2105            }
2106        }
2107        let top = top_y.expect("popup must render a top border");
2108        let bottom = bottom_y.expect("popup must render a bottom border");
2109        assert!(bottom > top + 2, "popup must have at least one body row");
2110
2111        // The inner row immediately above the bottom border is the trailing
2112        // blank. Read the body span (skip the left/right border columns).
2113        let trailing_y = bottom - 1;
2114        let mut left_border_x: Option<u16> = None;
2115        for x in 0..buf.area.width {
2116            if buf[(x, trailing_y)].symbol() == "\u{2502}" {
2117                left_border_x = Some(x);
2118                break;
2119            }
2120        }
2121        let left = left_border_x.expect("trailing row must have a left side border");
2122        let mut trailing = String::new();
2123        for x in (left + 1)..buf.area.width {
2124            let sym = buf[(x, trailing_y)].symbol();
2125            if sym == "\u{2502}" {
2126                break;
2127            }
2128            trailing.push_str(sym);
2129        }
2130        assert!(
2131            trailing.chars().all(|c| c == ' '),
2132            "trailing inner row above bottom border must be blank, got {trailing:?}"
2133        );
2134    }
2135
2136    #[test]
2137    fn render_confirm_popup_keeps_trailing_blank_when_body_fits_on_one_row() {
2138        // The trailing-blank invariant must hold for short bodies that
2139        // do not wrap, not only for the wrap case. Single-line "Delete
2140        // foo?" confirms used to be 5 rows tall; the helper now sizes
2141        // them to keep an explicit blank above the bottom border.
2142        let backend = TestBackend::new(60, 12);
2143        let mut terminal = Terminal::new(backend).unwrap();
2144        let (app, _dir) = make_app();
2145        terminal
2146            .draw(|frame| {
2147                render_destructive_popup(
2148                    frame,
2149                    "Confirm Delete",
2150                    "Delete \"foo\"?",
2151                    "",
2152                    "delete",
2153                    "keep",
2154                    &app,
2155                );
2156            })
2157            .unwrap();
2158        let buf = terminal.backend().buffer().clone();
2159        let trailing = trailing_inner_row(&buf).expect("popup must have a trailing row");
2160        assert!(
2161            trailing.chars().all(|c| c == ' '),
2162            "trailing inner row above bottom border must be blank, got {trailing:?}"
2163        );
2164    }
2165
2166    #[test]
2167    fn render_confirm_popup_neutral_kind_keeps_trailing_blank() {
2168        // PopupKind::Neutral (import, push key) shares the trailing-blank
2169        // invariant with Destructive. One test pins both code paths so a
2170        // future divergence between PopupKind arms shows up here.
2171        let backend = TestBackend::new(60, 12);
2172        let mut terminal = Terminal::new(backend).unwrap();
2173        let (app, _dir) = make_app();
2174        terminal
2175            .draw(|frame| {
2176                let content = vec![Line::from(Span::styled(
2177                    "  Import 12 hosts from known_hosts?".to_string(),
2178                    theme::bold(),
2179                ))];
2180                let footer_spans = confirm_footer_destructive("import", "skip").to_line().spans;
2181                render_confirm_popup(
2182                    frame,
2183                    52,
2184                    PopupKind::Neutral,
2185                    "Import",
2186                    content,
2187                    footer_spans,
2188                    &app,
2189                );
2190            })
2191            .unwrap();
2192        let buf = terminal.backend().buffer().clone();
2193        let trailing = trailing_inner_row(&buf).expect("popup must have a trailing row");
2194        assert!(
2195            trailing.chars().all(|c| c == ' '),
2196            "neutral popup trailing row must be blank, got {trailing:?}"
2197        );
2198    }
2199
2200    #[test]
2201    fn wrap_block_lines_preserves_hanging_indent_on_multi_span_pattern() {
2202        // Container action confirms compose body rows as
2203        // `[Span::raw("  "), Span::styled(text, style)]`. The wrap helper
2204        // must treat that two-span shape the same as a single-span
2205        // `Line::from(Span::styled("  text", style))`: leading whitespace
2206        // becomes a hanging indent on every continuation row.
2207        let input = vec![Line::from(vec![
2208            Span::raw("  "),
2209            Span::styled(
2210                "Sends SIGTERM, waits 10s, then SIGKILL. Live connections will drop.".to_string(),
2211                theme::muted(),
2212            ),
2213        ])];
2214        let out = wrap_block_lines(input, 32);
2215        assert!(
2216            out.len() >= 2,
2217            "long body must wrap, got {} rows",
2218            out.len()
2219        );
2220        for line in &out {
2221            let rendered: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
2222            assert!(
2223                rendered.starts_with("  "),
2224                "every wrapped row keeps the 2-space hanging indent: {rendered:?}"
2225            );
2226        }
2227    }
2228
2229    #[test]
2230    fn wrap_block_lines_bypasses_aligned_lines_verbatim() {
2231        // Welcome screen uses Line::alignment(Center) for banners and
2232        // typewriter subtitles. wrap_block_lines must NOT extract leading
2233        // whitespace as a hanging indent on aligned lines; ratatui's
2234        // alignment handles the centering and the spans should round-trip
2235        // unchanged (content, style, alignment).
2236        use ratatui::layout::Alignment;
2237        let aligned = Line::from(Span::styled(
2238            "Your SSH config, supercharged.".to_string(),
2239            theme::muted(),
2240        ))
2241        .alignment(Alignment::Center);
2242        let out = wrap_block_lines(vec![aligned], 60);
2243        assert_eq!(out.len(), 1, "aligned line stays a single row");
2244        assert_eq!(out[0].alignment, Some(Alignment::Center));
2245        let rendered: String = out[0].spans.iter().map(|s| s.content.as_ref()).collect();
2246        assert_eq!(rendered, "Your SSH config, supercharged.");
2247    }
2248
2249    #[test]
2250    fn render_body_wrapped_passes_blank_lines_through_unchanged() {
2251        // Blank input lines must stay blank rows so the dialog vertical
2252        // rhythm (top blank, question, blank, detail) is preserved.
2253        let backend = TestBackend::new(20, 6);
2254        let mut terminal = Terminal::new(backend).unwrap();
2255        terminal
2256            .draw(|frame| {
2257                let area = Rect::new(0, 0, 20, 6);
2258                let block = Block::default().borders(Borders::ALL);
2259                let text = vec![
2260                    Line::from(""),
2261                    Line::from(Span::styled("  hello".to_string(), theme::bold())),
2262                    Line::from(""),
2263                    Line::from(Span::styled("  world".to_string(), theme::muted())),
2264                ];
2265                render_body_wrapped(frame, area, block, text);
2266            })
2267            .unwrap();
2268        let buf = terminal.backend().buffer().clone();
2269        let row = |y: u16| -> String {
2270            let mut s = String::new();
2271            for x in 1..(buf.area.width - 1) {
2272                s.push_str(buf[(x, y)].symbol());
2273            }
2274            s
2275        };
2276        assert!(row(1).trim().is_empty(), "row 1 stays blank");
2277        assert!(row(2).contains("hello"), "row 2 holds question");
2278        assert!(row(3).trim().is_empty(), "row 3 stays blank");
2279        assert!(row(4).contains("world"), "row 4 holds detail");
2280    }
2281
2282    #[test]
2283    fn tab_empty_falls_back_to_single_line_on_narrow_areas() {
2284        // Below 44 cols wide, render_tab_empty should NOT panic — it
2285        // should fall back to the single-line render_empty_with_hint
2286        // path. Render to a tiny rect and assert no panic + content.
2287        let backend = ratatui::backend::TestBackend::new(40, 6);
2288        let mut terminal = ratatui::Terminal::new(backend).unwrap();
2289        terminal
2290            .draw(|frame| {
2291                let e = TabEmpty {
2292                    card_title: "X",
2293                    headline: "Cache is empty.",
2294                    explainer: "Nothing yet.",
2295                    hints: &[("R", "refresh")],
2296                };
2297                render_tab_empty(frame, Rect::new(0, 0, 40, 6), &e);
2298            })
2299            .unwrap();
2300    }
2301
2302    #[test]
2303    fn tab_empty_card_renders_on_wide_areas() {
2304        let backend = ratatui::backend::TestBackend::new(100, 20);
2305        let mut terminal = ratatui::Terminal::new(backend).unwrap();
2306        terminal
2307            .draw(|frame| {
2308                let e = TabEmpty {
2309                    card_title: "Containers",
2310                    headline: "No containers cached yet.",
2311                    explainer: "Containers are fetched per host on demand and cached locally.",
2312                    hints: &[("Enter", "open a shell"), ("R", "refresh hosts")],
2313                };
2314                render_tab_empty(frame, Rect::new(0, 0, 100, 20), &e);
2315            })
2316            .unwrap();
2317    }
2318
2319    #[test]
2320    fn ellipsize_respects_unicode_display_width() {
2321        // CJK characters take 2 display columns each.
2322        let s = "東京京都大阪";
2323        // Six glyphs × 2 cols each = 12 cols. Budget 9 → fit 4 glyphs (8 cols) + ellipsis.
2324        let out = ellipsize(s, 9);
2325        assert!(out.ends_with('…'));
2326        let inner = &out[..out.len() - '…'.len_utf8()];
2327        assert!(unicode_width::UnicodeWidthStr::width(inner) <= 8);
2328    }
2329}