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