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