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