Skip to main content

purple_ssh/ui/
mod.rs

1pub(crate) mod activity_chart;
2mod bulk_tag_editor;
3pub(crate) mod confirm_dialog;
4pub(crate) mod container_action_confirm;
5pub(crate) mod container_exec_prompt;
6pub(crate) mod container_host_picker;
7pub(crate) mod container_logs;
8pub(crate) mod containers;
9pub(crate) mod containers_overview;
10pub mod design;
11mod detail_panel;
12mod file_browser;
13mod help;
14mod host_detail;
15pub mod host_form;
16mod host_list;
17mod jump;
18mod key_detail;
19mod key_list;
20pub(crate) mod key_push_picker;
21pub(crate) mod keys_overview;
22mod picker_helpers;
23mod provider_list;
24mod snippet_form;
25mod snippet_host_picker;
26mod snippet_output;
27mod snippet_param_form;
28mod snippet_picker;
29mod snippets_overview;
30pub mod svg_export;
31mod tag_picker;
32pub mod theme;
33mod theme_picker;
34mod tunnel_form;
35mod tunnel_host_picker;
36mod tunnel_list;
37mod tunnels_detail;
38mod tunnels_format;
39pub(crate) mod tunnels_overview;
40mod whats_new;
41#[cfg(test)]
42mod whats_new_tests;
43
44use ratatui::Frame;
45use ratatui::layout::{Constraint, Layout, Rect};
46use ratatui::style::{Modifier, Style};
47use ratatui::text::{Line, Span};
48use ratatui::widgets::Paragraph;
49use unicode_width::UnicodeWidthStr;
50
51use crate::app::{App, Screen, TopPage};
52
53const MIN_WIDTH: u16 = 50;
54const MIN_HEIGHT: u16 = 14;
55
56/// App version string for display. Pinned in test builds, like `build_date`,
57/// so a `Cargo.toml` version bump does not invalidate the help and What's New
58/// goldens. Non-display version logic keeps reading `CARGO_PKG_VERSION`.
59#[cfg(not(test))]
60pub(crate) fn pkg_version() -> &'static str {
61    env!("CARGO_PKG_VERSION")
62}
63
64#[cfg(test)]
65pub(crate) fn pkg_version() -> &'static str {
66    "0.0.0-test"
67}
68
69/// Top-level render dispatcher.
70pub fn render(frame: &mut Frame, app: &mut App, anim: &mut crate::animation::AnimationState) {
71    anim.tick_overlay_anim();
72    let area = frame.area();
73
74    // Terminal too small guard
75    if area.width < MIN_WIDTH || area.height < MIN_HEIGHT {
76        let msg = Paragraph::new(Line::from(vec![
77            Span::styled(design::ICON_WARNING, theme::warning()),
78            Span::raw(" Terminal too small. Need at least 50x14."),
79        ]));
80        frame.render_widget(msg, area);
81        return;
82    }
83
84    // Render host list with animated detail panel width. When an overlay is active,
85    // hide the status so it only appears in the overlay's own footer.
86    // Note: host_list::render does not set app.status_center.status, so the unconditional restore
87    // is safe. If that invariant ever changes, use get_or_insert semantics instead.
88    let has_overlay = !matches!(app.screen, Screen::HostList) || app.jump.is_some();
89    let status = if has_overlay {
90        app.status_center.take_status()
91    } else {
92        None
93    };
94    let detail_progress = anim.detail_anim_progress();
95    match app.top_page {
96        TopPage::Hosts => host_list::render(frame, app, anim.spinner_tick, detail_progress),
97        TopPage::Tunnels => tunnels_overview::render(frame, app, anim),
98        TopPage::Containers => {
99            containers_overview::render(frame, app, anim.spinner_tick, detail_progress)
100        }
101        TopPage::Snippets => snippets_overview::render(frame, app, detail_progress),
102        TopPage::Keys => keys_overview::render(frame, app, anim.spinner_tick),
103    }
104    if let Some(s) = status {
105        app.status_center.restore_status(Some(s));
106    }
107    match &app.screen {
108        Screen::HostList => {
109            render_overlay_close(frame, anim);
110        }
111        Screen::AddHost | Screen::EditHost { .. } => {
112            render_overlay(frame, app, anim, host_form::render);
113        }
114        Screen::ConfirmDelete { alias } => {
115            let alias = alias.clone();
116            render_overlay(frame, app, anim, |frame, app| {
117                confirm_dialog::render(frame, app, &alias)
118            });
119        }
120        Screen::Help { .. } => {
121            render_overlay(frame, app, anim, help::render);
122        }
123        Screen::KeyList => {
124            render_overlay(frame, app, anim, key_list::render);
125        }
126        Screen::KeyDetail { index } => {
127            let index = *index;
128            render_overlay(frame, app, anim, |frame, app| {
129                key_list::render(frame, app);
130                key_detail::render(frame, app, index);
131            });
132        }
133        Screen::KeyPushPicker { key_index } => {
134            let key_index = *key_index;
135            render_overlay(frame, app, anim, move |frame, app| {
136                key_push_picker::render(frame, app, key_index)
137            });
138        }
139        Screen::ConfirmKeyPush { key_index } => {
140            let key_index = *key_index;
141            render_overlay(frame, app, anim, move |frame, app| {
142                let aliases = app.keys.push().committed.clone();
143                confirm_dialog::render_key_push(frame, app, key_index, &aliases)
144            });
145        }
146        Screen::HostDetail { index } => {
147            let index = *index;
148            render_overlay(frame, app, anim, |frame, app| {
149                host_detail::render(frame, app, index)
150            });
151        }
152        Screen::TagPicker => {
153            render_overlay(frame, app, anim, tag_picker::render);
154        }
155        Screen::BulkTagEditor => {
156            render_overlay(frame, app, anim, bulk_tag_editor::render);
157        }
158        Screen::ThemePicker => {
159            render_overlay_nodim(frame, app, anim, theme_picker::render);
160        }
161        Screen::Providers => {
162            render_overlay(frame, app, anim, |frame, app| {
163                provider_list::render_provider_list(frame, app)
164            });
165        }
166        Screen::ProviderForm { id } => {
167            let provider = id.provider.clone();
168            render_overlay(frame, app, anim, |frame, app| {
169                provider_list::render_provider_form(frame, app, &provider)
170            });
171        }
172        Screen::ProviderLabelMigration { provider } => {
173            let provider = provider.clone();
174            render_overlay(frame, app, anim, |frame, app| {
175                provider_list::render_label_migration(frame, app, &provider)
176            });
177        }
178        Screen::TunnelList { alias } => {
179            let alias = alias.clone();
180            render_overlay(frame, app, anim, |frame, app| {
181                tunnel_list::render(frame, app, &alias)
182            });
183        }
184        Screen::TunnelForm { alias, .. } => {
185            let alias = alias.clone();
186            render_overlay(frame, app, anim, |frame, app| {
187                // When the form is reached from the Tunnels overview the
188                // background is already the overview itself, so do not paint
189                // a per-host TunnelList behind it.
190                if !matches!(app.top_page, TopPage::Tunnels) {
191                    tunnel_list::render(frame, app, &alias);
192                }
193                tunnel_form::render(frame, app);
194            });
195        }
196        Screen::TunnelHostPicker => {
197            render_overlay(frame, app, anim, tunnel_host_picker::render);
198        }
199        Screen::ContainerHostPicker => {
200            render_overlay(frame, app, anim, container_host_picker::render);
201        }
202        Screen::ContainerLogs => {
203            render_overlay(frame, app, anim, container_logs::render);
204        }
205        Screen::ConfirmContainerRestart { .. } => {
206            render_overlay(frame, app, anim, container_action_confirm::render_restart);
207        }
208        Screen::ConfirmContainerStop { .. } => {
209            render_overlay(frame, app, anim, container_action_confirm::render_stop);
210        }
211        Screen::ContainerExecPrompt { .. } => {
212            render_overlay(frame, app, anim, container_exec_prompt::render);
213        }
214        Screen::ConfirmStackRestart => {
215            render_overlay(frame, app, anim, container_action_confirm::render_stack);
216        }
217        Screen::ConfirmHostRestartAll => {
218            render_overlay(
219                frame,
220                app,
221                anim,
222                container_action_confirm::render_host_restart_all,
223            );
224        }
225        Screen::ConfirmHostStopAll => {
226            render_overlay(
227                frame,
228                app,
229                anim,
230                container_action_confirm::render_host_stop_all,
231            );
232        }
233        Screen::SnippetPicker => {
234            render_overlay(frame, app, anim, snippet_picker::render);
235        }
236        Screen::SnippetForm => {
237            render_overlay(frame, app, anim, |frame, app| {
238                if app.snippets.form_return_to_tab() {
239                    snippets_overview::render(frame, app, None);
240                } else {
241                    snippet_picker::render(frame, app);
242                }
243                snippet_form::render(frame, app);
244            });
245        }
246        Screen::ConfirmHostKeyReset { hostname, .. } => {
247            let hostname = hostname.clone();
248            render_overlay(frame, app, anim, |frame, app| {
249                confirm_dialog::render_host_key_reset(frame, app, &hostname)
250            });
251        }
252        Screen::FileBrowser { .. } => {
253            render_overlay(frame, app, anim, file_browser::render);
254        }
255        Screen::SnippetOutput => {
256            render_overlay(frame, app, anim, snippet_output::render);
257        }
258        Screen::SnippetParamForm => {
259            render_overlay(frame, app, anim, |frame, app| {
260                if app.snippets.form_return_to_tab() {
261                    snippets_overview::render(frame, app, None);
262                } else {
263                    snippet_picker::render(frame, app);
264                }
265                snippet_param_form::render(frame, app);
266            });
267        }
268        Screen::SnippetHostPicker => {
269            render_overlay(frame, app, anim, snippet_host_picker::render);
270        }
271        Screen::ConfirmRunSnippet => {
272            render_overlay(frame, app, anim, |frame, app| {
273                let aliases = app.snippets.flow_targets().to_vec();
274                confirm_dialog::render_run_snippet(frame, app, &aliases);
275            });
276        }
277        Screen::ConfirmImport { count } => {
278            let count = *count;
279            render_overlay(frame, app, anim, |frame, app| {
280                confirm_dialog::render_confirm_import(frame, app, count)
281            });
282        }
283        Screen::Containers { .. } => {
284            render_overlay(frame, app, anim, containers::render);
285        }
286        Screen::ConfirmVaultSign => {
287            let aliases: Vec<String> = app
288                .vault
289                .pending_sign()
290                .map(|s| s.iter().map(|t| t.alias.clone()).collect())
291                .unwrap_or_default();
292            render_overlay(frame, app, anim, move |frame, app| {
293                confirm_dialog::render_confirm_vault_sign(frame, app, &aliases)
294            });
295        }
296        Screen::ConfirmPurgeStale => {
297            let Some(payload) = app.providers.pending_purge() else {
298                return;
299            };
300            let aliases = payload.aliases.clone();
301            let provider = payload.provider.clone();
302            render_overlay(frame, app, anim, |frame, app| {
303                confirm_dialog::render_confirm_purge_stale(frame, app, &aliases, &provider)
304            });
305        }
306        Screen::Welcome {
307            has_backup,
308            host_count,
309            known_hosts_count,
310        } => {
311            let has_backup = *has_backup;
312            let host_count = *host_count;
313            let known_hosts_count = *known_hosts_count;
314            render_overlay(frame, app, anim, |frame, app| {
315                confirm_dialog::render_welcome(
316                    frame,
317                    app,
318                    has_backup,
319                    host_count,
320                    known_hosts_count,
321                )
322            });
323        }
324        Screen::WhatsNew(_) => {
325            render_overlay(frame, app, anim, |frame, app| whats_new::render(frame, app));
326        }
327    }
328
329    // Jump renders on top of any screen. Rendered directly (not via
330    // render_overlay) to avoid polluting the overlay_close animation buffer,
331    // which is reserved for Screen-driven overlays.
332    if app.jump.is_some() {
333        dim_background(frame);
334        jump::render(frame, app);
335    }
336
337    // Toast overlay renders on top of everything
338    render_toast(frame, app);
339}
340
341/// Render an overlay with dimmed background and scale-clip animation.
342fn render_overlay(
343    frame: &mut Frame,
344    app: &mut App,
345    anim: &mut crate::animation::AnimationState,
346    f: impl FnOnce(&mut Frame, &mut App),
347) {
348    render_overlay_inner(frame, app, anim, true, f);
349}
350
351/// Render an overlay without dimming the background.
352/// Used for the theme picker so the live preview stays visible.
353fn render_overlay_nodim(
354    frame: &mut Frame,
355    app: &mut App,
356    anim: &mut crate::animation::AnimationState,
357    f: impl FnOnce(&mut Frame, &mut App),
358) {
359    render_overlay_inner(frame, app, anim, false, f);
360}
361
362/// Shared overlay render logic. Applies scale-clip animation for smooth open
363/// transitions. Saves the buffer and dim flag together in `OverlayCloseState`
364/// for the close animation. Status messages remain visible so overlay footers
365/// can display them via `render_footer_with_status`.
366fn render_overlay_inner(
367    frame: &mut Frame,
368    app: &mut App,
369    anim: &mut crate::animation::AnimationState,
370    dim: bool,
371    f: impl FnOnce(&mut Frame, &mut App),
372) {
373    if dim {
374        dim_background(frame);
375    }
376
377    // Save host list before overlay renders (needed for open animation).
378    let progress = anim.overlay_anim_progress();
379    let animating_open = progress.is_some();
380    let pre_overlay = if animating_open {
381        Some(frame.buffer_mut().clone())
382    } else {
383        None
384    };
385
386    f(frame, app);
387
388    // Save overlay state for close animation once (first stable frame).
389    // The dim flag is captured alongside the buffer so close knows whether to dim.
390    if !animating_open && anim.overlay_close.is_none() {
391        anim.overlay_close = Some(crate::animation::OverlayCloseState {
392            buffer: frame.buffer_mut().clone(),
393            dimmed: dim,
394        });
395    }
396
397    // Apply opening animation: clip overlay to a growing scaled region.
398    if let (Some(progress), Some(saved)) = (progress, pre_overlay) {
399        if progress < 1.0 {
400            apply_scale_clip(frame, &saved, progress);
401        }
402    }
403}
404
405/// Dim all cells in the frame buffer so the host list behind an overlay appears muted.
406/// On truecolor/ANSI-16 terminals the foreground is replaced with dark grey for a
407/// stronger effect. Cells that already have a coloured background (badges, selected
408/// row) only receive the DIM modifier so their text stays readable.
409fn dim_background(frame: &mut Frame) {
410    use ratatui::style::Color;
411
412    let dim_only = Style::default().add_modifier(Modifier::DIM);
413    let style = match theme::color_mode() {
414        2 => Style::default()
415            .fg(Color::Rgb(
416                design::DIM_FG_RGB.0,
417                design::DIM_FG_RGB.1,
418                design::DIM_FG_RGB.2,
419            ))
420            .add_modifier(Modifier::DIM),
421        1 => Style::default()
422            .fg(Color::DarkGray)
423            .add_modifier(Modifier::DIM),
424        _ => dim_only,
425    };
426    let area = frame.area();
427    let buf = frame.buffer_mut();
428    for y in area.y..area.y + area.height {
429        for x in area.x..area.x + area.width {
430            let has_bg = buf[(x, y)].bg != Color::Reset;
431            buf[(x, y)].set_style(if has_bg { dim_only } else { style });
432        }
433    }
434}
435
436/// Render the close animation: paint saved overlay buffer with shrinking scale clip.
437/// Uses the dim flag captured alongside the buffer so it matches the open animation.
438fn render_overlay_close(frame: &mut Frame, anim: &mut crate::animation::AnimationState) {
439    let is_closing = anim.overlay_anim.as_ref().is_some_and(|a| !a.opening);
440    if !is_closing {
441        return;
442    }
443
444    let progress = match anim.overlay_anim_progress() {
445        Some(p) => p,
446        None => return,
447    };
448
449    if let Some(ref state) = anim.overlay_close {
450        if progress > 0.0 {
451            if state.dimmed {
452                dim_background(frame);
453            }
454            let area = frame.area();
455            let (left, right, top, bottom) = scale_clip_rect(area, progress);
456            for y in top..bottom {
457                for x in left..right {
458                    if let Some(cell) = state.buffer.cell((x, y)) {
459                        frame.buffer_mut()[(x, y)] = cell.clone();
460                    }
461                }
462            }
463        }
464    }
465}
466
467/// Clip the frame buffer to a scaled region centered on screen (zoom effect).
468/// Cells outside the clip are restored from `saved` (the pre-overlay host list).
469fn apply_scale_clip(frame: &mut Frame, saved: &ratatui::buffer::Buffer, progress: f32) {
470    let area = frame.area();
471    let (left, right, top, bottom) = scale_clip_rect(area, progress);
472
473    for y in area.y..area.y + area.height {
474        for x in area.x..area.x + area.width {
475            if y < top || y >= bottom || x < left || x >= right {
476                if let Some(cell) = saved.cell((x, y)) {
477                    frame.buffer_mut()[(x, y)] = cell.clone();
478                }
479            }
480        }
481    }
482}
483
484/// Calculate the visible rect for a scale/zoom animation centered on the area.
485fn scale_clip_rect(area: Rect, progress: f32) -> (u16, u16, u16, u16) {
486    let visible_w = (area.width as f32 * progress).ceil() as u16;
487    let visible_h = (area.height as f32 * progress).ceil() as u16;
488    let left = area.x + area.width.saturating_sub(visible_w) / 2;
489    let right = (left + visible_w).min(area.x + area.width);
490    let top = area.y + area.height.saturating_sub(visible_h) / 2;
491    let bottom = (top + visible_h).min(area.y + area.height);
492    (left, right, top, bottom)
493}
494
495/// Build a padded footer keycap span: ` key ` with reversed style.
496pub fn footer_key_span(key: &str) -> Span<'static> {
497    Span::styled(format!(" {} ", key), theme::footer_key())
498}
499
500/// Build a footer action span: padded keycap + muted label.
501/// Use this for consistent footers across all screens.
502pub fn footer_action(key: &str, label: &str) -> [Span<'static>; 2] {
503    [
504        footer_key_span(key),
505        Span::styled(label.to_string(), theme::muted()),
506    ]
507}
508
509/// Build a primary footer action span: padded keycap + muted label.
510#[deprecated(note = "use design::Footer builder instead")]
511pub fn footer_primary(key: &str, label: &str) -> [Span<'static>; 2] {
512    [
513        footer_key_span(key),
514        Span::styled(label.to_string(), theme::muted()),
515    ]
516}
517
518/// Render footer with shortcuts on the left and "? more" or Info/Progress status on the right.
519/// Keyboard hints are always visible. Toast-class messages are NOT shown here.
520pub fn render_footer_with_help(
521    frame: &mut Frame,
522    area: Rect,
523    footer_spans: Vec<Span<'_>>,
524    app: &App,
525) {
526    // Only show footer-class status (Info or Progress), not toast-class
527    let footer_status = app.status_center.status().filter(|s| !s.is_toast());
528    if let Some(status) = footer_status {
529        render_footer_status_right(frame, area, footer_spans, status);
530        return;
531    }
532    let right_spans = vec![
533        Span::raw("  "),
534        Span::styled(" ? ", theme::footer_key()),
535        Span::styled(" more", theme::muted()),
536    ];
537    let right_width: u16 = right_spans.iter().map(|s| s.width()).sum::<usize>() as u16;
538    let [left, right] =
539        Layout::horizontal([Constraint::Fill(1), Constraint::Length(right_width)]).areas(area);
540    frame.render_widget(Paragraph::new(Line::from(footer_spans)), left);
541    frame.render_widget(Paragraph::new(Line::from(right_spans)), right);
542}
543
544/// Render footer with shortcuts always visible and optional status right-aligned.
545/// Used by overlay screens. Shows any active footer status (Info, Progress, or
546/// sticky messages set via notify_progress).
547pub fn render_footer_with_status(
548    frame: &mut Frame,
549    area: Rect,
550    footer_spans: Vec<Span<'_>>,
551    app: &App,
552) {
553    if let Some(status) = app.status_center.status() {
554        render_footer_status_right(frame, area, footer_spans, status);
555    } else {
556        frame.render_widget(Paragraph::new(Line::from(footer_spans)), area);
557    }
558}
559
560/// Render footer with shortcuts left and a status message right-aligned.
561/// Used for Info and Progress messages only (non-toast).
562fn render_footer_status_right(
563    frame: &mut Frame,
564    area: Rect,
565    mut footer_spans: Vec<Span<'_>>,
566    status: &crate::app::StatusMessage,
567) {
568    let shortcuts_width: usize = footer_spans.iter().map(|s| s.width()).sum();
569    let total_width = area.width as usize;
570
571    let (icon, icon_style, text) = if status.sticky {
572        // Sticky non-error = in-progress action. The spinner character
573        // is embedded in the status text by the caller, so no extra
574        // glyph prefix is needed here.
575        ("", Style::default(), format!(" {} ", status.text))
576    } else if matches!(status.class, crate::app::MessageClass::Error) {
577        (
578            design::ICON_ERROR,
579            theme::error(),
580            format!(" {} ", status.text),
581        )
582    } else if matches!(status.class, crate::app::MessageClass::Warning) {
583        (
584            design::ICON_WARNING,
585            theme::warning(),
586            format!(" {} ", status.text),
587        )
588    } else {
589        ("", theme::muted(), format!(" {} ", status.text))
590    };
591
592    let available = total_width.saturating_sub(shortcuts_width + icon.width() + 2);
593    let display_text = if text.width() > available && available > 3 {
594        format!(" {} ", truncate(&status.text, available - 1))
595    } else {
596        text
597    };
598    let status_width = icon.width() + display_text.width();
599    let gap = total_width.saturating_sub(shortcuts_width + status_width);
600    if gap > 0 {
601        footer_spans.push(Span::raw(" ".repeat(gap)));
602        if !icon.is_empty() {
603            footer_spans.push(Span::styled(icon, icon_style));
604        }
605        footer_spans.push(Span::styled(display_text, icon_style));
606    }
607    frame.render_widget(Paragraph::new(Line::from(footer_spans)), area);
608}
609
610/// Render a toast notification overlay in the bottom-right corner.
611/// Toast is a small bordered box (max 60% of terminal width, 3 rows tall)
612/// with a thin "drain bar" along the bottom border that visualises the
613/// remaining lifetime of the toast (full = just shown, empty = about to
614/// expire). Sticky toasts (Errors, Progress) skip the drain bar.
615fn render_toast(frame: &mut Frame, app: &App) {
616    let toast = match app.status_center.toast() {
617        Some(t) => t,
618        None => return,
619    };
620
621    let area = frame.area();
622    if area.width < MIN_WIDTH || area.height < MIN_HEIGHT {
623        return;
624    }
625
626    let (icon, border_style) = match toast.class {
627        crate::app::MessageClass::Error => (
628            format!("{} ", design::ICON_ERROR),
629            theme::toast_border_error(),
630        ),
631        crate::app::MessageClass::Warning => (
632            format!("{} ", design::ICON_WARNING),
633            theme::toast_border_warning(),
634        ),
635        crate::app::MessageClass::Success
636        | crate::app::MessageClass::Info
637        | crate::app::MessageClass::Progress => (
638            format!("{} ", design::ICON_SUCCESS),
639            theme::toast_border_success(),
640        ),
641    };
642
643    let content = format!("{}{}", icon, toast.text);
644    let content_width = content.width();
645    // +4 for border (2) + padding (2). Cap at 60% of terminal width.
646    let max_width = (area.width as usize * 60 / 100).max(30);
647    let box_width =
648        (content_width.saturating_add(4).min(max_width) as u16).min(area.width.saturating_sub(4));
649    let box_height = 3u16;
650    let x = area.width.saturating_sub(box_width + design::TOAST_INSET_X);
651    // Position above the footer row (which is the last row)
652    let y = area
653        .height
654        .saturating_sub(box_height + design::TOAST_INSET_Y);
655
656    let rect = Rect::new(x, y, box_width, box_height);
657
658    // Clear the area behind the toast so it doesn't blend with content
659    frame.render_widget(ratatui::widgets::Clear, rect);
660
661    let block = ratatui::widgets::Block::default()
662        .borders(ratatui::widgets::Borders::ALL)
663        .border_type(ratatui::widgets::BorderType::Rounded)
664        .border_style(border_style);
665
666    // Truncate content to fit inside box (box_width - 2 for borders - 2 for padding)
667    let inner_width = box_width.saturating_sub(4) as usize;
668    let display = if content_width > inner_width {
669        format!(" {} ", truncate(&content, inner_width))
670    } else {
671        format!(" {} ", content)
672    };
673
674    let paragraph = Paragraph::new(display).block(block);
675    frame.render_widget(paragraph, rect);
676
677    // Drain bar: thin horizontal bar across the bottom border that shrinks
678    // smoothly from full to empty as the toast nears expiry. The bar uses
679    // wall-clock time (Instant) so it animates at render frame-rate
680    // (currently 50ms / 20fps). Skips sticky toasts (Errors, Progress)
681    // where there is no expiry.
682    if !toast.sticky && !matches!(toast.class, crate::app::MessageClass::Progress) {
683        let total_ms = toast.timeout_ms();
684        if total_ms != u64::MAX && total_ms > 0 {
685            let elapsed_ms = toast.created_at.elapsed().as_millis() as u64;
686            // remaining_ratio: 1.0 = just shown, 0.0 = about to expire.
687            let remaining_ratio = if elapsed_ms >= total_ms {
688                0.0
689            } else {
690                1.0 - (elapsed_ms as f64 / total_ms as f64)
691            };
692            let inner_w = box_width.saturating_sub(2);
693            let bar_cols = (remaining_ratio * f64::from(inner_w)) as u16;
694            if bar_cols > 0 {
695                let bar_y = rect.y + rect.height.saturating_sub(1);
696                let bar_x = rect.x + 1;
697                let bar_rect = Rect::new(bar_x, bar_y, bar_cols.min(inner_w), 1);
698                let bar = Paragraph::new(Line::from(Span::styled(
699                    "\u{2501}".repeat(bar_rect.width as usize),
700                    border_style,
701                )));
702                frame.render_widget(bar, bar_rect);
703            }
704        }
705    }
706}
707
708/// Create a centered rect of given percentage within the parent rect.
709pub(crate) fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
710    let vertical = Layout::vertical([
711        Constraint::Percentage((100 - percent_y) / 2),
712        Constraint::Percentage(percent_y),
713        Constraint::Percentage((100 - percent_y) / 2),
714    ])
715    .split(area);
716
717    Layout::horizontal([
718        Constraint::Percentage((100 - percent_x) / 2),
719        Constraint::Percentage(percent_x),
720        Constraint::Percentage((100 - percent_x) / 2),
721    ])
722    .split(vertical[1])[1]
723}
724
725/// Truncate a string to fit within `max_cols` display columns (unicode-width-aware).
726pub(crate) fn truncate(s: &str, max_cols: usize) -> String {
727    use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
728    if s.width() <= max_cols {
729        return s.to_string();
730    }
731    if max_cols <= 1 {
732        return String::new();
733    }
734    let target = max_cols - 1;
735    let mut col = 0;
736    let mut byte_end = 0;
737    for ch in s.chars() {
738        let w = UnicodeWidthChar::width(ch).unwrap_or(0);
739        if col + w > target {
740            break;
741        }
742        col += w;
743        byte_end += ch.len_utf8();
744    }
745    format!("{}…", &s[..byte_end])
746}
747
748/// Render a horizontal divider: ├─ Label ───────┤
749/// The `├` and `┤` connectors use the border style so they blend with the outer
750/// border. The horizontal `─` fill is rendered DIM to keep dividers visually
751/// subordinate to the border.
752pub(crate) fn render_divider(
753    frame: &mut Frame,
754    block_area: Rect,
755    y: u16,
756    label: &str,
757    label_style: Style,
758    border_style: Style,
759) {
760    let dim = theme::muted();
761    let width = block_area.width as usize;
762    let label_w = label.width();
763    let fill = width.saturating_sub(3 + label_w);
764    let line = Line::from(vec![
765        Span::styled("├", border_style),
766        Span::styled("─", dim),
767        Span::styled(label.to_string(), label_style),
768        Span::styled("─".repeat(fill), dim),
769        Span::styled("┤", border_style),
770    ]);
771    frame.render_widget(
772        Paragraph::new(line),
773        Rect::new(block_area.x, y, block_area.width, 1),
774    );
775}
776
777/// Create a centered rect with fixed dimensions.
778pub(crate) fn centered_rect_fixed(width: u16, height: u16, area: Rect) -> Rect {
779    let x = area.x + area.width.saturating_sub(width) / 2;
780    let y = area.y + area.height.saturating_sub(height) / 2;
781    Rect::new(x, y, width.min(area.width), height.min(area.height))
782}
783
784/// Uniform width clamp for picker overlays (ProxyJump, Vault role,
785/// Password source). Keeps all simple list pickers visually aligned at
786/// the same minimum and maximum width regardless of terminal size.
787/// Re-exported under `ui::` for the nearby `#[cfg(test)]` module; the
788/// canonical values live in `design.rs`.
789#[cfg(test)]
790pub(crate) const PICKER_MIN_WIDTH: u16 = crate::ui::design::PICKER_MIN_W;
791#[cfg(test)]
792pub(crate) const PICKER_MAX_WIDTH: u16 = crate::ui::design::PICKER_MAX_W;
793
794/// Width a picker overlay should use on this frame. Delegates to
795/// `design::picker_width` so the picker-width formula lives in one place.
796pub fn picker_overlay_width(frame: &Frame) -> u16 {
797    design::picker_width(frame)
798}
799
800/// Minimum overlay height required to render rounded borders plus at
801/// least one row of content. Below this the overlay is skipped so
802/// ratatui does not collapse the borders into an unreadable glyph
803/// soup on extremely short terminals.
804pub const PICKER_MIN_HEIGHT: u16 = 3;
805
806/// Compose a picker block title, gracefully dropping a hint that would
807/// not fit inside the usable title width (overlay width minus the two
808/// border columns). Protects against silent clipping of picker-specific
809/// keybindings when the overlay is constrained by a narrow terminal.
810fn picker_title_text(title: &str, title_hint: Option<&str>, width: u16) -> String {
811    let inner = (width as usize).saturating_sub(2);
812    match title_hint {
813        Some(hint) => {
814            let full = format!(" {} · {} ", title, hint);
815            if full.chars().count() <= inner {
816                full
817            } else {
818                format!(" {} ", title)
819            }
820        }
821        None => format!(" {} ", title),
822    }
823}
824
825/// Render a list-style picker overlay with the canonical purple look:
826/// fixed width range (`design::PICKER_MIN_W..=PICKER_MAX_W`), height grows
827/// with item count up to `design::PICKER_MAX_H`, rounded border, muted
828/// accent, highlight on the selected row and a two-space highlight gutter.
829///
830/// The `title_hint`, if present and space permits, is appended to the block
831/// title separated by a middle dot so picker-specific keybindings (e.g.
832/// Ctrl+D for Password Source) can be surfaced without adding a divergent
833/// footer. If the full hinted title would overflow, the hint is dropped
834/// rather than silently clipped.
835///
836/// All pickers share this single helper so they look identical regardless
837/// of which form field opened them. The previous `_wide` variant has been
838/// removed; pickers that need more horizontal room (Key Picker's 3-column
839/// layout) truncate their secondary metadata instead of widening.
840pub fn render_picker_overlay<'a>(
841    frame: &mut Frame,
842    title: &str,
843    title_hint: Option<&str>,
844    items: Vec<ratatui::widgets::ListItem<'a>>,
845    list_state: &mut ratatui::widgets::ListState,
846) {
847    use ratatui::widgets::{Block, BorderType, Clear, List};
848
849    let width = picker_overlay_width(frame);
850    let content_rows = items.len() as u16;
851    let height = (content_rows + 2).min(design::PICKER_MAX_H);
852    if height < PICKER_MIN_HEIGHT {
853        return;
854    }
855    let area = centered_rect_fixed(width, height, frame.area());
856    if area.height < PICKER_MIN_HEIGHT {
857        return;
858    }
859    frame.render_widget(Clear, area);
860
861    let block = Block::bordered()
862        .border_type(BorderType::Rounded)
863        .title(Span::styled(
864            picker_title_text(title, title_hint, width),
865            theme::brand(),
866        ))
867        .border_style(theme::border_dim());
868
869    let list = List::new(items)
870        .block(block)
871        .highlight_style(theme::selected_row())
872        .highlight_symbol(design::LIST_HIGHLIGHT);
873
874    frame.render_stateful_widget(list, area, list_state);
875}
876
877/// Render an empty-state picker overlay with a muted message in place of
878/// a list. Used when a picker is opened with no candidates (e.g. no
879/// other hosts to use as ProxyJump).
880pub fn render_picker_empty_overlay(frame: &mut Frame, title: &str, message: &str) {
881    use ratatui::widgets::{Block, BorderType, Clear};
882
883    let width = picker_overlay_width(frame);
884    let area = centered_rect_fixed(width, 5, frame.area());
885    if area.height < PICKER_MIN_HEIGHT {
886        return;
887    }
888    frame.render_widget(Clear, area);
889    let block = Block::bordered()
890        .border_type(BorderType::Rounded)
891        .title(Span::styled(
892            picker_title_text(title, None, width),
893            theme::brand(),
894        ))
895        .border_style(theme::border_dim());
896    let msg = Paragraph::new(Line::from(Span::styled(
897        format!("  {}", message),
898        theme::muted(),
899    )))
900    .block(block);
901    frame.render_widget(msg, area);
902}
903
904#[cfg(test)]
905mod tests {
906    use ratatui::Terminal;
907    use ratatui::backend::TestBackend;
908    use ratatui::style::Color;
909
910    use super::*;
911
912    fn make_app() -> App {
913        let config = crate::ssh_config::model::SshConfigFile {
914            elements: crate::ssh_config::model::SshConfigFile::parse_content(""),
915            path: tempfile::tempdir()
916                .expect("tempdir")
917                .keep()
918                .join("test_config"),
919            crlf: false,
920            bom: false,
921        };
922        App::new(config)
923    }
924
925    #[test]
926    fn dim_background_applies_dim_modifier() {
927        let backend = TestBackend::new(10, 3);
928        let mut terminal = Terminal::new(backend).unwrap();
929        terminal
930            .draw(|frame| {
931                // Write some text so cells are non-empty.
932                let area = frame.area();
933                frame.render_widget(ratatui::widgets::Paragraph::new("hello"), area);
934                dim_background(frame);
935                let buf = frame.buffer_mut();
936                for x in 0..5 {
937                    assert!(
938                        buf[(x, 0)].modifier.contains(Modifier::DIM),
939                        "cell ({x}, 0) should have DIM modifier"
940                    );
941                }
942            })
943            .unwrap();
944    }
945
946    #[test]
947    fn dim_background_preserves_bg_color_cells() {
948        let backend = TestBackend::new(10, 3);
949        let mut terminal = Terminal::new(backend).unwrap();
950        terminal
951            .draw(|frame| {
952                let buf = frame.buffer_mut();
953                // Set a cell with a background color.
954                buf[(0, 0)].set_bg(Color::Blue);
955                buf[(0, 0)].set_fg(Color::White);
956                dim_background(frame);
957                let buf = frame.buffer_mut();
958                // Cells with bg color should only get DIM, not fg recolor.
959                assert!(buf[(0, 0)].modifier.contains(Modifier::DIM));
960                assert_eq!(buf[(0, 0)].fg, Color::White);
961            })
962            .unwrap();
963    }
964
965    #[test]
966    fn render_overlay_inner_captures_dimmed_true() {
967        let backend = TestBackend::new(80, 24);
968        let mut terminal = Terminal::new(backend).unwrap();
969        let mut app = make_app();
970        let mut anim = crate::animation::AnimationState::new();
971        terminal
972            .draw(|frame| {
973                render_overlay_inner(frame, &mut app, &mut anim, true, |_frame, _app| {});
974            })
975            .unwrap();
976        let close = anim.overlay_close.as_ref().unwrap();
977        assert!(close.dimmed);
978    }
979
980    #[test]
981    fn render_overlay_inner_captures_dimmed_false() {
982        let backend = TestBackend::new(80, 24);
983        let mut terminal = Terminal::new(backend).unwrap();
984        let mut app = make_app();
985        let mut anim = crate::animation::AnimationState::new();
986        terminal
987            .draw(|frame| {
988                render_overlay_inner(frame, &mut app, &mut anim, false, |_frame, _app| {});
989            })
990            .unwrap();
991        let close = anim.overlay_close.as_ref().unwrap();
992        assert!(!close.dimmed);
993    }
994
995    #[test]
996    fn render_overlay_inner_preserves_status_during_render() {
997        let backend = TestBackend::new(80, 24);
998        let mut terminal = Terminal::new(backend).unwrap();
999        let mut app = make_app();
1000        app.notify_info("test");
1001        let mut anim = crate::animation::AnimationState::new();
1002        terminal
1003            .draw(|frame| {
1004                render_overlay_inner(frame, &mut app, &mut anim, true, |_frame, app| {
1005                    assert!(
1006                        app.status_center.status().is_some(),
1007                        "status should be visible during overlay render"
1008                    );
1009                });
1010            })
1011            .unwrap();
1012        assert!(
1013            app.status_center.status().is_some(),
1014            "status should still be present after overlay render"
1015        );
1016    }
1017
1018    #[test]
1019    fn overlay_footer_renders_status_text_in_buffer() {
1020        let backend = TestBackend::new(80, 3);
1021        let mut terminal = Terminal::new(backend).unwrap();
1022        let mut app = make_app();
1023        app.notify_info("sync failed");
1024        let mut anim = crate::animation::AnimationState::new();
1025        terminal
1026            .draw(|frame| {
1027                render_overlay_inner(frame, &mut app, &mut anim, false, |frame, app| {
1028                    let area = frame.area();
1029                    // Render a footer row using the last line of the frame.
1030                    let footer = ratatui::layout::Rect::new(0, area.height - 1, area.width, 1);
1031                    render_footer_with_status(frame, footer, vec![], app);
1032                });
1033            })
1034            .unwrap();
1035        // Read the last row from the buffer and check the status text is present.
1036        let buf = terminal.backend().buffer();
1037        let mut line = String::new();
1038        for x in 0..80 {
1039            line.push_str(buf[(x, 2)].symbol());
1040        }
1041        assert!(
1042            line.contains("sync failed"),
1043            "status text should appear in overlay footer buffer, got: {line:?}"
1044        );
1045    }
1046
1047    #[test]
1048    fn host_list_footer_has_no_status_when_overlay_active() {
1049        let backend = TestBackend::new(80, 24);
1050        let mut terminal = Terminal::new(backend).unwrap();
1051        let mut app = make_app();
1052        app.notify_info("sync failed");
1053        // Simulate an overlay being active.
1054        app.screen = crate::app::Screen::Help {
1055            return_screen: Box::new(crate::app::Screen::HostList),
1056        };
1057        let has_overlay = !matches!(app.screen, crate::app::Screen::HostList);
1058        assert!(has_overlay, "should detect overlay");
1059        // Mimic render(): take status during host list render, then restore.
1060        let status = app.status_center.take_status();
1061        terminal
1062            .draw(|frame| {
1063                let area = frame.area();
1064                let footer = ratatui::layout::Rect::new(0, area.height - 1, area.width, 1);
1065                render_footer_with_status(frame, footer, vec![], &app);
1066            })
1067            .unwrap();
1068        // Host list footer should NOT contain the status text.
1069        let buf = terminal.backend().buffer();
1070        let mut line = String::new();
1071        for x in 0..80 {
1072            line.push_str(buf[(x, 23)].symbol());
1073        }
1074        assert!(
1075            !line.contains("sync failed"),
1076            "host list footer should not show status when overlay active, got: {line:?}"
1077        );
1078        // Restore and verify status is preserved for overlay.
1079        if let Some(s) = status {
1080            app.status_center.restore_status(Some(s));
1081        }
1082        assert!(
1083            app.status_center.status().is_some(),
1084            "status should be restored for overlay footer"
1085        );
1086    }
1087
1088    #[test]
1089    fn render_overlay_inner_saves_close_state() {
1090        let backend = TestBackend::new(80, 24);
1091        let mut terminal = Terminal::new(backend).unwrap();
1092        let mut app = make_app();
1093        let mut anim = crate::animation::AnimationState::new();
1094        assert!(anim.overlay_close.is_none());
1095        terminal
1096            .draw(|frame| {
1097                render_overlay_inner(frame, &mut app, &mut anim, true, |_frame, _app| {});
1098            })
1099            .unwrap();
1100        assert!(anim.overlay_close.is_some());
1101    }
1102
1103    #[test]
1104    fn scale_clip_rect_full_progress_covers_area() {
1105        let area = Rect::new(0, 0, 80, 24);
1106        let (left, right, top, bottom) = scale_clip_rect(area, 1.0);
1107        assert_eq!(left, 0);
1108        assert_eq!(right, 80);
1109        assert_eq!(top, 0);
1110        assert_eq!(bottom, 24);
1111    }
1112
1113    #[test]
1114    fn scale_clip_rect_zero_progress_is_empty() {
1115        let area = Rect::new(0, 0, 80, 24);
1116        let (left, right, top, bottom) = scale_clip_rect(area, 0.0);
1117        assert_eq!(right - left, 0);
1118        assert_eq!(bottom - top, 0);
1119    }
1120
1121    #[test]
1122    fn scale_clip_rect_half_progress_centered() {
1123        let area = Rect::new(0, 0, 80, 24);
1124        let (left, right, top, bottom) = scale_clip_rect(area, 0.5);
1125        let w = right - left;
1126        let h = bottom - top;
1127        assert_eq!(w, 40);
1128        assert_eq!(h, 12);
1129        // Centered
1130        assert_eq!(left, 20);
1131        assert_eq!(top, 6);
1132    }
1133
1134    // --- render_overlay_close tests ---
1135
1136    /// Helper: set up a closing animation at ~50% progress with a saved buffer and dim flag.
1137    fn setup_close_anim(anim: &mut crate::animation::AnimationState, dimmed: bool) {
1138        use std::time::{Duration, Instant};
1139        let duration = Duration::from_secs(1);
1140        anim.overlay_close = Some(crate::animation::OverlayCloseState {
1141            buffer: ratatui::buffer::Buffer::empty(Rect::new(0, 0, 20, 5)),
1142            dimmed,
1143        });
1144        // Start halfway through the close animation so the clip is small enough
1145        // that corner cells remain outside it (and thus show the dim effect).
1146        anim.overlay_anim = Some(crate::animation::OverlayAnim {
1147            start: Instant::now() - duration / 2,
1148            opening: false,
1149            duration_ms: duration.as_millis(),
1150        });
1151    }
1152
1153    #[test]
1154    fn render_overlay_close_dims_when_close_state_dimmed() {
1155        let backend = TestBackend::new(20, 5);
1156        let mut terminal = Terminal::new(backend).unwrap();
1157        let mut anim = crate::animation::AnimationState::new();
1158        setup_close_anim(&mut anim, true);
1159        terminal
1160            .draw(|frame| {
1161                // Write visible text so we can detect dimming.
1162                let area = frame.area();
1163                frame.render_widget(ratatui::widgets::Paragraph::new("ABCDE"), area);
1164                render_overlay_close(frame, &mut anim);
1165                // Cells outside the shrinking clip should be dimmed.
1166                let buf = frame.buffer_mut();
1167                // Corner cell (0,4) is outside any reasonable clip at the start of close.
1168                assert!(
1169                    buf[(0, 4)].modifier.contains(Modifier::DIM),
1170                    "background should be dimmed during close of a dimmed overlay"
1171                );
1172            })
1173            .unwrap();
1174    }
1175
1176    #[test]
1177    fn render_overlay_close_no_dim_when_close_state_not_dimmed() {
1178        let backend = TestBackend::new(20, 5);
1179        let mut terminal = Terminal::new(backend).unwrap();
1180        let mut anim = crate::animation::AnimationState::new();
1181        setup_close_anim(&mut anim, false);
1182        terminal
1183            .draw(|frame| {
1184                let area = frame.area();
1185                frame.render_widget(ratatui::widgets::Paragraph::new("ABCDE"), area);
1186                render_overlay_close(frame, &mut anim);
1187                let buf = frame.buffer_mut();
1188                assert!(
1189                    !buf[(0, 4)].modifier.contains(Modifier::DIM),
1190                    "background should NOT be dimmed during close of a non-dimmed overlay"
1191                );
1192            })
1193            .unwrap();
1194    }
1195
1196    #[test]
1197    fn render_overlay_close_skips_when_not_closing() {
1198        let backend = TestBackend::new(20, 5);
1199        let mut terminal = Terminal::new(backend).unwrap();
1200        let mut anim = crate::animation::AnimationState::new();
1201        // No close animation set up.
1202        terminal
1203            .draw(|frame| {
1204                let area = frame.area();
1205                frame.render_widget(ratatui::widgets::Paragraph::new("ABCDE"), area);
1206                render_overlay_close(frame, &mut anim);
1207                let buf = frame.buffer_mut();
1208                // Nothing should change.
1209                assert!(
1210                    !buf[(0, 0)].modifier.contains(Modifier::DIM),
1211                    "no dimming when there is no close animation"
1212                );
1213            })
1214            .unwrap();
1215    }
1216
1217    // --- apply_scale_clip tests ---
1218
1219    #[test]
1220    fn apply_scale_clip_restores_cells_outside_clip() {
1221        let backend = TestBackend::new(10, 4);
1222        let mut terminal = Terminal::new(backend).unwrap();
1223        terminal
1224            .draw(|frame| {
1225                let area = frame.area();
1226                // Render overlay content (fills entire buffer).
1227                frame.render_widget(ratatui::widgets::Paragraph::new("OVERLAY OK"), area);
1228
1229                // Create a "saved" background buffer with different content.
1230                let mut saved = ratatui::buffer::Buffer::empty(area);
1231                for x in 0..area.width {
1232                    for y in 0..area.height {
1233                        saved[(x, y)].set_symbol("B");
1234                    }
1235                }
1236
1237                // Apply clip at 50% progress: center 5x2 region keeps overlay,
1238                // outer cells restored from saved.
1239                apply_scale_clip(frame, &saved, 0.5);
1240
1241                let buf = frame.buffer_mut();
1242                // (0,0) is outside the clip and should be restored to "B".
1243                assert_eq!(buf[(0, 0)].symbol(), "B");
1244                // Center cell should still have overlay content.
1245                let cx = area.width / 2;
1246                let cy = area.height / 2;
1247                assert_ne!(buf[(cx, cy)].symbol(), "B");
1248            })
1249            .unwrap();
1250    }
1251
1252    #[test]
1253    fn render_toast_shows_confirmation_in_buffer() {
1254        let backend = TestBackend::new(80, 24);
1255        let mut terminal = Terminal::new(backend).unwrap();
1256        let mut app = make_app();
1257        app.notify("Copied web01"); // Goes to toast (Confirmation)
1258        terminal
1259            .draw(|frame| {
1260                render_toast(frame, &app);
1261            })
1262            .unwrap();
1263        let buf = terminal.backend().buffer();
1264        let mut found = false;
1265        for y in 0..24 {
1266            let mut line = String::new();
1267            for x in 0..80 {
1268                line.push_str(buf[(x, y)].symbol());
1269            }
1270            if line.contains("Copied web01") {
1271                found = true;
1272                break;
1273            }
1274        }
1275        assert!(found, "toast text should appear in buffer");
1276    }
1277
1278    #[test]
1279    fn render_toast_not_shown_when_no_toast() {
1280        let backend = TestBackend::new(80, 24);
1281        let mut terminal = Terminal::new(backend).unwrap();
1282        let app = make_app();
1283        assert!(app.status_center.toast().is_none());
1284        terminal
1285            .draw(|frame| {
1286                render_toast(frame, &app);
1287            })
1288            .unwrap();
1289        // Should not panic, just no-op
1290    }
1291
1292    #[test]
1293    fn render_toast_shows_error_with_error_icon() {
1294        let backend = TestBackend::new(80, 24);
1295        let mut terminal = Terminal::new(backend).unwrap();
1296        let mut app = make_app();
1297        app.notify_error("Connection failed"); // Routes to Error toast
1298        terminal
1299            .draw(|frame| {
1300                render_toast(frame, &app);
1301            })
1302            .unwrap();
1303        let buf = terminal.backend().buffer();
1304        let mut found_text = false;
1305        let mut found_icon = false;
1306        for y in 0..24 {
1307            let mut line = String::new();
1308            for x in 0..80 {
1309                line.push_str(buf[(x, y)].symbol());
1310            }
1311            if line.contains("Connection failed") {
1312                found_text = true;
1313            }
1314            // Errors use the heavy multiplication X glyph (ICON_ERROR),
1315            // distinct from the warning sign used by Warning-class toasts.
1316            if line.contains(design::ICON_ERROR) {
1317                found_icon = true;
1318            }
1319        }
1320        assert!(found_text, "error text should appear in buffer");
1321        assert!(found_icon, "error should show error icon");
1322    }
1323
1324    #[test]
1325    fn render_toast_shows_warning_with_alert_icon() {
1326        let backend = TestBackend::new(80, 24);
1327        let mut terminal = Terminal::new(backend).unwrap();
1328        let mut app = make_app();
1329        app.notify_warning("Stale host configuration");
1330        terminal.draw(|frame| render_toast(frame, &app)).unwrap();
1331        let buf = terminal.backend().buffer();
1332        let mut found_text = false;
1333        let mut found_icon = false;
1334        for y in 0..24 {
1335            let mut line = String::new();
1336            for x in 0..80 {
1337                line.push_str(buf[(x, y)].symbol());
1338            }
1339            if line.contains("Stale host configuration") {
1340                found_text = true;
1341            }
1342            // Warnings keep the warning sign glyph; errors use a different glyph.
1343            if line.contains(design::ICON_WARNING) {
1344                found_icon = true;
1345            }
1346        }
1347        assert!(found_text, "warning text should appear in buffer");
1348        assert!(
1349            found_icon,
1350            "warning should show warning sign (ICON_WARNING)"
1351        );
1352    }
1353
1354    #[test]
1355    fn render_toast_drain_bar_shrinks_over_time() {
1356        // Non-sticky toast (Success) → drain bar should appear at the
1357        // bottom border row and shrink smoothly as wall-clock time
1358        // advances toward the timeout. At created_at = now the bar fills
1359        // the inner width; at elapsed >= timeout_ms the bar is gone.
1360        use std::time::{Duration, Instant};
1361
1362        let backend = TestBackend::new(80, 24);
1363        let mut terminal = Terminal::new(backend).unwrap();
1364        let mut app = make_app();
1365        app.notify("Saved profile changes successfully");
1366        let timeout_ms = app.status_center.toast().unwrap().timeout_ms();
1367
1368        // Helper: count `\u{2501}` cells in the rendered buffer.
1369        let count_drain_bar = |app: &App, terminal: &mut Terminal<TestBackend>| -> usize {
1370            terminal.draw(|frame| render_toast(frame, app)).unwrap();
1371            let buf = terminal.backend().buffer();
1372            let mut count = 0;
1373            for y in 0..24 {
1374                for x in 0..80 {
1375                    if buf[(x, y)].symbol() == "\u{2501}" {
1376                        count += 1;
1377                    }
1378                }
1379            }
1380            count
1381        };
1382
1383        // Just created → full bar.
1384        let bar_full = count_drain_bar(&app, &mut terminal);
1385        assert!(
1386            bar_full > 0,
1387            "non-sticky Success toast must render a drain bar when just created"
1388        );
1389
1390        // Simulate halfway elapsed by backdating created_at.
1391        if let Some(toast) = app.status_center.toast_mut() {
1392            toast.created_at = Instant::now() - Duration::from_millis(timeout_ms / 2);
1393        }
1394        let bar_half = count_drain_bar(&app, &mut terminal);
1395        assert!(
1396            bar_half < bar_full,
1397            "drain bar must shrink as time passes ({} >= {})",
1398            bar_half,
1399            bar_full
1400        );
1401
1402        // Simulate past expiry.
1403        if let Some(toast) = app.status_center.toast_mut() {
1404            toast.created_at = Instant::now() - Duration::from_millis(timeout_ms + 1000);
1405        }
1406        let bar_empty = count_drain_bar(&app, &mut terminal);
1407        assert_eq!(
1408            bar_empty, 0,
1409            "drain bar must be empty once elapsed time exceeds timeout"
1410        );
1411    }
1412
1413    #[test]
1414    fn render_toast_drain_bar_absent_for_sticky_error() {
1415        // Sticky toasts (Errors, Progress) carry no expiry, so no drain bar.
1416        let backend = TestBackend::new(80, 24);
1417        let mut terminal = Terminal::new(backend).unwrap();
1418        let mut app = make_app();
1419        app.notify_error("Permission denied");
1420        terminal.draw(|frame| render_toast(frame, &app)).unwrap();
1421        let buf = terminal.backend().buffer();
1422        let mut count = 0;
1423        for y in 0..24 {
1424            for x in 0..80 {
1425                if buf[(x, y)].symbol() == "\u{2501}" {
1426                    count += 1;
1427                }
1428            }
1429        }
1430        assert_eq!(
1431            count, 0,
1432            "sticky error toast must NOT render a drain bar (nothing to drain)"
1433        );
1434    }
1435
1436    #[test]
1437    fn footer_shows_hints_when_toast_active() {
1438        let backend = TestBackend::new(80, 24);
1439        let mut terminal = Terminal::new(backend).unwrap();
1440        let mut app = make_app();
1441        app.notify("Copied"); // Goes to toast, NOT footer
1442        assert!(app.status_center.toast().is_some());
1443        assert!(app.status_center.status().is_none()); // Footer should be clear
1444        let footer_spans = vec![
1445            Span::styled(" ? ", theme::footer_key()),
1446            Span::styled(" more", theme::muted()),
1447        ];
1448        terminal
1449            .draw(|frame| {
1450                let area = Rect::new(0, 23, 80, 1);
1451                render_footer_with_help(frame, area, footer_spans, &app);
1452            })
1453            .unwrap();
1454        let buf = terminal.backend().buffer();
1455        let mut line = String::new();
1456        for x in 0..80 {
1457            line.push_str(buf[(x, 23)].symbol());
1458        }
1459        assert!(
1460            line.contains("more"),
1461            "footer should show hints when only toast is active"
1462        );
1463    }
1464
1465    #[test]
1466    fn footer_shows_info_status_instead_of_help_hint() {
1467        let backend = TestBackend::new(80, 24);
1468        let mut terminal = Terminal::new(backend).unwrap();
1469        let mut app = make_app();
1470        app.notify_info("Syncing AWS...");
1471        assert!(app.status_center.status().is_some());
1472        assert!(app.status_center.toast().is_none());
1473        let footer_spans = vec![
1474            Span::styled(" ? ", theme::footer_key()),
1475            Span::styled(" more", theme::muted()),
1476        ];
1477        terminal
1478            .draw(|frame| {
1479                let area = Rect::new(0, 23, 80, 1);
1480                render_footer_with_help(frame, area, footer_spans, &app);
1481            })
1482            .unwrap();
1483        let buf = terminal.backend().buffer();
1484        let mut line = String::new();
1485        for x in 0..80 {
1486            line.push_str(buf[(x, 23)].symbol());
1487        }
1488        assert!(
1489            line.contains("Syncing AWS"),
1490            "footer should show info status, got: {line:?}"
1491        );
1492    }
1493
1494    #[test]
1495    fn apply_scale_clip_full_progress_keeps_all_overlay() {
1496        let backend = TestBackend::new(10, 4);
1497        let mut terminal = Terminal::new(backend).unwrap();
1498        terminal
1499            .draw(|frame| {
1500                let area = frame.area();
1501                frame.render_widget(ratatui::widgets::Paragraph::new("OVERLAY OK"), area);
1502                let mut saved = ratatui::buffer::Buffer::empty(area);
1503                for x in 0..area.width {
1504                    for y in 0..area.height {
1505                        saved[(x, y)].set_symbol("B");
1506                    }
1507                }
1508                // Full progress: nothing should be restored from saved.
1509                apply_scale_clip(frame, &saved, 1.0);
1510                let buf = frame.buffer_mut();
1511                assert_eq!(buf[(0, 0)].symbol(), "O"); // First char of "OVERLAY OK"
1512            })
1513            .unwrap();
1514    }
1515
1516    /// Picker overlay width should clamp narrow terminals to
1517    /// `PICKER_MIN_WIDTH` so the layout never collapses below the
1518    /// minimum that the item renderers assume.
1519    #[test]
1520    fn picker_overlay_width_clamps_narrow_terminal() {
1521        let backend = TestBackend::new(30, 10);
1522        let mut terminal = Terminal::new(backend).unwrap();
1523        terminal
1524            .draw(|frame| {
1525                assert_eq!(picker_overlay_width(frame), PICKER_MIN_WIDTH);
1526            })
1527            .unwrap();
1528    }
1529
1530    /// Picker overlay width should cap wide terminals at
1531    /// `PICKER_MAX_WIDTH` so the overlay stays centered and compact
1532    /// instead of stretching across the full width of a large terminal.
1533    #[test]
1534    fn picker_overlay_width_caps_wide_terminal() {
1535        let backend = TestBackend::new(200, 40);
1536        let mut terminal = Terminal::new(backend).unwrap();
1537        terminal
1538            .draw(|frame| {
1539                assert_eq!(picker_overlay_width(frame), PICKER_MAX_WIDTH);
1540            })
1541            .unwrap();
1542    }
1543
1544    /// Terminals between `PICKER_MIN_WIDTH` and `PICKER_MAX_WIDTH`
1545    /// should use the terminal's actual width so the overlay fills
1546    /// available space without exceeding the cap.
1547    #[test]
1548    fn picker_overlay_width_passes_through_midrange() {
1549        // PICKER_MIN_W (60) < 66 < PICKER_MAX_W (72), so passes through unclamped.
1550        let backend = TestBackend::new(66, 20);
1551        let mut terminal = Terminal::new(backend).unwrap();
1552        terminal
1553            .draw(|frame| {
1554                assert_eq!(picker_overlay_width(frame), 66);
1555            })
1556            .unwrap();
1557    }
1558
1559    /// Concatenate every row of a terminal buffer into a single string
1560    /// so tests can grep for rendered content without pinning the exact
1561    /// centering offset of an overlay.
1562    fn buffer_dump(buf: &ratatui::buffer::Buffer) -> String {
1563        let mut out = String::new();
1564        for y in 0..buf.area.height {
1565            for x in 0..buf.area.width {
1566                out.push_str(buf[(x, y)].symbol());
1567            }
1568            out.push('\n');
1569        }
1570        out
1571    }
1572
1573    /// `render_picker_overlay` must surface picker-specific keybindings
1574    /// via the block title rather than a divergent footer. A title hint
1575    /// should appear as `" Title · hint "` in the rendered buffer so
1576    /// all pickers share the same outer shape.
1577    #[test]
1578    fn render_picker_overlay_writes_title_hint_to_border() {
1579        use ratatui::widgets::{ListItem, ListState};
1580        let backend = TestBackend::new(80, 10);
1581        let mut terminal = Terminal::new(backend).unwrap();
1582        terminal
1583            .draw(|frame| {
1584                let mut state = ListState::default();
1585                let items = vec![ListItem::new("one"), ListItem::new("two")];
1586                render_picker_overlay(
1587                    frame,
1588                    "Password Source",
1589                    Some("Ctrl+D: global default"),
1590                    items,
1591                    &mut state,
1592                );
1593                let dump = buffer_dump(frame.buffer_mut());
1594                assert!(
1595                    dump.contains("Password Source · Ctrl+D: global default"),
1596                    "rendered buffer must contain the hinted title, got:\n{dump}"
1597                );
1598            })
1599            .unwrap();
1600    }
1601
1602    /// A picker without a hint should render the title as-is, with no
1603    /// middle-dot separator. Prevents a regression where a bare `None`
1604    /// accidentally introduces stray punctuation into the title.
1605    #[test]
1606    fn render_picker_overlay_plain_title_has_no_dot_separator() {
1607        use ratatui::widgets::{ListItem, ListState};
1608        let backend = TestBackend::new(80, 10);
1609        let mut terminal = Terminal::new(backend).unwrap();
1610        terminal
1611            .draw(|frame| {
1612                let mut state = ListState::default();
1613                let items = vec![ListItem::new("one")];
1614                render_picker_overlay(frame, "ProxyJump", None, items, &mut state);
1615                let dump = buffer_dump(frame.buffer_mut());
1616                assert!(dump.contains("ProxyJump"));
1617                assert!(
1618                    !dump.contains('·'),
1619                    "plain title must not emit a middle-dot separator, got:\n{dump}"
1620                );
1621            })
1622            .unwrap();
1623    }
1624
1625    /// `render_picker_overlay` must cap the rendered height at
1626    /// `design::PICKER_MAX_H` even when the item count would demand more.
1627    /// The overlay is pinned at exactly that height so a long list
1628    /// scrolls inside the overlay instead of growing off-screen.
1629    #[test]
1630    fn render_picker_overlay_caps_height_at_design_max() {
1631        use ratatui::widgets::{ListItem, ListState};
1632        let backend = TestBackend::new(80, 40);
1633        let mut terminal = Terminal::new(backend).unwrap();
1634        terminal
1635            .draw(|frame| {
1636                let mut state = ListState::default();
1637                let items: Vec<ListItem> = (0..40)
1638                    .map(|i| ListItem::new(format!("item {}", i)))
1639                    .collect();
1640                render_picker_overlay(frame, "Many", None, items, &mut state);
1641                let dump = buffer_dump(frame.buffer_mut());
1642                // Count rows that contain any overlay glyph (border or
1643                // title or list content) to assert the overlay itself
1644                // is exactly `PICKER_MAX_H` rows tall.
1645                let rows_with_overlay = dump
1646                    .lines()
1647                    .filter(|line| line.contains('╭') || line.contains('╰') || line.contains('│'))
1648                    .count();
1649                assert_eq!(
1650                    rows_with_overlay,
1651                    design::PICKER_MAX_H as usize,
1652                    "overlay must be capped at design::PICKER_MAX_H, got:\n{dump}"
1653                );
1654            })
1655            .unwrap();
1656    }
1657
1658    /// When the hinted title would overflow the overlay's inner width,
1659    /// `render_picker_overlay` must drop the hint instead of silently
1660    /// clipping it — the affordance is either fully visible or
1661    /// gracefully suppressed.
1662    #[test]
1663    fn render_picker_overlay_drops_hint_when_it_would_overflow() {
1664        use ratatui::widgets::{ListItem, ListState};
1665        // Narrow terminal → clamped to PICKER_MIN_WIDTH (50).
1666        let backend = TestBackend::new(40, 12);
1667        let mut terminal = Terminal::new(backend).unwrap();
1668        terminal
1669            .draw(|frame| {
1670                let mut state = ListState::default();
1671                let items = vec![ListItem::new("only")];
1672                // A hint that together with the title clearly exceeds
1673                // the 48-col inner title bar at width 50.
1674                render_picker_overlay(
1675                    frame,
1676                    "Password Source",
1677                    Some("this is an excessively long keybinding description that will not fit"),
1678                    items,
1679                    &mut state,
1680                );
1681                let dump = buffer_dump(frame.buffer_mut());
1682                assert!(
1683                    dump.contains("Password Source"),
1684                    "title must still render, got:\n{dump}"
1685                );
1686                assert!(
1687                    !dump.contains('·'),
1688                    "overflow hint must be dropped, not clipped, got:\n{dump}"
1689                );
1690            })
1691            .unwrap();
1692    }
1693
1694    /// Unit test for the pure title composer: ensures graceful hint
1695    /// drop without rendering side effects. Pins the behaviour that
1696    /// `render_picker_overlay` depends on.
1697    #[test]
1698    fn picker_title_text_drops_overflow_hint() {
1699        let plain = picker_title_text("Title", None, 50);
1700        assert_eq!(plain, " Title ");
1701        let fits = picker_title_text("Title", Some("short"), 50);
1702        assert_eq!(fits, " Title · short ");
1703        let overflows = picker_title_text("Title", Some(&"x".repeat(200)), 50);
1704        assert_eq!(
1705            overflows, " Title ",
1706            "overlong hint must be dropped entirely"
1707        );
1708    }
1709
1710    /// On a terminal too short to host the rounded borders and a row
1711    /// of content, `render_picker_overlay` must bail out rather than
1712    /// emit a degenerate box that ratatui would render as unreadable
1713    /// glyphs.
1714    #[test]
1715    fn render_picker_overlay_skips_terminal_shorter_than_minimum() {
1716        use ratatui::widgets::{ListItem, ListState};
1717        let backend = TestBackend::new(80, 2);
1718        let mut terminal = Terminal::new(backend).unwrap();
1719        terminal
1720            .draw(|frame| {
1721                let mut state = ListState::default();
1722                let items = vec![ListItem::new("entry")];
1723                render_picker_overlay(frame, "Tiny", None, items, &mut state);
1724                let dump = buffer_dump(frame.buffer_mut());
1725                assert!(
1726                    !dump.contains("Tiny"),
1727                    "overlay must not render on a 2-row terminal, got:\n{dump}"
1728                );
1729            })
1730            .unwrap();
1731    }
1732
1733    /// Empty-state overlay should reuse the uniform picker width and
1734    /// surface both the title and the body message so it is visually
1735    /// consistent with the populated variant that replaces it the
1736    /// moment a candidate becomes available.
1737    #[test]
1738    fn render_picker_empty_overlay_renders_title_and_message() {
1739        let backend = TestBackend::new(200, 20);
1740        let mut terminal = Terminal::new(backend).unwrap();
1741        terminal
1742            .draw(|frame| {
1743                render_picker_empty_overlay(frame, "ProxyJump", "No other hosts configured");
1744                let dump = buffer_dump(frame.buffer_mut());
1745                assert!(dump.contains("ProxyJump"), "title missing, got:\n{dump}");
1746                assert!(
1747                    dump.contains("No other hosts configured"),
1748                    "empty-state message missing, got:\n{dump}"
1749                );
1750            })
1751            .unwrap();
1752    }
1753}