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