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