1use std::ops::Range;
2
3use ratatui::{
4 layout::{Alignment, Constraint, Direction, Layout, Rect},
5 style::{Color, Modifier, Style},
6 text::{Line, Span},
7 widgets::{Block, BorderType, Borders, Clear, List, ListItem, ListState, Paragraph},
8 Frame,
9};
10
11use crate::app::{
12 FormField, HostForm, PassphrasePrompt, PasswordPrompt, SnippetForm, SnippetResultEntry,
13 UpdateButton, UpdatePopup, UpdatePopupPhase, FORM_FIELD_LABELS, SNIPPET_FORM_FIELD_LABELS,
14 UPDATE_BUTTONS,
15};
16use crate::ui::theme::Theme;
17use omnyssh_core::ssh::client::Host;
18
19pub fn centred_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
26 let layout_v = Layout::default()
27 .direction(Direction::Vertical)
28 .constraints([
29 Constraint::Percentage((100 - percent_y) / 2),
30 Constraint::Percentage(percent_y),
31 Constraint::Percentage((100 - percent_y) / 2),
32 ])
33 .split(area);
34
35 Layout::default()
36 .direction(Direction::Horizontal)
37 .constraints([
38 Constraint::Percentage((100 - percent_x) / 2),
39 Constraint::Percentage(percent_x),
40 Constraint::Percentage((100 - percent_x) / 2),
41 ])
42 .split(layout_v[1])[1]
43}
44
45pub fn render_help(frame: &mut Frame, theme: &Theme) {
51 let area = centred_rect(95, 85, frame.area());
52 frame.render_widget(Clear, area);
53
54 let block = Block::default()
55 .title(" Help — Keyboard Shortcuts ")
56 .title_alignment(Alignment::Center)
57 .borders(Borders::ALL)
58 .border_type(BorderType::Rounded)
59 .border_style(Style::default().fg(theme.warning_border));
60
61 let inner = block.inner(area);
62 frame.render_widget(block, area);
63
64 let section_style = Style::default()
66 .fg(theme.text_warning)
67 .add_modifier(Modifier::BOLD);
68 let key_style = Style::default()
69 .fg(theme.accent)
70 .add_modifier(Modifier::BOLD);
71 let desc_style = Style::default().fg(theme.text_primary);
72
73 let columns = Layout::default()
75 .direction(Direction::Horizontal)
76 .constraints([
77 Constraint::Percentage(33),
78 Constraint::Percentage(33),
79 Constraint::Percentage(34),
80 ])
81 .split(inner);
82
83 let mut col1_lines = vec![Line::from("")];
85 col1_lines.push(Line::from(Span::styled(
86 " GLOBAL NAVIGATION",
87 section_style,
88 )));
89 col1_lines.push(Line::from(vec![
90 Span::styled(" 1", key_style),
91 Span::styled(" Dashboard", desc_style),
92 ]));
93 col1_lines.push(Line::from(vec![
94 Span::styled(" 2", key_style),
95 Span::styled(" File Manager", desc_style),
96 ]));
97 col1_lines.push(Line::from(vec![
98 Span::styled(" 3", key_style),
99 Span::styled(" Snippets", desc_style),
100 ]));
101 col1_lines.push(Line::from(vec![
102 Span::styled(" 4", key_style),
103 Span::styled(" Terminal", desc_style),
104 ]));
105 col1_lines.push(Line::from(vec![
106 Span::styled(" ?", key_style),
107 Span::styled(" Help", desc_style),
108 ]));
109 col1_lines.push(Line::from(vec![
110 Span::styled(" q", key_style),
111 Span::styled(" Quit", desc_style),
112 ]));
113 col1_lines.push(Line::from(""));
114
115 col1_lines.push(Line::from(Span::styled(" DASHBOARD", section_style)));
116 col1_lines.push(Line::from(vec![
117 Span::styled(" Enter", key_style),
118 Span::styled(" Open details", desc_style),
119 ]));
120 col1_lines.push(Line::from(vec![
121 Span::styled(" a", key_style),
122 Span::styled(" Add host", desc_style),
123 ]));
124 col1_lines.push(Line::from(vec![
125 Span::styled(" e", key_style),
126 Span::styled(" Edit host", desc_style),
127 ]));
128 col1_lines.push(Line::from(vec![
129 Span::styled(" d", key_style),
130 Span::styled(" Delete host", desc_style),
131 ]));
132 col1_lines.push(Line::from(vec![
133 Span::styled(" r", key_style),
134 Span::styled(" Refresh", desc_style),
135 ]));
136 col1_lines.push(Line::from(vec![
137 Span::styled(" s", key_style),
138 Span::styled(" Sort", desc_style),
139 ]));
140 col1_lines.push(Line::from(vec![
141 Span::styled(" t", key_style),
142 Span::styled(" Filter tags", desc_style),
143 ]));
144 col1_lines.push(Line::from(vec![
145 Span::styled(" g", key_style),
146 Span::styled(" Group by tag", desc_style),
147 ]));
148 col1_lines.push(Line::from(vec![
149 Span::styled(" /", key_style),
150 Span::styled(" Search", desc_style),
151 ]));
152 col1_lines.push(Line::from(vec![
153 Span::styled(" x", key_style),
154 Span::styled(" Quick exec", desc_style),
155 ]));
156 col1_lines.push(Line::from(vec![
157 Span::styled(" K", key_style),
158 Span::styled(" Setup SSH key", desc_style),
159 ]));
160 col1_lines.push(Line::from(vec![
161 Span::styled(" f", key_style),
162 Span::styled(" Tunnel on/off", desc_style),
163 ]));
164 col1_lines.push(Line::from(vec![
165 Span::styled(" hjkl", key_style),
166 Span::styled(" Navigate", desc_style),
167 ]));
168
169 frame.render_widget(Paragraph::new(col1_lines), columns[0]);
170
171 let mut col2_lines = vec![Line::from("")];
173 col2_lines.push(Line::from(Span::styled(" DETAIL VIEW", section_style)));
174 col2_lines.push(Line::from(vec![
175 Span::styled(" Enter", key_style),
176 Span::styled(" Connect", desc_style),
177 ]));
178 col2_lines.push(Line::from(vec![
179 Span::styled(" r", key_style),
180 Span::styled(" Refresh", desc_style),
181 ]));
182 col2_lines.push(Line::from(vec![
183 Span::styled(" Esc", key_style),
184 Span::styled(" Back", desc_style),
185 ]));
186 col2_lines.push(Line::from(vec![
187 Span::styled(" 4-9", key_style),
188 Span::styled(" Quick view", desc_style),
189 ]));
190 col2_lines.push(Line::from(vec![
191 Span::styled(" f", key_style),
192 Span::styled(" Tunnel on/off", desc_style),
193 ]));
194 col2_lines.push(Line::from(""));
195
196 col2_lines.push(Line::from(Span::styled(" FILE MANAGER", section_style)));
197 col2_lines.push(Line::from(vec![
198 Span::styled(" hjkl", key_style),
199 Span::styled(" Navigate", desc_style),
200 ]));
201 col2_lines.push(Line::from(vec![
202 Span::styled(" Tab", key_style),
203 Span::styled(" Switch panel", desc_style),
204 ]));
205 col2_lines.push(Line::from(vec![
206 Span::styled(" Space", key_style),
207 Span::styled(" Mark file", desc_style),
208 ]));
209 col2_lines.push(Line::from(vec![
210 Span::styled(" c", key_style),
211 Span::styled(" Copy", desc_style),
212 ]));
213 col2_lines.push(Line::from(vec![
214 Span::styled(" p", key_style),
215 Span::styled(" Paste", desc_style),
216 ]));
217 col2_lines.push(Line::from(vec![
218 Span::styled(" n", key_style),
219 Span::styled(" New dir", desc_style),
220 ]));
221 col2_lines.push(Line::from(vec![
222 Span::styled(" R", key_style),
223 Span::styled(" Rename", desc_style),
224 ]));
225 col2_lines.push(Line::from(vec![
226 Span::styled(" D", key_style),
227 Span::styled(" Delete", desc_style),
228 ]));
229 col2_lines.push(Line::from(vec![
230 Span::styled(" H", key_style),
231 Span::styled(" Connect", desc_style),
232 ]));
233 col2_lines.push(Line::from(vec![
234 Span::styled(" .", key_style),
235 Span::styled(" Toggle hidden", desc_style),
236 ]));
237 if cfg!(windows) {
238 col2_lines.push(Line::from(vec![
239 Span::styled(" d", key_style),
240 Span::styled(" Next drive", desc_style),
241 ]));
242 }
243 col2_lines.push(Line::from(""));
244
245 col2_lines.push(Line::from(Span::styled(" SNIPPETS", section_style)));
246 col2_lines.push(Line::from(vec![
247 Span::styled(" Enter", key_style),
248 Span::styled(" Run snippet", desc_style),
249 ]));
250 col2_lines.push(Line::from(vec![
251 Span::styled(" n", key_style),
252 Span::styled(" New", desc_style),
253 ]));
254 col2_lines.push(Line::from(vec![
255 Span::styled(" e", key_style),
256 Span::styled(" Edit", desc_style),
257 ]));
258 col2_lines.push(Line::from(vec![
259 Span::styled(" d", key_style),
260 Span::styled(" Delete", desc_style),
261 ]));
262 col2_lines.push(Line::from(vec![
263 Span::styled(" b", key_style),
264 Span::styled(" Broadcast", desc_style),
265 ]));
266 col2_lines.push(Line::from(vec![
267 Span::styled(" /", key_style),
268 Span::styled(" Search", desc_style),
269 ]));
270
271 frame.render_widget(Paragraph::new(col2_lines), columns[1]);
272
273 let mut col3_lines = vec![Line::from("")];
275 col3_lines.push(Line::from(Span::styled(" TERMINAL", section_style)));
276 col3_lines.push(Line::from(vec![
277 Span::styled(" Ctrl+T", key_style),
278 Span::styled(" New tab", desc_style),
279 ]));
280 col3_lines.push(Line::from(vec![
281 Span::styled(" Ctrl+W", key_style),
282 Span::styled(" Close tab", desc_style),
283 ]));
284 col3_lines.push(Line::from(vec![
285 Span::styled(" Ctrl+N", key_style),
286 Span::styled(" Next tab", desc_style),
287 ]));
288 col3_lines.push(Line::from(vec![
289 Span::styled(" Ctrl+\\", key_style),
290 Span::styled(" V-split", desc_style),
291 ]));
292 col3_lines.push(Line::from(vec![
293 Span::styled(" Ctrl+]", key_style),
294 Span::styled(" H-split", desc_style),
295 ]));
296 col3_lines.push(Line::from(vec![
297 Span::styled(" Ctrl+Q", key_style),
298 Span::styled(" Exit", desc_style),
299 ]));
300 col3_lines.push(Line::from(""));
301 col3_lines.push(Line::from(Span::styled(" COPY TEXT", section_style)));
302 col3_lines.push(Line::from(vec![
303 Span::styled(" Mouse drag", key_style),
304 Span::styled(" Select text", desc_style),
305 ]));
306 col3_lines.push(Line::from(vec![
307 Span::styled(" Cmd+C/Ctrl+C", key_style),
308 Span::styled(" Copy", desc_style),
309 ]));
310 col3_lines.push(Line::from(vec![Span::styled(
311 " (Terminal screen only)",
312 Style::default()
313 .fg(theme.text_secondary)
314 .add_modifier(Modifier::ITALIC),
315 )]));
316 col3_lines.push(Line::from(""));
317 col3_lines.push(Line::from(""));
318 col3_lines.push(Line::from(""));
319 col3_lines.push(Line::from(""));
320 col3_lines.push(Line::from(""));
321 col3_lines.push(Line::from(Span::styled(
322 " Press Esc or ? to close",
323 Style::default()
324 .fg(theme.text_muted)
325 .add_modifier(Modifier::ITALIC),
326 )));
327
328 frame.render_widget(Paragraph::new(col3_lines), columns[2]);
329}
330
331pub fn render_host_form(frame: &mut Frame, form: &HostForm, title: &str, theme: &Theme) {
339 let frame_area = frame.area();
344 let needed = u32::from(FORM_FIELD_LABELS.len() as u16) * 2 + 5;
345 let percent = (needed * 100)
346 .div_ceil(u32::from(frame_area.height.max(1)))
347 .clamp(50, 100) as u16;
348 let area = centred_rect(70, percent, frame_area);
349 frame.render_widget(Clear, area);
350
351 let block = Block::default()
352 .title(format!(" {} ", title))
353 .title_alignment(Alignment::Center)
354 .borders(Borders::ALL)
355 .border_type(BorderType::Rounded)
356 .border_style(Style::default().fg(theme.success_border));
357
358 let inner = block.inner(area);
360 frame.render_widget(block, area);
361
362 let capacity = usize::from(inner.height.saturating_sub(3) / 2);
365 let window = field_window(FORM_FIELD_LABELS.len(), form.focused_field, capacity);
366 let num_fields = window.len();
367 let mut constraints: Vec<Constraint> = Vec::with_capacity(num_fields * 2 + 3);
369 constraints.push(Constraint::Length(1)); for _ in 0..num_fields {
371 constraints.push(Constraint::Length(1)); constraints.push(Constraint::Length(1)); }
374 constraints.push(Constraint::Length(1)); constraints.push(Constraint::Length(1)); constraints.push(Constraint::Min(0)); let rows = Layout::default()
379 .direction(Direction::Vertical)
380 .constraints(constraints)
381 .split(inner);
382
383 let focused_style = Style::default()
384 .fg(theme.form_focused_fg)
385 .bg(theme.accent)
386 .add_modifier(Modifier::BOLD);
387 let normal_style = Style::default()
388 .fg(theme.text_primary)
389 .bg(theme.selected_bg);
390 let label_style = Style::default().fg(theme.text_secondary);
391 let focused_label_style = Style::default()
392 .fg(theme.accent)
393 .add_modifier(Modifier::BOLD);
394
395 let more_style = Style::default().fg(theme.text_muted);
396 if window.start > 0 {
397 let more = format!(" ↑ {} more", window.start);
398 frame.render_widget(Paragraph::new(Span::styled(more, more_style)), rows[0]);
399 }
400 if window.end < FORM_FIELD_LABELS.len() {
401 let more = format!(" ↓ {} more", FORM_FIELD_LABELS.len() - window.end);
402 frame.render_widget(
403 Paragraph::new(Span::styled(more, more_style)),
404 rows[1 + num_fields * 2],
405 );
406 }
407
408 for (row, i) in window.enumerate() {
409 let label = FORM_FIELD_LABELS[i];
410 let label_row = rows[1 + row * 2];
411 let input_row = rows[2 + row * 2];
412 let is_focused = i == form.focused_field;
413
414 let lbl_span = Span::styled(
416 format!(" {}: ", label),
417 if is_focused {
418 focused_label_style
419 } else {
420 label_style
421 },
422 );
423 frame.render_widget(Paragraph::new(Line::from(lbl_span)), label_row);
424
425 let field = &form.fields[i];
427 let value_style = if is_focused {
428 focused_style
429 } else {
430 normal_style
431 };
432
433 let display = if is_focused {
434 let (before, after) = field.value.split_at(field.cursor.min(field.value.len()));
436 format!(" {}|{} ", before, after)
437 } else {
438 format!(" {} ", field.value)
439 };
440
441 frame.render_widget(
442 Paragraph::new(Line::from(Span::styled(display, value_style))),
443 input_row,
444 );
445 }
446
447 let hint_row_idx = 1 + num_fields * 2 + 1;
449 if hint_row_idx < rows.len() {
450 let hint = Line::from(vec![
451 Span::styled(
452 " Tab",
453 Style::default()
454 .fg(theme.accent)
455 .add_modifier(Modifier::BOLD),
456 ),
457 Span::styled(":next ", Style::default().fg(theme.text_muted)),
458 Span::styled(
459 "Enter",
460 Style::default()
461 .fg(theme.text_success)
462 .add_modifier(Modifier::BOLD),
463 ),
464 Span::styled(":save ", Style::default().fg(theme.text_muted)),
465 Span::styled(
466 "Esc",
467 Style::default()
468 .fg(theme.text_warning)
469 .add_modifier(Modifier::BOLD),
470 ),
471 Span::styled(":cancel", Style::default().fg(theme.text_muted)),
472 ]);
473 frame.render_widget(Paragraph::new(hint), rows[hint_row_idx]);
474 }
475}
476
477fn field_window(count: usize, focused: usize, capacity: usize) -> Range<usize> {
480 let capacity = capacity.clamp(1, count.max(1));
481 let start = focused
482 .saturating_sub(capacity - 1)
483 .min(count.saturating_sub(capacity));
484 start..start + capacity.min(count)
485}
486
487pub fn render_tag_filter_popup(
500 frame: &mut Frame,
501 tags: &[String],
502 selected_idx: usize,
503 active_filter: Option<&str>,
504 theme: &Theme,
505) {
506 let area = centred_rect(40, 60, frame.area());
507 frame.render_widget(Clear, area);
508
509 let title = match active_filter {
510 Some(t) => format!(" Filter by tag [{}] ", t),
511 None => " Filter by tag ".to_string(),
512 };
513
514 let block = Block::default()
515 .title(title)
516 .title_alignment(Alignment::Center)
517 .borders(Borders::ALL)
518 .border_type(BorderType::Rounded)
519 .border_style(Style::default().fg(theme.popup_border));
520
521 let inner = block.inner(area);
522 frame.render_widget(block, area);
523
524 let mut items: Vec<ListItem> = vec![ListItem::new(Line::from(vec![
526 Span::styled(" ", Style::default()),
527 Span::styled(
528 "All (clear filter)",
529 Style::default()
530 .fg(theme.text_secondary)
531 .add_modifier(Modifier::ITALIC),
532 ),
533 ]))];
534
535 for tag in tags {
536 items.push(ListItem::new(Line::from(vec![
537 Span::styled(" ", Style::default()),
538 Span::styled(tag.as_str(), Style::default().fg(theme.text_primary)),
539 ])));
540 }
541
542 let list = List::new(items)
543 .highlight_style(
544 Style::default()
545 .fg(theme.form_focused_fg)
546 .bg(theme.accent)
547 .add_modifier(Modifier::BOLD),
548 )
549 .highlight_symbol("▶ ");
550
551 let mut list_state = ListState::default().with_selected(Some(selected_idx));
552 frame.render_stateful_widget(list, inner, &mut list_state);
553
554 if inner.height > 3 {
556 let hint_area = Rect {
557 x: inner.x,
558 y: inner.y + inner.height.saturating_sub(1),
559 width: inner.width,
560 height: 1,
561 };
562 frame.render_widget(
563 Paragraph::new(Line::from(vec![
564 Span::styled(
565 "Enter",
566 Style::default()
567 .fg(theme.text_success)
568 .add_modifier(Modifier::BOLD),
569 ),
570 Span::styled(":select ", Style::default().fg(theme.text_muted)),
571 Span::styled(
572 "Esc",
573 Style::default()
574 .fg(theme.text_warning)
575 .add_modifier(Modifier::BOLD),
576 ),
577 Span::styled(":close", Style::default().fg(theme.text_muted)),
578 ])),
579 hint_area,
580 );
581 }
582}
583
584pub fn render_delete_confirm(frame: &mut Frame, host_name: &str, theme: &Theme) {
586 let area = centred_rect(50, 25, frame.area());
587 frame.render_widget(Clear, area);
588
589 let block = Block::default()
590 .title(" Confirm Delete ")
591 .title_alignment(Alignment::Center)
592 .borders(Borders::ALL)
593 .border_type(BorderType::Rounded)
594 .border_style(Style::default().fg(theme.danger_border));
595
596 let inner = block.inner(area);
597 frame.render_widget(block, area);
598
599 let rows = Layout::default()
600 .direction(Direction::Vertical)
601 .constraints([
602 Constraint::Length(1),
603 Constraint::Length(1),
604 Constraint::Length(1),
605 Constraint::Length(1),
606 Constraint::Min(0),
607 ])
608 .split(inner);
609
610 frame.render_widget(
611 Paragraph::new(Line::from(Span::styled(
612 format!(" Delete host '{}'?", host_name),
613 Style::default()
614 .fg(theme.text_primary)
615 .add_modifier(Modifier::BOLD),
616 ))),
617 rows[1],
618 );
619
620 frame.render_widget(
621 Paragraph::new(Line::from(vec![Span::styled(
622 " This cannot be undone. ",
623 Style::default().fg(theme.text_muted),
624 )])),
625 rows[2],
626 );
627
628 frame.render_widget(
629 Paragraph::new(Line::from(vec![
630 Span::styled(" ", Style::default()),
631 Span::styled(
632 "y",
633 Style::default()
634 .fg(theme.text_error)
635 .add_modifier(Modifier::BOLD),
636 ),
637 Span::styled(":Yes ", Style::default().fg(theme.text_muted)),
638 Span::styled(
639 "n / Esc",
640 Style::default()
641 .fg(theme.text_success)
642 .add_modifier(Modifier::BOLD),
643 ),
644 Span::styled(":No", Style::default().fg(theme.text_muted)),
645 ])),
646 rows[3],
647 );
648}
649
650pub fn render_snippet_form(frame: &mut Frame, form: &SnippetForm, title: &str, theme: &Theme) {
656 let area = centred_rect(70, 85, frame.area());
657 frame.render_widget(Clear, area);
658
659 let block = Block::default()
660 .title(format!(" {} ", title))
661 .title_alignment(Alignment::Center)
662 .borders(Borders::ALL)
663 .border_type(BorderType::Rounded)
664 .border_style(Style::default().fg(theme.success_border));
665
666 let inner = block.inner(area);
667 frame.render_widget(block, area);
668
669 let num_fields = SNIPPET_FORM_FIELD_LABELS.len();
670 let mut constraints: Vec<Constraint> = Vec::with_capacity(num_fields * 2 + 3);
672 constraints.push(Constraint::Length(1));
673 for _ in 0..num_fields {
674 constraints.push(Constraint::Length(1));
675 constraints.push(Constraint::Length(1));
676 }
677 constraints.push(Constraint::Length(1));
678 constraints.push(Constraint::Length(1));
679 constraints.push(Constraint::Min(0));
680
681 let rows = Layout::default()
682 .direction(Direction::Vertical)
683 .constraints(constraints)
684 .split(inner);
685
686 let focused_style = Style::default()
687 .fg(theme.form_focused_fg)
688 .bg(theme.accent)
689 .add_modifier(Modifier::BOLD);
690 let normal_style = Style::default()
691 .fg(theme.text_primary)
692 .bg(theme.selected_bg);
693 let label_style = Style::default().fg(theme.text_secondary);
694 let focused_label_style = Style::default()
695 .fg(theme.accent)
696 .add_modifier(Modifier::BOLD);
697
698 for (i, label) in SNIPPET_FORM_FIELD_LABELS.iter().enumerate() {
699 let label_row = rows[1 + i * 2];
700 let input_row = rows[2 + i * 2];
701 let is_focused = i == form.focused_field;
702
703 frame.render_widget(
704 Paragraph::new(Line::from(Span::styled(
705 format!(" {}: ", label),
706 if is_focused {
707 focused_label_style
708 } else {
709 label_style
710 },
711 ))),
712 label_row,
713 );
714
715 let field = &form.fields[i];
716 let display = if is_focused {
717 let (before, after) = field.value.split_at(field.cursor.min(field.value.len()));
718 format!(" {}|{} ", before, after)
719 } else {
720 format!(" {} ", field.value)
721 };
722
723 frame.render_widget(
724 Paragraph::new(Line::from(Span::styled(
725 display,
726 if is_focused {
727 focused_style
728 } else {
729 normal_style
730 },
731 ))),
732 input_row,
733 );
734 }
735
736 let hint_row_idx = 1 + num_fields * 2 + 1;
737 if hint_row_idx < rows.len() {
738 frame.render_widget(
739 Paragraph::new(Line::from(vec![
740 Span::styled(
741 " Tab",
742 Style::default()
743 .fg(theme.accent)
744 .add_modifier(Modifier::BOLD),
745 ),
746 Span::styled(":next ", Style::default().fg(theme.text_muted)),
747 Span::styled(
748 "Enter",
749 Style::default()
750 .fg(theme.text_success)
751 .add_modifier(Modifier::BOLD),
752 ),
753 Span::styled(":save ", Style::default().fg(theme.text_muted)),
754 Span::styled(
755 "Esc",
756 Style::default()
757 .fg(theme.text_warning)
758 .add_modifier(Modifier::BOLD),
759 ),
760 Span::styled(":cancel", Style::default().fg(theme.text_muted)),
761 ])),
762 rows[hint_row_idx],
763 );
764 }
765}
766
767pub fn render_snippet_delete_confirm(frame: &mut Frame, snippet_name: &str, theme: &Theme) {
769 let area = centred_rect(55, 25, frame.area());
770 frame.render_widget(Clear, area);
771
772 let block = Block::default()
773 .title(" Confirm Delete Snippet ")
774 .title_alignment(Alignment::Center)
775 .borders(Borders::ALL)
776 .border_type(BorderType::Rounded)
777 .border_style(Style::default().fg(theme.danger_border));
778
779 let inner = block.inner(area);
780 frame.render_widget(block, area);
781
782 let rows = Layout::default()
783 .direction(Direction::Vertical)
784 .constraints([
785 Constraint::Length(1),
786 Constraint::Length(1),
787 Constraint::Length(1),
788 Constraint::Length(1),
789 Constraint::Min(0),
790 ])
791 .split(inner);
792
793 frame.render_widget(
794 Paragraph::new(Line::from(Span::styled(
795 format!(" Delete snippet '{}'?", snippet_name),
796 Style::default()
797 .fg(theme.text_primary)
798 .add_modifier(Modifier::BOLD),
799 ))),
800 rows[1],
801 );
802
803 frame.render_widget(
804 Paragraph::new(Line::from(Span::styled(
805 " This cannot be undone.",
806 Style::default().fg(theme.text_muted),
807 ))),
808 rows[2],
809 );
810
811 frame.render_widget(
812 Paragraph::new(Line::from(vec![
813 Span::styled(" ", Style::default()),
814 Span::styled(
815 "y",
816 Style::default()
817 .fg(theme.text_error)
818 .add_modifier(Modifier::BOLD),
819 ),
820 Span::styled(":Yes ", Style::default().fg(theme.text_muted)),
821 Span::styled(
822 "n / Esc",
823 Style::default()
824 .fg(theme.text_success)
825 .add_modifier(Modifier::BOLD),
826 ),
827 Span::styled(":No", Style::default().fg(theme.text_muted)),
828 ])),
829 rows[3],
830 );
831}
832
833pub fn render_param_input(
835 frame: &mut Frame,
836 snippet_name: &str,
837 param_names: &[String],
838 param_fields: &[FormField],
839 focused_field: usize,
840 theme: &Theme,
841) {
842 let area = centred_rect(60, 70, frame.area());
843 frame.render_widget(Clear, area);
844
845 let block = Block::default()
846 .title(format!(" Parameters — {} ", snippet_name))
847 .title_alignment(Alignment::Center)
848 .borders(Borders::ALL)
849 .border_type(BorderType::Rounded)
850 .border_style(Style::default().fg(theme.text_warning));
851
852 let inner = block.inner(area);
853 frame.render_widget(block, area);
854
855 let n = param_names.len();
856 let mut constraints = vec![Constraint::Length(1)];
857 for _ in 0..n {
858 constraints.push(Constraint::Length(1));
859 constraints.push(Constraint::Length(1));
860 }
861 constraints.push(Constraint::Length(1));
862 constraints.push(Constraint::Length(1));
863 constraints.push(Constraint::Min(0));
864
865 let rows = Layout::default()
866 .direction(Direction::Vertical)
867 .constraints(constraints)
868 .split(inner);
869
870 let focused_style = Style::default()
871 .fg(theme.form_focused_fg)
872 .bg(theme.warning_border)
873 .add_modifier(Modifier::BOLD);
874 let normal_style = Style::default()
875 .fg(theme.text_primary)
876 .bg(theme.selected_bg);
877
878 for i in 0..n {
879 let label_row = rows[1 + i * 2];
880 let input_row = rows[2 + i * 2];
881 let is_focused = i == focused_field;
882
883 frame.render_widget(
884 Paragraph::new(Line::from(Span::styled(
885 format!(" {{{{{}}}}}:", param_names[i]),
886 Style::default()
887 .fg(if is_focused {
888 Color::Yellow
889 } else {
890 Color::Gray
891 })
892 .add_modifier(if is_focused {
893 Modifier::BOLD
894 } else {
895 Modifier::empty()
896 }),
897 ))),
898 label_row,
899 );
900
901 let field = ¶m_fields[i];
902 let display = if is_focused {
903 let (before, after) = field.value.split_at(field.cursor.min(field.value.len()));
904 format!(" {}|{} ", before, after)
905 } else {
906 format!(" {} ", field.value)
907 };
908
909 frame.render_widget(
910 Paragraph::new(Line::from(Span::styled(
911 display,
912 if is_focused {
913 focused_style
914 } else {
915 normal_style
916 },
917 ))),
918 input_row,
919 );
920 }
921
922 let hint_row_idx = 1 + n * 2 + 1;
923 if hint_row_idx < rows.len() {
924 frame.render_widget(
925 Paragraph::new(Line::from(vec![
926 Span::styled(
927 " Tab",
928 Style::default()
929 .fg(theme.text_warning)
930 .add_modifier(Modifier::BOLD),
931 ),
932 Span::styled(":next ", Style::default().fg(theme.text_muted)),
933 Span::styled(
934 "Enter",
935 Style::default()
936 .fg(theme.text_success)
937 .add_modifier(Modifier::BOLD),
938 ),
939 Span::styled(":run ", Style::default().fg(theme.text_muted)),
940 Span::styled(
941 "Esc",
942 Style::default()
943 .fg(theme.text_warning)
944 .add_modifier(Modifier::BOLD),
945 ),
946 Span::styled(":cancel", Style::default().fg(theme.text_muted)),
947 ])),
948 rows[hint_row_idx],
949 );
950 }
951}
952
953pub fn render_broadcast_picker(
955 frame: &mut Frame,
956 hosts: &[Host],
957 selected_host_indices: &[usize],
958 cursor: usize,
959 theme: &Theme,
960) {
961 let area = centred_rect(60, 75, frame.area());
962 frame.render_widget(Clear, area);
963
964 let block = Block::default()
965 .title(" Broadcast — Select Hosts ")
966 .title_alignment(Alignment::Center)
967 .borders(Borders::ALL)
968 .border_type(BorderType::Rounded)
969 .border_style(Style::default().fg(theme.popup_border));
970
971 let inner = block.inner(area);
972 frame.render_widget(block, area);
973
974 let list_height = inner.height.saturating_sub(1) as usize;
976 let list_area = Rect {
977 height: inner.height.saturating_sub(1),
978 ..inner
979 };
980 let hint_area = Rect {
981 y: inner.y + inner.height.saturating_sub(1),
982 height: 1,
983 ..inner
984 };
985
986 let offset = if cursor >= list_height {
988 cursor - list_height + 1
989 } else {
990 0
991 };
992
993 let items: Vec<ListItem> = hosts
994 .iter()
995 .enumerate()
996 .skip(offset)
997 .take(list_height)
998 .map(|(i, h)| {
999 let checked = selected_host_indices.contains(&i);
1000 let is_cursor = i == cursor;
1001 let checkbox = if checked {
1002 Span::styled(
1003 "[x] ",
1004 Style::default()
1005 .fg(theme.text_success)
1006 .add_modifier(Modifier::BOLD),
1007 )
1008 } else {
1009 Span::styled("[ ] ", Style::default().fg(theme.text_muted))
1010 };
1011 let name = Span::styled(
1012 h.name.as_str(),
1013 if is_cursor {
1014 Style::default()
1015 .fg(theme.text_primary)
1016 .add_modifier(Modifier::BOLD)
1017 } else {
1018 Style::default().fg(theme.text_secondary)
1019 },
1020 );
1021 let item = ListItem::new(Line::from(vec![Span::raw(" "), checkbox, name]));
1022 if is_cursor {
1023 item.style(Style::default().bg(theme.selected_bg))
1024 } else {
1025 item
1026 }
1027 })
1028 .collect();
1029
1030 let list = List::new(items);
1031 frame.render_widget(list, list_area);
1032
1033 frame.render_widget(
1034 Paragraph::new(Line::from(vec![
1035 Span::styled(
1036 "Space",
1037 Style::default()
1038 .fg(theme.accent)
1039 .add_modifier(Modifier::BOLD),
1040 ),
1041 Span::styled(":toggle ", Style::default().fg(theme.text_muted)),
1042 Span::styled(
1043 "Enter",
1044 Style::default()
1045 .fg(theme.text_success)
1046 .add_modifier(Modifier::BOLD),
1047 ),
1048 Span::styled(":run ", Style::default().fg(theme.text_muted)),
1049 Span::styled(
1050 "Esc",
1051 Style::default()
1052 .fg(theme.text_warning)
1053 .add_modifier(Modifier::BOLD),
1054 ),
1055 Span::styled(":cancel", Style::default().fg(theme.text_muted)),
1056 ])),
1057 hint_area,
1058 );
1059}
1060
1061pub fn render_passphrase_prompt(frame: &mut Frame, prompt: &PassphrasePrompt, theme: &Theme) {
1063 let input = if prompt.unlocking {
1064 String::from(" Unlocking…")
1065 } else {
1066 masked(&prompt.field)
1067 };
1068 render_secret_prompt(
1069 frame,
1070 theme,
1071 SecretPrompt {
1072 title: format!(" Unlock SSH key — {} ", prompt.host_name),
1073 detail: &prompt.key_path,
1074 label: " Passphrase (kept in memory until OmnySSH exits):",
1075 input,
1076 error: prompt.error.clone(),
1077 action: ":unlock ",
1078 },
1079 );
1080}
1081
1082pub fn render_password_prompt(frame: &mut Frame, prompt: &PasswordPrompt, theme: &Theme) {
1084 render_secret_prompt(
1085 frame,
1086 theme,
1087 SecretPrompt {
1088 title: format!(" SSH login — {} ", prompt.host_name),
1089 detail: &prompt.login,
1090 label: " Password (kept in memory until OmnySSH exits):",
1091 input: masked(&prompt.field),
1092 error: prompt
1093 .retry
1094 .then(|| String::from("Permission denied, please try again."))
1095 .or_else(|| {
1096 prompt
1097 .new_host_key
1098 .as_ref()
1099 .map(|key| format!("New host key recorded: {key}"))
1100 }),
1101 action: ":log in ",
1102 },
1103 );
1104}
1105
1106fn masked(field: &FormField) -> String {
1107 format!(" {}| ", "*".repeat(field.value.chars().count()))
1108}
1109
1110struct SecretPrompt<'a> {
1112 title: String,
1113 detail: &'a str,
1114 label: &'a str,
1115 input: String,
1116 error: Option<String>,
1117 action: &'a str,
1119}
1120
1121fn render_secret_prompt(frame: &mut Frame, theme: &Theme, prompt: SecretPrompt<'_>) {
1122 let screen = frame.area();
1124 let width = (screen.width * 7 / 10).max(60).min(screen.width);
1125 let height = 8.min(screen.height);
1126 let area = Rect::new(
1127 screen.x + (screen.width - width) / 2,
1128 screen.y + (screen.height - height) / 2,
1129 width,
1130 height,
1131 );
1132 frame.render_widget(Clear, area);
1133
1134 let block = Block::default()
1135 .title(prompt.title)
1136 .title_alignment(Alignment::Center)
1137 .borders(Borders::ALL)
1138 .border_type(BorderType::Rounded)
1139 .border_style(Style::default().fg(theme.accent));
1140
1141 let inner = block.inner(area);
1142 frame.render_widget(block, area);
1143
1144 let rows = Layout::default()
1145 .direction(Direction::Vertical)
1146 .constraints([Constraint::Length(1); 6])
1147 .split(inner);
1148
1149 frame.render_widget(
1150 Paragraph::new(Line::from(Span::styled(
1151 format!(" {}", prompt.detail),
1152 Style::default().fg(theme.text_secondary),
1153 ))),
1154 rows[0],
1155 );
1156 frame.render_widget(
1157 Paragraph::new(Line::from(Span::styled(
1158 prompt.label,
1159 Style::default().fg(theme.text_primary),
1160 ))),
1161 rows[1],
1162 );
1163 frame.render_widget(
1164 Paragraph::new(Line::from(Span::styled(
1165 prompt.input,
1166 Style::default()
1167 .fg(theme.form_focused_fg)
1168 .bg(theme.success_border)
1169 .add_modifier(Modifier::BOLD),
1170 ))),
1171 rows[2],
1172 );
1173
1174 if let Some(error) = prompt.error {
1175 frame.render_widget(
1176 Paragraph::new(Line::from(Span::styled(
1177 format!(" {error}"),
1178 Style::default().fg(theme.text_error),
1179 ))),
1180 rows[3],
1181 );
1182 }
1183
1184 frame.render_widget(
1185 Paragraph::new(Line::from(vec![
1186 Span::styled(
1187 " Enter",
1188 Style::default()
1189 .fg(theme.text_success)
1190 .add_modifier(Modifier::BOLD),
1191 ),
1192 Span::styled(prompt.action, Style::default().fg(theme.text_muted)),
1193 Span::styled(
1194 "Esc",
1195 Style::default()
1196 .fg(theme.text_warning)
1197 .add_modifier(Modifier::BOLD),
1198 ),
1199 Span::styled(":cancel", Style::default().fg(theme.text_muted)),
1200 ])),
1201 rows[5],
1202 );
1203}
1204
1205pub fn render_quick_execute_input(
1207 frame: &mut Frame,
1208 host_name: &str,
1209 command_field: &FormField,
1210 theme: &Theme,
1211) {
1212 let area = centred_rect(65, 20, frame.area());
1213 frame.render_widget(Clear, area);
1214
1215 let block = Block::default()
1216 .title(format!(" Quick Execute — {} ", host_name))
1217 .title_alignment(Alignment::Center)
1218 .borders(Borders::ALL)
1219 .border_type(BorderType::Rounded)
1220 .border_style(Style::default().fg(theme.success_border));
1221
1222 let inner = block.inner(area);
1223 frame.render_widget(block, area);
1224
1225 if inner.height < 2 {
1226 return;
1227 }
1228
1229 let rows = Layout::default()
1230 .direction(Direction::Vertical)
1231 .constraints([
1232 Constraint::Length(1),
1233 Constraint::Length(1),
1234 Constraint::Min(0),
1235 ])
1236 .split(inner);
1237
1238 let (before, after) = command_field
1240 .value
1241 .split_at(command_field.cursor.min(command_field.value.len()));
1242 let display = format!(" {}|{} ", before, after);
1243 frame.render_widget(
1244 Paragraph::new(Line::from(Span::styled(
1245 display,
1246 Style::default()
1247 .fg(theme.form_focused_fg)
1248 .bg(theme.success_border)
1249 .add_modifier(Modifier::BOLD),
1250 ))),
1251 rows[0],
1252 );
1253
1254 frame.render_widget(
1256 Paragraph::new(Line::from(vec![
1257 Span::styled(
1258 " Enter",
1259 Style::default()
1260 .fg(theme.text_success)
1261 .add_modifier(Modifier::BOLD),
1262 ),
1263 Span::styled(":run ", Style::default().fg(theme.text_muted)),
1264 Span::styled(
1265 "Esc",
1266 Style::default()
1267 .fg(theme.text_warning)
1268 .add_modifier(Modifier::BOLD),
1269 ),
1270 Span::styled(":cancel", Style::default().fg(theme.text_muted)),
1271 ])),
1272 rows[1],
1273 );
1274}
1275
1276const SPINNER_FRAMES: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
1278const _: () = assert!(
1279 !SPINNER_FRAMES.is_empty(),
1280 "SPINNER_FRAMES must not be empty"
1281);
1282
1283pub fn render_snippet_results(
1285 frame: &mut Frame,
1286 entries: &[SnippetResultEntry],
1287 scroll: usize,
1288 tick_count: u64,
1289 theme: &Theme,
1290) {
1291 let area = centred_rect(80, 85, frame.area());
1292 frame.render_widget(Clear, area);
1293
1294 let spinner = SPINNER_FRAMES[(tick_count as usize / 2) % SPINNER_FRAMES.len()];
1295
1296 let block = Block::default()
1297 .title(" Results ")
1298 .title_alignment(Alignment::Center)
1299 .borders(Borders::ALL)
1300 .border_type(BorderType::Rounded)
1301 .border_style(Style::default().fg(theme.popup_border));
1302
1303 let inner = block.inner(area);
1304 frame.render_widget(block, area);
1305
1306 if entries.is_empty() {
1307 return;
1308 }
1309
1310 if entries.len() == 1 {
1311 render_single_result(frame, inner, &entries[0], scroll, spinner, theme);
1313 } else {
1314 let n = entries.len().min(6); let constraints: Vec<Constraint> = (0..n).map(|_| Constraint::Min(3)).collect();
1317 let sections = Layout::default()
1318 .direction(Direction::Vertical)
1319 .constraints(constraints)
1320 .split(inner);
1321 for (i, entry) in entries.iter().take(n).enumerate() {
1322 render_single_result(frame, sections[i], entry, 0, spinner, theme);
1323 }
1324 }
1325}
1326
1327fn render_single_result(
1329 frame: &mut Frame,
1330 area: Rect,
1331 entry: &SnippetResultEntry,
1332 scroll: usize,
1333 spinner: char,
1334 theme: &Theme,
1335) {
1336 if area.height < 2 {
1337 return;
1338 }
1339
1340 let header_area = Rect { height: 1, ..area };
1342 let body_area = Rect {
1343 y: area.y + 1,
1344 height: area.height.saturating_sub(1),
1345 ..area
1346 };
1347
1348 let status_span = if entry.pending {
1349 Span::styled(
1350 format!(" {} Running… ", spinner),
1351 Style::default().fg(theme.text_warning),
1352 )
1353 } else if entry.output.is_ok() {
1354 Span::styled(" ✓ Done ", Style::default().fg(theme.text_success))
1355 } else {
1356 Span::styled(" ✗ Error ", Style::default().fg(theme.text_error))
1357 };
1358
1359 frame.render_widget(
1360 Paragraph::new(Line::from(vec![
1361 Span::styled(
1362 format!(" {} — {} ", entry.host_name, entry.snippet_name),
1363 Style::default()
1364 .fg(theme.text_primary)
1365 .add_modifier(Modifier::BOLD),
1366 ),
1367 status_span,
1368 ])),
1369 header_area,
1370 );
1371
1372 if area.height < 3 {
1373 return;
1374 }
1375
1376 let text = match &entry.output {
1378 Ok(out) if !out.is_empty() => out.as_str(),
1379 Ok(_) if entry.pending => "",
1380 Ok(_) => "(no output)",
1381 Err(err) => err.as_str(),
1382 };
1383
1384 let text_color = if entry.output.is_err() {
1385 theme.text_error
1386 } else {
1387 theme.text_secondary
1388 };
1389
1390 let lines: Vec<Line> = text
1391 .lines()
1392 .skip(scroll)
1393 .map(|l| {
1394 Line::from(Span::styled(
1395 format!(" {}", l),
1396 Style::default().fg(text_color),
1397 ))
1398 })
1399 .collect();
1400
1401 let hint = if !entry.pending {
1402 Line::from(vec![
1403 Span::styled(
1404 "j/k",
1405 Style::default()
1406 .fg(theme.accent)
1407 .add_modifier(Modifier::BOLD),
1408 ),
1409 Span::styled(":scroll ", Style::default().fg(theme.text_muted)),
1410 Span::styled(
1411 "Esc",
1412 Style::default()
1413 .fg(theme.text_warning)
1414 .add_modifier(Modifier::BOLD),
1415 ),
1416 Span::styled(":close", Style::default().fg(theme.text_muted)),
1417 ])
1418 } else {
1419 Line::from(Span::styled(
1420 " Waiting for result…",
1421 Style::default().fg(theme.text_muted),
1422 ))
1423 };
1424
1425 let text_area = Rect {
1427 height: body_area.height.saturating_sub(1),
1428 ..body_area
1429 };
1430 let hint_area = Rect {
1431 y: body_area.y + body_area.height.saturating_sub(1),
1432 height: 1,
1433 ..body_area
1434 };
1435
1436 frame.render_widget(Paragraph::new(lines), text_area);
1437 frame.render_widget(Paragraph::new(hint), hint_area);
1438}
1439
1440pub fn render_key_setup_confirm(
1449 frame: &mut Frame,
1450 host: Option<&omnyssh_core::ssh::client::Host>,
1451 theme: &Theme,
1452) {
1453 let area = centred_rect(55, 40, frame.area());
1454 frame.render_widget(Clear, area);
1455
1456 let block = Block::default()
1457 .title(" SSH Key Setup ")
1458 .title_alignment(Alignment::Center)
1459 .borders(Borders::ALL)
1460 .border_type(BorderType::Rounded)
1461 .border_style(Style::default().fg(theme.warning_border));
1462
1463 let inner = block.inner(area);
1464 frame.render_widget(block, area);
1465
1466 let rows = Layout::default()
1467 .direction(Direction::Vertical)
1468 .constraints([
1469 Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Min(0),
1480 ])
1481 .split(inner);
1482
1483 let host_name = host.map(|h| h.name.as_str()).unwrap_or("?");
1484 let host_addr = host.map(|h| h.hostname.as_str()).unwrap_or("?");
1485
1486 frame.render_widget(
1487 Paragraph::new(Line::from(Span::styled(
1488 " Configure SSH key authentication",
1489 Style::default()
1490 .fg(theme.text_primary)
1491 .add_modifier(Modifier::BOLD),
1492 ))),
1493 rows[1],
1494 );
1495
1496 frame.render_widget(
1497 Paragraph::new(Line::from(vec![
1498 Span::styled(" Host: ", Style::default().fg(theme.text_secondary)),
1499 Span::styled(
1500 format!("{} ({})", host_name, host_addr),
1501 Style::default()
1502 .fg(theme.accent)
1503 .add_modifier(Modifier::BOLD),
1504 ),
1505 ])),
1506 rows[3],
1507 );
1508
1509 frame.render_widget(
1510 Paragraph::new(Line::from(Span::styled(
1511 " This will:",
1512 Style::default().fg(theme.text_secondary),
1513 ))),
1514 rows[5],
1515 );
1516
1517 frame.render_widget(
1518 Paragraph::new(Line::from(Span::styled(
1519 " • Generate an Ed25519 key pair",
1520 Style::default().fg(theme.text_secondary),
1521 ))),
1522 rows[6],
1523 );
1524
1525 frame.render_widget(
1526 Paragraph::new(Line::from(Span::styled(
1527 " • Disable password auth on server (if sudo available)",
1528 Style::default().fg(theme.text_warning),
1529 ))),
1530 rows[7],
1531 );
1532
1533 frame.render_widget(
1534 Paragraph::new(Line::from(vec![
1535 Span::styled(" ", Style::default()),
1536 Span::styled(
1537 "y / Enter",
1538 Style::default()
1539 .fg(theme.text_success)
1540 .add_modifier(Modifier::BOLD),
1541 ),
1542 Span::styled(":confirm ", Style::default().fg(theme.text_muted)),
1543 Span::styled(
1544 "n / Esc",
1545 Style::default()
1546 .fg(theme.text_warning)
1547 .add_modifier(Modifier::BOLD),
1548 ),
1549 Span::styled(":cancel", Style::default().fg(theme.text_muted)),
1550 ])),
1551 rows[9],
1552 );
1553}
1554
1555pub fn render_key_setup_progress(
1562 frame: &mut Frame,
1563 host_name: &str,
1564 current_step: Option<&omnyssh_core::ssh::key_setup::KeySetupStep>,
1565 theme: &Theme,
1566) {
1567 use omnyssh_core::ssh::key_setup::KeySetupStep;
1568
1569 let area = centred_rect(55, 55, frame.area());
1570 frame.render_widget(Clear, area);
1571
1572 let block = Block::default()
1573 .title(format!(" Key Setup — {} ", host_name))
1574 .title_alignment(Alignment::Center)
1575 .borders(Borders::ALL)
1576 .border_type(BorderType::Rounded)
1577 .border_style(Style::default().fg(theme.warning_border));
1578
1579 let inner = block.inner(area);
1580 frame.render_widget(block, area);
1581
1582 let all_steps = KeySetupStep::all_steps();
1583
1584 let mut constraints = vec![
1586 Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), ];
1590 for _ in &all_steps {
1591 constraints.push(Constraint::Length(1));
1592 }
1593 constraints.push(Constraint::Length(1)); constraints.push(Constraint::Length(1)); constraints.push(Constraint::Min(0));
1596
1597 let rows = Layout::default()
1598 .direction(Direction::Vertical)
1599 .constraints(constraints)
1600 .split(inner);
1601
1602 frame.render_widget(
1604 Paragraph::new(Line::from(Span::styled(
1605 " Setting up SSH key authentication…",
1606 Style::default()
1607 .fg(theme.text_primary)
1608 .add_modifier(Modifier::BOLD),
1609 ))),
1610 rows[1],
1611 );
1612
1613 let current_idx = current_step.map(|s| *s as usize);
1615
1616 let spinner_char = SPINNER_FRAMES[(frame.count() / 2) % SPINNER_FRAMES.len()];
1618
1619 for (i, step) in all_steps.iter().enumerate() {
1620 let step_num = *step as usize; let row = rows[3 + i];
1622
1623 let (icon, icon_style, desc_style) = if let Some(cur) = current_idx {
1624 if step_num < cur {
1625 (
1627 " ✓ ".to_string(),
1628 Style::default()
1629 .fg(theme.text_success)
1630 .add_modifier(Modifier::BOLD),
1631 Style::default().fg(theme.text_secondary),
1632 )
1633 } else if step_num == cur {
1634 (
1636 format!(" {} ", spinner_char),
1637 Style::default()
1638 .fg(theme.text_warning)
1639 .add_modifier(Modifier::BOLD),
1640 Style::default()
1641 .fg(theme.text_warning)
1642 .add_modifier(Modifier::BOLD),
1643 )
1644 } else {
1645 (
1647 " · ".to_string(),
1648 Style::default().fg(theme.text_muted),
1649 Style::default().fg(theme.text_muted),
1650 )
1651 }
1652 } else {
1653 (
1655 " · ".to_string(),
1656 Style::default().fg(theme.text_muted),
1657 Style::default().fg(theme.text_muted),
1658 )
1659 };
1660
1661 frame.render_widget(
1662 Paragraph::new(Line::from(vec![
1663 Span::styled(icon, icon_style),
1664 Span::styled(step.description(), desc_style),
1665 ])),
1666 row,
1667 );
1668 }
1669
1670 let hint_row = rows[3 + all_steps.len() + 1];
1672 let is_done = current_step.is_some_and(|s| *s as usize >= 6);
1673 if is_done {
1674 frame.render_widget(
1675 Paragraph::new(Line::from(vec![
1676 Span::styled(
1677 " Esc",
1678 Style::default()
1679 .fg(theme.text_warning)
1680 .add_modifier(Modifier::BOLD),
1681 ),
1682 Span::styled(":close", Style::default().fg(theme.text_muted)),
1683 ])),
1684 hint_row,
1685 );
1686 } else {
1687 frame.render_widget(
1688 Paragraph::new(Line::from(Span::styled(
1689 " Please wait…",
1690 Style::default()
1691 .fg(theme.text_muted)
1692 .add_modifier(Modifier::ITALIC),
1693 ))),
1694 hint_row,
1695 );
1696 }
1697}
1698
1699fn update_button_label(button: UpdateButton, can_self_update: bool) -> &'static str {
1705 match button {
1706 UpdateButton::Primary if can_self_update => "Update now",
1707 UpdateButton::Primary => "Remind me later",
1708 UpdateButton::Skip => "Skip this version",
1709 UpdateButton::Disable => "Don't check again",
1710 }
1711}
1712
1713pub fn render_update(frame: &mut Frame, popup: &UpdatePopup, theme: &Theme) {
1715 let area = centred_rect(62, 42, frame.area());
1716 frame.render_widget(Clear, area);
1717
1718 let block = Block::default()
1719 .title(" Update Available ")
1720 .title_alignment(Alignment::Center)
1721 .borders(Borders::ALL)
1722 .border_type(BorderType::Rounded)
1723 .border_style(Style::default().fg(theme.accent));
1724
1725 let inner = block.inner(area);
1726 frame.render_widget(block, area);
1727
1728 let rows = Layout::default()
1730 .direction(Direction::Vertical)
1731 .constraints([
1732 Constraint::Min(1),
1733 Constraint::Length(1),
1734 Constraint::Length(1),
1735 ])
1736 .split(inner);
1737
1738 let mut lines: Vec<Line> = vec![
1740 Line::from(""),
1741 Line::from(vec![
1742 Span::styled(" Version ", Style::default().fg(theme.text_secondary)),
1743 Span::styled(
1744 popup.info.current.clone(),
1745 Style::default().fg(theme.text_muted),
1746 ),
1747 Span::styled(" → ", Style::default().fg(theme.text_secondary)),
1748 Span::styled(
1749 popup.info.latest.clone(),
1750 Style::default()
1751 .fg(theme.accent)
1752 .add_modifier(Modifier::BOLD),
1753 ),
1754 ]),
1755 Line::from(""),
1756 ];
1757
1758 let info_style = Style::default().fg(theme.text_primary);
1759 let emphasis_style = Style::default()
1760 .fg(theme.text_warning)
1761 .add_modifier(Modifier::BOLD);
1762
1763 match &popup.phase {
1764 UpdatePopupPhase::Prompt { .. } => {
1765 if popup.info.can_self_update {
1766 lines.push(Line::from(Span::styled(
1767 " This update can be downloaded and installed now.",
1768 info_style,
1769 )));
1770 lines.push(Line::from(Span::styled(
1771 " The archive checksum is verified before installing.",
1772 Style::default().fg(theme.text_muted),
1773 )));
1774 } else if let Some(cmd) = popup.info.method.upgrade_command() {
1775 lines.push(Line::from(Span::styled(
1776 " Update it with your package manager:",
1777 info_style,
1778 )));
1779 lines.push(Line::from(""));
1780 lines.push(Line::from(Span::styled(
1781 format!(" {cmd}"),
1782 emphasis_style,
1783 )));
1784 } else {
1785 lines.push(Line::from(Span::styled(
1786 " Download the new release from:",
1787 info_style,
1788 )));
1789 lines.push(Line::from(""));
1790 lines.push(Line::from(Span::styled(
1791 format!(" {}", popup.info.release_url()),
1792 emphasis_style,
1793 )));
1794 }
1795 }
1796 UpdatePopupPhase::Installing => {
1797 let spinner = SPINNER_FRAMES[(frame.count() / 2) % SPINNER_FRAMES.len()];
1798 lines.push(Line::from(Span::styled(
1799 format!(" {spinner} Downloading and installing — please wait…"),
1800 Style::default().fg(theme.text_warning),
1801 )));
1802 }
1803 UpdatePopupPhase::Done { message, ok } => {
1804 let color = if *ok {
1805 theme.text_success
1806 } else {
1807 theme.text_error
1808 };
1809 for line in message.lines() {
1810 lines.push(Line::from(Span::styled(
1811 format!(" {line}"),
1812 Style::default().fg(color),
1813 )));
1814 }
1815 }
1816 }
1817
1818 frame.render_widget(Paragraph::new(lines), rows[0]);
1819
1820 if let UpdatePopupPhase::Prompt { selected } = &popup.phase {
1822 let mut spans: Vec<Span> = vec![Span::raw(" ")];
1823 for (i, &button) in UPDATE_BUTTONS.iter().enumerate() {
1824 let label = update_button_label(button, popup.info.can_self_update);
1825 let style = if i == *selected {
1826 Style::default()
1827 .fg(theme.form_focused_fg)
1828 .bg(theme.accent)
1829 .add_modifier(Modifier::BOLD)
1830 } else {
1831 Style::default().fg(theme.text_secondary)
1832 };
1833 spans.push(Span::styled(format!(" {label} "), style));
1834 spans.push(Span::raw(" "));
1835 }
1836 frame.render_widget(Paragraph::new(Line::from(spans)), rows[1]);
1837 }
1838
1839 let hint = match &popup.phase {
1841 UpdatePopupPhase::Prompt { .. } => Line::from(vec![
1842 Span::styled(
1843 " ←/→",
1844 Style::default()
1845 .fg(theme.accent)
1846 .add_modifier(Modifier::BOLD),
1847 ),
1848 Span::styled(":select ", Style::default().fg(theme.text_muted)),
1849 Span::styled(
1850 "Enter",
1851 Style::default()
1852 .fg(theme.text_success)
1853 .add_modifier(Modifier::BOLD),
1854 ),
1855 Span::styled(":confirm ", Style::default().fg(theme.text_muted)),
1856 Span::styled(
1857 "Esc",
1858 Style::default()
1859 .fg(theme.text_warning)
1860 .add_modifier(Modifier::BOLD),
1861 ),
1862 Span::styled(":dismiss", Style::default().fg(theme.text_muted)),
1863 ]),
1864 UpdatePopupPhase::Installing => Line::from(Span::styled(
1865 " Please wait…",
1866 Style::default()
1867 .fg(theme.text_muted)
1868 .add_modifier(Modifier::ITALIC),
1869 )),
1870 UpdatePopupPhase::Done { .. } => Line::from(vec![
1871 Span::styled(
1872 " Enter / Esc",
1873 Style::default()
1874 .fg(theme.text_warning)
1875 .add_modifier(Modifier::BOLD),
1876 ),
1877 Span::styled(":close", Style::default().fg(theme.text_muted)),
1878 ]),
1879 };
1880 frame.render_widget(Paragraph::new(hint), rows[2]);
1881}
1882
1883#[cfg(test)]
1884mod tests {
1885 use super::*;
1886
1887 #[test]
1888 fn the_form_window_always_holds_the_focused_field() {
1889 for count in 1..=12 {
1890 for capacity in 0..=13 {
1891 for focused in 0..count {
1892 let window = field_window(count, focused, capacity);
1893 assert!(
1894 window.contains(&focused),
1895 "{count} fields, {capacity} slots, focus {focused}: {window:?}"
1896 );
1897 assert_eq!(window.len(), capacity.clamp(1, count));
1898 assert!(window.end <= count);
1899 }
1900 }
1901 }
1902 }
1903
1904 #[test]
1905 fn the_form_window_scrolls_only_past_the_last_slot() {
1906 assert_eq!(field_window(12, 0, 9), 0..9);
1908 assert_eq!(field_window(12, 8, 9), 0..9);
1909 assert_eq!(field_window(12, 9, 9), 1..10);
1910 assert_eq!(field_window(12, 11, 9), 3..12);
1911 assert_eq!(field_window(12, 11, 20), 0..12);
1912 }
1913
1914 #[test]
1915 fn the_passphrase_prompt_fits_the_smallest_screen() {
1916 let prompt = PassphrasePrompt {
1917 host_name: String::from("lab"),
1918 key_path: String::from("/home/me/.ssh/test_key"),
1919 field: FormField::with_value("secret"),
1920 error: Some(String::from("wrong passphrase")),
1921 unlocking: false,
1922 };
1923 let backend = ratatui::backend::TestBackend::new(80, 24);
1924 let mut terminal = ratatui::Terminal::new(backend).expect("terminal");
1925 terminal
1926 .draw(|frame| render_passphrase_prompt(frame, &prompt, &Theme::default()))
1927 .expect("draw");
1928 let screen: String = terminal
1929 .backend()
1930 .buffer()
1931 .content()
1932 .iter()
1933 .map(|cell| cell.symbol())
1934 .collect();
1935 for text in [
1936 "Unlock SSH key — lab",
1937 "/home/me/.ssh/test_key",
1938 "******|",
1939 "wrong passphrase",
1940 "Enter:unlock",
1941 ] {
1942 assert!(screen.contains(text), "{text:?} is not on screen");
1943 }
1944 assert!(!screen.contains("secret"), "the passphrase is never drawn");
1945 }
1946
1947 #[test]
1948 fn the_password_prompt_names_the_login_and_hides_the_password() {
1949 let prompt = PasswordPrompt {
1950 request_id: 1,
1951 host_name: String::from("udm"),
1952 login: String::from("root@192.168.1.1"),
1953 retry: true,
1954 new_host_key: None,
1955 field: FormField::with_value("hunter2"),
1956 };
1957 let backend = ratatui::backend::TestBackend::new(80, 24);
1958 let mut terminal = ratatui::Terminal::new(backend).expect("terminal");
1959 terminal
1960 .draw(|frame| render_password_prompt(frame, &prompt, &Theme::default()))
1961 .expect("draw");
1962 let screen: String = terminal
1963 .backend()
1964 .buffer()
1965 .content()
1966 .iter()
1967 .map(|cell| cell.symbol())
1968 .collect();
1969 for text in [
1970 "SSH login — udm",
1971 "root@192.168.1.1",
1972 "*******|",
1973 "Permission denied, please try again.",
1974 "Enter:log in",
1975 ] {
1976 assert!(screen.contains(text), "{text:?} is not on screen");
1977 }
1978 assert!(!screen.contains("hunter2"), "the password is never drawn");
1979 }
1980}