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