1use ratatui::Frame;
2use ratatui::layout::Rect;
3use ratatui::text::{Line, Span};
4use ratatui::widgets::{Clear, ListItem, Paragraph};
5use unicode_width::UnicodeWidthStr;
6
7use super::design;
8use super::theme;
9use crate::app::{App, FormField, Screen};
10
11fn placeholder_for(
12 field: FormField,
13 is_pattern: bool,
14 paths: Option<&crate::runtime::env::Paths>,
15) -> String {
16 use crate::messages::hints;
17 match field {
18 FormField::AskPass => {
19 if let Some(default) = crate::preferences::load_askpass_default(paths) {
20 hints::askpass_default(&default)
21 } else {
22 hints::HOST_ASKPASS_PICK.to_string()
23 }
24 }
25 FormField::Alias if is_pattern => hints::HOST_ALIAS_PATTERN.to_string(),
26 FormField::Alias => hints::HOST_ALIAS.to_string(),
27 FormField::Hostname => hints::HOST_HOSTNAME.to_string(),
28 FormField::User => hints::DEFAULT_SSH_USER.to_string(),
29 FormField::Port => hints::HOST_PORT.to_string(),
30 FormField::IdentityFile => hints::IDENTITY_FILE_PICK.to_string(),
31 FormField::ProxyJump => hints::HOST_PROXY_JUMP.to_string(),
32 FormField::VaultSsh => hints::HOST_VAULT_SSH.to_string(),
35 FormField::VaultAddr => hints::HOST_VAULT_ADDR.to_string(),
36 FormField::Tags => hints::HOST_TAGS.to_string(),
37 }
38}
39
40const REQUIRED_FIELDS: &[(FormField, bool)] =
42 &[(FormField::Alias, true), (FormField::Hostname, true)];
43
44const ALL_FIELDS: &[(FormField, bool)] = &[
50 (FormField::Alias, true),
51 (FormField::Hostname, true),
52 (FormField::User, false),
53 (FormField::Port, false),
54 (FormField::IdentityFile, false),
55 (FormField::VaultSsh, false),
56 (FormField::VaultAddr, false),
57 (FormField::ProxyJump, false),
58 (FormField::AskPass, false),
59 (FormField::Tags, false),
60];
61
62pub fn render(frame: &mut Frame, app: &mut App) {
63 let expanded = app.forms.host().expanded;
71 let role_set = !app.forms.host().vault_ssh.trim().is_empty();
72 let base: &[(FormField, bool)] = if expanded {
73 ALL_FIELDS
74 } else {
75 REQUIRED_FIELDS
76 };
77 let filtered: Vec<(FormField, bool)> = base
78 .iter()
79 .copied()
80 .filter(|(f, _)| *f != FormField::VaultAddr || role_set)
81 .collect();
82 let visible_fields: &[(FormField, bool)] = &filtered;
83 let block_height = 2 + visible_fields.len() as u16 * 2;
85 let total_height = block_height + 1; let form_area = design::overlay_area(frame, design::OVERLAY_W, design::OVERLAY_H, total_height);
88
89 let title = if app.forms.host().is_pattern {
90 match &app.screen {
91 Screen::AddHost => "Add Pattern".to_string(),
92 Screen::EditHost { alias } => {
93 let max_alias = (form_area.width as usize).saturating_sub(14);
94 let truncated = super::truncate(alias, max_alias);
95 format!("Edit: {}", truncated)
96 }
97 _ => "Pattern".to_string(),
98 }
99 } else {
100 match &app.screen {
101 Screen::AddHost => "Add New Host".to_string(),
102 Screen::EditHost { alias } => {
103 let max_alias = (form_area.width as usize).saturating_sub(12);
104 let truncated = super::truncate(alias, max_alias);
105 format!("Edit: {}", truncated)
106 }
107 _ => "Host".to_string(),
108 }
109 };
110 frame.render_widget(Clear, form_area);
111
112 let block_area = Rect::new(form_area.x, form_area.y, form_area.width, block_height);
113
114 let block = design::overlay_block(&title);
115 let inner = block.inner(block_area);
116 frame.render_widget(block, block_area);
117
118 let picker_open = app.ui.key_picker().open
120 || app.ui.proxyjump_picker().open
121 || app.ui.password_picker().open
122 || app.ui.vault_role_picker().open;
123 let has_vault_roles = !app.vault_role_candidates().is_empty();
124
125 let vault_provider_hint: Option<(String, String)> =
127 if let Screen::EditHost { alias } = &app.screen {
128 app.hosts_state
129 .list()
130 .iter()
131 .find(|h| h.alias == *alias)
132 .and_then(|h| h.provider.as_ref())
133 .and_then(|prov| {
134 app.providers.config().section(prov).and_then(|s| {
135 if s.vault_role.is_empty() {
136 None
137 } else {
138 Some((s.vault_role.clone(), prov.clone()))
139 }
140 })
141 })
142 } else {
143 None
144 };
145
146 let vault_addr_provider_hint: Option<(String, String)> =
151 if let Screen::EditHost { alias } = &app.screen {
152 app.hosts_state
153 .list()
154 .iter()
155 .find(|h| h.alias == *alias)
156 .and_then(|h| h.provider.as_ref())
157 .and_then(|prov| {
158 app.providers.config().section(prov).and_then(|s| {
159 if s.vault_addr.is_empty() {
160 None
161 } else {
162 Some((s.vault_addr.clone(), prov.clone()))
163 }
164 })
165 })
166 } else {
167 None
168 };
169
170 for (idx, &(field, field_required)) in visible_fields.iter().enumerate() {
171 let divider_y = design::form_divider_y(inner, idx);
172 let content_y = divider_y + 1;
173
174 let is_focused = app.forms.host().focused_field == field;
175 let label_style = if is_focused {
176 theme::accent_bold()
177 } else {
178 theme::muted()
179 };
180 let field_label = if app.forms.host().is_pattern && field == FormField::Alias {
181 "Pattern"
182 } else {
183 field.label()
184 };
185 let is_required = if app.forms.host().is_pattern && field == FormField::Hostname {
186 false
187 } else {
188 field_required
189 };
190 let label = if is_required {
191 format!(" {}* ", field_label)
192 } else {
193 format!(" {} ", field_label)
194 };
195 super::render_divider(
196 frame,
197 block_area,
198 divider_y,
199 &label,
200 label_style,
201 theme::border_dim(),
202 );
203
204 let content_area = Rect::new(inner.x + 1, content_y, inner.width.saturating_sub(1), 1);
205 render_field_content(
206 frame,
207 content_area,
208 field,
209 app.forms.host(),
210 picker_open,
211 vault_provider_hint.as_ref(),
212 vault_addr_provider_hint.as_ref(),
213 has_vault_roles,
214 app.env().paths(),
215 );
216 }
217
218 let footer_area = design::render_overlay_footer(frame, block_area);
222 if app.forms.is_discard_pending() {
223 design::render_discard_prompt(frame, footer_area, app);
224 } else {
225 let mode = if !expanded {
226 design::FormFooterMode::Collapsed
227 } else {
228 design::FormFooterMode::Expanded(app.forms.host().focused_field.kind())
229 };
230 let mut footer_spans = design::form_save_footer(mode).into_spans();
231 if let Some(ref hint) = app.forms.host().form_hint {
232 let hint_width: usize = hint.width() + 4; let shortcuts_width: usize = footer_spans.iter().map(|s| s.width()).sum();
234 let total = footer_area.width as usize;
235 let gap = total.saturating_sub(shortcuts_width + hint_width);
236 if gap > 0 {
237 footer_spans.push(Span::raw(" ".repeat(gap)));
238 footer_spans.push(Span::styled(
239 format!("{} {} ", design::ICON_WARNING, hint),
240 theme::warning(),
241 ));
242 }
243 frame.render_widget(Paragraph::new(Line::from(footer_spans)), footer_area);
246 } else {
247 super::render_footer_with_status(frame, footer_area, footer_spans, app);
248 }
249 }
250
251 if app.ui.key_picker().open {
253 render_key_picker_overlay(frame, app);
254 }
255
256 if app.ui.proxyjump_picker().open {
258 render_proxyjump_picker_overlay(frame, app);
259 }
260
261 if app.ui.password_picker().open {
263 render_password_picker_overlay(frame, app);
264 }
265
266 if app.ui.vault_role_picker().open {
268 render_vault_role_picker_overlay(frame, app);
269 }
270}
271
272pub fn render_key_picker_overlay(frame: &mut Frame, app: &mut App) {
274 if app.keys.list().is_empty() {
275 super::render_picker_empty_overlay(frame, "Select Key", "No keys found in ~/.ssh/");
276 return;
277 }
278
279 let width = super::picker_overlay_width(frame);
285 let usable = (width as usize).saturating_sub(6);
288 let gap: usize = design::COL_GAP as usize;
289
290 let name_w = design::padded_usize(
291 app.keys
292 .list()
293 .iter()
294 .map(|k| k.name.len())
295 .max()
296 .unwrap_or(4)
297 .max(4),
298 );
299 let type_w = design::padded_usize(
300 app.keys
301 .list()
302 .iter()
303 .map(|k| k.type_display().len())
304 .max()
305 .unwrap_or(4)
306 .max(4),
307 );
308 let left = name_w + gap + type_w;
309 let comment_w = usable.saturating_sub(left + gap);
310 let gap_str = design::COL_GAP_STR;
311
312 let items: Vec<ListItem> = app
313 .keys
314 .list()
315 .iter()
316 .map(|key| {
317 let type_display = key.type_display();
318 let comment = if key.comment.is_empty() {
319 String::new()
320 } else {
321 super::truncate(&key.comment, comment_w.saturating_sub(1))
322 };
323 let mut spans = vec![
324 Span::styled(format!(" {:<name_w$}", key.name), theme::bold()),
325 Span::raw(gap_str),
326 Span::styled(format!("{:<type_w$}", type_display), theme::muted()),
327 ];
328 if comment_w > 0 {
329 spans.push(Span::raw(gap_str));
330 spans.push(Span::styled(comment, theme::muted()));
331 }
332 let line = Line::from(spans);
333 ListItem::new(line)
334 })
335 .collect();
336
337 super::render_picker_overlay(
338 frame,
339 "Select Key",
340 None,
341 items,
342 &mut app.ui.key_picker_mut().list,
343 );
344}
345
346fn render_proxyjump_picker_overlay(frame: &mut Frame, app: &mut App) {
347 let candidates = app.proxyjump_candidates();
348
349 if candidates.is_empty() {
350 super::render_picker_empty_overlay(frame, "ProxyJump", "No other hosts configured");
351 return;
352 }
353
354 let width = super::picker_overlay_width(frame);
355 let inner = (width as usize).saturating_sub(6);
361 let alias_col = 20;
362 let min_gap = 2;
363 let host_max = inner.saturating_sub(alias_col + min_gap);
364
365 let items: Vec<ListItem> = candidates
366 .iter()
367 .map(|candidate| match candidate {
368 crate::app::ProxyJumpCandidate::SectionLabel(label) => ListItem::new(Line::from(
369 Span::styled(format!(" {}", label.to_ascii_uppercase()), theme::muted()),
370 )),
371 crate::app::ProxyJumpCandidate::Separator => ListItem::new(Line::from(Span::styled(
372 " ".to_string() + &"─".repeat(inner.saturating_sub(2)),
377 theme::muted(),
378 ))),
379 crate::app::ProxyJumpCandidate::Host {
380 alias, hostname, ..
381 } => {
382 let alias_display = super::truncate(alias, alias_col);
383 let host_display = super::truncate(hostname, host_max);
384 let host_width = host_display.width();
392 let alias_width = inner
393 .saturating_sub(host_width)
394 .saturating_sub(1)
395 .max(alias_col);
396 let line = Line::from(vec![
397 Span::styled(
398 format!(" {:<width$}", alias_display, width = alias_width),
399 theme::bold(),
400 ),
401 Span::styled(host_display, theme::muted()),
402 ]);
403 ListItem::new(line)
404 }
405 })
406 .collect();
407
408 super::render_picker_overlay(
409 frame,
410 "ProxyJump",
411 None,
412 items,
413 &mut app.ui.proxyjump_picker_mut().list,
414 );
415}
416
417fn render_vault_role_picker_overlay(frame: &mut Frame, app: &mut App) {
418 let candidates = app.vault_role_candidates();
419
420 let width = super::picker_overlay_width(frame);
421 let max_role = (width as usize).saturating_sub(6);
422 let items: Vec<ListItem> = candidates
423 .iter()
424 .map(|role| {
425 ListItem::new(Line::from(Span::styled(
426 format!(" {}", super::truncate(role, max_role)),
427 theme::bold(),
428 )))
429 })
430 .collect();
431
432 super::render_picker_overlay(
433 frame,
434 "Vault SSH Role",
435 None,
436 items,
437 &mut app.ui.vault_role_picker_mut().list,
438 );
439}
440
441fn render_password_picker_overlay(frame: &mut Frame, app: &mut App) {
442 let sources = crate::askpass::PASSWORD_SOURCES;
443 let width = super::picker_overlay_width(frame);
444 let inner_width = (width as usize).saturating_sub(6);
447 let items: Vec<ListItem> = sources
448 .iter()
449 .map(|src| {
450 let hint_width = src.hint.len();
451 let label_width = inner_width.saturating_sub(hint_width).saturating_sub(1);
452 let line = Line::from(vec![
453 Span::styled(
454 format!(" {:<width$}", src.label, width = label_width),
455 theme::bold(),
456 ),
457 Span::styled(src.hint, theme::muted()),
458 ]);
459 ListItem::new(line)
460 })
461 .collect();
462
463 super::render_picker_overlay(
464 frame,
465 "Password Source",
466 Some("Ctrl+D: global default"),
467 items,
468 &mut app.ui.password_picker_mut().list,
469 );
470}
471
472#[cfg(test)]
474pub fn placeholder_text(field: FormField) -> String {
475 placeholder_for(field, false, None)
476}
477
478#[cfg(test)]
479pub fn placeholder_text_pattern(field: FormField) -> String {
480 placeholder_for(field, true, None)
481}
482
483#[allow(clippy::too_many_arguments)]
485fn render_field_content(
486 frame: &mut Frame,
487 area: Rect,
488 field: FormField,
489 form: &crate::app::HostForm,
490 picker_open: bool,
491 vault_provider_hint: Option<&(String, String)>,
492 vault_addr_provider_hint: Option<&(String, String)>,
493 has_vault_roles: bool,
494 paths: Option<&crate::runtime::env::Paths>,
495) {
496 use crate::messages::hints;
497 let is_focused = form.focused_field == field;
498
499 let value = match field {
500 FormField::Alias => &form.alias,
501 FormField::Hostname => &form.hostname,
502 FormField::User => &form.user,
503 FormField::Port => &form.port,
504 FormField::IdentityFile => &form.identity_file,
505 FormField::ProxyJump => &form.proxy_jump,
506 FormField::AskPass => &form.askpass,
507 FormField::VaultSsh => &form.vault_ssh,
508 FormField::VaultAddr => &form.vault_addr,
509 FormField::Tags => &form.tags,
510 };
511
512 let is_picker = matches!(
513 field,
514 FormField::IdentityFile | FormField::ProxyJump | FormField::AskPass
515 ) || (field == FormField::VaultSsh && has_vault_roles);
516
517 let inherited_hint = match field {
519 FormField::ProxyJump => form.inherited.proxy_jump.as_ref(),
520 FormField::User => form.inherited.user.as_ref(),
521 FormField::IdentityFile => form.inherited.identity_file.as_ref(),
522 _ => None,
523 };
524
525 let content = if let (true, Some((inh_val, inh_src))) = (value.is_empty(), inherited_hint) {
528 let inner_width = area.width as usize;
529 let is_loop = field == FormField::ProxyJump
531 && crate::ssh_config::model::proxy_jump_contains_self(inh_val, &form.alias);
532 if is_loop {
533 let msg = format!("loops via {}", inh_src);
534 let display = super::truncate(&msg, inner_width);
535 Line::from(vec![Span::styled(display, theme::error())])
536 } else {
537 let source_suffix = format!(" \u{2190} {}", inh_src);
538 let val_budget = inner_width.saturating_sub(source_suffix.width());
539 let display = super::truncate(inh_val, val_budget);
540 if is_picker && is_focused {
541 let arrow_pos = inner_width.saturating_sub(1);
542 let used = display.width() + source_suffix.width();
543 let gap = arrow_pos.saturating_sub(used);
544 Line::from(vec![
545 Span::styled(display, theme::muted()),
546 Span::styled(source_suffix, theme::muted()),
547 Span::raw(" ".repeat(gap)),
548 Span::styled(design::PICKER_ARROW, theme::muted()),
549 ])
550 } else {
551 Line::from(vec![
552 Span::styled(display, theme::muted()),
553 Span::styled(source_suffix, theme::muted()),
554 ])
555 }
556 }
557 } else if let (true, FormField::VaultSsh, Some((role, prov))) =
558 (value.is_empty(), field, vault_provider_hint)
559 {
560 let hint = hints::inherits_from(role, prov);
561 Line::from(Span::styled(hint, theme::muted()))
562 } else if let (true, FormField::VaultAddr, Some((addr, prov))) =
563 (value.is_empty(), field, vault_addr_provider_hint)
564 {
565 let hint = hints::inherits_from(addr, prov);
566 Line::from(Span::styled(hint, theme::muted()))
567 } else if value.is_empty() && is_focused && !is_picker {
568 let ph = placeholder_for(field, form.is_pattern, paths);
569 Line::from(Span::styled(ph, theme::muted()))
570 } else if is_picker && is_focused {
571 let inner_width = area.width as usize;
572 let arrow_pos = inner_width.saturating_sub(1);
573 let (display, display_style) = if value.is_empty() {
574 let ph = if field == FormField::VaultSsh {
575 hints::HOST_VAULT_SSH_PICKER.to_string()
576 } else {
577 placeholder_for(field, form.is_pattern, paths)
578 };
579 (ph, theme::muted())
580 } else {
581 (value.to_string(), theme::bold())
582 };
583 let val_width = display.width();
584 let gap = arrow_pos.saturating_sub(val_width);
585 Line::from(vec![
586 Span::styled(display, display_style),
587 Span::raw(" ".repeat(gap)),
588 Span::styled(design::PICKER_ARROW, theme::muted()),
589 ])
590 } else if value.is_empty() {
591 Line::from(Span::raw(""))
592 } else {
593 Line::from(Span::styled(value.to_string(), theme::bold()))
594 };
595
596 frame.render_widget(Paragraph::new(content), area);
597
598 if is_focused && !picker_open {
599 let prefix: String = value.chars().take(form.cursor_pos).collect();
600 let cursor_x = area
601 .x
602 .saturating_add(prefix.width().min(u16::MAX as usize) as u16);
603 let cursor_y = area.y;
604 if area.width > 0 && cursor_x < area.x.saturating_add(area.width) {
605 frame.set_cursor_position((cursor_x, cursor_y));
606 }
607 }
608}
609
610#[cfg(test)]
611mod tests {
612 use super::*;
613 use ratatui::Terminal;
614 use ratatui::backend::TestBackend;
615
616 fn make_app() -> App {
617 let config = crate::ssh_config::model::SshConfigFile {
618 elements: crate::ssh_config::model::SshConfigFile::parse_content(""),
619 path: tempfile::tempdir()
620 .expect("tempdir")
621 .keep()
622 .join("test_config"),
623 crlf: false,
624 bom: false,
625 };
626 App::new(config)
627 }
628
629 fn buffer_dump(buf: &ratatui::buffer::Buffer) -> String {
630 let mut out = String::new();
631 for y in 0..buf.area.height {
632 for x in 0..buf.area.width {
633 out.push_str(buf[(x, y)].symbol());
634 }
635 out.push('\n');
636 }
637 out
638 }
639
640 #[test]
646 fn render_password_picker_overlay_shows_ctrl_d_hint_in_title() {
647 let backend = TestBackend::new(80, 20);
648 let mut terminal = Terminal::new(backend).unwrap();
649 let mut app = make_app();
650 app.ui.password_picker_mut().open_at(0);
651 terminal
652 .draw(|frame| {
653 render_password_picker_overlay(frame, &mut app);
654 let dump = buffer_dump(frame.buffer_mut());
655 assert!(
656 dump.contains("Password Source · Ctrl+D: global default"),
657 "password picker must surface Ctrl+D hint in title, got:\n{dump}"
658 );
659 })
660 .unwrap();
661 }
662
663 #[test]
669 fn render_password_picker_overlay_has_no_footer_row_with_ctrl_d() {
670 let backend = TestBackend::new(80, 20);
671 let mut terminal = Terminal::new(backend).unwrap();
672 let mut app = make_app();
673 app.ui.password_picker_mut().open_at(0);
674 terminal
675 .draw(|frame| {
676 render_password_picker_overlay(frame, &mut app);
677 let buf = frame.buffer_mut();
678 let mut title_row: Option<u16> = None;
682 for y in 0..buf.area.height {
683 let mut row = String::new();
684 for x in 0..buf.area.width {
685 row.push_str(buf[(x, y)].symbol());
686 }
687 if row.contains("Password Source") {
688 title_row = Some(y);
689 break;
690 }
691 }
692 let title_row = title_row.expect("title row must exist");
693 for y in 0..buf.area.height {
694 if y == title_row {
695 continue;
696 }
697 let mut row = String::new();
698 for x in 0..buf.area.width {
699 row.push_str(buf[(x, y)].symbol());
700 }
701 assert!(
702 !row.contains("Ctrl+D"),
703 "row {y} must not contain 'Ctrl+D' (footer regression): {row:?}"
704 );
705 }
706 })
707 .unwrap();
708 }
709
710 fn proxyjump_picker_fixture(config_text: &str, editing_alias: &str) -> App {
715 let cfg = crate::ssh_config::model::SshConfigFile {
716 elements: crate::ssh_config::model::SshConfigFile::parse_content(config_text),
717 path: tempfile::tempdir()
718 .expect("tempdir")
719 .keep()
720 .join("test_config"),
721 crlf: false,
722 bom: false,
723 };
724 let mut app = App::new(cfg);
725 app.screen = Screen::EditHost {
726 alias: editing_alias.to_string(),
727 };
728 app.ui.proxyjump_picker_mut().open = true;
729 let idx = app.proxyjump_first_host_index();
730 app.ui.proxyjump_picker_mut().list.select(idx);
731 app
732 }
733
734 fn find_needle_in_buffer(
740 buf: &ratatui::buffer::Buffer,
741 needle: &str,
742 ) -> Option<(u16, u16, u16)> {
743 let chars: Vec<String> = needle.chars().map(|c| c.to_string()).collect();
744 let len = chars.len() as u16;
745 if len == 0 || buf.area.width < len {
746 return None;
747 }
748 for y in 0..buf.area.height {
749 for start_x in 0..=buf.area.width - len {
750 let matches = (0..len).all(|i| buf[(start_x + i, y)].symbol() == chars[i as usize]);
751 if matches {
752 return Some((y, start_x, start_x + len - 1));
753 }
754 }
755 }
756 None
757 }
758
759 fn right_border_col(buf: &ratatui::buffer::Buffer, y: u16) -> Option<u16> {
761 for x in (0..buf.area.width).rev() {
762 let s = buf[(x, y)].symbol();
763 if s == "│" || s == "╮" || s == "╯" {
764 return Some(x);
765 }
766 }
767 None
768 }
769
770 #[test]
776 fn render_proxyjump_picker_host_column_is_right_aligned() {
777 let backend = TestBackend::new(80, 20);
778 let mut terminal = Terminal::new(backend).unwrap();
779 let mut app = proxyjump_picker_fixture(
780 concat!(
781 "Host editing\n HostName 9.9.9.9\n",
782 "Host plain\n HostName 1.1.1.1\n",
783 ),
784 "editing",
785 );
786 terminal
787 .draw(|frame| {
788 render_proxyjump_picker_overlay(frame, &mut app);
789 let buf = frame.buffer_mut();
790 let (y, _start, end_col) = find_needle_in_buffer(buf, "1.1.1.1")
791 .expect("candidate host row must render '1.1.1.1'");
792 let border = right_border_col(buf, y).expect("right border on host row");
793 let gap = border.saturating_sub(end_col);
794 assert!(
795 end_col < border && gap <= 3,
796 "hostname must end flush with right border (end_col={end_col}, border_x={border}, gap={gap})"
797 );
798 })
799 .unwrap();
800 }
801
802 #[test]
807 fn render_proxyjump_picker_long_hostname_does_not_overflow() {
808 let backend = TestBackend::new(80, 20);
809 let mut terminal = Terminal::new(backend).unwrap();
810 let mut app = proxyjump_picker_fixture(
813 concat!(
814 "Host editing\n HostName 9.9.9.9\n",
815 "Host plain\n HostName very-long-hostname-that-should-be-truncated.example.com\n",
816 ),
817 "editing",
818 );
819 terminal
820 .draw(|frame| {
821 render_proxyjump_picker_overlay(frame, &mut app);
822 let buf = frame.buffer_mut();
823 let (y, _start, end_col) = find_needle_in_buffer(buf, "very-long-hostname")
827 .expect("truncated hostname prefix must render");
828 let border = right_border_col(buf, y).expect("right border on host row");
829 assert!(
830 end_col < border,
831 "truncated hostname must not overflow right border (end_col={end_col}, border_x={border})"
832 );
833 })
834 .unwrap();
835 }
836
837 #[test]
841 fn render_proxyjump_picker_right_aligns_on_narrow_terminal() {
842 let backend = TestBackend::new(60, 20);
845 let mut terminal = Terminal::new(backend).unwrap();
846 let mut app = proxyjump_picker_fixture(
847 concat!(
848 "Host editing\n HostName 9.9.9.9\n",
849 "Host plain\n HostName 1.1.1.1\n",
850 ),
851 "editing",
852 );
853 terminal
854 .draw(|frame| {
855 render_proxyjump_picker_overlay(frame, &mut app);
856 let buf = frame.buffer_mut();
857 let (y, _start, end_col) = find_needle_in_buffer(buf, "1.1.1.1")
858 .expect("hostname must render on narrow terminal");
859 let border = right_border_col(buf, y).expect("right border present");
860 assert!(
861 end_col < border && border - end_col <= 3,
862 "right-align must hold on narrow terminal (end_col={end_col}, border_x={border})"
863 );
864 })
865 .unwrap();
866 }
867
868 #[test]
872 fn render_proxyjump_picker_right_aligns_suggested_host_below_label() {
873 let backend = TestBackend::new(80, 20);
874 let mut terminal = Terminal::new(backend).unwrap();
875 let mut app = proxyjump_picker_fixture(
878 concat!(
879 "Host editing\n HostName 9.9.9.9\n",
880 "Host bastion\n HostName 1.2.3.4\n",
881 "Host plain\n HostName 5.6.7.8\n",
882 ),
883 "editing",
884 );
885 terminal
886 .draw(|frame| {
887 render_proxyjump_picker_overlay(frame, &mut app);
888 let buf = frame.buffer_mut();
889 let (label_y, _, _) = find_needle_in_buffer(buf, "SUGGESTIONS")
892 .expect("SectionLabel must render above the suggested host");
893 let (y, _start, end_col) = find_needle_in_buffer(buf, "1.2.3.4")
894 .expect("suggested host must render");
895 assert!(
896 y > label_y,
897 "suggested host must render below the SectionLabel (label_y={label_y}, host_y={y})"
898 );
899 let border = right_border_col(buf, y).expect("right border on host row");
900 assert!(
901 end_col < border && border - end_col <= 3,
902 "suggested host must right-align (end_col={end_col}, border_x={border})"
903 );
904 })
905 .unwrap();
906 }
907
908 #[test]
912 fn render_proxyjump_picker_multiple_hosts_share_right_edge() {
913 let backend = TestBackend::new(80, 20);
914 let mut terminal = Terminal::new(backend).unwrap();
915 let mut app = proxyjump_picker_fixture(
916 concat!(
917 "Host editing\n HostName 9.9.9.9\n",
918 "Host host-a\n HostName 1.1.1.1\n",
919 "Host host-b\n HostName 2.2.2.2\n",
920 ),
921 "editing",
922 );
923 terminal
924 .draw(|frame| {
925 render_proxyjump_picker_overlay(frame, &mut app);
926 let buf = frame.buffer_mut();
927 let (y1, _, end1) =
928 find_needle_in_buffer(buf, "1.1.1.1").expect("host-a row must render");
929 let (y2, _, end2) =
930 find_needle_in_buffer(buf, "2.2.2.2").expect("host-b row must render");
931 assert_ne!(y1, y2, "two distinct rows expected");
932 assert_eq!(
933 end1, end2,
934 "both hostnames must end at the same column (end1={end1}, end2={end2})"
935 );
936 })
937 .unwrap();
938 }
939
940 #[test]
945 fn render_proxyjump_picker_preserves_minimum_gap_between_columns() {
946 let backend = TestBackend::new(80, 20);
947 let mut terminal = Terminal::new(backend).unwrap();
948 let mut app = proxyjump_picker_fixture(
949 concat!(
950 "Host editing\n HostName 9.9.9.9\n",
951 "Host a\n HostName 1.1.1.1\n",
952 ),
953 "editing",
954 );
955 terminal
956 .draw(|frame| {
957 render_proxyjump_picker_overlay(frame, &mut app);
958 let buf = frame.buffer_mut();
959 let (y, host_start, _) = find_needle_in_buffer(buf, "1.1.1.1")
960 .expect("hostname must render for gap check");
961 let mut gap = 0_u16;
965 let mut x = host_start;
966 while x > 0 {
967 x -= 1;
968 if buf[(x, y)].symbol() == " " {
969 gap += 1;
970 } else {
971 break;
972 }
973 }
974 assert!(
975 gap >= 2,
976 "at least two spaces must separate alias and hostname columns (gap={gap})"
977 );
978 })
979 .unwrap();
980 }
981}