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
53pub 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 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 let has_overlay = !matches!(app.screen, Screen::HostList) || app.jump.is_some();
73 let status = if has_overlay {
74 app.status_center.take_status()
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.restore_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 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 if app.jump.is_some() {
292 dim_background(frame);
293 jump::render(frame, app);
294 }
295
296 render_toast(frame, app);
298}
299
300fn 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
310fn 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
321fn 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 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 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 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
364fn 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
395fn 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
426fn 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
443fn 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
454pub fn footer_key_span(key: &str) -> Span<'static> {
456 Span::styled(format!(" {} ", key), theme::footer_key())
457}
458
459pub 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#[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
477pub fn render_footer_with_help(
480 frame: &mut Frame,
481 area: Rect,
482 footer_spans: Vec<Span<'_>>,
483 app: &App,
484) {
485 let footer_status = app.status_center.status().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
503pub 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(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
519fn 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 ("", 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
569fn render_toast(frame: &mut Frame, app: &App) {
575 let toast = match app.status_center.toast() {
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 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 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 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 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 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 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
667pub(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
684pub(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
707pub(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
736pub(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#[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
753pub fn picker_overlay_width(frame: &Frame) -> u16 {
756 design::picker_width(frame)
757}
758
759pub const PICKER_MIN_HEIGHT: u16 = 3;
764
765fn 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
784pub 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
836pub 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 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 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 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 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 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 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 let status = app.status_center.take_status();
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 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 if let Some(s) = status {
1039 app.status_center.restore_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 assert_eq!(left, 20);
1090 assert_eq!(top, 6);
1091 }
1092
1093 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 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 let area = frame.area();
1122 frame.render_widget(ratatui::widgets::Paragraph::new("ABCDE"), area);
1123 render_overlay_close(frame, &mut anim);
1124 let buf = frame.buffer_mut();
1126 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 terminal
1162 .draw(|frame| {
1163 let area = frame.area();
1164 frame.render_widget(ratatui::widgets::Paragraph::new("ABCDE"), area);
1165 render_overlay_close(frame, &mut anim);
1166 let buf = frame.buffer_mut();
1167 assert!(
1169 !buf[(0, 0)].modifier.contains(Modifier::DIM),
1170 "no dimming when there is no close animation"
1171 );
1172 })
1173 .unwrap();
1174 }
1175
1176 #[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 frame.render_widget(ratatui::widgets::Paragraph::new("OVERLAY OK"), area);
1187
1188 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_scale_clip(frame, &saved, 0.5);
1199
1200 let buf = frame.buffer_mut();
1201 assert_eq!(buf[(0, 0)].symbol(), "B");
1203 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"); 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 }
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"); 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 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 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 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().unwrap().timeout_ms();
1326
1327 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 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 if let Some(toast) = app.status_center.toast_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 if let Some(toast) = app.status_center.toast_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 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"); assert!(app.status_center.toast().is_some());
1402 assert!(app.status_center.status().is_none()); 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 apply_scale_clip(frame, &saved, 1.0);
1469 let buf = frame.buffer_mut();
1470 assert_eq!(buf[(0, 0)].symbol(), "O"); })
1472 .unwrap();
1473 }
1474
1475 #[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 #[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 #[test]
1507 fn picker_overlay_width_passes_through_midrange() {
1508 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 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 #[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 #[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 #[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 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 #[test]
1622 fn render_picker_overlay_drops_hint_when_it_would_overflow() {
1623 use ratatui::widgets::{ListItem, ListState};
1624 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 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 #[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 #[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 #[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}