1use ratatui::{
14 layout::{Alignment, Constraint, Direction, Layout, Rect},
15 style::{Color, Modifier, Style},
16 text::{Line, Span},
17 widgets::{Block, BorderType, Borders, Clear, List, ListItem, ListState, Paragraph},
18 Frame,
19};
20use std::time::Instant;
21
22pub struct AppState {
26 pub cols: u16,
28 pub rows: u16,
29 pub save_path: Option<String>,
31 pub live_save_count: usize,
33 pub backup_count: usize,
35 pub has_ini_backup: bool,
37 pub context_path: Option<String>,
40 pub version: String,
42 pub status_message: Option<String>,
44 pub status_style: StatusStyle,
45 pub spinner_active: bool,
47 pub spinner_start: Option<Instant>,
48 pub whale_start: Instant,
49}
50
51#[derive(Clone, Copy, PartialEq)]
52pub enum StatusStyle {
53 Info,
54 Success,
55 Warning,
56 Error,
57 Neutral,
58}
59
60impl Default for AppState {
61 fn default() -> Self {
62 Self {
63 cols: 80,
64 rows: 24,
65 save_path: None,
66 live_save_count: 0,
67 backup_count: 0,
68 has_ini_backup: false,
69 context_path: None,
70 version: String::new(),
71 status_message: None,
72 status_style: StatusStyle::Neutral,
73 spinner_active: false,
74 spinner_start: None,
75 whale_start: Instant::now(),
76 }
77 }
78}
79
80pub fn draw_main_menu(f: &mut Frame, state: &mut ListState, app: &AppState) {
84 let items: Vec<&str> = vec![
85 " Set Subnautica 2 location",
86 " Recover save file",
87 "",
88 " Set backup location",
89 " Create full backup",
90 " Restore full backup",
91 "",
92 " Manage UE5 Config (.ini) files",
93 "",
94 " View disclaimer",
95 " Exit",
96 ];
97 let descs: Vec<&str> = vec![
98 "Enter your Subnautica 2 save folder path (paste supported)",
99 "Restore a save file from a backup",
100 "",
101 "Choose where backup archives are stored (default: next to the binary)",
102 "Copy the savegame files to NotAlterra_Backups",
103 "Restore a full backup from NotAlterra_Backups",
104 "",
105 "Backup, restore, or delete .ini files in Config/Windows",
106 "",
107 "Re-read the disclaimer and terms of use",
108 "Close NotAlterra",
109 ];
110 let chunks = standard_layout(f.area(), items.len());
111
112 draw_header(f, chunks[0], app);
113 draw_status_dashboard(f, chunks[1], app);
114
115 let prompt = "↑/↓ navigate Enter select";
116 draw_select_list(f, chunks[2], &items, &descs, prompt, state);
117
118 draw_status_bar(f, chunks[4], app);
119}
120
121pub fn draw_disclaimer_popup(f: &mut Frame, app: &AppState, selected_yes: bool) {
123 let bar = Rect {
125 x: 0,
126 y: f.area().height.saturating_sub(1),
127 width: f.area().width,
128 height: 1,
129 };
130 draw_whale_separator(f, bar, app);
131 let popup_w = 60.min(f.area().width.saturating_sub(4));
132 let popup_h = 18.min(f.area().height.saturating_sub(4));
133 let area = centered_rect_size(popup_w, popup_h, f.area());
134 f.render_widget(Clear, area);
135
136 let block = Block::default()
137 .borders(Borders::ALL)
138 .border_type(BorderType::Plain)
139 .border_style(Style::default().fg(Color::Yellow));
140 f.render_widget(block, area);
141
142 let inner = inner(area, 2, 1);
143
144 let lines = vec![
145 Line::from(Span::styled(
146 "DISCLAIMER",
147 Style::default()
148 .fg(Color::Yellow)
149 .add_modifier(Modifier::BOLD),
150 )),
151 Line::from(""),
152 Line::from(Span::styled(
153 "This tool was created using an AI Agent. While",
154 Style::default().fg(Color::White),
155 )),
156 Line::from(Span::styled(
157 "every effort has been made to ensure it works",
158 Style::default().fg(Color::White),
159 )),
160 Line::from(Span::styled(
161 "correctly, you should review the code and test",
162 Style::default().fg(Color::White),
163 )),
164 Line::from(Span::styled(
165 "on a backup before using it on live save files.",
166 Style::default().fg(Color::White),
167 )),
168 Line::from(""),
169 Line::from(Span::styled(
170 "NotAlterra is not affiliated with Unknown Worlds",
171 Style::default().fg(Color::DarkGray),
172 )),
173 Line::from(Span::styled(
174 "Entertainment or KRAFTON. Use at your own risk.",
175 Style::default().fg(Color::DarkGray),
176 )),
177 Line::from(""),
178 Line::from(Span::styled(
179 "The author is NOT responsible for any data loss.",
180 Style::default()
181 .fg(Color::White)
182 .add_modifier(Modifier::BOLD),
183 )),
184 ];
185 f.render_widget(
186 Paragraph::new(lines).alignment(Alignment::Center),
187 Rect {
188 height: 11,
189 ..inner
190 },
191 );
192
193 let yes_style = if selected_yes {
194 Style::default()
195 .fg(Color::Black)
196 .bg(Color::Green)
197 .add_modifier(Modifier::BOLD)
198 } else {
199 Style::default().fg(Color::Green)
200 };
201 let no_style = if !selected_yes {
202 Style::default()
203 .fg(Color::Black)
204 .bg(Color::Red)
205 .add_modifier(Modifier::BOLD)
206 } else {
207 Style::default().fg(Color::Red)
208 };
209 let buttons = Line::from(vec![
210 Span::styled("[ Accept ]", yes_style),
211 Span::raw(" "),
212 Span::styled("[ Decline ]", no_style),
213 ]);
214 f.render_widget(
215 Paragraph::new(buttons).alignment(Alignment::Center),
216 Rect {
217 y: inner.y + 12,
218 height: 1,
219 ..inner
220 },
221 );
222}
223
224pub fn draw_confirm_popup(
226 f: &mut Frame,
227 app: &AppState,
228 title: &str,
229 details: &[(&str, &str)],
230 selected_yes: bool,
231) {
232 let max_w = details
233 .iter()
234 .map(|(k, v)| k.len() + v.len() + 4)
235 .max()
236 .unwrap_or(20)
237 .max(30) as u16;
238 let popup_w = (max_w + 4).min(f.area().width.saturating_sub(4));
239 let popup_h = (details.len() as u16 + 6).min(f.area().height.saturating_sub(4));
240 let area = centered_rect_size(popup_w, popup_h, f.area());
241 f.render_widget(Clear, area);
242
243 let block = Block::default()
244 .borders(Borders::ALL)
245 .border_type(BorderType::Plain)
246 .border_style(Style::default().fg(Color::Yellow));
247 f.render_widget(block, area);
248
249 let inner = inner(area, 2, 1);
250
251 f.render_widget(
253 Paragraph::new(Span::styled(
254 title,
255 Style::default()
256 .fg(Color::Yellow)
257 .add_modifier(Modifier::BOLD),
258 ))
259 .alignment(Alignment::Center),
260 Rect { height: 1, ..inner },
261 );
262
263 let detail_lines: Vec<Line> = details
265 .iter()
266 .map(|(k, v)| {
267 let icon = if k.starts_with('⚠') {
268 Color::Yellow
269 } else {
270 Color::Gray
271 };
272 Line::from(vec![
273 Span::styled(format!("{k}: "), Style::default().fg(icon)),
274 Span::styled(*v, Style::default()),
275 ])
276 })
277 .collect();
278 f.render_widget(
279 Paragraph::new(detail_lines),
280 Rect {
281 y: inner.y + 2,
282 height: details.len() as u16,
283 ..inner
284 },
285 );
286
287 let yes_style = if selected_yes {
289 Style::default()
290 .fg(Color::Black)
291 .bg(Color::Green)
292 .add_modifier(Modifier::BOLD)
293 } else {
294 Style::default().fg(Color::Green)
295 };
296 let no_style = if !selected_yes {
297 Style::default()
298 .fg(Color::Black)
299 .bg(Color::Red)
300 .add_modifier(Modifier::BOLD)
301 } else {
302 Style::default().fg(Color::Red)
303 };
304 let buttons = Line::from(vec![
305 Span::styled("[ Yes ]", yes_style),
306 Span::raw(" "),
307 Span::styled("[ No ]", no_style),
308 ]);
309 f.render_widget(
310 Paragraph::new(buttons).alignment(Alignment::Center),
311 Rect {
312 y: inner.y + inner.height.saturating_sub(1),
313 height: 1,
314 ..inner
315 },
316 );
317
318 let bar = Rect {
320 x: 0,
321 y: f.area().height.saturating_sub(1),
322 width: f.area().width,
323 height: 1,
324 };
325 draw_whale_separator(f, bar, app);
326}
327
328pub fn draw_ok_dialog(f: &mut Frame, app: &AppState, title: &str, message: &str) {
332 let content_w = message
333 .lines()
334 .map(|l| l.len())
335 .max()
336 .unwrap_or(20)
337 .max(title.len()) as u16
338 + 10;
339 let popup_w = content_w.max(50).min(f.area().width.saturating_sub(4));
340 let popup_h = (message.lines().count() as u16 + 7).min(f.area().height.saturating_sub(4));
341 let area = centered_rect_size(popup_w, popup_h, f.area());
342 f.render_widget(Clear, area);
343 let block = Block::default()
344 .borders(Borders::ALL)
345 .border_type(BorderType::Plain)
346 .border_style(Style::default().fg(Color::Cyan));
347 f.render_widget(block, area);
348 let inner = inner(area, 2, 1);
349 f.render_widget(
350 Paragraph::new(Span::styled(
351 title,
352 Style::default()
353 .fg(Color::Cyan)
354 .add_modifier(Modifier::BOLD),
355 ))
356 .alignment(Alignment::Center),
357 Rect { height: 1, ..inner },
358 );
359 let msg_h = message.lines().count() as u16;
360 f.render_widget(
361 Paragraph::new(message.to_string())
362 .style(Style::default().fg(Color::Gray))
363 .alignment(Alignment::Left),
364 Rect {
365 x: inner.x + 2,
366 y: inner.y + 2,
367 width: inner.width.saturating_sub(4),
368 height: msg_h,
369 },
370 );
371 let ok = Span::styled(
372 "[ OK ]",
373 Style::default()
374 .fg(Color::Black)
375 .bg(Color::Cyan)
376 .add_modifier(Modifier::BOLD),
377 );
378 f.render_widget(
379 Paragraph::new(ok).alignment(Alignment::Center),
380 Rect {
381 y: inner.y + inner.height.saturating_sub(2),
382 height: 1,
383 ..inner
384 },
385 );
386
387 let bar = Rect {
389 x: 0,
390 y: f.area().height.saturating_sub(1),
391 width: f.area().width,
392 height: 1,
393 };
394 draw_whale_separator(f, bar, app);
395}
396
397pub fn draw_info_dialog(f: &mut Frame, app: &AppState, title: &str, message: &str) {
401 let content_w = message
402 .lines()
403 .map(|l| l.len())
404 .max()
405 .unwrap_or(20)
406 .max(title.len()) as u16
407 + 10;
408 let popup_w = content_w.max(50).min(f.area().width.saturating_sub(4));
409 let popup_h = (message.lines().count() as u16 + 6).min(f.area().height.saturating_sub(4));
410 let area = centered_rect_size(popup_w, popup_h, f.area());
411 f.render_widget(Clear, area);
412 let block = Block::default()
413 .borders(Borders::ALL)
414 .border_type(BorderType::Plain)
415 .border_style(Style::default().fg(Color::Cyan));
416 f.render_widget(block, area);
417 let inner = inner(area, 2, 1);
418 f.render_widget(
419 Paragraph::new(Span::styled(
420 title,
421 Style::default()
422 .fg(Color::Cyan)
423 .add_modifier(Modifier::BOLD),
424 ))
425 .alignment(Alignment::Center),
426 Rect { height: 1, ..inner },
427 );
428 let msg_h = message.lines().count() as u16;
429 f.render_widget(
430 Paragraph::new(message.to_string())
431 .style(Style::default().fg(Color::Gray))
432 .alignment(Alignment::Left),
433 Rect {
434 x: inner.x + 2,
435 y: inner.y + 2,
436 width: inner.width.saturating_sub(4),
437 height: msg_h,
438 },
439 );
440 let bar = Rect {
444 x: 0,
445 y: f.area().height.saturating_sub(1),
446 width: f.area().width,
447 height: 1,
448 };
449 draw_whale_separator(f, bar, app);
450}
451
452pub fn draw_ok_dialog_styled(f: &mut Frame, app: &AppState, title: &str, lines: &[Line]) {
456 let content_w = lines
457 .iter()
458 .map(|l| l.width() as u16)
459 .max()
460 .unwrap_or(20)
461 .max(title.len() as u16)
462 + 10;
463 let popup_w = content_w.max(50).min(f.area().width.saturating_sub(4));
464 let popup_h = (lines.len() as u16 + 7).min(f.area().height.saturating_sub(4));
465 let area = centered_rect_size(popup_w, popup_h, f.area());
466 f.render_widget(Clear, area);
467 let block = Block::default()
468 .borders(Borders::ALL)
469 .border_type(BorderType::Plain)
470 .border_style(Style::default().fg(Color::Cyan));
471 f.render_widget(block, area);
472 let inner = inner(area, 2, 1);
473 f.render_widget(
474 Paragraph::new(Span::styled(
475 title,
476 Style::default()
477 .fg(Color::Cyan)
478 .add_modifier(Modifier::BOLD),
479 ))
480 .alignment(Alignment::Center),
481 Rect { height: 1, ..inner },
482 );
483 f.render_widget(
484 Paragraph::new(lines.to_vec())
485 .style(Style::default())
486 .alignment(Alignment::Left),
487 Rect {
488 x: inner.x + 2,
489 y: inner.y + 2,
490 width: inner.width.saturating_sub(4),
491 height: lines.len() as u16,
492 },
493 );
494 let ok = Span::styled(
495 "[ OK ]",
496 Style::default()
497 .fg(Color::Black)
498 .bg(Color::Cyan)
499 .add_modifier(Modifier::BOLD),
500 );
501 f.render_widget(
502 Paragraph::new(ok).alignment(Alignment::Center),
503 Rect {
504 y: inner.y + inner.height.saturating_sub(2),
505 height: 1,
506 ..inner
507 },
508 );
509
510 let bar = Rect {
512 x: 0,
513 y: f.area().height.saturating_sub(1),
514 width: f.area().width,
515 height: 1,
516 };
517 draw_whale_separator(f, bar, app);
518}
519
520fn centered_rect_size(w: u16, h: u16, r: Rect) -> Rect {
524 let popup = Layout::default()
525 .direction(Direction::Vertical)
526 .constraints([
527 Constraint::Length((r.height.saturating_sub(h)) / 2),
528 Constraint::Length(h),
529 Constraint::Length((r.height.saturating_sub(h)) / 2),
530 ])
531 .split(r);
532 Layout::default()
533 .direction(Direction::Horizontal)
534 .constraints([
535 Constraint::Length((r.width.saturating_sub(w)) / 2),
536 Constraint::Length(w),
537 Constraint::Length((r.width.saturating_sub(w)) / 2),
538 ])
539 .split(popup[1])[1]
540}
541
542pub fn draw_sub_menu(
544 f: &mut Frame,
545 app: &AppState,
546 title: &str,
547 items: &[&str],
548 descs: &[&str],
549 state: &mut ListState,
550) {
551 let chunks = standard_layout(f.area(), items.len());
552
553 draw_header(f, chunks[0], app);
554
555 let title_p = Paragraph::new(Span::styled(
556 format!(" {title}"),
557 Style::default()
558 .fg(Color::Cyan)
559 .add_modifier(Modifier::BOLD),
560 ));
561 f.render_widget(title_p, chunks[1]);
562
563 draw_select_list(
564 f,
565 chunks[2],
566 items,
567 descs,
568 "↑/↓ navigate Enter select Esc back",
569 state,
570 );
571 draw_status_bar(f, chunks[4], app);
572}
573
574pub fn draw_text_screen(f: &mut Frame, app: &AppState, lines: &[Line], prompt: &str) {
578 let chunks = standard_layout(f.area(), lines.len());
579 draw_header(f, chunks[0], app);
580
581 f.render_widget(Paragraph::new(lines.to_vec()), chunks[2]);
582
583 let prompt_p = Paragraph::new(Span::styled(prompt, Style::default().fg(Color::DarkGray)))
584 .alignment(Alignment::Center);
585 f.render_widget(prompt_p, chunks[4]);
586}
587
588pub fn draw_picker(
591 f: &mut Frame,
592 app: &AppState,
593 items: &[&str],
594 descs: &[&str],
595 state: &mut ListState,
596 pinned_header: bool,
597) {
598 draw_picker_with_info(f, app, items, descs, state, pinned_header);
599}
600
601pub fn draw_picker_with_info(
605 f: &mut Frame,
606 app: &AppState,
607 items: &[&str],
608 descs: &[&str],
609 state: &mut ListState,
610 pinned_header: bool,
611) {
612 let chunks = standard_layout(f.area(), items.len());
613 draw_header(f, chunks[0], app);
614
615 let prompt = "↑/↓ navigate | Enter select | Esc cancel";
616 draw_select_list_with_info(f, chunks[2], items, descs, prompt, state, pinned_header);
617 draw_status_bar(f, chunks[4], app);
618}
619
620fn standard_layout(area: Rect, _menu_items: usize) -> Vec<Rect> {
623 Layout::default()
624 .direction(Direction::Vertical)
625 .constraints([
626 Constraint::Length(3), Constraint::Length(2), Constraint::Min(1), Constraint::Length(1), Constraint::Length(1), ])
632 .split(area)
633 .to_vec()
634}
635
636fn draw_header(f: &mut Frame, area: Rect, app: &AppState) {
638 let header_block = Block::default()
639 .borders(Borders::BOTTOM)
640 .border_type(BorderType::Plain)
641 .border_style(Style::default().fg(Color::Cyan));
642
643 let chunks = Layout::default()
644 .direction(Direction::Horizontal)
645 .constraints([Constraint::Length(20), Constraint::Min(0)])
646 .split(inner(area, 1, 0));
647
648 let title_line = Line::from(vec![
649 Span::styled(
650 "NotAlterra",
651 Style::default()
652 .fg(Color::Cyan)
653 .add_modifier(Modifier::BOLD),
654 ),
655 Span::raw(" "),
656 Span::styled(app.version.clone(), Style::default().fg(Color::DarkGray)),
657 ]);
658 f.render_widget(Paragraph::new(title_line), chunks[0]);
659
660 let max_w = chunks[1].width.saturating_sub(2) as usize;
665 let path_line = match &app.context_path {
666 Some(p) if p.is_empty() => {
667 Paragraph::new(Span::raw(""))
669 }
670 Some(p) => {
671 let display = truncate_path_tail(p, max_w);
672 Paragraph::new(Span::styled(display, Style::default().fg(Color::Gray)))
673 .alignment(Alignment::Right)
674 }
675 None => {
676 if let Some(ref save) = app.save_path {
678 let display = truncate_path_tail(save, max_w);
679 Paragraph::new(Span::styled(display, Style::default().fg(Color::Gray)))
680 .alignment(Alignment::Right)
681 } else {
682 Paragraph::new(Span::styled(
683 "no save folder selected",
684 Style::default().fg(Color::DarkGray),
685 ))
686 .alignment(Alignment::Right)
687 }
688 }
689 };
690 f.render_widget(path_line, chunks[1]);
691 f.render_widget(header_block, area);
692}
693
694fn draw_status_dashboard(f: &mut Frame, area: Rect, app: &AppState) {
696 let live = Span::styled(
697 format!(
698 " Save{}: {} ",
699 if app.live_save_count == 1 { "" } else { "s" },
700 if app.save_path.is_some() {
701 app.live_save_count.to_string()
702 } else {
703 "—".into()
704 }
705 ),
706 Style::default().fg(Color::Green),
707 );
708 let bak = Span::styled(
709 format!(
710 " Backup{}: {} ",
711 if app.backup_count == 1 { "" } else { "s" },
712 app.backup_count
713 ),
714 Style::default().fg(Color::Yellow),
715 );
716 let ini = Span::styled(
717 format!(
718 " .ini backup: {} ",
719 if app.has_ini_backup { "yes" } else { "no" }
720 ),
721 Style::default().fg(if app.has_ini_backup {
722 Color::Green
723 } else {
724 Color::DarkGray
725 }),
726 );
727
728 let line = Line::from(vec![
729 Span::raw(" "),
730 live,
731 Span::raw(" "),
732 bak,
733 Span::raw(" "),
734 ini,
735 ]);
736
737 f.render_widget(Paragraph::new(line), area);
738}
739
740#[allow(unused)]
742fn draw_select_list(
743 f: &mut Frame,
744 area: Rect,
745 items: &[&str],
746 descs: &[&str],
747 prompt: &str,
748 state: &mut ListState,
749) {
750 let list_area = Rect {
751 height: area.height.saturating_sub(1),
752 ..area
753 };
754
755 let list_items: Vec<ListItem> = items
756 .iter()
757 .map(|item| ListItem::new(Span::raw(*item)).style(Style::default()))
758 .collect();
759
760 let list = List::new(list_items)
761 .highlight_style(
762 Style::default()
763 .fg(Color::Yellow)
764 .add_modifier(Modifier::BOLD),
765 )
766 .highlight_symbol("► ")
767 .repeat_highlight_symbol(true);
768
769 f.render_stateful_widget(list, list_area, state);
770
771 let desc_idx = state
773 .selected()
774 .unwrap_or(0)
775 .min(descs.len().saturating_sub(1));
776 let desc = descs.get(desc_idx).copied().unwrap_or("");
777 let desc_line = Paragraph::new(Span::styled(
778 format!(" {desc}"),
779 Style::default().fg(Color::DarkGray),
780 ));
781
782 f.render_widget(
783 desc_line,
784 Rect {
785 x: area.x,
786 y: area.y + area.height.saturating_sub(1),
787 width: area.width,
788 height: 1,
789 },
790 );
791
792 let prompt_len = prompt.len() as u16;
794 if area.width > prompt_len + 2 {
795 let prompt_p = Paragraph::new(Span::styled(prompt, Style::default().fg(Color::DarkGray)))
796 .alignment(Alignment::Right);
797 f.render_widget(
798 prompt_p,
799 Rect {
800 x: area.x,
801 y: area.y + area.height.saturating_sub(1),
802 width: area.width.saturating_sub(2),
803 height: 1,
804 },
805 );
806 }
807}
808
809fn draw_select_list_with_info(
811 f: &mut Frame,
812 area: Rect,
813 items: &[&str],
814 descs: &[&str],
815 prompt: &str,
816 state: &mut ListState,
817 pinned_header: bool,
818) {
819 let (list_start_y, list_items_slice): (u16, &[&str]) = if pinned_header && !items.is_empty() {
822 let header_style = Style::default()
823 .fg(Color::Cyan)
824 .add_modifier(Modifier::BOLD);
825 f.render_widget(
826 Paragraph::new(Span::styled(items[0], header_style)),
827 Rect {
828 x: area.x,
829 y: area.y,
830 width: area.width,
831 height: 1,
832 },
833 );
834 (area.y + 2, &items[2..])
836 } else {
837 (area.y, items)
838 };
839
840 let list_area = Rect {
841 y: list_start_y,
842 height: area.height.saturating_sub(2 + (list_start_y - area.y)),
843 ..area
844 };
845
846 let header_offset = if pinned_header && !items.is_empty() {
850 2u16
851 } else {
852 0u16
853 };
854 let orig_selected = state.selected();
855 if header_offset > 0 {
856 if let Some(s) = orig_selected {
857 state.select(Some(s.saturating_sub(header_offset as usize)));
858 }
859 }
860
861 let list_items: Vec<ListItem> = list_items_slice
862 .iter()
863 .map(|item| ListItem::new(Span::raw(*item)).style(Style::default()))
864 .collect();
865
866 let list = List::new(list_items)
867 .highlight_style(
868 Style::default()
869 .fg(Color::Yellow)
870 .add_modifier(Modifier::BOLD),
871 )
872 .highlight_symbol("► ")
873 .repeat_highlight_symbol(true);
874
875 f.render_stateful_widget(list, list_area, state);
876
877 if header_offset > 0 {
879 let s = state.selected().unwrap_or(0);
880 state.select(Some(s + header_offset as usize));
881 }
882
883 let base_y = area.y + area.height.saturating_sub(1);
885 let desc_idx = orig_selected
886 .unwrap_or(0)
887 .min(descs.len().saturating_sub(1));
888 let desc = descs.get(desc_idx).copied().unwrap_or("");
889 let desc_line = Paragraph::new(Span::styled(
890 format!(" {desc}"),
891 Style::default().fg(Color::DarkGray),
892 ));
893
894 f.render_widget(
895 desc_line,
896 Rect {
897 x: area.x,
898 y: base_y,
899 width: area.width,
900 height: 1,
901 },
902 );
903
904 let prompt_len = prompt.len() as u16;
905 if area.width > prompt_len + 2 {
906 let prompt_p = Paragraph::new(Span::styled(prompt, Style::default().fg(Color::DarkGray)))
907 .alignment(Alignment::Right);
908 f.render_widget(
909 prompt_p,
910 Rect {
911 x: area.x,
912 y: base_y,
913 width: area.width.saturating_sub(2),
914 height: 1,
915 },
916 );
917 }
918}
919
920fn draw_select_list_pip(f: &mut Frame, area: Rect, items: &[&str], state: &mut ListState) {
925 if area.height < 2 || area.width < 10 {
926 return;
927 }
928
929 let dim_val = Style::default().fg(Color::Rgb(160, 160, 160));
930
931 let list_items: Vec<ListItem> = items
932 .iter()
933 .enumerate()
934 .map(|(i, item)| {
935 let style = if i == 0 {
936 Style::default()
938 .fg(Color::Cyan)
939 .add_modifier(Modifier::BOLD)
940 } else if i >= 2 {
941 dim_val
943 } else {
944 Style::default()
945 };
946 ListItem::new(Span::raw(*item)).style(style)
947 })
948 .collect();
949
950 let list = List::new(list_items)
951 .highlight_style(
952 Style::default()
953 .fg(Color::Yellow)
954 .add_modifier(Modifier::BOLD),
955 )
956 .highlight_symbol("► ")
957 .repeat_highlight_symbol(true);
958
959 f.render_stateful_widget(list, area, state);
960}
961
962fn draw_right_pane(f: &mut Frame, area: Rect, filename: &str, meta_lines: &[Line]) {
968 if area.height < 3 || area.width < 10 {
969 return;
970 }
971
972 let dim = Style::default().fg(Color::Rgb(160, 160, 160));
973
974 let mut y = area.y;
976 let fname = if filename.len() as u16 > area.width.saturating_sub(2) {
977 format!(
978 "{}…",
979 &filename[..area.width.saturating_sub(3).max(1) as usize]
980 )
981 } else {
982 filename.to_string()
983 };
984 f.render_widget(
985 Paragraph::new(Span::styled(
986 &fname,
987 Style::default()
988 .fg(Color::Cyan)
989 .add_modifier(Modifier::BOLD),
990 )),
991 Rect {
992 x: area.x + 1,
993 y,
994 width: area.width.saturating_sub(2),
995 height: 1,
996 },
997 );
998 y += 2;
999
1000 if meta_lines.is_empty() {
1001 let msg = if filename.is_empty() {
1003 Span::styled("select a file", dim)
1004 } else {
1005 Span::styled("select to load metadata", dim)
1006 };
1007 f.render_widget(
1008 Paragraph::new(msg),
1009 Rect {
1010 x: area.x + 1,
1011 y,
1012 width: area.width.saturating_sub(2),
1013 height: 1,
1014 },
1015 );
1016 return;
1017 }
1018
1019 let max_lines = area.height.saturating_sub(2) as usize;
1021 for (i, line) in meta_lines.iter().enumerate().take(max_lines) {
1022 f.render_widget(
1023 Paragraph::new(line.clone()),
1024 Rect {
1025 x: area.x + 1,
1026 y: y + i as u16,
1027 width: area.width.saturating_sub(2),
1028 height: 1,
1029 },
1030 );
1031 }
1032}
1033
1034pub fn draw_picker_split(
1039 f: &mut Frame,
1040 app: &AppState,
1041 items: &[&str],
1042 _descs: &[&str],
1043 state: &mut ListState,
1044 selected_info: Option<&str>,
1045 meta_lines: &[Line],
1046) {
1047 let chunks = standard_layout(f.area(), items.len());
1048 draw_header(f, chunks[0], app);
1049
1050 let menu_area = chunks[2];
1051 let prompt = "↑/↓ navigate | Enter select | Esc cancel";
1052
1053 let prompt_y = menu_area.y + menu_area.height.saturating_sub(1);
1055 let content_area = Rect {
1056 height: menu_area.height.saturating_sub(1),
1057 ..menu_area
1058 };
1059
1060 let halves = Layout::default()
1062 .direction(Direction::Horizontal)
1063 .constraints([Constraint::Percentage(60), Constraint::Percentage(40)])
1064 .split(content_area);
1065
1066 draw_select_list_pip(f, halves[0], items, state);
1068
1069 draw_right_pane(f, halves[1], selected_info.unwrap_or(""), meta_lines);
1071
1072 let prompt_len = prompt.len() as u16;
1074 if menu_area.width > prompt_len + 2 {
1075 f.render_widget(
1076 Paragraph::new(Span::styled(prompt, Style::default().fg(Color::DarkGray)))
1077 .alignment(Alignment::Right),
1078 Rect {
1079 x: menu_area.x,
1080 y: prompt_y,
1081 width: menu_area.width.saturating_sub(2),
1082 height: 1,
1083 },
1084 );
1085 }
1086
1087 draw_status_bar(f, chunks[4], app);
1088}
1089
1090fn draw_status_bar(f: &mut Frame, area: Rect, app: &AppState) {
1092 draw_whale_separator(f, area, app);
1093}
1094
1095pub fn draw_whale_separator(f: &mut Frame, area: Rect, app: &AppState) {
1100 if area.width < 4 {
1101 return;
1102 }
1103 let elapsed = app.whale_start.elapsed().as_millis() as u64;
1104 let bar_w = area.width as u64;
1105 let speed_ms: u64 = 180;
1106 let cooldown_ticks: u64 = 30;
1107 let total = bar_w + cooldown_ticks;
1108 let t = (elapsed / speed_ms) % total;
1109 if t < bar_w {
1110 let x = bar_w - t - 1;
1111 let switch = ((elapsed / 400) * 7 + (elapsed / 600) * 13) % 2;
1112 let variants: &[&str] = &["🐋", "🐳"];
1113 let whale = variants[(switch % variants.len() as u64) as usize];
1114 f.render_widget(
1115 Paragraph::new(Span::styled(whale, Style::default().fg(Color::Cyan))),
1116 Rect {
1117 x: area.x + (x as u16).min(area.width.saturating_sub(4)),
1118 y: area.y,
1119 width: 4,
1120 height: 1,
1121 },
1122 );
1123 }
1124}
1125
1126pub struct InputDialogState {
1130 pub input: String,
1131 pub cursor: usize,
1132 pub prompt: String,
1133 pub confirmed: bool,
1134 pub cancelled: bool,
1135}
1136
1137impl InputDialogState {
1138 pub fn new(prompt: impl Into<String>) -> Self {
1139 Self {
1140 input: String::new(),
1141 cursor: 0,
1142 prompt: prompt.into(),
1143 confirmed: false,
1144 cancelled: false,
1145 }
1146 }
1147
1148 pub fn reset(&mut self) {
1149 self.input.clear();
1150 self.cursor = 0;
1151 self.confirmed = false;
1152 self.cancelled = false;
1153 }
1154
1155 pub fn insert(&mut self, c: char) {
1157 self.input.insert(self.cursor, c);
1158 self.cursor += c.len_utf8();
1159 }
1160
1161 pub fn insert_str(&mut self, s: &str) {
1163 self.input.insert_str(self.cursor, s);
1164 self.cursor += s.len();
1165 }
1166
1167 pub fn backspace(&mut self) {
1169 if self.cursor > 0 {
1170 let prev = self.cursor.saturating_sub(1);
1171 self.input.remove(prev);
1172 self.cursor = prev;
1173 }
1174 }
1175
1176 pub fn delete(&mut self) {
1178 if self.cursor < self.input.len() {
1179 self.input.remove(self.cursor);
1180 }
1181 }
1182
1183 pub fn cursor_left(&mut self) {
1185 if self.cursor > 0 {
1186 self.cursor = self.input[..self.cursor]
1187 .char_indices()
1188 .nth_back(0)
1189 .map(|(i, _c)| i)
1190 .unwrap_or(0);
1191 }
1192 }
1193
1194 pub fn cursor_right(&mut self) {
1196 if self.cursor < self.input.len() {
1197 self.cursor = self.input[self.cursor..]
1198 .char_indices()
1199 .nth(1)
1200 .map(|(i, _c)| self.cursor + i)
1201 .unwrap_or(self.input.len());
1202 }
1203 }
1204}
1205
1206pub fn draw_input_dialog(
1211 f: &mut Frame,
1212 _app: &AppState,
1213 state: &InputDialogState,
1214 ok_selected: bool,
1215) {
1216 let prompt_w = state.prompt.len() as u16 + 4;
1217 let input_display = &state.input;
1218 let display_w = input_display.len() + 4; let popup_w =
1220 (prompt_w.max(display_w as u16).max(40) + 4).min(f.area().width.saturating_sub(4));
1221 let popup_h = 10u16.min(f.area().height.saturating_sub(4));
1222 let area = centered_rect_size(popup_w, popup_h, f.area());
1223 f.render_widget(Clear, area);
1224
1225 let block = Block::default()
1226 .borders(Borders::ALL)
1227 .border_type(BorderType::Plain)
1228 .border_style(Style::default().fg(Color::Yellow));
1229 f.render_widget(block, area);
1230
1231 let inner = inner(area, 2, 1);
1232
1233 f.render_widget(
1235 Paragraph::new(Span::styled(
1236 "Set Save Folder",
1237 Style::default()
1238 .fg(Color::Yellow)
1239 .add_modifier(Modifier::BOLD),
1240 )),
1241 Rect { height: 1, ..inner },
1242 );
1243
1244 f.render_widget(
1246 Paragraph::new(Span::styled(
1247 &state.prompt,
1248 Style::default().fg(Color::White),
1249 )),
1250 Rect {
1251 y: inner.y + 2,
1252 height: 1,
1253 width: inner.width,
1254 x: inner.x,
1255 },
1256 );
1257
1258 let cursor_visible = (std::time::Instant::now().elapsed().as_millis() / 500).is_multiple_of(2);
1260 let mut input_spans = vec![Span::styled(" ", Style::default())];
1261 let before = &state.input[..state.cursor.min(state.input.len())];
1263 let after = if state.cursor < state.input.len() {
1264 Some(&state.input[state.cursor..])
1265 } else {
1266 None
1267 };
1268 input_spans.push(Span::styled(before, Style::default().fg(Color::White)));
1269 if cursor_visible && !state.confirmed && !state.cancelled {
1270 input_spans.push(Span::styled("█", Style::default().fg(Color::Cyan)));
1271 }
1272 if let Some(a) = after {
1273 input_spans.push(Span::styled(a, Style::default().fg(Color::White)));
1274 }
1275 f.render_widget(
1276 Paragraph::new(Line::from(input_spans)),
1277 Rect {
1278 y: inner.y + 3,
1279 height: 1,
1280 width: inner.width,
1281 x: inner.x,
1282 },
1283 );
1284
1285 f.render_widget(
1287 Paragraph::new(Span::styled(
1288 "Type a path, then Tab to buttons Enter to confirm Esc to cancel",
1289 Style::default().fg(Color::DarkGray),
1290 )),
1291 Rect {
1292 y: inner.y + 5,
1293 height: 1,
1294 width: inner.width,
1295 x: inner.x,
1296 },
1297 );
1298
1299 let ok_style = if ok_selected {
1301 Style::default()
1302 .fg(Color::Black)
1303 .bg(Color::Green)
1304 .add_modifier(Modifier::BOLD)
1305 } else {
1306 Style::default().fg(Color::Green)
1307 };
1308 let cancel_style = if !ok_selected {
1309 Style::default()
1310 .fg(Color::Black)
1311 .bg(Color::Red)
1312 .add_modifier(Modifier::BOLD)
1313 } else {
1314 Style::default().fg(Color::Red)
1315 };
1316 let buttons = Line::from(vec![
1317 Span::styled(" [ OK ] ", ok_style),
1318 Span::raw(" "),
1319 Span::styled(" [ Cancel ] ", cancel_style),
1320 ]);
1321 f.render_widget(
1322 Paragraph::new(buttons).alignment(Alignment::Center),
1323 Rect {
1324 y: inner.y + 6,
1325 height: 1,
1326 width: inner.width,
1327 x: inner.x,
1328 },
1329 );
1330
1331 let bar = Rect {
1333 x: 0,
1334 y: f.area().height.saturating_sub(1),
1335 width: f.area().width,
1336 height: 1,
1337 };
1338 draw_whale_separator(f, bar, _app);
1339}
1340
1341fn truncate_path_tail(path: &str, max_width: usize) -> String {
1349 if path.len() <= max_width {
1350 return path.to_string();
1351 }
1352 let keep = max_width.saturating_sub(3);
1353 if keep == 0 {
1354 return "…".to_string();
1355 }
1356 let tail = &path[path.len().saturating_sub(keep)..];
1357 if let Some(sep_pos) = tail.find(&['\\', '/'][..]) {
1359 let start = path.len().saturating_sub(keep) + sep_pos;
1360 format!("…{}", &path[start..])
1361 } else {
1362 format!("…{}", tail)
1363 }
1364}
1365
1366fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
1370 let popup_layout = Layout::default()
1371 .direction(Direction::Vertical)
1372 .constraints([
1373 Constraint::Percentage((100 - percent_y) / 2),
1374 Constraint::Percentage(percent_y),
1375 Constraint::Percentage((100 - percent_y) / 2),
1376 ])
1377 .split(r);
1378
1379 Layout::default()
1380 .direction(Direction::Horizontal)
1381 .constraints([
1382 Constraint::Percentage((100 - percent_x) / 2),
1383 Constraint::Percentage(percent_x),
1384 Constraint::Percentage((100 - percent_x) / 2),
1385 ])
1386 .split(popup_layout[1])[1]
1387}
1388
1389fn inner(rect: Rect, margin_x: u16, margin_y: u16) -> Rect {
1391 Rect {
1392 x: rect.x + margin_x,
1393 y: rect.y + margin_y,
1394 width: rect.width.saturating_sub(margin_x * 2),
1395 height: rect.height.saturating_sub(margin_y * 2),
1396 }
1397}