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