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