1pub(crate) mod activity_chart;
2mod bulk_tag_editor;
3pub(crate) mod confirm_dialog;
4pub(crate) mod container_action_confirm;
5pub(crate) mod container_exec_prompt;
6pub(crate) mod container_host_picker;
7pub(crate) mod container_logs;
8pub(crate) mod containers;
9pub(crate) mod containers_overview;
10pub mod design;
11mod detail_panel;
12mod file_browser;
13mod help;
14mod host_detail;
15pub mod host_form;
16mod host_list;
17mod jump;
18mod key_detail;
19mod key_list;
20pub(crate) mod key_push_picker;
21pub(crate) mod keys_overview;
22mod picker_helpers;
23mod provider_list;
24mod snippet_form;
25mod snippet_host_picker;
26mod snippet_output;
27mod snippet_param_form;
28mod snippet_picker;
29mod snippets_overview;
30mod tag_picker;
31pub mod theme;
32mod theme_picker;
33mod tunnel_form;
34mod tunnel_host_picker;
35mod tunnel_list;
36mod tunnels_detail;
37mod tunnels_format;
38pub(crate) mod tunnels_overview;
39mod whats_new;
40#[cfg(test)]
41mod whats_new_tests;
42
43use ratatui::Frame;
44use ratatui::layout::{Constraint, Layout, Rect};
45use ratatui::style::{Modifier, Style};
46use ratatui::text::{Line, Span};
47use ratatui::widgets::Paragraph;
48use unicode_width::UnicodeWidthStr;
49
50use crate::app::{App, Screen, TopPage};
51
52const MIN_WIDTH: u16 = 50;
53const MIN_HEIGHT: u16 = 14;
54
55#[cfg(not(test))]
59pub(crate) fn pkg_version() -> &'static str {
60 env!("CARGO_PKG_VERSION")
61}
62
63#[cfg(test)]
64pub(crate) fn pkg_version() -> &'static str {
65 "0.0.0-test"
66}
67
68pub fn render(frame: &mut Frame, app: &mut App, anim: &mut crate::animation::AnimationState) {
70 anim.tick_overlay_anim();
71 let area = frame.area();
72
73 if area.width < MIN_WIDTH || area.height < MIN_HEIGHT {
75 let msg = Paragraph::new(Line::from(vec![
76 Span::styled(design::ICON_WARNING, theme::warning()),
77 Span::raw(" Terminal too small. Need at least 50x14."),
78 ]));
79 frame.render_widget(msg, area);
80 return;
81 }
82
83 let has_overlay = !matches!(app.screen, Screen::HostList) || app.jump.is_some();
88 let status = if has_overlay {
89 app.status_center.take_status()
90 } else {
91 None
92 };
93 let detail_progress = anim.detail_anim_progress();
94 match app.top_page {
95 TopPage::Hosts => host_list::render(frame, app, anim.spinner_tick, detail_progress),
96 TopPage::Tunnels => tunnels_overview::render(frame, app, anim),
97 TopPage::Containers => {
98 containers_overview::render(frame, app, anim.spinner_tick, detail_progress)
99 }
100 TopPage::Snippets => snippets_overview::render(frame, app, detail_progress),
101 TopPage::Keys => keys_overview::render(frame, app, anim.spinner_tick),
102 }
103 if let Some(s) = status {
104 app.status_center.restore_status(Some(s));
105 }
106 match &app.screen {
107 Screen::HostList => {
108 render_overlay_close(frame, anim);
109 }
110 Screen::AddHost | Screen::EditHost { .. } => {
111 render_overlay(frame, app, anim, host_form::render);
112 }
113 Screen::ConfirmDelete { alias } => {
114 let alias = alias.clone();
115 render_overlay(frame, app, anim, |frame, app| {
116 confirm_dialog::render(frame, app, &alias)
117 });
118 }
119 Screen::Help { .. } => {
120 render_overlay(frame, app, anim, help::render);
121 }
122 Screen::KeyList => {
123 render_overlay(frame, app, anim, key_list::render);
124 }
125 Screen::KeyDetail { index } => {
126 let index = *index;
127 render_overlay(frame, app, anim, |frame, app| {
128 key_list::render(frame, app);
129 key_detail::render(frame, app, index);
130 });
131 }
132 Screen::KeyPushPicker { key_index } => {
133 let key_index = *key_index;
134 render_overlay(frame, app, anim, move |frame, app| {
135 key_push_picker::render(frame, app, key_index)
136 });
137 }
138 Screen::ConfirmKeyPush { key_index } => {
139 let key_index = *key_index;
140 render_overlay(frame, app, anim, move |frame, app| {
141 let aliases = app.keys.push().committed.clone();
142 confirm_dialog::render_key_push(frame, app, key_index, &aliases)
143 });
144 }
145 Screen::HostDetail { index } => {
146 let index = *index;
147 render_overlay(frame, app, anim, |frame, app| {
148 host_detail::render(frame, app, index)
149 });
150 }
151 Screen::TagPicker => {
152 render_overlay(frame, app, anim, tag_picker::render);
153 }
154 Screen::BulkTagEditor => {
155 render_overlay(frame, app, anim, bulk_tag_editor::render);
156 }
157 Screen::ThemePicker => {
158 render_overlay_nodim(frame, app, anim, theme_picker::render);
159 }
160 Screen::Providers => {
161 render_overlay(frame, app, anim, |frame, app| {
162 provider_list::render_provider_list(frame, app)
163 });
164 }
165 Screen::ProviderForm { id } => {
166 let provider = id.provider.clone();
167 render_overlay(frame, app, anim, |frame, app| {
168 provider_list::render_provider_form(frame, app, &provider)
169 });
170 }
171 Screen::ProviderLabelMigration { provider } => {
172 let provider = provider.clone();
173 render_overlay(frame, app, anim, |frame, app| {
174 provider_list::render_label_migration(frame, app, &provider)
175 });
176 }
177 Screen::TunnelList { alias } => {
178 let alias = alias.clone();
179 render_overlay(frame, app, anim, |frame, app| {
180 tunnel_list::render(frame, app, &alias)
181 });
182 }
183 Screen::TunnelForm { alias, .. } => {
184 let alias = alias.clone();
185 render_overlay(frame, app, anim, |frame, app| {
186 if !matches!(app.top_page, TopPage::Tunnels) {
190 tunnel_list::render(frame, app, &alias);
191 }
192 tunnel_form::render(frame, app);
193 });
194 }
195 Screen::TunnelHostPicker => {
196 render_overlay(frame, app, anim, tunnel_host_picker::render);
197 }
198 Screen::ContainerHostPicker => {
199 render_overlay(frame, app, anim, container_host_picker::render);
200 }
201 Screen::ContainerLogs => {
202 render_overlay(frame, app, anim, container_logs::render);
203 }
204 Screen::ConfirmContainerRestart { .. } => {
205 render_overlay(frame, app, anim, container_action_confirm::render_restart);
206 }
207 Screen::ConfirmContainerStop { .. } => {
208 render_overlay(frame, app, anim, container_action_confirm::render_stop);
209 }
210 Screen::ContainerExecPrompt { .. } => {
211 render_overlay(frame, app, anim, container_exec_prompt::render);
212 }
213 Screen::ConfirmStackRestart => {
214 render_overlay(frame, app, anim, container_action_confirm::render_stack);
215 }
216 Screen::ConfirmHostRestartAll => {
217 render_overlay(
218 frame,
219 app,
220 anim,
221 container_action_confirm::render_host_restart_all,
222 );
223 }
224 Screen::ConfirmHostStopAll => {
225 render_overlay(
226 frame,
227 app,
228 anim,
229 container_action_confirm::render_host_stop_all,
230 );
231 }
232 Screen::SnippetPicker => {
233 render_overlay(frame, app, anim, snippet_picker::render);
234 }
235 Screen::SnippetForm => {
236 render_overlay(frame, app, anim, |frame, app| {
237 if app.snippets.form_return_to_tab() {
238 snippets_overview::render(frame, app, None);
239 } else {
240 snippet_picker::render(frame, app);
241 }
242 snippet_form::render(frame, app);
243 });
244 }
245 Screen::ConfirmHostKeyReset { hostname, .. } => {
246 let hostname = hostname.clone();
247 render_overlay(frame, app, anim, |frame, app| {
248 confirm_dialog::render_host_key_reset(frame, app, &hostname)
249 });
250 }
251 Screen::FileBrowser { .. } => {
252 render_overlay(frame, app, anim, file_browser::render);
253 }
254 Screen::SnippetOutput => {
255 render_overlay(frame, app, anim, snippet_output::render);
256 }
257 Screen::SnippetParamForm => {
258 render_overlay(frame, app, anim, |frame, app| {
259 if app.snippets.form_return_to_tab() {
260 snippets_overview::render(frame, app, None);
261 } else {
262 snippet_picker::render(frame, app);
263 }
264 snippet_param_form::render(frame, app);
265 });
266 }
267 Screen::SnippetHostPicker => {
268 render_overlay(frame, app, anim, snippet_host_picker::render);
269 }
270 Screen::ConfirmRunSnippet => {
271 render_overlay(frame, app, anim, |frame, app| {
272 let aliases = app.snippets.flow_targets().to_vec();
273 confirm_dialog::render_run_snippet(frame, app, &aliases);
274 });
275 }
276 Screen::ConfirmImport { count } => {
277 let count = *count;
278 render_overlay(frame, app, anim, |frame, app| {
279 confirm_dialog::render_confirm_import(frame, app, count)
280 });
281 }
282 Screen::Containers { .. } => {
283 render_overlay(frame, app, anim, containers::render);
284 }
285 Screen::ConfirmVaultSign => {
286 let aliases: Vec<String> = app
287 .vault
288 .pending_sign()
289 .map(|s| s.iter().map(|t| t.alias.clone()).collect())
290 .unwrap_or_default();
291 render_overlay(frame, app, anim, move |frame, app| {
292 confirm_dialog::render_confirm_vault_sign(frame, app, &aliases)
293 });
294 }
295 Screen::ConfirmPurgeStale => {
296 let Some(payload) = app.providers.pending_purge() else {
297 return;
298 };
299 let aliases = payload.aliases.clone();
300 let provider = payload.provider.clone();
301 render_overlay(frame, app, anim, |frame, app| {
302 confirm_dialog::render_confirm_purge_stale(frame, app, &aliases, &provider)
303 });
304 }
305 Screen::Welcome {
306 has_backup,
307 host_count,
308 known_hosts_count,
309 } => {
310 let has_backup = *has_backup;
311 let host_count = *host_count;
312 let known_hosts_count = *known_hosts_count;
313 render_overlay(frame, app, anim, |frame, app| {
314 confirm_dialog::render_welcome(
315 frame,
316 app,
317 has_backup,
318 host_count,
319 known_hosts_count,
320 )
321 });
322 }
323 Screen::WhatsNew(_) => {
324 render_overlay(frame, app, anim, |frame, app| whats_new::render(frame, app));
325 }
326 }
327
328 if app.jump.is_some() {
332 dim_background(frame);
333 jump::render(frame, app);
334 }
335
336 render_toast(frame, app);
338}
339
340fn render_overlay(
342 frame: &mut Frame,
343 app: &mut App,
344 anim: &mut crate::animation::AnimationState,
345 f: impl FnOnce(&mut Frame, &mut App),
346) {
347 render_overlay_inner(frame, app, anim, true, f);
348}
349
350fn render_overlay_nodim(
353 frame: &mut Frame,
354 app: &mut App,
355 anim: &mut crate::animation::AnimationState,
356 f: impl FnOnce(&mut Frame, &mut App),
357) {
358 render_overlay_inner(frame, app, anim, false, f);
359}
360
361fn render_overlay_inner(
366 frame: &mut Frame,
367 app: &mut App,
368 anim: &mut crate::animation::AnimationState,
369 dim: bool,
370 f: impl FnOnce(&mut Frame, &mut App),
371) {
372 if dim {
373 dim_background(frame);
374 }
375
376 let progress = anim.overlay_anim_progress();
378 let animating_open = progress.is_some();
379 let pre_overlay = if animating_open {
380 Some(frame.buffer_mut().clone())
381 } else {
382 None
383 };
384
385 f(frame, app);
386
387 if !animating_open && anim.overlay_close.is_none() {
390 anim.overlay_close = Some(crate::animation::OverlayCloseState {
391 buffer: frame.buffer_mut().clone(),
392 dimmed: dim,
393 });
394 }
395
396 if let (Some(progress), Some(saved)) = (progress, pre_overlay) {
398 if progress < 1.0 {
399 apply_scale_clip(frame, &saved, progress);
400 }
401 }
402}
403
404fn dim_background(frame: &mut Frame) {
409 use ratatui::style::Color;
410
411 let dim_only = Style::default().add_modifier(Modifier::DIM);
412 let style = match theme::color_mode() {
413 2 => Style::default()
414 .fg(Color::Rgb(
415 design::DIM_FG_RGB.0,
416 design::DIM_FG_RGB.1,
417 design::DIM_FG_RGB.2,
418 ))
419 .add_modifier(Modifier::DIM),
420 1 => Style::default()
421 .fg(Color::DarkGray)
422 .add_modifier(Modifier::DIM),
423 _ => dim_only,
424 };
425 let area = frame.area();
426 let buf = frame.buffer_mut();
427 for y in area.y..area.y + area.height {
428 for x in area.x..area.x + area.width {
429 let has_bg = buf[(x, y)].bg != Color::Reset;
430 buf[(x, y)].set_style(if has_bg { dim_only } else { style });
431 }
432 }
433}
434
435fn render_overlay_close(frame: &mut Frame, anim: &mut crate::animation::AnimationState) {
438 let is_closing = anim.overlay_anim.as_ref().is_some_and(|a| !a.opening);
439 if !is_closing {
440 return;
441 }
442
443 let progress = match anim.overlay_anim_progress() {
444 Some(p) => p,
445 None => return,
446 };
447
448 if let Some(ref state) = anim.overlay_close {
449 if progress > 0.0 {
450 if state.dimmed {
451 dim_background(frame);
452 }
453 let area = frame.area();
454 let (left, right, top, bottom) = scale_clip_rect(area, progress);
455 for y in top..bottom {
456 for x in left..right {
457 if let Some(cell) = state.buffer.cell((x, y)) {
458 frame.buffer_mut()[(x, y)] = cell.clone();
459 }
460 }
461 }
462 }
463 }
464}
465
466fn apply_scale_clip(frame: &mut Frame, saved: &ratatui::buffer::Buffer, progress: f32) {
469 let area = frame.area();
470 let (left, right, top, bottom) = scale_clip_rect(area, progress);
471
472 for y in area.y..area.y + area.height {
473 for x in area.x..area.x + area.width {
474 if y < top || y >= bottom || x < left || x >= right {
475 if let Some(cell) = saved.cell((x, y)) {
476 frame.buffer_mut()[(x, y)] = cell.clone();
477 }
478 }
479 }
480 }
481}
482
483fn scale_clip_rect(area: Rect, progress: f32) -> (u16, u16, u16, u16) {
485 let visible_w = (area.width as f32 * progress).ceil() as u16;
486 let visible_h = (area.height as f32 * progress).ceil() as u16;
487 let left = area.x + area.width.saturating_sub(visible_w) / 2;
488 let right = (left + visible_w).min(area.x + area.width);
489 let top = area.y + area.height.saturating_sub(visible_h) / 2;
490 let bottom = (top + visible_h).min(area.y + area.height);
491 (left, right, top, bottom)
492}
493
494pub fn footer_key_span(key: &str) -> Span<'static> {
496 Span::styled(format!(" {} ", key), theme::footer_key())
497}
498
499pub fn footer_action(key: &str, label: &str) -> [Span<'static>; 2] {
502 [
503 footer_key_span(key),
504 Span::styled(label.to_string(), theme::muted()),
505 ]
506}
507
508#[deprecated(note = "use design::Footer builder instead")]
510pub fn footer_primary(key: &str, label: &str) -> [Span<'static>; 2] {
511 [
512 footer_key_span(key),
513 Span::styled(label.to_string(), theme::muted()),
514 ]
515}
516
517pub fn render_footer_with_help(
520 frame: &mut Frame,
521 area: Rect,
522 footer_spans: Vec<Span<'_>>,
523 app: &App,
524) {
525 let footer_status = app.status_center.status().filter(|s| !s.is_toast());
527 if let Some(status) = footer_status {
528 render_footer_status_right(frame, area, footer_spans, status);
529 return;
530 }
531 let right_spans = vec![
532 Span::raw(" "),
533 Span::styled(" ? ", theme::footer_key()),
534 Span::styled(" more", theme::muted()),
535 ];
536 let right_width: u16 = right_spans.iter().map(|s| s.width()).sum::<usize>() as u16;
537 let [left, right] =
538 Layout::horizontal([Constraint::Fill(1), Constraint::Length(right_width)]).areas(area);
539 frame.render_widget(Paragraph::new(Line::from(footer_spans)), left);
540 frame.render_widget(Paragraph::new(Line::from(right_spans)), right);
541}
542
543pub fn render_footer_with_status(
547 frame: &mut Frame,
548 area: Rect,
549 footer_spans: Vec<Span<'_>>,
550 app: &App,
551) {
552 if let Some(status) = app.status_center.status() {
553 render_footer_status_right(frame, area, footer_spans, status);
554 } else {
555 frame.render_widget(Paragraph::new(Line::from(footer_spans)), area);
556 }
557}
558
559fn render_footer_status_right(
562 frame: &mut Frame,
563 area: Rect,
564 mut footer_spans: Vec<Span<'_>>,
565 status: &crate::app::StatusMessage,
566) {
567 let shortcuts_width: usize = footer_spans.iter().map(|s| s.width()).sum();
568 let total_width = area.width as usize;
569
570 let (icon, icon_style, text) = if status.sticky {
571 ("", Style::default(), format!(" {} ", status.text))
575 } else if matches!(status.class, crate::app::MessageClass::Error) {
576 (
577 design::ICON_ERROR,
578 theme::error(),
579 format!(" {} ", status.text),
580 )
581 } else if matches!(status.class, crate::app::MessageClass::Warning) {
582 (
583 design::ICON_WARNING,
584 theme::warning(),
585 format!(" {} ", status.text),
586 )
587 } else {
588 ("", theme::muted(), format!(" {} ", status.text))
589 };
590
591 let available = total_width.saturating_sub(shortcuts_width + icon.width() + 2);
592 let display_text = if text.width() > available && available > 3 {
593 format!(" {} ", truncate(&status.text, available - 1))
594 } else {
595 text
596 };
597 let status_width = icon.width() + display_text.width();
598 let gap = total_width.saturating_sub(shortcuts_width + status_width);
599 if gap > 0 {
600 footer_spans.push(Span::raw(" ".repeat(gap)));
601 if !icon.is_empty() {
602 footer_spans.push(Span::styled(icon, icon_style));
603 }
604 footer_spans.push(Span::styled(display_text, icon_style));
605 }
606 frame.render_widget(Paragraph::new(Line::from(footer_spans)), area);
607}
608
609fn render_toast(frame: &mut Frame, app: &App) {
615 let toast = match app.status_center.toast() {
616 Some(t) => t,
617 None => return,
618 };
619
620 let area = frame.area();
621 if area.width < MIN_WIDTH || area.height < MIN_HEIGHT {
622 return;
623 }
624
625 let (icon, border_style) = match toast.class {
626 crate::app::MessageClass::Error => (
627 format!("{} ", design::ICON_ERROR),
628 theme::toast_border_error(),
629 ),
630 crate::app::MessageClass::Warning => (
631 format!("{} ", design::ICON_WARNING),
632 theme::toast_border_warning(),
633 ),
634 crate::app::MessageClass::Success
635 | crate::app::MessageClass::Info
636 | crate::app::MessageClass::Progress => (
637 format!("{} ", design::ICON_SUCCESS),
638 theme::toast_border_success(),
639 ),
640 };
641
642 let content = format!("{}{}", icon, toast.text);
643 let content_width = content.width();
644 let max_width = (area.width as usize * 60 / 100).max(30);
646 let box_width =
647 (content_width.saturating_add(4).min(max_width) as u16).min(area.width.saturating_sub(4));
648 let box_height = 3u16;
649 let x = area.width.saturating_sub(box_width + design::TOAST_INSET_X);
650 let y = area
652 .height
653 .saturating_sub(box_height + design::TOAST_INSET_Y);
654
655 let rect = Rect::new(x, y, box_width, box_height);
656
657 frame.render_widget(ratatui::widgets::Clear, rect);
659
660 let block = ratatui::widgets::Block::default()
661 .borders(ratatui::widgets::Borders::ALL)
662 .border_type(ratatui::widgets::BorderType::Rounded)
663 .border_style(border_style);
664
665 let inner_width = box_width.saturating_sub(4) as usize;
667 let display = if content_width > inner_width {
668 format!(" {} ", truncate(&content, inner_width))
669 } else {
670 format!(" {} ", content)
671 };
672
673 let paragraph = Paragraph::new(display).block(block);
674 frame.render_widget(paragraph, rect);
675
676 if !toast.sticky && !matches!(toast.class, crate::app::MessageClass::Progress) {
682 let total_ms = toast.timeout_ms();
683 if total_ms != u64::MAX && total_ms > 0 {
684 let elapsed_ms = toast.created_at.elapsed().as_millis() as u64;
685 let remaining_ratio = if elapsed_ms >= total_ms {
687 0.0
688 } else {
689 1.0 - (elapsed_ms as f64 / total_ms as f64)
690 };
691 let inner_w = box_width.saturating_sub(2);
692 let bar_cols = (remaining_ratio * f64::from(inner_w)) as u16;
693 if bar_cols > 0 {
694 let bar_y = rect.y + rect.height.saturating_sub(1);
695 let bar_x = rect.x + 1;
696 let bar_rect = Rect::new(bar_x, bar_y, bar_cols.min(inner_w), 1);
697 let bar = Paragraph::new(Line::from(Span::styled(
698 "\u{2501}".repeat(bar_rect.width as usize),
699 border_style,
700 )));
701 frame.render_widget(bar, bar_rect);
702 }
703 }
704 }
705}
706
707pub(crate) fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
709 let vertical = Layout::vertical([
710 Constraint::Percentage((100 - percent_y) / 2),
711 Constraint::Percentage(percent_y),
712 Constraint::Percentage((100 - percent_y) / 2),
713 ])
714 .split(area);
715
716 Layout::horizontal([
717 Constraint::Percentage((100 - percent_x) / 2),
718 Constraint::Percentage(percent_x),
719 Constraint::Percentage((100 - percent_x) / 2),
720 ])
721 .split(vertical[1])[1]
722}
723
724pub(crate) fn truncate(s: &str, max_cols: usize) -> String {
726 use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
727 if s.width() <= max_cols {
728 return s.to_string();
729 }
730 if max_cols <= 1 {
731 return String::new();
732 }
733 let target = max_cols - 1;
734 let mut col = 0;
735 let mut byte_end = 0;
736 for ch in s.chars() {
737 let w = UnicodeWidthChar::width(ch).unwrap_or(0);
738 if col + w > target {
739 break;
740 }
741 col += w;
742 byte_end += ch.len_utf8();
743 }
744 format!("{}…", &s[..byte_end])
745}
746
747pub(crate) fn render_divider(
752 frame: &mut Frame,
753 block_area: Rect,
754 y: u16,
755 label: &str,
756 label_style: Style,
757 border_style: Style,
758) {
759 let dim = theme::muted();
760 let width = block_area.width as usize;
761 let label_w = label.width();
762 let fill = width.saturating_sub(3 + label_w);
763 let line = Line::from(vec![
764 Span::styled("├", border_style),
765 Span::styled("─", dim),
766 Span::styled(label.to_string(), label_style),
767 Span::styled("─".repeat(fill), dim),
768 Span::styled("┤", border_style),
769 ]);
770 frame.render_widget(
771 Paragraph::new(line),
772 Rect::new(block_area.x, y, block_area.width, 1),
773 );
774}
775
776pub(crate) fn centered_rect_fixed(width: u16, height: u16, area: Rect) -> Rect {
778 let x = area.x + area.width.saturating_sub(width) / 2;
779 let y = area.y + area.height.saturating_sub(height) / 2;
780 Rect::new(x, y, width.min(area.width), height.min(area.height))
781}
782
783#[cfg(test)]
789pub(crate) const PICKER_MIN_WIDTH: u16 = crate::ui::design::PICKER_MIN_W;
790#[cfg(test)]
791pub(crate) const PICKER_MAX_WIDTH: u16 = crate::ui::design::PICKER_MAX_W;
792
793pub fn picker_overlay_width(frame: &Frame) -> u16 {
796 design::picker_width(frame)
797}
798
799pub const PICKER_MIN_HEIGHT: u16 = 3;
804
805fn picker_title_text(title: &str, title_hint: Option<&str>, width: u16) -> String {
810 let inner = (width as usize).saturating_sub(2);
811 match title_hint {
812 Some(hint) => {
813 let full = format!(" {} · {} ", title, hint);
814 if full.chars().count() <= inner {
815 full
816 } else {
817 format!(" {} ", title)
818 }
819 }
820 None => format!(" {} ", title),
821 }
822}
823
824pub fn render_picker_overlay<'a>(
840 frame: &mut Frame,
841 title: &str,
842 title_hint: Option<&str>,
843 items: Vec<ratatui::widgets::ListItem<'a>>,
844 list_state: &mut ratatui::widgets::ListState,
845) {
846 use ratatui::widgets::{Block, BorderType, Clear, List};
847
848 let width = picker_overlay_width(frame);
849 let content_rows = items.len() as u16;
850 let height = (content_rows + 2).min(design::PICKER_MAX_H);
851 if height < PICKER_MIN_HEIGHT {
852 return;
853 }
854 let area = centered_rect_fixed(width, height, frame.area());
855 if area.height < PICKER_MIN_HEIGHT {
856 return;
857 }
858 frame.render_widget(Clear, area);
859
860 let block = Block::bordered()
861 .border_type(BorderType::Rounded)
862 .title(Span::styled(
863 picker_title_text(title, title_hint, width),
864 theme::brand(),
865 ))
866 .border_style(theme::border_dim());
867
868 let list = List::new(items)
869 .block(block)
870 .highlight_style(theme::selected_row())
871 .highlight_symbol(design::LIST_HIGHLIGHT);
872
873 frame.render_stateful_widget(list, area, list_state);
874}
875
876pub fn render_picker_empty_overlay(frame: &mut Frame, title: &str, message: &str) {
880 use ratatui::widgets::{Block, BorderType, Clear};
881
882 let width = picker_overlay_width(frame);
883 let area = centered_rect_fixed(width, 5, frame.area());
884 if area.height < PICKER_MIN_HEIGHT {
885 return;
886 }
887 frame.render_widget(Clear, area);
888 let block = Block::bordered()
889 .border_type(BorderType::Rounded)
890 .title(Span::styled(
891 picker_title_text(title, None, width),
892 theme::brand(),
893 ))
894 .border_style(theme::border_dim());
895 let msg = Paragraph::new(Line::from(Span::styled(
896 format!(" {}", message),
897 theme::muted(),
898 )))
899 .block(block);
900 frame.render_widget(msg, area);
901}
902
903#[cfg(test)]
904mod tests {
905 use ratatui::Terminal;
906 use ratatui::backend::TestBackend;
907 use ratatui::style::Color;
908
909 use super::*;
910
911 fn make_app() -> App {
912 let config = crate::ssh_config::model::SshConfigFile {
913 elements: crate::ssh_config::model::SshConfigFile::parse_content(""),
914 path: tempfile::tempdir()
915 .expect("tempdir")
916 .keep()
917 .join("test_config"),
918 crlf: false,
919 bom: false,
920 };
921 App::new(config)
922 }
923
924 #[test]
925 fn dim_background_applies_dim_modifier() {
926 let backend = TestBackend::new(10, 3);
927 let mut terminal = Terminal::new(backend).unwrap();
928 terminal
929 .draw(|frame| {
930 let area = frame.area();
932 frame.render_widget(ratatui::widgets::Paragraph::new("hello"), area);
933 dim_background(frame);
934 let buf = frame.buffer_mut();
935 for x in 0..5 {
936 assert!(
937 buf[(x, 0)].modifier.contains(Modifier::DIM),
938 "cell ({x}, 0) should have DIM modifier"
939 );
940 }
941 })
942 .unwrap();
943 }
944
945 #[test]
946 fn dim_background_preserves_bg_color_cells() {
947 let backend = TestBackend::new(10, 3);
948 let mut terminal = Terminal::new(backend).unwrap();
949 terminal
950 .draw(|frame| {
951 let buf = frame.buffer_mut();
952 buf[(0, 0)].set_bg(Color::Blue);
954 buf[(0, 0)].set_fg(Color::White);
955 dim_background(frame);
956 let buf = frame.buffer_mut();
957 assert!(buf[(0, 0)].modifier.contains(Modifier::DIM));
959 assert_eq!(buf[(0, 0)].fg, Color::White);
960 })
961 .unwrap();
962 }
963
964 #[test]
965 fn render_overlay_inner_captures_dimmed_true() {
966 let backend = TestBackend::new(80, 24);
967 let mut terminal = Terminal::new(backend).unwrap();
968 let mut app = make_app();
969 let mut anim = crate::animation::AnimationState::new();
970 terminal
971 .draw(|frame| {
972 render_overlay_inner(frame, &mut app, &mut anim, true, |_frame, _app| {});
973 })
974 .unwrap();
975 let close = anim.overlay_close.as_ref().unwrap();
976 assert!(close.dimmed);
977 }
978
979 #[test]
980 fn render_overlay_inner_captures_dimmed_false() {
981 let backend = TestBackend::new(80, 24);
982 let mut terminal = Terminal::new(backend).unwrap();
983 let mut app = make_app();
984 let mut anim = crate::animation::AnimationState::new();
985 terminal
986 .draw(|frame| {
987 render_overlay_inner(frame, &mut app, &mut anim, false, |_frame, _app| {});
988 })
989 .unwrap();
990 let close = anim.overlay_close.as_ref().unwrap();
991 assert!(!close.dimmed);
992 }
993
994 #[test]
995 fn render_overlay_inner_preserves_status_during_render() {
996 let backend = TestBackend::new(80, 24);
997 let mut terminal = Terminal::new(backend).unwrap();
998 let mut app = make_app();
999 app.notify_info("test");
1000 let mut anim = crate::animation::AnimationState::new();
1001 terminal
1002 .draw(|frame| {
1003 render_overlay_inner(frame, &mut app, &mut anim, true, |_frame, app| {
1004 assert!(
1005 app.status_center.status().is_some(),
1006 "status should be visible during overlay render"
1007 );
1008 });
1009 })
1010 .unwrap();
1011 assert!(
1012 app.status_center.status().is_some(),
1013 "status should still be present after overlay render"
1014 );
1015 }
1016
1017 #[test]
1018 fn overlay_footer_renders_status_text_in_buffer() {
1019 let backend = TestBackend::new(80, 3);
1020 let mut terminal = Terminal::new(backend).unwrap();
1021 let mut app = make_app();
1022 app.notify_info("sync failed");
1023 let mut anim = crate::animation::AnimationState::new();
1024 terminal
1025 .draw(|frame| {
1026 render_overlay_inner(frame, &mut app, &mut anim, false, |frame, app| {
1027 let area = frame.area();
1028 let footer = ratatui::layout::Rect::new(0, area.height - 1, area.width, 1);
1030 render_footer_with_status(frame, footer, vec![], app);
1031 });
1032 })
1033 .unwrap();
1034 let buf = terminal.backend().buffer();
1036 let mut line = String::new();
1037 for x in 0..80 {
1038 line.push_str(buf[(x, 2)].symbol());
1039 }
1040 assert!(
1041 line.contains("sync failed"),
1042 "status text should appear in overlay footer buffer, got: {line:?}"
1043 );
1044 }
1045
1046 #[test]
1047 fn host_list_footer_has_no_status_when_overlay_active() {
1048 let backend = TestBackend::new(80, 24);
1049 let mut terminal = Terminal::new(backend).unwrap();
1050 let mut app = make_app();
1051 app.notify_info("sync failed");
1052 app.screen = crate::app::Screen::Help {
1054 return_screen: Box::new(crate::app::Screen::HostList),
1055 };
1056 let has_overlay = !matches!(app.screen, crate::app::Screen::HostList);
1057 assert!(has_overlay, "should detect overlay");
1058 let status = app.status_center.take_status();
1060 terminal
1061 .draw(|frame| {
1062 let area = frame.area();
1063 let footer = ratatui::layout::Rect::new(0, area.height - 1, area.width, 1);
1064 render_footer_with_status(frame, footer, vec![], &app);
1065 })
1066 .unwrap();
1067 let buf = terminal.backend().buffer();
1069 let mut line = String::new();
1070 for x in 0..80 {
1071 line.push_str(buf[(x, 23)].symbol());
1072 }
1073 assert!(
1074 !line.contains("sync failed"),
1075 "host list footer should not show status when overlay active, got: {line:?}"
1076 );
1077 if let Some(s) = status {
1079 app.status_center.restore_status(Some(s));
1080 }
1081 assert!(
1082 app.status_center.status().is_some(),
1083 "status should be restored for overlay footer"
1084 );
1085 }
1086
1087 #[test]
1088 fn render_overlay_inner_saves_close_state() {
1089 let backend = TestBackend::new(80, 24);
1090 let mut terminal = Terminal::new(backend).unwrap();
1091 let mut app = make_app();
1092 let mut anim = crate::animation::AnimationState::new();
1093 assert!(anim.overlay_close.is_none());
1094 terminal
1095 .draw(|frame| {
1096 render_overlay_inner(frame, &mut app, &mut anim, true, |_frame, _app| {});
1097 })
1098 .unwrap();
1099 assert!(anim.overlay_close.is_some());
1100 }
1101
1102 #[test]
1103 fn scale_clip_rect_full_progress_covers_area() {
1104 let area = Rect::new(0, 0, 80, 24);
1105 let (left, right, top, bottom) = scale_clip_rect(area, 1.0);
1106 assert_eq!(left, 0);
1107 assert_eq!(right, 80);
1108 assert_eq!(top, 0);
1109 assert_eq!(bottom, 24);
1110 }
1111
1112 #[test]
1113 fn scale_clip_rect_zero_progress_is_empty() {
1114 let area = Rect::new(0, 0, 80, 24);
1115 let (left, right, top, bottom) = scale_clip_rect(area, 0.0);
1116 assert_eq!(right - left, 0);
1117 assert_eq!(bottom - top, 0);
1118 }
1119
1120 #[test]
1121 fn scale_clip_rect_half_progress_centered() {
1122 let area = Rect::new(0, 0, 80, 24);
1123 let (left, right, top, bottom) = scale_clip_rect(area, 0.5);
1124 let w = right - left;
1125 let h = bottom - top;
1126 assert_eq!(w, 40);
1127 assert_eq!(h, 12);
1128 assert_eq!(left, 20);
1130 assert_eq!(top, 6);
1131 }
1132
1133 fn setup_close_anim(anim: &mut crate::animation::AnimationState, dimmed: bool) {
1137 use std::time::{Duration, Instant};
1138 let duration = Duration::from_secs(1);
1139 anim.overlay_close = Some(crate::animation::OverlayCloseState {
1140 buffer: ratatui::buffer::Buffer::empty(Rect::new(0, 0, 20, 5)),
1141 dimmed,
1142 });
1143 anim.overlay_anim = Some(crate::animation::OverlayAnim {
1146 start: Instant::now() - duration / 2,
1147 opening: false,
1148 duration_ms: duration.as_millis(),
1149 });
1150 }
1151
1152 #[test]
1153 fn render_overlay_close_dims_when_close_state_dimmed() {
1154 let backend = TestBackend::new(20, 5);
1155 let mut terminal = Terminal::new(backend).unwrap();
1156 let mut anim = crate::animation::AnimationState::new();
1157 setup_close_anim(&mut anim, true);
1158 terminal
1159 .draw(|frame| {
1160 let area = frame.area();
1162 frame.render_widget(ratatui::widgets::Paragraph::new("ABCDE"), area);
1163 render_overlay_close(frame, &mut anim);
1164 let buf = frame.buffer_mut();
1166 assert!(
1168 buf[(0, 4)].modifier.contains(Modifier::DIM),
1169 "background should be dimmed during close of a dimmed overlay"
1170 );
1171 })
1172 .unwrap();
1173 }
1174
1175 #[test]
1176 fn render_overlay_close_no_dim_when_close_state_not_dimmed() {
1177 let backend = TestBackend::new(20, 5);
1178 let mut terminal = Terminal::new(backend).unwrap();
1179 let mut anim = crate::animation::AnimationState::new();
1180 setup_close_anim(&mut anim, false);
1181 terminal
1182 .draw(|frame| {
1183 let area = frame.area();
1184 frame.render_widget(ratatui::widgets::Paragraph::new("ABCDE"), area);
1185 render_overlay_close(frame, &mut anim);
1186 let buf = frame.buffer_mut();
1187 assert!(
1188 !buf[(0, 4)].modifier.contains(Modifier::DIM),
1189 "background should NOT be dimmed during close of a non-dimmed overlay"
1190 );
1191 })
1192 .unwrap();
1193 }
1194
1195 #[test]
1196 fn render_overlay_close_skips_when_not_closing() {
1197 let backend = TestBackend::new(20, 5);
1198 let mut terminal = Terminal::new(backend).unwrap();
1199 let mut anim = crate::animation::AnimationState::new();
1200 terminal
1202 .draw(|frame| {
1203 let area = frame.area();
1204 frame.render_widget(ratatui::widgets::Paragraph::new("ABCDE"), area);
1205 render_overlay_close(frame, &mut anim);
1206 let buf = frame.buffer_mut();
1207 assert!(
1209 !buf[(0, 0)].modifier.contains(Modifier::DIM),
1210 "no dimming when there is no close animation"
1211 );
1212 })
1213 .unwrap();
1214 }
1215
1216 #[test]
1219 fn apply_scale_clip_restores_cells_outside_clip() {
1220 let backend = TestBackend::new(10, 4);
1221 let mut terminal = Terminal::new(backend).unwrap();
1222 terminal
1223 .draw(|frame| {
1224 let area = frame.area();
1225 frame.render_widget(ratatui::widgets::Paragraph::new("OVERLAY OK"), area);
1227
1228 let mut saved = ratatui::buffer::Buffer::empty(area);
1230 for x in 0..area.width {
1231 for y in 0..area.height {
1232 saved[(x, y)].set_symbol("B");
1233 }
1234 }
1235
1236 apply_scale_clip(frame, &saved, 0.5);
1239
1240 let buf = frame.buffer_mut();
1241 assert_eq!(buf[(0, 0)].symbol(), "B");
1243 let cx = area.width / 2;
1245 let cy = area.height / 2;
1246 assert_ne!(buf[(cx, cy)].symbol(), "B");
1247 })
1248 .unwrap();
1249 }
1250
1251 #[test]
1252 fn render_toast_shows_confirmation_in_buffer() {
1253 let backend = TestBackend::new(80, 24);
1254 let mut terminal = Terminal::new(backend).unwrap();
1255 let mut app = make_app();
1256 app.notify("Copied web01"); terminal
1258 .draw(|frame| {
1259 render_toast(frame, &app);
1260 })
1261 .unwrap();
1262 let buf = terminal.backend().buffer();
1263 let mut found = false;
1264 for y in 0..24 {
1265 let mut line = String::new();
1266 for x in 0..80 {
1267 line.push_str(buf[(x, y)].symbol());
1268 }
1269 if line.contains("Copied web01") {
1270 found = true;
1271 break;
1272 }
1273 }
1274 assert!(found, "toast text should appear in buffer");
1275 }
1276
1277 #[test]
1278 fn render_toast_not_shown_when_no_toast() {
1279 let backend = TestBackend::new(80, 24);
1280 let mut terminal = Terminal::new(backend).unwrap();
1281 let app = make_app();
1282 assert!(app.status_center.toast().is_none());
1283 terminal
1284 .draw(|frame| {
1285 render_toast(frame, &app);
1286 })
1287 .unwrap();
1288 }
1290
1291 #[test]
1292 fn render_toast_shows_error_with_error_icon() {
1293 let backend = TestBackend::new(80, 24);
1294 let mut terminal = Terminal::new(backend).unwrap();
1295 let mut app = make_app();
1296 app.notify_error("Connection failed"); terminal
1298 .draw(|frame| {
1299 render_toast(frame, &app);
1300 })
1301 .unwrap();
1302 let buf = terminal.backend().buffer();
1303 let mut found_text = false;
1304 let mut found_icon = false;
1305 for y in 0..24 {
1306 let mut line = String::new();
1307 for x in 0..80 {
1308 line.push_str(buf[(x, y)].symbol());
1309 }
1310 if line.contains("Connection failed") {
1311 found_text = true;
1312 }
1313 if line.contains(design::ICON_ERROR) {
1316 found_icon = true;
1317 }
1318 }
1319 assert!(found_text, "error text should appear in buffer");
1320 assert!(found_icon, "error should show error icon");
1321 }
1322
1323 #[test]
1324 fn render_toast_shows_warning_with_alert_icon() {
1325 let backend = TestBackend::new(80, 24);
1326 let mut terminal = Terminal::new(backend).unwrap();
1327 let mut app = make_app();
1328 app.notify_warning("Stale host configuration");
1329 terminal.draw(|frame| render_toast(frame, &app)).unwrap();
1330 let buf = terminal.backend().buffer();
1331 let mut found_text = false;
1332 let mut found_icon = false;
1333 for y in 0..24 {
1334 let mut line = String::new();
1335 for x in 0..80 {
1336 line.push_str(buf[(x, y)].symbol());
1337 }
1338 if line.contains("Stale host configuration") {
1339 found_text = true;
1340 }
1341 if line.contains(design::ICON_WARNING) {
1343 found_icon = true;
1344 }
1345 }
1346 assert!(found_text, "warning text should appear in buffer");
1347 assert!(
1348 found_icon,
1349 "warning should show warning sign (ICON_WARNING)"
1350 );
1351 }
1352
1353 #[test]
1354 fn render_toast_drain_bar_shrinks_over_time() {
1355 use std::time::{Duration, Instant};
1360
1361 let backend = TestBackend::new(80, 24);
1362 let mut terminal = Terminal::new(backend).unwrap();
1363 let mut app = make_app();
1364 app.notify("Saved profile changes successfully");
1365 let timeout_ms = app.status_center.toast().unwrap().timeout_ms();
1366
1367 let count_drain_bar = |app: &App, terminal: &mut Terminal<TestBackend>| -> usize {
1369 terminal.draw(|frame| render_toast(frame, app)).unwrap();
1370 let buf = terminal.backend().buffer();
1371 let mut count = 0;
1372 for y in 0..24 {
1373 for x in 0..80 {
1374 if buf[(x, y)].symbol() == "\u{2501}" {
1375 count += 1;
1376 }
1377 }
1378 }
1379 count
1380 };
1381
1382 let bar_full = count_drain_bar(&app, &mut terminal);
1384 assert!(
1385 bar_full > 0,
1386 "non-sticky Success toast must render a drain bar when just created"
1387 );
1388
1389 if let Some(toast) = app.status_center.toast_mut() {
1391 toast.created_at = Instant::now() - Duration::from_millis(timeout_ms / 2);
1392 }
1393 let bar_half = count_drain_bar(&app, &mut terminal);
1394 assert!(
1395 bar_half < bar_full,
1396 "drain bar must shrink as time passes ({} >= {})",
1397 bar_half,
1398 bar_full
1399 );
1400
1401 if let Some(toast) = app.status_center.toast_mut() {
1403 toast.created_at = Instant::now() - Duration::from_millis(timeout_ms + 1000);
1404 }
1405 let bar_empty = count_drain_bar(&app, &mut terminal);
1406 assert_eq!(
1407 bar_empty, 0,
1408 "drain bar must be empty once elapsed time exceeds timeout"
1409 );
1410 }
1411
1412 #[test]
1413 fn render_toast_drain_bar_absent_for_sticky_error() {
1414 let backend = TestBackend::new(80, 24);
1416 let mut terminal = Terminal::new(backend).unwrap();
1417 let mut app = make_app();
1418 app.notify_error("Permission denied");
1419 terminal.draw(|frame| render_toast(frame, &app)).unwrap();
1420 let buf = terminal.backend().buffer();
1421 let mut count = 0;
1422 for y in 0..24 {
1423 for x in 0..80 {
1424 if buf[(x, y)].symbol() == "\u{2501}" {
1425 count += 1;
1426 }
1427 }
1428 }
1429 assert_eq!(
1430 count, 0,
1431 "sticky error toast must NOT render a drain bar (nothing to drain)"
1432 );
1433 }
1434
1435 #[test]
1436 fn footer_shows_hints_when_toast_active() {
1437 let backend = TestBackend::new(80, 24);
1438 let mut terminal = Terminal::new(backend).unwrap();
1439 let mut app = make_app();
1440 app.notify("Copied"); assert!(app.status_center.toast().is_some());
1442 assert!(app.status_center.status().is_none()); let footer_spans = vec![
1444 Span::styled(" ? ", theme::footer_key()),
1445 Span::styled(" more", theme::muted()),
1446 ];
1447 terminal
1448 .draw(|frame| {
1449 let area = Rect::new(0, 23, 80, 1);
1450 render_footer_with_help(frame, area, footer_spans, &app);
1451 })
1452 .unwrap();
1453 let buf = terminal.backend().buffer();
1454 let mut line = String::new();
1455 for x in 0..80 {
1456 line.push_str(buf[(x, 23)].symbol());
1457 }
1458 assert!(
1459 line.contains("more"),
1460 "footer should show hints when only toast is active"
1461 );
1462 }
1463
1464 #[test]
1465 fn footer_shows_info_status_instead_of_help_hint() {
1466 let backend = TestBackend::new(80, 24);
1467 let mut terminal = Terminal::new(backend).unwrap();
1468 let mut app = make_app();
1469 app.notify_info("Syncing AWS...");
1470 assert!(app.status_center.status().is_some());
1471 assert!(app.status_center.toast().is_none());
1472 let footer_spans = vec![
1473 Span::styled(" ? ", theme::footer_key()),
1474 Span::styled(" more", theme::muted()),
1475 ];
1476 terminal
1477 .draw(|frame| {
1478 let area = Rect::new(0, 23, 80, 1);
1479 render_footer_with_help(frame, area, footer_spans, &app);
1480 })
1481 .unwrap();
1482 let buf = terminal.backend().buffer();
1483 let mut line = String::new();
1484 for x in 0..80 {
1485 line.push_str(buf[(x, 23)].symbol());
1486 }
1487 assert!(
1488 line.contains("Syncing AWS"),
1489 "footer should show info status, got: {line:?}"
1490 );
1491 }
1492
1493 #[test]
1494 fn apply_scale_clip_full_progress_keeps_all_overlay() {
1495 let backend = TestBackend::new(10, 4);
1496 let mut terminal = Terminal::new(backend).unwrap();
1497 terminal
1498 .draw(|frame| {
1499 let area = frame.area();
1500 frame.render_widget(ratatui::widgets::Paragraph::new("OVERLAY OK"), area);
1501 let mut saved = ratatui::buffer::Buffer::empty(area);
1502 for x in 0..area.width {
1503 for y in 0..area.height {
1504 saved[(x, y)].set_symbol("B");
1505 }
1506 }
1507 apply_scale_clip(frame, &saved, 1.0);
1509 let buf = frame.buffer_mut();
1510 assert_eq!(buf[(0, 0)].symbol(), "O"); })
1512 .unwrap();
1513 }
1514
1515 #[test]
1519 fn picker_overlay_width_clamps_narrow_terminal() {
1520 let backend = TestBackend::new(30, 10);
1521 let mut terminal = Terminal::new(backend).unwrap();
1522 terminal
1523 .draw(|frame| {
1524 assert_eq!(picker_overlay_width(frame), PICKER_MIN_WIDTH);
1525 })
1526 .unwrap();
1527 }
1528
1529 #[test]
1533 fn picker_overlay_width_caps_wide_terminal() {
1534 let backend = TestBackend::new(200, 40);
1535 let mut terminal = Terminal::new(backend).unwrap();
1536 terminal
1537 .draw(|frame| {
1538 assert_eq!(picker_overlay_width(frame), PICKER_MAX_WIDTH);
1539 })
1540 .unwrap();
1541 }
1542
1543 #[test]
1547 fn picker_overlay_width_passes_through_midrange() {
1548 let backend = TestBackend::new(66, 20);
1550 let mut terminal = Terminal::new(backend).unwrap();
1551 terminal
1552 .draw(|frame| {
1553 assert_eq!(picker_overlay_width(frame), 66);
1554 })
1555 .unwrap();
1556 }
1557
1558 fn buffer_dump(buf: &ratatui::buffer::Buffer) -> String {
1562 let mut out = String::new();
1563 for y in 0..buf.area.height {
1564 for x in 0..buf.area.width {
1565 out.push_str(buf[(x, y)].symbol());
1566 }
1567 out.push('\n');
1568 }
1569 out
1570 }
1571
1572 #[test]
1577 fn render_picker_overlay_writes_title_hint_to_border() {
1578 use ratatui::widgets::{ListItem, ListState};
1579 let backend = TestBackend::new(80, 10);
1580 let mut terminal = Terminal::new(backend).unwrap();
1581 terminal
1582 .draw(|frame| {
1583 let mut state = ListState::default();
1584 let items = vec![ListItem::new("one"), ListItem::new("two")];
1585 render_picker_overlay(
1586 frame,
1587 "Password Source",
1588 Some("Ctrl+D: global default"),
1589 items,
1590 &mut state,
1591 );
1592 let dump = buffer_dump(frame.buffer_mut());
1593 assert!(
1594 dump.contains("Password Source · Ctrl+D: global default"),
1595 "rendered buffer must contain the hinted title, got:\n{dump}"
1596 );
1597 })
1598 .unwrap();
1599 }
1600
1601 #[test]
1605 fn render_picker_overlay_plain_title_has_no_dot_separator() {
1606 use ratatui::widgets::{ListItem, ListState};
1607 let backend = TestBackend::new(80, 10);
1608 let mut terminal = Terminal::new(backend).unwrap();
1609 terminal
1610 .draw(|frame| {
1611 let mut state = ListState::default();
1612 let items = vec![ListItem::new("one")];
1613 render_picker_overlay(frame, "ProxyJump", None, items, &mut state);
1614 let dump = buffer_dump(frame.buffer_mut());
1615 assert!(dump.contains("ProxyJump"));
1616 assert!(
1617 !dump.contains('·'),
1618 "plain title must not emit a middle-dot separator, got:\n{dump}"
1619 );
1620 })
1621 .unwrap();
1622 }
1623
1624 #[test]
1629 fn render_picker_overlay_caps_height_at_design_max() {
1630 use ratatui::widgets::{ListItem, ListState};
1631 let backend = TestBackend::new(80, 40);
1632 let mut terminal = Terminal::new(backend).unwrap();
1633 terminal
1634 .draw(|frame| {
1635 let mut state = ListState::default();
1636 let items: Vec<ListItem> = (0..40)
1637 .map(|i| ListItem::new(format!("item {}", i)))
1638 .collect();
1639 render_picker_overlay(frame, "Many", None, items, &mut state);
1640 let dump = buffer_dump(frame.buffer_mut());
1641 let rows_with_overlay = dump
1645 .lines()
1646 .filter(|line| line.contains('╭') || line.contains('╰') || line.contains('│'))
1647 .count();
1648 assert_eq!(
1649 rows_with_overlay,
1650 design::PICKER_MAX_H as usize,
1651 "overlay must be capped at design::PICKER_MAX_H, got:\n{dump}"
1652 );
1653 })
1654 .unwrap();
1655 }
1656
1657 #[test]
1662 fn render_picker_overlay_drops_hint_when_it_would_overflow() {
1663 use ratatui::widgets::{ListItem, ListState};
1664 let backend = TestBackend::new(40, 12);
1666 let mut terminal = Terminal::new(backend).unwrap();
1667 terminal
1668 .draw(|frame| {
1669 let mut state = ListState::default();
1670 let items = vec![ListItem::new("only")];
1671 render_picker_overlay(
1674 frame,
1675 "Password Source",
1676 Some("this is an excessively long keybinding description that will not fit"),
1677 items,
1678 &mut state,
1679 );
1680 let dump = buffer_dump(frame.buffer_mut());
1681 assert!(
1682 dump.contains("Password Source"),
1683 "title must still render, got:\n{dump}"
1684 );
1685 assert!(
1686 !dump.contains('·'),
1687 "overflow hint must be dropped, not clipped, got:\n{dump}"
1688 );
1689 })
1690 .unwrap();
1691 }
1692
1693 #[test]
1697 fn picker_title_text_drops_overflow_hint() {
1698 let plain = picker_title_text("Title", None, 50);
1699 assert_eq!(plain, " Title ");
1700 let fits = picker_title_text("Title", Some("short"), 50);
1701 assert_eq!(fits, " Title · short ");
1702 let overflows = picker_title_text("Title", Some(&"x".repeat(200)), 50);
1703 assert_eq!(
1704 overflows, " Title ",
1705 "overlong hint must be dropped entirely"
1706 );
1707 }
1708
1709 #[test]
1714 fn render_picker_overlay_skips_terminal_shorter_than_minimum() {
1715 use ratatui::widgets::{ListItem, ListState};
1716 let backend = TestBackend::new(80, 2);
1717 let mut terminal = Terminal::new(backend).unwrap();
1718 terminal
1719 .draw(|frame| {
1720 let mut state = ListState::default();
1721 let items = vec![ListItem::new("entry")];
1722 render_picker_overlay(frame, "Tiny", None, items, &mut state);
1723 let dump = buffer_dump(frame.buffer_mut());
1724 assert!(
1725 !dump.contains("Tiny"),
1726 "overlay must not render on a 2-row terminal, got:\n{dump}"
1727 );
1728 })
1729 .unwrap();
1730 }
1731
1732 #[test]
1737 fn render_picker_empty_overlay_renders_title_and_message() {
1738 let backend = TestBackend::new(200, 20);
1739 let mut terminal = Terminal::new(backend).unwrap();
1740 terminal
1741 .draw(|frame| {
1742 render_picker_empty_overlay(frame, "ProxyJump", "No other hosts configured");
1743 let dump = buffer_dump(frame.buffer_mut());
1744 assert!(dump.contains("ProxyJump"), "title missing, got:\n{dump}");
1745 assert!(
1746 dump.contains("No other hosts configured"),
1747 "empty-state message missing, got:\n{dump}"
1748 );
1749 })
1750 .unwrap();
1751 }
1752}