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