1use crate::app::{
2 AddItemField, App, ChangeKeyField, ImportExportField, ImportExportMode, ItemCounts, PasswordGenField, Screen,
3 StatusType, UnlockField, ViewMode,
4};
5use chamber_vault::ItemKind;
6use color_eyre::Result;
7use ratatui::crossterm::event;
8use ratatui::crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
9use ratatui::layout::Alignment;
10use ratatui::{
11 Frame, Terminal,
12 backend::CrosstermBackend,
13 layout::{Constraint, Direction, Layout, Rect},
14 style::{Color, Modifier, Style},
15 text::{Line, Span, Text},
16 widgets::{Block, BorderType, Borders, Clear, List, ListItem, Paragraph, Wrap},
17};
18
19const fn c_bg() -> Color {
21 Color::Rgb(18, 18, 23)
22}
23const fn c_bg_panel() -> Color {
24 Color::Rgb(24, 26, 33)
25}
26const fn c_border() -> Color {
27 Color::Rgb(60, 66, 80)
28}
29const fn c_accent() -> Color {
30 Color::Rgb(80, 200, 255)
31} const fn c_accent2() -> Color {
33 Color::Rgb(148, 92, 255)
34} const fn c_ok() -> Color {
36 Color::Rgb(120, 220, 120)
37}
38const fn c_warn() -> Color {
39 Color::Rgb(255, 210, 90)
40}
41const fn c_err() -> Color {
42 Color::Rgb(255, 120, 120)
43}
44const fn c_text() -> Color {
45 Color::Rgb(220, 224, 232)
46}
47const fn c_text_dim() -> Color {
48 Color::Rgb(140, 145, 160)
49}
50const fn c_badge_pwd() -> Color {
51 Color::Rgb(255, 140, 140)
52}
53const fn c_badge_env() -> Color {
54 Color::Rgb(120, 220, 120)
55}
56const fn c_badge_note() -> Color {
57 Color::Rgb(120, 180, 255)
58}
59
60const fn c_badge_api() -> Color {
61 Color::Rgb(255, 165, 0) }
63
64const fn c_badge_ssh() -> Color {
65 Color::Rgb(0, 255, 255) }
67
68const fn c_badge_cert() -> Color {
69 Color::Rgb(255, 20, 147) }
71
72const fn c_badge_db() -> Color {
73 Color::Rgb(50, 205, 50) }
75
76const fn c_badge_creditcard() -> Color {
77 Color::Rgb(255, 215, 0) }
79
80const fn c_badge_securenote() -> Color {
81 Color::Rgb(138, 43, 226) }
83
84const fn c_badge_identity() -> Color {
85 Color::Rgb(0, 191, 255) }
87
88const fn c_badge_server() -> Color {
89 Color::Rgb(220, 20, 60) }
91
92const fn c_badge_wifi() -> Color {
93 Color::Rgb(34, 139, 34) }
95
96const fn c_badge_license() -> Color {
97 Color::Rgb(255, 140, 0) }
99
100const fn c_badge_bankaccount() -> Color {
101 Color::Rgb(0, 100, 0) }
103
104const fn c_badge_document() -> Color {
105 Color::Rgb(105, 105, 105) }
107
108const fn c_badge_recovery() -> Color {
109 Color::Rgb(255, 20, 147) }
111
112const fn c_badge_oauth() -> Color {
113 Color::Rgb(30, 144, 255) }
115
116fn truncate_text(text: &str, max_width: usize) -> String {
117 if text.chars().count() <= max_width {
118 text.to_string()
119 } else {
120 let truncated: String = text.chars().take(max_width.saturating_sub(3)).collect();
121 format!("{truncated}...")
122 }
123}
124
125pub async fn run_app(app: &mut App) -> Result<()> {
160 let backend = CrosstermBackend::new(std::io::stdout());
161 let mut terminal = Terminal::new(backend)?;
162
163 let mut last_countdown_update = std::time::Instant::now();
164 let countdown_update_interval = std::time::Duration::from_secs(1);
165
166 terminal.clear()?;
167 let _auto_lock_handle = if let Some(service) = &app.auto_lock_service {
168 Some(service.start().await)
169 } else {
170 None
171 };
172
173 loop {
174 if last_countdown_update.elapsed() >= countdown_update_interval {
175 app.update_countdown_info().await;
176 last_countdown_update = std::time::Instant::now();
177 }
178
179 terminal.draw(|f| draw(f, app))?;
180 if app.check_auto_lock().await {
181 continue;
182 }
183
184 if event::poll(std::time::Duration::from_millis(250))? {
185 if let Event::Key(key) = event::read()? {
186 app.update_activity().await;
187 if key.kind == KeyEventKind::Press && handle_key(app, key)? {
188 break;
189 }
190 }
191 }
192 }
193 Ok(())
194}
195
196#[allow(clippy::too_many_lines)]
197#[allow(clippy::cognitive_complexity)]
198fn handle_key(app: &mut App, key: KeyEvent) -> Result<bool> {
199 if app.search_mode {
200 match key.code {
201 KeyCode::Esc => {
202 app.search_mode = false;
203 return Ok(false);
204 }
205 KeyCode::Enter => {
206 app.search_mode = false;
207 app.update_filtered_items();
208 return Ok(false);
209 }
210 KeyCode::Backspace => {
211 app.search_query.pop();
212 app.update_filtered_items();
213 return Ok(false);
214 }
215 KeyCode::Char(c) => {
216 app.search_query.push(c);
217 app.update_filtered_items();
218 return Ok(false);
219 }
220 _ => return Ok(false),
221 }
222 }
223
224 if key.modifiers.contains(KeyModifiers::CONTROL) {
226 match key.code {
227 KeyCode::Char('v' | 'V') => {
228 match app.screen {
230 Screen::Unlock => {
231 if let Ok(mut clipboard) = arboard::Clipboard::new() {
232 if let Ok(text) = clipboard.get_text() {
233 match app.unlock_focus {
234 UnlockField::Master => app.master_input.push_str(&text),
235 UnlockField::Confirm => {
236 if app.master_mode_is_setup {
237 app.master_confirm_input.push_str(&text);
238 }
239 }
240 }
241 }
242 }
243 return Ok(false); }
245 Screen::AddItem => {
246 match app.add_focus {
247 AddItemField::Value => {
248 if let Err(e) = app.paste_to_add_value() {
249 app.set_status(format!("Paste failed: {e}"), StatusType::Error);
250 }
251 }
252 AddItemField::Name => {
253 if let Ok(mut clipboard) = arboard::Clipboard::new() {
254 if let Ok(text) = clipboard.get_text() {
255 app.add_name.push_str(&text);
256 app.set_status(
257 format!("Pasted {} characters to name field", text.len()),
258 StatusType::Success,
259 );
260 }
261 }
262 }
263 AddItemField::Kind => {}
264 }
265 return Ok(false); }
267 Screen::EditItem => {
268 if let Ok(mut clipboard) = arboard::Clipboard::new() {
269 if let Ok(text) = clipboard.get_text() {
270 app.edit_value.push_str(&text);
271 app.set_status(
272 format!("Pasted {} characters to edit field", text.len()),
273 StatusType::Success,
274 );
275 }
276 }
277 return Ok(false); }
279 Screen::ChangeMaster => {
280 if let Ok(mut clipboard) = arboard::Clipboard::new() {
281 if let Ok(text) = clipboard.get_text() {
282 match app.ck_focus {
283 ChangeKeyField::Current => app.ck_current.push_str(&text),
284 ChangeKeyField::New => app.ck_new.push_str(&text),
285 ChangeKeyField::Confirm => app.ck_confirm.push_str(&text),
286 }
287 }
288 }
289 return Ok(false); }
291 Screen::ImportExport => {
292 if matches!(app.ie_focus, ImportExportField::Path) {
293 if let Ok(mut clipboard) = arboard::Clipboard::new() {
294 if let Ok(text) = clipboard.get_text() {
295 app.ie_path.push_str(&text);
296 app.set_status(
297 format!("Pasted {} characters to path field", text.len()),
298 StatusType::Success,
299 );
300 }
301 }
302 }
303 return Ok(false); }
305 _ => {}
306 }
307 return Ok(false); }
309 KeyCode::Char('c' | 'C') => {
310 if matches!(app.screen, Screen::Main) {
312 app.copy_selected()?;
313 }
314 return Ok(false); }
316 KeyCode::Enter => {
317 match app.screen {
319 Screen::AddItem if matches!(app.add_focus, AddItemField::Value) => {
320 return app.add_item().map(|()| false);
322 }
323 _ => {
324 }
326 }
327 return Ok(false);
328 }
329
330 _ => {
331 return Ok(false);
333 }
334 }
335 }
336
337 match app.screen {
339 Screen::Unlock => match key.code {
340 KeyCode::Esc => return Ok(true),
341 KeyCode::Enter => {
342 app.unlock()?;
343 }
344 KeyCode::Tab => {
345 if app.master_mode_is_setup {
346 app.unlock_focus = match app.unlock_focus {
347 UnlockField::Master => UnlockField::Confirm,
348 UnlockField::Confirm => UnlockField::Master,
349 };
350 }
351 }
352 KeyCode::Backspace => match app.unlock_focus {
353 UnlockField::Master => {
354 app.master_input.pop();
355 }
356 UnlockField::Confirm => {
357 if app.master_mode_is_setup {
358 app.master_confirm_input.pop();
359 }
360 }
361 },
362 KeyCode::Char(c) => {
363 match app.unlock_focus {
365 UnlockField::Master => app.master_input.push(c),
366 UnlockField::Confirm => {
367 if app.master_mode_is_setup {
368 app.master_confirm_input.push(c);
369 }
370 }
371 }
372 }
373 _ => {}
374 },
375
376 Screen::Main => match key.code {
377 KeyCode::Char('q') => return Ok(true),
378 KeyCode::Char('a') => {
379 app.screen = Screen::AddItem;
380 }
381 KeyCode::Char('c') => {
382 if !key.modifiers.contains(KeyModifiers::CONTROL) {
384 app.copy_selected()?;
385 }
386 }
387 KeyCode::Char('v') => {
388 app.view_selected();
390 }
391 KeyCode::Char('e') => {
392 app.edit_selected();
393 }
394 KeyCode::Char('k') => {
395 app.ck_current.clear();
396 app.ck_new.clear();
397 app.ck_confirm.clear();
398 app.ck_focus = ChangeKeyField::Current;
399 app.error = None;
400 app.screen = Screen::ChangeMaster;
401 }
402 KeyCode::Char('g') => {
403 app.open_password_generator();
404 }
405 KeyCode::Char('x') => {
406 app.open_import_export(ImportExportMode::Export);
407 }
408 KeyCode::Char('i') => {
409 app.open_import_export(ImportExportMode::Import);
410 }
411 KeyCode::Char('d') => {
412 app.delete_selected()?;
413 }
414 KeyCode::Down => {
415 if app.filtered_items.is_empty() {
416 return Ok(false);
417 }
418
419 if app.selected < app.filtered_items.len().saturating_sub(1) {
420 app.selected += 1;
421 } else {
422 app.selected = 0;
423 }
424
425 let viewport_height = 10;
426 if app.selected >= app.scroll_offset + viewport_height {
427 app.scroll_offset = app.selected.saturating_sub(viewport_height - 1);
428 } else if app.selected == 0 {
429 app.scroll_offset = 0;
430 }
431 }
432 KeyCode::Up => {
433 if app.filtered_items.is_empty() {
434 return Ok(false);
435 }
436
437 if app.selected > 0 {
438 app.selected -= 1;
439 } else {
440 app.selected = app.filtered_items.len().saturating_sub(1);
441 }
442 if app.selected < app.scroll_offset {
443 app.scroll_offset = app.selected;
444 }
445 }
446 KeyCode::Char('r') => {
447 app.refresh_items()?;
448 }
449 KeyCode::F(2) => {
450 app.open_vault_selector();
451 }
452 KeyCode::Char('/' | 's') => {
453 app.search_mode = true;
454 }
455 KeyCode::Esc => {
456 if !app.search_query.is_empty() {
457 app.search_query.clear();
458 app.update_filtered_items();
459 }
460 }
461 _ => {}
462 },
463
464 Screen::AddItem => match key.code {
465 KeyCode::Esc => {
466 app.screen = Screen::Main;
467 }
468 KeyCode::Tab => {
469 app.add_value = app.add_value_textarea.lines().join("\n");
470 app.add_focus = match app.add_focus {
471 AddItemField::Name => AddItemField::Kind,
472 AddItemField::Kind => AddItemField::Value,
473 AddItemField::Value => AddItemField::Name,
474 };
475 }
476 KeyCode::Left | KeyCode::Right if matches!(app.add_focus, AddItemField::Kind) => {
477 let total_kinds = 17;
478 if key.code == KeyCode::Right {
479 app.add_kind_idx = (app.add_kind_idx + 1) % total_kinds;
480 } else {
481 app.add_kind_idx = if app.add_kind_idx == 0 {
482 total_kinds - 1
483 } else {
484 app.add_kind_idx - 1
485 };
486 }
487 }
488 KeyCode::Char(c) => {
489 match app.add_focus {
491 AddItemField::Name => app.add_name.push(c),
492 AddItemField::Value => {
493 app.add_value_textarea.input(key);
494 }
495 AddItemField::Kind => {}
496 }
497 }
498 _ => {
499 match app.add_focus {
500 AddItemField::Name => {
501 match key.code {
503 KeyCode::Enter => {
504 app.add_value = app.add_value_textarea.lines().join("\n");
506 return app.add_item().map(|()| false);
507 }
508 KeyCode::Backspace => {
509 app.add_name.pop();
510 }
511 KeyCode::Char(c) => {
512 app.add_name.push(c);
513 }
514 _ => {}
515 }
516 }
517 AddItemField::Kind => {
518 if matches!(key.code, KeyCode::Enter) {
520 app.add_value = app.add_value_textarea.lines().join("\n");
521 return app.add_item().map(|()| false);
522 }
523 }
524 AddItemField::Value => {
525 match key.code {
527 KeyCode::Enter if key.modifiers.contains(KeyModifiers::CONTROL) => {
529 app.add_value = app.add_value_textarea.lines().join("\n");
530 return app.add_item().map(|()| false);
531 }
532 _ => {
534 app.add_value_textarea.input(key);
535 }
536 }
537 }
538 }
539 }
540 },
541
542 Screen::ViewItem => match key.code {
543 KeyCode::Esc => {
544 app.screen = Screen::Main;
545 }
546 KeyCode::Char('t') | KeyCode::Enter => {
547 app.toggle_value_visibility();
548 }
549 KeyCode::Char('c') => {
550 if let Some(item) = &app.view_item {
551 if let Ok(mut clipboard) = arboard::Clipboard::new() {
552 let _ = clipboard.set_text(&item.value);
553 app.set_status(format!("Copied '{}' to clipboard", item.name), StatusType::Success);
554 }
555 }
556 }
557 _ => {}
558 },
559
560 Screen::EditItem => match key.code {
561 KeyCode::Esc => {
562 app.screen = Screen::Main;
563 }
564 KeyCode::Enter => {
565 app.save_edit()?;
566 }
567 KeyCode::Backspace => {
568 app.edit_value.pop();
569 }
570 KeyCode::Char(c) => {
571 app.edit_value.push(c);
573 }
574 _ => {}
575 },
576
577 Screen::ChangeMaster => match key.code {
578 KeyCode::Esc => {
579 app.screen = Screen::Main;
580 }
581 KeyCode::Enter => {
582 app.change_master()?;
583 }
584 KeyCode::Tab => {
585 app.ck_focus = match app.ck_focus {
586 ChangeKeyField::Current => ChangeKeyField::New,
587 ChangeKeyField::New => ChangeKeyField::Confirm,
588 ChangeKeyField::Confirm => ChangeKeyField::Current,
589 };
590 }
591 KeyCode::Backspace => match app.ck_focus {
592 ChangeKeyField::Current => {
593 app.ck_current.pop();
594 }
595 ChangeKeyField::New => {
596 app.ck_new.pop();
597 }
598 ChangeKeyField::Confirm => {
599 app.ck_confirm.pop();
600 }
601 },
602 KeyCode::Char(c) => {
603 match app.ck_focus {
605 ChangeKeyField::Current => app.ck_current.push(c),
606 ChangeKeyField::New => app.ck_new.push(c),
607 ChangeKeyField::Confirm => app.ck_confirm.push(c),
608 }
609 }
610 _ => {}
611 },
612
613 Screen::GeneratePassword => match key.code {
614 KeyCode::Esc => {
615 app.screen = Screen::Main;
616 }
617 KeyCode::Tab => {
618 app.gen_focus = match app.gen_focus {
619 PasswordGenField::Length => PasswordGenField::Options,
620 PasswordGenField::Options => PasswordGenField::Generate,
621 PasswordGenField::Generate => PasswordGenField::Length,
622 };
623 }
624 KeyCode::Char('g') | KeyCode::Enter => {
625 app.generate_password();
626 }
627 KeyCode::Char('c') => {
628 if let Err(e) = app.copy_generated_password() {
629 app.set_status(format!("Copy failed: {e}"), StatusType::Error);
630 }
631 }
632 KeyCode::Char('u') => {
633 app.use_generated_password();
634 }
635 KeyCode::Char(c) if matches!(app.gen_focus, PasswordGenField::Length) => {
636 if c.is_ascii_digit() {
637 app.gen_length_str.push(c);
638 }
639 }
640 KeyCode::Backspace if matches!(app.gen_focus, PasswordGenField::Length) => {
641 app.gen_length_str.pop();
642 }
643 KeyCode::Char('1') if matches!(app.gen_focus, PasswordGenField::Options) => {
644 app.gen_config.include_uppercase = !app.gen_config.include_uppercase;
645 }
646 KeyCode::Char('2') if matches!(app.gen_focus, PasswordGenField::Options) => {
647 app.gen_config.include_lowercase = !app.gen_config.include_lowercase;
648 }
649 KeyCode::Char('3') if matches!(app.gen_focus, PasswordGenField::Options) => {
650 app.gen_config.include_digits = !app.gen_config.include_digits;
651 }
652 KeyCode::Char('4') if matches!(app.gen_focus, PasswordGenField::Options) => {
653 app.gen_config.include_symbols = !app.gen_config.include_symbols;
654 }
655 KeyCode::Char('5') if matches!(app.gen_focus, PasswordGenField::Options) => {
656 app.gen_config.exclude_ambiguous = !app.gen_config.exclude_ambiguous;
657 }
658 _ => {}
659 },
660
661 Screen::ImportExport => match key.code {
662 KeyCode::Esc => {
663 app.screen = Screen::Main;
664 }
665 KeyCode::Tab => {
666 app.ie_focus = match app.ie_focus {
667 ImportExportField::Path => ImportExportField::Format,
668 ImportExportField::Format => ImportExportField::Action,
669 ImportExportField::Action => ImportExportField::Path,
670 };
671 }
672 KeyCode::Enter => {
673 if matches!(app.ie_focus, ImportExportField::Action) {
674 app.execute_import_export()?;
675 }
676 }
677 KeyCode::Left | KeyCode::Right if matches!(app.ie_focus, ImportExportField::Format) => {
678 if key.code == KeyCode::Right {
679 app.ie_format_idx = (app.ie_format_idx + 1) % app.ie_formats.len();
680 } else {
681 app.ie_format_idx = if app.ie_format_idx == 0 {
682 app.ie_formats.len() - 1
683 } else {
684 app.ie_format_idx - 1
685 };
686 }
687 }
688 KeyCode::Backspace if matches!(app.ie_focus, ImportExportField::Path) => {
689 app.ie_path.pop();
690 }
691 KeyCode::Char(c) if matches!(app.ie_focus, ImportExportField::Path) => {
692 app.ie_path.push(c);
694 }
695 _ => {}
696 },
697 Screen::VaultSelector => {
698 if let Some(action) = app.vault_selector.handle_input(key) {
699 app.handle_vault_action(action)?;
700 }
701 return Ok(false);
702 }
703 }
704
705 Ok(false)
706}
707
708fn draw(f: &mut Frame, app: &mut App) {
709 let size = f.area();
710 let bg_block = Block::default().style(Style::default().bg(c_bg()));
711 f.render_widget(bg_block, size);
712
713 let root = Layout::default()
714 .direction(Direction::Vertical)
715 .constraints([
716 Constraint::Length(1), Constraint::Min(0), Constraint::Length(1), ])
720 .split(size);
721
722 draw_header(f, root[0]);
723 draw_status_bar(f, app, root[2]);
724
725 match app.screen {
726 Screen::Unlock => draw_unlock(f, app, root[1]),
727 Screen::Main => draw_main(f, app, root[1]),
728 Screen::AddItem => {
729 draw_main(f, app, root[1]);
730 draw_add_item(f, app);
731 }
732 Screen::ViewItem => {
733 draw_main(f, app, root[1]);
734 draw_view_item(f, app);
735 }
736 Screen::EditItem => {
737 draw_main(f, app, root[1]);
738 draw_edit_item(f, app);
739 }
740 Screen::ChangeMaster => {
741 draw_main(f, app, root[1]);
742 draw_change_master(f, app);
743 }
744 Screen::GeneratePassword => {
745 draw_main(f, app, root[1]);
746 draw_generate_password(f, app);
747 }
748 Screen::ImportExport => {
749 draw_main(f, app, root[1]);
750 draw_import_export(f, app);
751 }
752 Screen::VaultSelector => draw_vault_selector(f, app, root[1]),
753 }
754}
755
756fn draw_vault_selector(f: &mut Frame, app: &mut App, area: Rect) {
757 app.vault_selector.render(f, area);
758}
759
760fn draw_header(f: &mut Frame, area: Rect) {
761 let title = Line::from(vec![
762 Span::styled(
763 " ◈ chamber ",
764 Style::default().fg(c_accent()).add_modifier(Modifier::BOLD),
765 ),
766 Span::raw(" "),
767 Span::styled("secure vault", Style::default().fg(c_text_dim())),
768 ]);
769
770 let bar = Block::default()
771 .borders(Borders::BOTTOM)
772 .border_type(BorderType::Plain)
773 .border_style(Style::default().fg(c_border()))
774 .style(Style::default().bg(c_bg_panel()));
775 f.render_widget(bar, area);
776
777 let inner = Layout::default()
778 .direction(Direction::Horizontal)
779 .constraints([Constraint::Percentage(70), Constraint::Percentage(30)])
780 .split(area);
781
782 let title_para = Paragraph::new(title).style(Style::default().fg(c_text()));
783 f.render_widget(title_para, inner[0]);
784
785 let version_info = Paragraph::new(Line::from(vec![
786 Span::styled("v", Style::default().fg(c_text_dim())),
787 Span::styled(env!("CARGO_PKG_VERSION"), Style::default().fg(c_accent())),
788 Span::styled(" © 2025", Style::default().fg(c_text_dim())),
789 ]))
790 .style(Style::default().fg(c_text()))
791 .alignment(Alignment::Right);
792 f.render_widget(version_info, inner[1]);
793}
794
795fn draw_unlock(f: &mut Frame, app: &App, body: Rect) {
796 let area = centered_rect(60, 40, body);
797 let title = if app.master_mode_is_setup {
798 " Create Master Key "
799 } else {
800 " Unlock "
801 };
802 let block = Block::default()
803 .borders(Borders::ALL)
804 .border_type(BorderType::Rounded)
805 .title(Span::styled(
806 title,
807 Style::default().fg(c_accent()).add_modifier(Modifier::BOLD),
808 ))
809 .style(Style::default().bg(c_bg_panel()).fg(c_text()))
810 .border_style(Style::default().fg(c_border()));
811
812 let highlight = Style::default().fg(c_accent()).add_modifier(Modifier::BOLD);
813 let dim = Style::default().fg(c_text_dim());
814
815 let mk_label = if app.master_mode_is_setup {
816 "New master key"
817 } else {
818 "Master key"
819 };
820 let mk_value = field_box(
821 &mask(&app.master_input),
822 matches!(app.unlock_focus, UnlockField::Master),
823 );
824
825 let mut lines: Vec<Line> = vec![
826 Line::from(Span::styled(mk_label, Style::default().fg(c_text_dim()))),
827 Line::from(mk_value),
828 ];
829
830 if app.master_mode_is_setup {
831 let cf_value = field_box(
832 &mask(&app.master_confirm_input),
833 matches!(app.unlock_focus, UnlockField::Confirm),
834 );
835 lines.push(Line::default());
836 lines.push(Line::from(Span::styled(
837 "Confirm master key",
838 Style::default().fg(c_text_dim()),
839 )));
840 lines.push(Line::from(cf_value));
841 lines.push(Line::default());
842 lines.push(Line::from(vec![
843 Span::styled("[Tab]", highlight),
844 Span::styled(" switch ", dim),
845 Span::styled("[Enter]", highlight),
846 Span::styled(" initialize & unlock ", dim),
847 Span::styled("[Esc]", highlight),
848 Span::styled(" quit", dim),
849 ]));
850 } else {
851 lines.push(Line::default());
852 lines.push(Line::from(vec![
853 Span::styled("[Enter]", highlight),
854 Span::styled(" unlock ", dim),
855 Span::styled("[Esc]", highlight),
856 Span::styled(" quit", dim),
857 ]));
858 }
859
860 if let Some(err) = &app.error {
861 lines.push(Line::default());
862 lines.push(Line::from(Span::styled(
863 err,
864 Style::default().fg(c_err()).add_modifier(Modifier::BOLD),
865 )));
866 }
867
868 let p = Paragraph::new(Text::from(lines)).block(block).wrap(Wrap { trim: true });
869 f.render_widget(Clear, area);
870 f.render_widget(p, area);
871}
872
873#[allow(clippy::too_many_lines)]
874fn draw_main(f: &mut Frame, app: &App, body: Rect) {
875 let main_layout = Layout::default()
877 .direction(Direction::Horizontal)
878 .constraints([
879 Constraint::Percentage(60), Constraint::Percentage(20), Constraint::Percentage(20), ])
883 .split(centered_rect(96, 90, body));
884
885 let items_area = main_layout[0];
886 let categories_area = main_layout[1];
887 let help_area = main_layout[2];
888
889 draw_items_section(f, app, items_area);
891
892 draw_categories_section(f, app, categories_area);
894
895 draw_help_section(f, help_area);
897}
898
899#[allow(clippy::too_many_lines)]
900fn draw_items_section(f: &mut Frame, app: &App, area: Rect) {
901 let (search_area, items_area) = if app.search_mode || !app.search_query.is_empty() {
903 let chunks = Layout::default()
904 .direction(Direction::Vertical)
905 .constraints([Constraint::Length(3), Constraint::Min(1)])
906 .split(area);
907 (Some(chunks[0]), chunks[1])
908 } else {
909 (None, area) };
911
912 if let Some(search_rect) = search_area {
914 let search_text = if app.search_mode {
915 format!("Search: {}_", app.search_query) } else {
917 format!("Search: {} (Press '/' or 's' to edit, Esc to clear)", app.search_query)
918 };
919
920 let search_style = if app.search_mode {
921 Style::default().fg(c_accent()).add_modifier(Modifier::BOLD)
922 } else {
923 Style::default().fg(c_text_dim())
924 };
925
926 let search_block = Block::default()
927 .borders(Borders::ALL)
928 .border_type(BorderType::Rounded)
929 .border_style(Style::default().fg(if app.search_mode { c_accent() } else { c_border() }))
930 .style(Style::default().bg(c_bg_panel()));
931
932 let search_paragraph = Paragraph::new(search_text).style(search_style).block(search_block);
933
934 f.render_widget(search_paragraph, search_rect);
935 }
936
937 let viewport_height = items_area.height.saturating_sub(2) as usize; let content_length = app.filtered_items.len();
940
941 let visible_start = app.scroll_offset.min(content_length.saturating_sub(1));
943 let visible_end = (visible_start + viewport_height).min(content_length);
944
945 let mut list_items: Vec<ListItem> = Vec::new();
947 let mut current_category = None;
948
949 for (index, item) in app.filtered_items.iter().enumerate() {
950 if index < visible_start || index >= visible_end {
952 continue;
953 }
954
955 let item_category = item.kind.as_str();
957 if current_category != Some(item_category) {
958 current_category = Some(item_category);
959
960 if !list_items.is_empty() {
962 list_items.push(ListItem::new(Line::from("")));
963 }
964
965 let (category_name, category_icon, category_color) = match item.kind {
967 ItemKind::Password => (" PASSWORDS", "🔐", c_badge_pwd()),
968 ItemKind::EnvVar => (" ENVIRONMENT VARIABLES", "🌍", c_badge_env()),
969 ItemKind::Note => (" NOTES", "📝", c_badge_note()),
970 ItemKind::ApiKey => (" API KEYS", "🔑", c_badge_api()),
971 ItemKind::SshKey => (" SSH KEYS", "🔒", c_badge_ssh()),
972 ItemKind::Certificate => (" CERTIFICATES", "📜", c_badge_cert()),
973 ItemKind::Database => (" DATABASES", "🗄️", c_badge_db()),
974 ItemKind::CreditCard => (" CREDIT CARDS", "💳", c_badge_creditcard()),
975 ItemKind::SecureNote => (" SECURE NOTES", "🔒", c_badge_securenote()),
976 ItemKind::Identity => (" IDENTITIES", "🆔", c_badge_identity()),
977 ItemKind::Server => (" SERVERS", "🖥️", c_badge_server()),
978 ItemKind::WifiPassword => (" WIFI", "📶", c_badge_wifi()),
979 ItemKind::License => (" LICENSES", "📄", c_badge_license()),
980 ItemKind::BankAccount => (" BANK ACCOUNTS", "🏦", c_badge_bankaccount()),
981 ItemKind::Document => (" DOCUMENTS", "📋", c_badge_document()),
982 ItemKind::Recovery => (" RECOVERY", "🔄", c_badge_recovery()),
983 ItemKind::OAuth => (" OAUTH TOKENS", "🎫", c_badge_oauth()),
984 };
985
986 let header_line = Line::from(vec![
987 Span::styled(
988 format!(" {category_icon} "),
989 Style::default().fg(category_color).add_modifier(Modifier::BOLD),
990 ),
991 Span::styled(
992 category_name,
993 Style::default().fg(category_color).add_modifier(Modifier::BOLD),
994 ),
995 Span::styled(" ".repeat(50), Style::default().fg(c_border())),
996 ]);
997
998 list_items.push(ListItem::new(header_line).style(Style::default().bg(Color::Rgb(30, 32, 38))));
999 }
1000
1001 let (badge, badge_color) = match item.kind {
1003 ItemKind::Password => ("🔐", c_badge_pwd()),
1004 ItemKind::EnvVar => ("🌍", c_badge_env()),
1005 ItemKind::Note => ("📝", c_badge_note()),
1006 ItemKind::ApiKey => ("🔑", c_badge_api()),
1007 ItemKind::SshKey => ("🔒", c_badge_ssh()),
1008 ItemKind::Certificate => ("📜", c_badge_cert()),
1009 ItemKind::Database => ("🗄️", c_badge_db()),
1010 ItemKind::CreditCard => ("💳", c_badge_creditcard()),
1011 ItemKind::SecureNote => ("🔒", c_badge_securenote()),
1012 ItemKind::Identity => ("🆔", c_badge_identity()),
1013 ItemKind::Server => ("🖥️", c_badge_server()),
1014 ItemKind::WifiPassword => ("📶", c_badge_wifi()),
1015 ItemKind::License => ("📄", c_badge_license()),
1016 ItemKind::BankAccount => ("🏦", c_badge_bankaccount()),
1017 ItemKind::Document => ("📋", c_badge_document()),
1018 ItemKind::Recovery => ("🔄", c_badge_recovery()),
1019 ItemKind::OAuth => ("🎫", c_badge_oauth()),
1020 };
1021
1022 let created_date = match time::format_description::parse("[year]-[month]-[day]") {
1023 Ok(format) => item
1024 .created_at
1025 .format(&format)
1026 .unwrap_or_else(|_| "unknown".to_string()),
1027 Err(_) => "unknown".to_string(),
1028 };
1029
1030 let content_width = items_area.width.saturating_sub(8) as usize;
1031 let max_name_width = content_width.saturating_sub(20); let truncated_name = if max_name_width < 1 {
1034 "…".to_string() } else {
1036 truncate_text(&item.name, max_name_width.max(1))
1037 };
1038
1039 let item_name_spans = if !app.search_query.is_empty() && !app.search_mode {
1041 highlight_search_matches(&truncated_name, &app.search_query, c_text(), c_accent())
1042 } else {
1043 vec![Span::styled(
1044 truncated_name,
1045 Style::default().fg(c_text()).add_modifier(Modifier::BOLD),
1046 )]
1047 };
1048
1049 let mut item_line_spans = vec![
1050 Span::raw(" "), Span::styled(format!("{badge} "), Style::default().fg(badge_color)),
1052 ];
1053 item_line_spans.extend(item_name_spans);
1054 item_line_spans.push(Span::styled(
1055 format!(" ({created_date})"),
1056 Style::default().fg(c_text_dim()),
1057 ));
1058
1059 let item_line = Line::from(item_line_spans);
1060
1061 let item_style = if app.selected == index {
1063 Style::default()
1064 .bg(Color::Rgb(40, 46, 60))
1065 .fg(c_accent())
1066 .add_modifier(Modifier::BOLD)
1067 } else {
1068 Style::default()
1069 };
1070
1071 list_items.push(ListItem::new(item_line).style(item_style));
1072 }
1073
1074 if list_items.is_empty() && app.filtered_items.is_empty() {
1076 let empty_message = if app.search_query.is_empty() {
1077 match app.view_mode {
1078 ViewMode::All => "No items in vault".to_string(),
1079 ViewMode::Passwords => "No passwords stored".to_string(),
1080 ViewMode::Environment => "No environment variables stored".to_string(),
1081 ViewMode::Notes => "No notes stored".to_string(),
1082 }
1083 } else {
1084 format!("No items match search: '{}'", app.search_query)
1085 };
1086
1087 list_items.push(ListItem::new(Line::from(vec![Span::styled(
1088 format!(" {empty_message}"),
1089 Style::default().fg(c_text_dim()),
1090 )])));
1091 }
1092
1093 let items_title = if app.search_query.is_empty() {
1094 format!(
1095 " {} ({}/{}) ",
1096 app.view_mode.as_str(),
1097 app.filtered_items.len(),
1098 app.items.len()
1099 )
1100 } else {
1101 format!(
1102 " {} ({}/{}) - Search: '{}' ",
1103 app.view_mode.as_str(),
1104 app.filtered_items.len(),
1105 app.items.len(),
1106 app.search_query
1107 )
1108 };
1109
1110 let list_block = Block::default()
1111 .borders(Borders::ALL)
1112 .border_type(BorderType::Rounded)
1113 .border_style(Style::default().fg(c_border()))
1114 .style(Style::default().bg(c_bg_panel()))
1115 .title(Span::styled(
1116 &items_title,
1117 Style::default().fg(c_accent()).add_modifier(Modifier::BOLD),
1118 ));
1119
1120 let list = List::new(list_items).block(list_block.clone());
1121 f.render_widget(list, items_area);
1122
1123 let mut scrollbar_state =
1125 ratatui::widgets::ScrollbarState::new(content_length.max(1).saturating_sub(1)).position(app.scroll_offset);
1126
1127 let scrollbar = ratatui::widgets::Scrollbar::new(ratatui::widgets::ScrollbarOrientation::VerticalRight)
1128 .begin_symbol(Some("↑"))
1129 .end_symbol(Some("↓"))
1130 .thumb_style(Style::default().fg(c_accent()).add_modifier(Modifier::BOLD))
1131 .track_style(Style::default().fg(c_text_dim()));
1132
1133 let inner_area = list_block.inner(items_area);
1134 let scrollbar_area = Rect {
1135 x: inner_area.x + inner_area.width.saturating_sub(1),
1136 y: inner_area.y,
1137 width: 1,
1138 height: inner_area.height,
1139 };
1140 f.render_stateful_widget(scrollbar, scrollbar_area, &mut scrollbar_state);
1141}
1142
1143fn highlight_search_matches(
1145 text: &str,
1146 query: &str,
1147 normal_color: Color,
1148 highlight_color: Color,
1149) -> Vec<Span<'static>> {
1150 if query.is_empty() {
1151 return vec![Span::styled(
1152 text.to_string(),
1153 Style::default().fg(normal_color).add_modifier(Modifier::BOLD),
1154 )];
1155 }
1156
1157 let mut spans = Vec::new();
1158 let text_lower = text.to_lowercase();
1159 let query_lower = query.to_lowercase();
1160 let mut last_end = 0;
1161
1162 for match_start in text_lower.match_indices(&query_lower).map(|(i, _)| i) {
1164 if match_start > last_end {
1166 spans.push(Span::styled(
1167 text[last_end..match_start].to_string(),
1168 Style::default().fg(normal_color).add_modifier(Modifier::BOLD),
1169 ));
1170 }
1171
1172 let match_end = match_start + query.len();
1174 spans.push(Span::styled(
1175 text[match_start..match_end].to_string(),
1176 Style::default()
1177 .fg(highlight_color)
1178 .bg(Color::Rgb(60, 60, 0))
1179 .add_modifier(Modifier::BOLD),
1180 ));
1181
1182 last_end = match_end;
1183 }
1184
1185 if last_end < text.len() {
1187 spans.push(Span::styled(
1188 text[last_end..].to_string(),
1189 Style::default().fg(normal_color).add_modifier(Modifier::BOLD),
1190 ));
1191 }
1192
1193 spans
1194}
1195
1196#[allow(clippy::too_many_lines)]
1197fn draw_categories_section(f: &mut Frame, app: &App, area: Rect) {
1198 let ItemCounts {
1199 total: _,
1200 passwords,
1201 env_vars,
1202 notes,
1203 api_keys,
1204 ssh_keys,
1205 certificates,
1206 databases,
1207 credit_cards,
1208 secure_notes,
1209 identities,
1210 servers,
1211 wifi_passwords,
1212 licenses,
1213 bank_accounts,
1214 documents,
1215 recovery_codes,
1216 oauth_tokens,
1217 } = app.get_item_counts();
1218
1219 let categories_content = vec![
1220 Line::from(vec![
1221 Span::styled("🔐 ", Style::default().fg(c_badge_pwd())),
1222 Span::styled(format!("Passwords ({passwords})"), Style::default().fg(c_text())),
1223 ]),
1224 Line::from(vec![
1225 Span::styled("🌍 ", Style::default().fg(c_badge_env())),
1226 Span::styled(format!("Environment ({env_vars})"), Style::default().fg(c_text())),
1227 ]),
1228 Line::from(vec![
1229 Span::styled("📝 ", Style::default().fg(c_badge_note())),
1230 Span::styled(format!("Notes ({notes})"), Style::default().fg(c_text())),
1231 ]),
1232 Line::from(vec![
1233 Span::styled("🔑 ", Style::default().fg(c_badge_api())),
1234 Span::styled(format!("API Keys ({api_keys})"), Style::default().fg(c_text())),
1235 ]),
1236 Line::from(vec![
1237 Span::styled("🔒 ", Style::default().fg(c_badge_ssh())),
1238 Span::styled(format!("SSH Keys ({ssh_keys})"), Style::default().fg(c_text())),
1239 ]),
1240 Line::from(vec![
1241 Span::styled("📜 ", Style::default().fg(c_badge_cert())),
1242 Span::styled(format!("Certificates ({certificates})"), Style::default().fg(c_text())),
1243 ]),
1244 Line::from(vec![
1245 Span::styled("🗄️ ", Style::default().fg(c_badge_db())),
1246 Span::styled(format!("Databases ({databases})"), Style::default().fg(c_text())),
1247 ]),
1248 Line::from(vec![
1249 Span::styled("💳 ", Style::default().fg(c_badge_creditcard())),
1250 Span::styled(format!("Credit Cards ({credit_cards})"), Style::default().fg(c_text())),
1251 ]),
1252 Line::from(vec![
1253 Span::styled("🔒 ", Style::default().fg(c_badge_securenote())),
1254 Span::styled(format!("Secure Notes ({secure_notes})"), Style::default().fg(c_text())),
1255 ]),
1256 Line::from(vec![
1257 Span::styled("🆔 ", Style::default().fg(c_badge_identity())),
1258 Span::styled(format!("Identities ({identities})"), Style::default().fg(c_text())),
1259 ]),
1260 Line::from(vec![
1261 Span::styled("🖥️ ", Style::default().fg(c_badge_server())),
1262 Span::styled(format!("Servers ({servers})"), Style::default().fg(c_text())),
1263 ]),
1264 Line::from(vec![
1265 Span::styled("📶 ", Style::default().fg(c_badge_wifi())),
1266 Span::styled(format!("WiFi ({wifi_passwords})"), Style::default().fg(c_text())),
1267 ]),
1268 Line::from(vec![
1269 Span::styled("📄 ", Style::default().fg(c_badge_license())),
1270 Span::styled(format!("Licenses ({licenses})"), Style::default().fg(c_text())),
1271 ]),
1272 Line::from(vec![
1273 Span::styled("🏦 ", Style::default().fg(c_badge_bankaccount())),
1274 Span::styled(
1275 format!("Bank Accounts ({bank_accounts})"),
1276 Style::default().fg(c_text()),
1277 ),
1278 ]),
1279 Line::from(vec![
1280 Span::styled("📋 ", Style::default().fg(c_badge_document())),
1281 Span::styled(format!("Documents ({documents})"), Style::default().fg(c_text())),
1282 ]),
1283 Line::from(vec![
1284 Span::styled("🔄 ", Style::default().fg(c_badge_recovery())),
1285 Span::styled(format!("Recovery ({recovery_codes})"), Style::default().fg(c_text())),
1286 ]),
1287 Line::from(vec![
1288 Span::styled("🎫 ", Style::default().fg(c_badge_oauth())),
1289 Span::styled(format!("OAuth ({oauth_tokens})"), Style::default().fg(c_text())),
1290 ]),
1291 ];
1292
1293 let categories_block = Block::default()
1294 .borders(Borders::ALL)
1295 .border_type(BorderType::Rounded)
1296 .border_style(Style::default().fg(c_border()))
1297 .style(Style::default().bg(c_bg_panel()))
1298 .title(Span::styled(
1299 " Categories ",
1300 Style::default().fg(c_accent()).add_modifier(Modifier::BOLD),
1301 ));
1302
1303 let categories_paragraph = Paragraph::new(categories_content)
1304 .block(categories_block)
1305 .wrap(Wrap { trim: true })
1306 .style(Style::default().fg(c_text()));
1307
1308 f.render_widget(categories_paragraph, area);
1309}
1310
1311fn draw_help_section(f: &mut Frame, area: Rect) {
1312 let help_content = vec![
1313 Line::from(vec![
1314 Span::styled("a ", Style::default().fg(c_accent()).add_modifier(Modifier::BOLD)),
1315 Span::raw("Add item"),
1316 ]),
1317 Line::from(vec![
1318 Span::styled("e ", Style::default().fg(c_accent()).add_modifier(Modifier::BOLD)),
1319 Span::raw("Edit item"),
1320 ]),
1321 Line::from(vec![
1322 Span::styled("c ", Style::default().fg(c_accent()).add_modifier(Modifier::BOLD)),
1323 Span::raw("Copy value"),
1324 ]),
1325 Line::from(vec![
1326 Span::styled("s or / ", Style::default().fg(c_accent()).add_modifier(Modifier::BOLD)),
1327 Span::raw("Start search"),
1328 ]),
1329 Line::from(vec![
1330 Span::styled("g ", Style::default().fg(c_accent()).add_modifier(Modifier::BOLD)),
1331 Span::raw("Generate password"),
1332 ]),
1333 Line::from(vec![
1334 Span::styled("x ", Style::default().fg(c_accent()).add_modifier(Modifier::BOLD)),
1335 Span::raw("Export items"),
1336 ]),
1337 Line::from(vec![
1338 Span::styled("i ", Style::default().fg(c_accent()).add_modifier(Modifier::BOLD)),
1339 Span::raw("Import items"),
1340 ]),
1341 Line::from(vec![
1342 Span::styled("d ", Style::default().fg(c_accent()).add_modifier(Modifier::BOLD)),
1343 Span::raw("Delete selected"),
1344 ]),
1345 Line::from(vec![
1346 Span::styled("v ", Style::default().fg(c_accent()).add_modifier(Modifier::BOLD)),
1347 Span::raw("View item"),
1348 ]),
1349 Line::from(vec![
1350 Span::styled("k ", Style::default().fg(c_accent()).add_modifier(Modifier::BOLD)),
1351 Span::raw("Change master key"),
1352 ]),
1353 Line::from(vec![
1354 Span::styled("Ctrl+v ", Style::default().fg(c_accent()).add_modifier(Modifier::BOLD)),
1355 Span::raw("Paste clipboard"),
1356 ]),
1357 Line::from(vec![
1358 Span::styled("F2 ", Style::default().fg(c_accent()).add_modifier(Modifier::BOLD)),
1359 Span::raw("Vault registry"),
1360 ]),
1361 Line::from(vec![
1362 Span::styled("q ", Style::default().fg(c_accent()).add_modifier(Modifier::BOLD)),
1363 Span::raw("Quit"),
1364 ]),
1365 ];
1366
1367 let help_block = Block::default()
1368 .borders(Borders::ALL)
1369 .border_type(BorderType::Rounded)
1370 .border_style(Style::default().fg(c_border()))
1371 .style(Style::default().bg(c_bg_panel()))
1372 .title(Span::styled(
1373 " Help ",
1374 Style::default().fg(c_accent2()).add_modifier(Modifier::BOLD),
1375 ));
1376
1377 let help_paragraph = Paragraph::new(help_content)
1378 .block(help_block)
1379 .wrap(Wrap { trim: true })
1380 .style(Style::default().fg(c_text()));
1381
1382 f.render_widget(help_paragraph, area);
1383}
1384fn draw_status_bar(f: &mut Frame, app: &App, area: Rect) {
1385 let status_block = Block::default()
1387 .borders(Borders::TOP)
1388 .border_style(Style::default().fg(c_border()))
1389 .style(Style::default().bg(c_bg_panel()));
1390
1391 f.render_widget(status_block, area);
1392
1393 let show_countdown = app
1395 .get_countdown_info()
1396 .is_some_and(|info| info.minutes_left <= 3 && info.enabled);
1397
1398 if show_countdown {
1399 let chunks = Layout::default()
1401 .direction(Direction::Horizontal)
1402 .constraints([
1403 Constraint::Percentage(50), Constraint::Length(20), Constraint::Min(20), ])
1407 .split(area);
1408
1409 let (message, message_style) = get_status_message_and_style(app);
1411 let status_paragraph = Paragraph::new(Line::from(vec![
1412 Span::raw(" "), Span::styled(message, message_style),
1414 ]))
1415 .style(Style::default().bg(c_bg_panel()))
1416 .wrap(Wrap { trim: true });
1417
1418 f.render_widget(status_paragraph, chunks[0]);
1419
1420 if let Some(countdown_info) = app.get_countdown_info() {
1422 let countdown_text = if countdown_info.seconds_left > 0 {
1423 format!(
1424 "🔒 {}m{}s",
1425 countdown_info.minutes_left,
1426 countdown_info.seconds_left % 60
1427 )
1428 } else {
1429 "🔒 Locking...".to_string()
1430 };
1431
1432 let countdown_color = if countdown_info.minutes_left <= 1 {
1433 Color::Red
1434 } else if countdown_info.minutes_left <= 2 {
1435 Color::Yellow
1436 } else {
1437 Color::Cyan
1438 };
1439
1440 let countdown_paragraph = Paragraph::new(Line::from(vec![Span::styled(
1441 countdown_text,
1442 Style::default().fg(countdown_color).add_modifier(Modifier::BOLD),
1443 )]))
1444 .style(Style::default().bg(c_bg_panel()))
1445 .alignment(Alignment::Center);
1446
1447 f.render_widget(countdown_paragraph, chunks[1]);
1448 }
1449
1450 let key_hints = get_key_hints_for_screen(app);
1452 let hints_paragraph = Paragraph::new(Line::from(key_hints))
1453 .style(Style::default().bg(c_bg_panel()).fg(c_text_dim()))
1454 .alignment(Alignment::Right)
1455 .wrap(Wrap { trim: true });
1456
1457 f.render_widget(hints_paragraph, chunks[2]);
1458 } else {
1459 let chunks = Layout::default()
1461 .direction(Direction::Horizontal)
1462 .constraints([Constraint::Percentage(70), Constraint::Percentage(30)])
1463 .split(area);
1464
1465 let (message, message_style) = get_status_message_and_style(app);
1467 let status_paragraph = Paragraph::new(Line::from(vec![
1468 Span::raw(" "), Span::styled(message, message_style),
1470 ]))
1471 .style(Style::default().bg(c_bg_panel()))
1472 .wrap(Wrap { trim: true });
1473
1474 f.render_widget(status_paragraph, chunks[0]);
1475
1476 let key_hints = get_key_hints_for_screen(app);
1478 let hints_paragraph = Paragraph::new(Line::from(key_hints))
1479 .style(Style::default().bg(c_bg_panel()).fg(c_text_dim()))
1480 .alignment(Alignment::Right)
1481 .wrap(Wrap { trim: true });
1482
1483 f.render_widget(hints_paragraph, chunks[1]);
1484 }
1485}
1486
1487fn get_status_message_and_style(app: &App) -> (String, Style) {
1488 if let Some(message) = &app.status_message {
1489 let style = match app.status_type {
1490 StatusType::Success => Style::default().fg(c_ok()).add_modifier(Modifier::BOLD),
1491 StatusType::Warning => Style::default().fg(c_warn()).add_modifier(Modifier::BOLD),
1492 StatusType::Error => Style::default().fg(c_err()).add_modifier(Modifier::BOLD),
1493 StatusType::Info => Style::default().fg(c_accent()),
1494 };
1495 (message.clone(), style)
1496 } else {
1497 let context_message = match app.screen {
1499 Screen::Unlock => "Enter your master key to unlock the vault".to_string(),
1500 Screen::Main => {
1501 format!(" {} items in vault", app.items.len())
1502 }
1503 Screen::AddItem => "Fill in the item details and press Enter to save".to_string(),
1504 Screen::ViewItem => "Viewing item details".to_string(),
1505 Screen::EditItem => "Edit the item value and press Enter to save".to_string(),
1506 Screen::ChangeMaster => "Change your master key".to_string(),
1507 Screen::GeneratePassword => "Configure and generate a new password".to_string(),
1508 Screen::ImportExport => match app.ie_mode {
1509 crate::app::ImportExportMode::Export => "Export items to file".to_string(),
1510 crate::app::ImportExportMode::Import => "Import items from file".to_string(),
1511 },
1512 Screen::VaultSelector => "Select a vault to open".to_string(),
1513 };
1514 (context_message, Style::default().fg(c_text_dim()))
1515 }
1516}
1517
1518fn get_key_hints_for_screen(app: &App) -> Vec<Span<'static>> {
1519 let mut spans = Vec::new();
1520
1521 let add_hint = |spans: &mut Vec<Span<'static>>, key: &'static str, action: &'static str, emphasized: bool| {
1522 if !spans.is_empty() {
1523 spans.push(Span::raw(" "));
1524 }
1525 let key_style = if emphasized {
1526 Style::default().fg(c_accent()).add_modifier(Modifier::BOLD)
1527 } else {
1528 Style::default().fg(c_text()).add_modifier(Modifier::BOLD)
1529 };
1530 spans.push(Span::styled(format!("[{key}]"), key_style));
1531 spans.push(Span::styled(format!(" {action}"), Style::default().fg(c_text_dim())));
1532 };
1533
1534 match app.screen {
1535 Screen::Unlock => {
1536 add_hint(&mut spans, "Tab", "Switch", false);
1537 add_hint(&mut spans, "Enter", "Unlock", true);
1538 }
1539 Screen::Main => {
1540 if app.search_mode {
1541 add_hint(&mut spans, "Type", "Search", true);
1543 add_hint(&mut spans, "Enter", "Confirm", false);
1544 add_hint(&mut spans, "Esc", "Exit Search", false);
1545 } else {
1546 add_hint(&mut spans, "↑↓", "Navigate", false);
1548
1549 if !app.filtered_items.is_empty() {
1550 add_hint(&mut spans, "Enter", "View", true);
1551 add_hint(&mut spans, "e", "Edit", false);
1552 add_hint(&mut spans, "c", "Copy", false);
1553 add_hint(&mut spans, "Del", "Delete", false);
1554 }
1555
1556 add_hint(&mut spans, "a", "Add", false);
1557 add_hint(&mut spans, "/", "Search", false);
1558
1559 if app.search_query.is_empty() {
1561 add_hint(&mut spans, "q", "Quit", false);
1562 } else {
1563 add_hint(&mut spans, "Esc", "Clear Search", false);
1564 }
1565
1566 add_hint(&mut spans, "g", "Generate", false);
1567 add_hint(&mut spans, "i", "Import", false);
1568 add_hint(&mut spans, "o", "Export", false);
1569 add_hint(&mut spans, "v", "Vaults", false);
1570 }
1571 }
1572 Screen::AddItem => {
1573 add_hint(&mut spans, "Tab", "Next Field", false);
1574 add_hint(&mut spans, "Ctrl+V", "Paste", false);
1575 add_hint(&mut spans, "Enter", "Save", true);
1576 add_hint(&mut spans, "Esc", "Cancel", false);
1577 }
1578 Screen::ViewItem => {
1579 add_hint(&mut spans, "v", "Toggle Value", true);
1580 add_hint(&mut spans, "e", "Edit", false);
1581 add_hint(&mut spans, "c", "Copy", false);
1582 add_hint(&mut spans, "Esc", "Back", false);
1583 }
1584 Screen::EditItem => {
1585 add_hint(&mut spans, "Enter", "Save", true);
1586 add_hint(&mut spans, "Esc", "Cancel", false);
1587 }
1588 Screen::ChangeMaster => {
1589 add_hint(&mut spans, "Tab", "Next Field", false);
1590 add_hint(&mut spans, "Enter", "Change", true);
1591 add_hint(&mut spans, "Esc", "Cancel", false);
1592 }
1593 Screen::GeneratePassword => {
1594 add_hint(&mut spans, "Space", "Generate", true);
1595 add_hint(&mut spans, "c", "Copy", false);
1596 add_hint(&mut spans, "u", "Use", false);
1597 add_hint(&mut spans, "Esc", "Back", false);
1598 }
1599 Screen::ImportExport => {
1600 add_hint(&mut spans, "Tab", "Next Field", false);
1601 add_hint(&mut spans, "Enter", "Execute", true);
1602 add_hint(&mut spans, "Esc", "Cancel", false);
1603 }
1604 Screen::VaultSelector => {
1605 add_hint(&mut spans, "↑↓", "Navigate", false);
1606 add_hint(&mut spans, "Tab", "Next Field", false);
1607 add_hint(&mut spans, "Enter", "Select", true);
1608 add_hint(&mut spans, "Esc", "Close", false);
1609 }
1610 }
1611
1612 spans.push(Span::raw(" "));
1614 spans
1615}
1616
1617#[allow(clippy::too_many_lines)]
1618fn draw_add_item(f: &mut Frame, app: &App) {
1619 let modal_area = centered_rect(70, 80, f.area());
1621
1622 f.render_widget(Clear, modal_area);
1624
1625 let block = Block::default()
1626 .title("Add Item")
1627 .borders(Borders::ALL)
1628 .border_style(Style::default().fg(c_border()))
1629 .style(Style::default().bg(c_bg_panel()));
1630
1631 let inner = block.inner(modal_area);
1632 f.render_widget(block, modal_area);
1633
1634 let chunks = Layout::default()
1635 .direction(Direction::Vertical)
1636 .margin(1)
1637 .constraints([
1638 Constraint::Length(3), Constraint::Length(3), Constraint::Min(5), Constraint::Length(5), ])
1643 .split(inner);
1644
1645 let name_block = Block::default().title("Name").borders(Borders::ALL).border_style(
1647 if matches!(app.add_focus, AddItemField::Name) {
1648 Style::default().fg(c_accent())
1649 } else {
1650 Style::default().fg(c_border())
1651 },
1652 );
1653 let name_input = Paragraph::new(app.add_name.as_str())
1654 .block(name_block)
1655 .style(Style::default().fg(c_text()));
1656 f.render_widget(name_input, chunks[0]);
1657
1658 let selected_kind = ItemKind::all()[app.add_kind_idx.min(ItemKind::all().len() - 1)];
1660 let kind_text = format!("◄ {} ►", selected_kind.display_name());
1661 let kind_block = Block::default().title("Type").borders(Borders::ALL).border_style(
1662 if matches!(app.add_focus, AddItemField::Kind) {
1663 Style::default().fg(c_accent())
1664 } else {
1665 Style::default().fg(c_border())
1666 },
1667 );
1668 let kind_widget = Paragraph::new(kind_text)
1669 .block(kind_block)
1670 .style(Style::default().fg(if matches!(app.add_focus, AddItemField::Kind) {
1671 c_accent()
1672 } else {
1673 c_text()
1674 }))
1675 .alignment(Alignment::Center);
1676 f.render_widget(kind_widget, chunks[1]);
1677
1678 let value_title = get_value_title_for_kind(selected_kind);
1679
1680 let mut textarea = app.add_value_textarea.clone();
1682
1683 textarea.set_block(
1685 Block::default()
1686 .title(format!("{value_title} (Enter for new line, Ctrl+Enter to save)"))
1687 .borders(Borders::ALL)
1688 .border_style(if matches!(app.add_focus, AddItemField::Value) {
1689 Style::default().fg(c_accent())
1690 } else {
1691 Style::default().fg(c_border())
1692 }),
1693 );
1694
1695 textarea.set_style(Style::default().fg(c_text()));
1697
1698 if matches!(app.add_focus, AddItemField::Value) {
1700 textarea.set_cursor_line_style(Style::default().bg(c_bg()));
1701 textarea.set_cursor_style(Style::default().bg(c_accent()));
1702 }
1703
1704 textarea.set_line_number_style(Style::default().fg(c_text_dim()));
1706
1707 f.render_widget(&textarea, chunks[2]);
1708
1709 let instructions = match app.add_focus {
1711 AddItemField::Name => {
1712 vec![Line::from(vec![
1713 Span::styled("Enter ", Style::default().fg(c_text_dim())),
1714 Span::styled("Tab", Style::default().fg(c_accent()).add_modifier(Modifier::BOLD)),
1715 Span::styled(" to continue", Style::default().fg(c_text_dim())),
1716 ])]
1717 }
1718 AddItemField::Kind => {
1719 vec![Line::from(vec![
1720 Span::styled("Use ", Style::default().fg(c_text_dim())),
1721 Span::styled("←/→", Style::default().fg(c_accent()).add_modifier(Modifier::BOLD)),
1722 Span::styled(" to select, ", Style::default().fg(c_text_dim())),
1723 Span::styled("Tab", Style::default().fg(c_accent()).add_modifier(Modifier::BOLD)),
1724 Span::styled(" to continue", Style::default().fg(c_text_dim())),
1725 ])]
1726 }
1727 AddItemField::Value => {
1728 vec![
1729 Line::from(vec![
1730 Span::styled("Enter the ", Style::default().fg(c_text_dim())),
1731 Span::styled(
1732 get_value_title_for_kind(selected_kind).to_lowercase(),
1733 Style::default().fg(c_accent()),
1734 ),
1735 Span::styled(" value", Style::default().fg(c_text_dim())),
1736 ]),
1737 Line::from(vec![
1738 Span::styled("Press ", Style::default().fg(c_text_dim())),
1739 Span::styled("Enter", Style::default().fg(c_accent()).add_modifier(Modifier::BOLD)),
1740 Span::styled(" for new line, ", Style::default().fg(c_text_dim())),
1741 Span::styled(
1742 "Ctrl+Enter",
1743 Style::default().fg(c_accent()).add_modifier(Modifier::BOLD),
1744 ),
1745 Span::styled(" to save", Style::default().fg(c_text_dim())),
1746 ]),
1747 Line::from(vec![
1748 Span::styled("Press ", Style::default().fg(c_text_dim())),
1749 Span::styled("Ctrl+v", Style::default().fg(c_accent()).add_modifier(Modifier::BOLD)),
1750 Span::styled(" to paste content from clipboard, ", Style::default().fg(c_text_dim())),
1751 ]),
1752 ]
1753 }
1754 };
1755
1756 let instr_block = Block::default()
1757 .borders(Borders::ALL)
1758 .border_style(Style::default().fg(c_border()));
1759 let instr_text = Paragraph::new(instructions)
1760 .block(instr_block)
1761 .style(Style::default().fg(c_text_dim()))
1762 .wrap(Wrap { trim: false });
1763 f.render_widget(instr_text, chunks[3]);
1764}
1765
1766const fn get_value_title_for_kind(kind: ItemKind) -> &'static str {
1767 match kind {
1768 ItemKind::Password => "Password",
1769 ItemKind::EnvVar => "Environment Variable Value",
1770 ItemKind::Note => "Note Content",
1771 ItemKind::ApiKey => "API Key / Token",
1772 ItemKind::SshKey => "SSH Private Key",
1773 ItemKind::Certificate => "Certificate (PEM format)",
1774 ItemKind::Database => "Connection String",
1775 ItemKind::CreditCard => "Card Details",
1776 ItemKind::SecureNote => "Secure Note Content",
1777 ItemKind::Identity => "Identity Information",
1778 ItemKind::Server => "Server Credentials",
1779 ItemKind::WifiPassword => "WiFi Password",
1780 ItemKind::License => "License Key",
1781 ItemKind::BankAccount => "Account Details",
1782 ItemKind::Document => "Document Content",
1783 ItemKind::Recovery => "Recovery Codes",
1784 ItemKind::OAuth => "OAuth Token",
1785 }
1786}
1787
1788#[allow(clippy::too_many_lines)]
1789fn draw_import_export(f: &mut Frame, app: &App) {
1790 let area = centered_rect(80, 70, f.area());
1791 f.render_widget(Clear, area);
1792
1793 let title = match app.ie_mode {
1794 ImportExportMode::Export => " Export Items ",
1795 ImportExportMode::Import => " Import Items ",
1796 };
1797
1798 let block = Block::default()
1799 .borders(Borders::ALL)
1800 .border_type(BorderType::Rounded)
1801 .border_style(Style::default().fg(c_border()))
1802 .style(Style::default().bg(c_bg_panel()).fg(c_text()))
1803 .title(Span::styled(
1804 title,
1805 Style::default().fg(c_accent2()).add_modifier(Modifier::BOLD),
1806 ));
1807 f.render_widget(block, area);
1808
1809 let inner = Layout::default()
1810 .direction(Direction::Vertical)
1811 .constraints([
1812 Constraint::Length(2), Constraint::Length(5), Constraint::Length(3), Constraint::Length(3), Constraint::Length(3), Constraint::Length(2), ])
1819 .split(pad(area, 2, 2));
1820
1821 let desc_text = match app.ie_mode {
1823 ImportExportMode::Export => format!("Export {} items to file", app.items.len()),
1824 ImportExportMode::Import => "Import items from file".to_string(),
1825 };
1826 let desc = Paragraph::new(vec![
1827 Line::from(vec![Span::styled(&desc_text, Style::default().fg(c_text_dim()))]),
1828 Line::from(vec![Span::styled(
1829 "Tip: Use / for paths on all systems, ~ for home directory",
1830 Style::default().fg(c_text_dim()),
1831 )]),
1832 ]);
1833 f.render_widget(desc, inner[0]);
1834
1835 let focused = |field: ImportExportField| app.ie_focus == field;
1836
1837 let path_hint = match app.ie_mode {
1839 ImportExportMode::Export => "e.g., ~/Documents/backup.json or C:/backup.json",
1840 ImportExportMode::Import => "e.g., ~/Documents/data.csv or /path/to/import.json",
1841 };
1842
1843 let path_block = Block::default()
1844 .borders(Borders::ALL)
1845 .border_type(BorderType::Rounded)
1846 .border_style(if focused(ImportExportField::Path) {
1847 Style::default().fg(c_accent())
1848 } else {
1849 Style::default().fg(c_border())
1850 })
1851 .style(Style::default().bg(Color::Rgb(30, 32, 40)))
1852 .title(Span::styled(" File Path ", Style::default().fg(c_text_dim())));
1853
1854 let path_content = if app.ie_path.is_empty() && !focused(ImportExportField::Path) {
1855 Paragraph::new(vec![Line::from(Span::styled(
1856 path_hint,
1857 Style::default().fg(c_text_dim()),
1858 ))])
1859 } else {
1860 Paragraph::new(vec![
1861 Line::from(&*app.ie_path),
1862 Line::from(Span::styled(path_hint, Style::default().fg(c_text_dim()))),
1863 ])
1864 };
1865
1866 let path_display = path_content.block(path_block).style(Style::default().fg(c_text()));
1867 f.render_widget(path_display, inner[1]);
1868
1869 let format_display = format!("< {} >", app.ie_formats[app.ie_format_idx]);
1871 let format_block = Block::default()
1872 .borders(Borders::ALL)
1873 .border_type(BorderType::Rounded)
1874 .border_style(if focused(ImportExportField::Format) {
1875 Style::default().fg(c_accent())
1876 } else {
1877 Style::default().fg(c_border())
1878 })
1879 .style(Style::default().bg(Color::Rgb(30, 32, 40)))
1880 .title(Span::styled(" Format ", Style::default().fg(c_text_dim())));
1881
1882 let format_content = Paragraph::new(Line::from(vec![
1883 Span::styled(
1884 &format_display,
1885 if focused(ImportExportField::Format) {
1886 Style::default().fg(c_accent()).add_modifier(Modifier::BOLD)
1887 } else {
1888 Style::default().fg(c_text())
1889 },
1890 ),
1891 if focused(ImportExportField::Format) {
1892 Span::styled(" [←/→ to change]", Style::default().fg(c_text_dim()))
1893 } else {
1894 Span::raw("")
1895 },
1896 ]))
1897 .block(format_block);
1898 f.render_widget(format_content, inner[2]);
1899
1900 let action_text = match app.ie_mode {
1902 ImportExportMode::Export => "Export",
1903 ImportExportMode::Import => "Import",
1904 };
1905 let action_block = Block::default()
1906 .borders(Borders::ALL)
1907 .border_type(BorderType::Rounded)
1908 .border_style(if focused(ImportExportField::Action) {
1909 Style::default().fg(c_ok())
1910 } else {
1911 Style::default().fg(c_border())
1912 })
1913 .style(Style::default().bg(if focused(ImportExportField::Action) {
1914 Color::Rgb(20, 40, 20)
1915 } else {
1916 Color::Rgb(30, 32, 40)
1917 }));
1918
1919 let action_content = Paragraph::new(Line::from(vec![Span::styled(
1920 format!(" {action_text} "),
1921 Style::default().fg(c_ok()).add_modifier(Modifier::BOLD),
1922 )]))
1923 .block(action_block);
1924 f.render_widget(action_content, inner[3]);
1925
1926 let actions = Paragraph::new(vec![
1928 Line::from(vec![
1929 Span::styled("[Tab]", Style::default().fg(c_accent()).add_modifier(Modifier::BOLD)),
1930 Span::styled(" switch ", Style::default().fg(c_text_dim())),
1931 Span::styled("[Enter]", Style::default().fg(c_ok()).add_modifier(Modifier::BOLD)),
1932 Span::styled(" execute ", Style::default().fg(c_text_dim())),
1933 Span::styled("[Esc]", Style::default().fg(c_err()).add_modifier(Modifier::BOLD)),
1934 Span::styled(" cancel", Style::default().fg(c_text_dim())),
1935 ]),
1936 Line::from(vec![
1937 Span::styled("Examples: ", Style::default().fg(c_text_dim())),
1938 Span::styled("./backup.json", Style::default().fg(c_accent())),
1939 Span::styled(", ", Style::default().fg(c_text_dim())),
1940 Span::styled("~/Documents/vault.csv", Style::default().fg(c_accent())),
1941 ]),
1942 ]);
1943 f.render_widget(actions, inner[4]);
1944
1945 if let Some(err) = &app.error {
1947 let color = if err.contains("Exported") || err.contains("Imported") {
1948 c_ok()
1949 } else {
1950 c_err()
1951 };
1952 let err_p = Paragraph::new(Span::styled(
1953 err.clone(),
1954 Style::default().fg(color).add_modifier(Modifier::BOLD),
1955 ));
1956 f.render_widget(err_p, inner[5]);
1957 }
1958}
1959
1960#[allow(clippy::too_many_lines)]
1961fn draw_generate_password(f: &mut Frame, app: &App) {
1962 let area = centered_rect(75, 80, f.area());
1963 f.render_widget(Clear, area);
1964
1965 let block = Block::default()
1966 .borders(Borders::ALL)
1967 .border_type(BorderType::Rounded)
1968 .border_style(Style::default().fg(c_border()))
1969 .style(Style::default().bg(c_bg_panel()).fg(c_text()))
1970 .title(Span::styled(
1971 " Password Generator ",
1972 Style::default().fg(c_accent2()).add_modifier(Modifier::BOLD),
1973 ));
1974 f.render_widget(block, area);
1975
1976 let inner = Layout::default()
1977 .direction(Direction::Vertical)
1978 .constraints([
1979 Constraint::Length(2), Constraint::Length(3), Constraint::Length(8), Constraint::Length(4), Constraint::Length(2), Constraint::Length(2), ])
1986 .split(pad(area, 2, 2));
1987
1988 let title = Paragraph::new(Line::from(vec![Span::styled(
1990 "Configure and generate secure passwords",
1991 Style::default().fg(c_text_dim()),
1992 )]));
1993 f.render_widget(title, inner[0]);
1994
1995 let length_block = Block::default()
1997 .borders(Borders::ALL)
1998 .border_type(BorderType::Rounded)
1999 .border_style(if matches!(app.gen_focus, PasswordGenField::Length) {
2000 Style::default().fg(c_accent())
2001 } else {
2002 Style::default().fg(c_border())
2003 })
2004 .style(Style::default().bg(Color::Rgb(30, 32, 40)))
2005 .title(Span::styled(" Length ", Style::default().fg(c_text_dim())));
2006
2007 let length_content = Paragraph::new(&*app.gen_length_str)
2008 .block(length_block)
2009 .style(Style::default().fg(c_text()));
2010 f.render_widget(length_content, inner[1]);
2011
2012 let options_block = Block::default()
2014 .borders(Borders::ALL)
2015 .border_type(BorderType::Rounded)
2016 .border_style(if matches!(app.gen_focus, PasswordGenField::Options) {
2017 Style::default().fg(c_accent())
2018 } else {
2019 Style::default().fg(c_border())
2020 })
2021 .style(Style::default().bg(Color::Rgb(30, 32, 40)))
2022 .title(Span::styled(" Options ", Style::default().fg(c_text_dim())));
2023
2024 let check_mark = |enabled: bool| if enabled { "☑" } else { "☐" };
2025 let options_lines = vec![
2026 Line::from(vec![
2027 Span::styled("[1] ", Style::default().fg(c_accent())),
2028 Span::styled(
2029 check_mark(app.gen_config.include_uppercase),
2030 Style::default().fg(c_text()),
2031 ),
2032 Span::styled(" Uppercase letters (A-Z)", Style::default().fg(c_text())),
2033 ]),
2034 Line::from(vec![
2035 Span::styled("[2] ", Style::default().fg(c_accent())),
2036 Span::styled(
2037 check_mark(app.gen_config.include_lowercase),
2038 Style::default().fg(c_text()),
2039 ),
2040 Span::styled(" Lowercase letters (a-z)", Style::default().fg(c_text())),
2041 ]),
2042 Line::from(vec![
2043 Span::styled("[3] ", Style::default().fg(c_accent())),
2044 Span::styled(check_mark(app.gen_config.include_digits), Style::default().fg(c_text())),
2045 Span::styled(" Digits (0-9)", Style::default().fg(c_text())),
2046 ]),
2047 Line::from(vec![
2048 Span::styled("[4] ", Style::default().fg(c_accent())),
2049 Span::styled(
2050 check_mark(app.gen_config.include_symbols),
2051 Style::default().fg(c_text()),
2052 ),
2053 Span::styled(" Symbols (!@#$%^&*...)", Style::default().fg(c_text())),
2054 ]),
2055 Line::from(vec![
2056 Span::styled("[5] ", Style::default().fg(c_accent())),
2057 Span::styled(
2058 check_mark(app.gen_config.exclude_ambiguous),
2059 Style::default().fg(c_text()),
2060 ),
2061 Span::styled(" Exclude ambiguous (0,O,1,l,I)", Style::default().fg(c_text())),
2062 ]),
2063 ];
2064
2065 let options_content = Paragraph::new(options_lines).block(options_block);
2066 f.render_widget(options_content, inner[2]);
2067
2068 let pwd_block = Block::default()
2070 .borders(Borders::ALL)
2071 .border_type(BorderType::Rounded)
2072 .border_style(Style::default().fg(c_border()))
2073 .style(Style::default().bg(Color::Rgb(20, 25, 35)))
2074 .title(Span::styled(" Generated Password ", Style::default().fg(c_warn())));
2075
2076 let pwd_content = if let Some(password) = &app.generated_password {
2077 Paragraph::new(password.clone())
2078 .style(Style::default().fg(c_accent()).add_modifier(Modifier::BOLD))
2079 .wrap(Wrap { trim: true })
2080 } else {
2081 Paragraph::new("Press 'g' or Enter to generate").style(Style::default().fg(c_text_dim()))
2082 };
2083 let pwd_display = pwd_content.block(pwd_block);
2084 f.render_widget(pwd_display, inner[3]);
2085
2086 let actions = Paragraph::new(Line::from(vec![
2088 Span::styled("[Tab]", Style::default().fg(c_accent()).add_modifier(Modifier::BOLD)),
2089 Span::styled(" switch ", Style::default().fg(c_text_dim())),
2090 Span::styled("[g/Enter]", Style::default().fg(c_ok()).add_modifier(Modifier::BOLD)),
2091 Span::styled(" generate ", Style::default().fg(c_text_dim())),
2092 Span::styled("[c]", Style::default().fg(c_accent()).add_modifier(Modifier::BOLD)),
2093 Span::styled(" copy ", Style::default().fg(c_text_dim())),
2094 Span::styled("[u]", Style::default().fg(c_warn()).add_modifier(Modifier::BOLD)),
2095 Span::styled(" use ", Style::default().fg(c_text_dim())),
2096 Span::styled("[Esc]", Style::default().fg(c_err()).add_modifier(Modifier::BOLD)),
2097 Span::styled(" back", Style::default().fg(c_text_dim())),
2098 ]));
2099 f.render_widget(actions, inner[4]);
2100
2101 if let Some(err) = &app.error {
2103 let err_p = Paragraph::new(Span::styled(
2104 err.clone(),
2105 Style::default()
2106 .fg(if err.contains("copied") { c_ok() } else { c_err() })
2107 .add_modifier(Modifier::BOLD),
2108 ));
2109 f.render_widget(err_p, inner[5]);
2110 }
2111}
2112
2113fn draw_change_master(f: &mut Frame, app: &App) {
2114 let area = centered_rect(65, 70, f.area());
2115 f.render_widget(Clear, area);
2116 let block = Block::default()
2117 .borders(Borders::ALL)
2118 .border_type(BorderType::Rounded)
2119 .border_style(Style::default().fg(c_border()))
2120 .style(Style::default().bg(c_bg_panel()).fg(c_text()))
2121 .title(Span::styled(
2122 " Change Master Key ",
2123 Style::default().fg(c_accent2()).add_modifier(Modifier::BOLD),
2124 ));
2125 f.render_widget(block, area);
2126
2127 let inner = Layout::default()
2128 .direction(Direction::Vertical)
2129 .constraints([
2130 Constraint::Length(2),
2131 Constraint::Length(3),
2132 Constraint::Length(3),
2133 Constraint::Length(3),
2134 Constraint::Length(2),
2135 Constraint::Length(2),
2136 ])
2137 .split(pad(area, 2, 2));
2138
2139 let subtitle = Paragraph::new(Line::from(vec![
2140 Span::styled("Policy: ", Style::default().fg(c_text_dim())),
2141 Span::styled("≥8 chars, upper, lower, digit", Style::default().fg(c_accent())),
2142 ]));
2143 f.render_widget(subtitle, inner[0]);
2144
2145 let focused = |foc: ChangeKeyField| app.ck_focus == foc;
2146
2147 let current_block = Block::default()
2148 .borders(Borders::ALL)
2149 .border_type(BorderType::Rounded)
2150 .border_style(if focused(ChangeKeyField::Current) {
2151 Style::default().fg(c_accent())
2152 } else {
2153 Style::default().fg(c_border())
2154 })
2155 .style(Style::default().bg(Color::Rgb(30, 32, 40)))
2156 .title(Span::styled(" Current master key ", Style::default().fg(c_text_dim())));
2157
2158 let current_content = Paragraph::new(mask(&app.ck_current))
2159 .block(current_block)
2160 .style(Style::default().fg(c_text()));
2161 f.render_widget(current_content, inner[1]);
2162
2163 let new_block = Block::default()
2164 .borders(Borders::ALL)
2165 .border_type(BorderType::Rounded)
2166 .border_style(if focused(ChangeKeyField::New) {
2167 Style::default().fg(c_accent())
2168 } else {
2169 Style::default().fg(c_border())
2170 })
2171 .style(Style::default().bg(Color::Rgb(30, 32, 40)))
2172 .title(Span::styled(" New master key ", Style::default().fg(c_text_dim())));
2173
2174 let new_content = Paragraph::new(mask(&app.ck_new))
2175 .block(new_block)
2176 .style(Style::default().fg(c_text()));
2177 f.render_widget(new_content, inner[2]);
2178
2179 let confirm_block = Block::default()
2180 .borders(Borders::ALL)
2181 .border_type(BorderType::Rounded)
2182 .border_style(if focused(ChangeKeyField::Confirm) {
2183 Style::default().fg(c_accent())
2184 } else {
2185 Style::default().fg(c_border())
2186 })
2187 .style(Style::default().bg(Color::Rgb(30, 32, 40)))
2188 .title(Span::styled(
2189 " Confirm new master key ",
2190 Style::default().fg(c_text_dim()),
2191 ));
2192
2193 let confirm_content = Paragraph::new(mask(&app.ck_confirm))
2194 .block(confirm_block)
2195 .style(Style::default().fg(c_text()));
2196 f.render_widget(confirm_content, inner[3]);
2197
2198 let hints = Paragraph::new(Line::from(vec![
2199 Span::styled("[Tab]", Style::default().fg(c_accent()).add_modifier(Modifier::BOLD)),
2200 Span::styled(" switch ", Style::default().fg(c_text_dim())),
2201 Span::styled("[Enter]", Style::default().fg(c_ok()).add_modifier(Modifier::BOLD)),
2202 Span::styled(" apply ", Style::default().fg(c_text_dim())),
2203 Span::styled("[Esc]", Style::default().fg(c_err()).add_modifier(Modifier::BOLD)),
2204 Span::styled(" cancel", Style::default().fg(c_text_dim())),
2205 ]));
2206 f.render_widget(hints, inner[4]);
2207
2208 if let Some(err) = &app.error {
2209 let err_p = Paragraph::new(Span::styled(
2210 err.clone(),
2211 Style::default().fg(c_err()).add_modifier(Modifier::BOLD),
2212 ));
2213 f.render_widget(err_p, inner[5]);
2214 }
2215}
2216
2217#[allow(clippy::too_many_lines)]
2218fn draw_view_item(f: &mut Frame, app: &App) {
2219 if let Some(item) = &app.view_item {
2220 let area = centered_rect(70, 60, f.area());
2221 f.render_widget(Clear, area);
2222
2223 let block = Block::default()
2224 .borders(Borders::ALL)
2225 .border_type(BorderType::Rounded)
2226 .border_style(Style::default().fg(c_border()))
2227 .style(Style::default().bg(c_bg_panel()).fg(c_text()))
2228 .title(Span::styled(
2229 " View Item ",
2230 Style::default().fg(c_accent2()).add_modifier(Modifier::BOLD),
2231 ));
2232 f.render_widget(block, area);
2233
2234 let inner = Layout::default()
2235 .direction(Direction::Vertical)
2236 .constraints([
2237 Constraint::Length(3),
2238 Constraint::Length(3),
2239 Constraint::Length(6),
2240 Constraint::Length(3),
2241 Constraint::Length(3),
2242 Constraint::Length(2),
2243 ])
2244 .split(pad(area, 2, 2));
2245
2246 let name_block = Block::default()
2247 .borders(Borders::ALL)
2248 .border_type(BorderType::Rounded)
2249 .border_style(Style::default().fg(c_border()))
2250 .style(Style::default().bg(Color::Rgb(30, 32, 40)))
2251 .title(Span::styled(" Name ", Style::default().fg(c_text_dim())));
2252 let name_content = Paragraph::new(&*item.name)
2253 .block(name_block)
2254 .style(Style::default().fg(c_text()));
2255 f.render_widget(name_content, inner[0]);
2256
2257 let (badge, color) = match item.kind.as_str() {
2258 "password" => ("Password", c_badge_pwd()),
2259 "env" => ("Environment Variable", c_badge_env()),
2260 _ => ("Note", c_badge_note()),
2261 };
2262 let kind_block = Block::default()
2263 .borders(Borders::ALL)
2264 .border_type(BorderType::Rounded)
2265 .border_style(Style::default().fg(c_border()))
2266 .style(Style::default().bg(Color::Rgb(30, 32, 40)))
2267 .title(Span::styled(" Kind ", Style::default().fg(c_text_dim())));
2268 let kind_content = Paragraph::new(Line::from(vec![Span::styled(
2269 format!(" {badge} "),
2270 Style::default().bg(color).fg(Color::Black).add_modifier(Modifier::BOLD),
2271 )]))
2272 .block(kind_block);
2273 f.render_widget(kind_content, inner[1]);
2274
2275 let value_display = if app.view_show_value {
2276 &item.value
2277 } else {
2278 "••••••••••••••••••••••••••••••••••••"
2279 };
2280
2281 let value_block = Block::default()
2282 .borders(Borders::ALL)
2283 .border_type(BorderType::Rounded)
2284 .border_style(Style::default().fg(c_border()))
2285 .style(Style::default().bg(Color::Rgb(30, 32, 40)))
2286 .title(Span::styled(
2287 if app.view_show_value {
2288 " Value (visible) "
2289 } else {
2290 " Value (hidden) "
2291 },
2292 Style::default().fg(if app.view_show_value { c_warn() } else { c_text_dim() }),
2293 ));
2294 let value_content = Paragraph::new(value_display)
2295 .block(value_block)
2296 .style(Style::default().fg(if app.view_show_value { c_text() } else { c_text_dim() }))
2297 .wrap(Wrap { trim: true });
2298 f.render_widget(value_content, inner[2]);
2299
2300 let created_str = item
2301 .created_at
2302 .format(&time::format_description::well_known::Rfc3339)
2303 .unwrap_or_else(|_| "Unknown".to_string());
2304 let created_block = Block::default()
2305 .borders(Borders::ALL)
2306 .border_type(BorderType::Rounded)
2307 .border_style(Style::default().fg(c_border()))
2308 .style(Style::default().bg(Color::Rgb(30, 32, 40)))
2309 .title(Span::styled(" Created ", Style::default().fg(c_text_dim())));
2310 let created_content = Paragraph::new(created_str)
2311 .block(created_block)
2312 .style(Style::default().fg(c_text()));
2313 f.render_widget(created_content, inner[3]);
2314
2315 let updated_str = item
2316 .updated_at
2317 .format(&time::format_description::well_known::Rfc3339)
2318 .unwrap_or_else(|_| "Unknown".to_string());
2319 let updated_block = Block::default()
2320 .borders(Borders::ALL)
2321 .border_type(BorderType::Rounded)
2322 .border_style(Style::default().fg(c_border()))
2323 .style(Style::default().bg(Color::Rgb(30, 32, 40)))
2324 .title(Span::styled(" Updated ", Style::default().fg(c_text_dim())));
2325 let updated_content = Paragraph::new(updated_str)
2326 .block(updated_block)
2327 .style(Style::default().fg(c_text()));
2328 f.render_widget(updated_content, inner[4]);
2329
2330 let actions = Paragraph::new(Line::from(vec![
2331 Span::styled("[t/Enter]", Style::default().fg(c_warn()).add_modifier(Modifier::BOLD)),
2332 Span::styled(" Toggle visibility ", Style::default().fg(c_text_dim())),
2333 Span::styled("[c]", Style::default().fg(c_accent()).add_modifier(Modifier::BOLD)),
2334 Span::styled(" Copy ", Style::default().fg(c_text_dim())),
2335 Span::styled("[Esc]", Style::default().fg(c_err()).add_modifier(Modifier::BOLD)),
2336 Span::styled(" Close", Style::default().fg(c_text_dim())),
2337 ]));
2338 f.render_widget(actions, inner[5]);
2339 }
2340}
2341
2342fn draw_edit_item(f: &mut Frame, app: &App) {
2343 if let Some(item) = &app.edit_item {
2344 let area = centered_rect(70, 50, f.area());
2345 f.render_widget(Clear, area);
2346
2347 let block = Block::default()
2348 .borders(Borders::ALL)
2349 .border_type(BorderType::Rounded)
2350 .border_style(Style::default().fg(c_border()))
2351 .style(Style::default().bg(c_bg_panel()).fg(c_text()))
2352 .title(Span::styled(
2353 " Edit Item ",
2354 Style::default().fg(c_accent2()).add_modifier(Modifier::BOLD),
2355 ));
2356 f.render_widget(block, area);
2357
2358 let inner = Layout::default()
2359 .direction(Direction::Vertical)
2360 .constraints([
2361 Constraint::Length(2),
2362 Constraint::Length(3),
2363 Constraint::Length(3),
2364 Constraint::Length(6),
2365 Constraint::Length(2),
2366 ])
2367 .split(pad(area, 2, 2));
2368
2369 let subtitle = Paragraph::new(Line::from(vec![
2370 Span::styled("Editing: ", Style::default().fg(c_text_dim())),
2371 Span::styled(&item.name, Style::default().fg(c_accent()).add_modifier(Modifier::BOLD)),
2372 ]));
2373 f.render_widget(subtitle, inner[0]);
2374
2375 let name_block = Block::default()
2376 .borders(Borders::ALL)
2377 .border_type(BorderType::Rounded)
2378 .border_style(Style::default().fg(c_border()))
2379 .style(Style::default().bg(Color::Rgb(40, 42, 50)))
2380 .title(Span::styled(" Name (read-only) ", Style::default().fg(c_text_dim())));
2381 let name_content = Paragraph::new(&*item.name)
2382 .block(name_block)
2383 .style(Style::default().fg(c_text_dim()));
2384 f.render_widget(name_content, inner[1]);
2385
2386 let (badge, color) = match item.kind.as_str() {
2387 "password" => ("Password", c_badge_pwd()),
2388 "env" => ("Environment Variable", c_badge_env()),
2389 _ => ("Note", c_badge_note()),
2390 };
2391 let kind_block = Block::default()
2392 .borders(Borders::ALL)
2393 .border_type(BorderType::Rounded)
2394 .border_style(Style::default().fg(c_border()))
2395 .style(Style::default().bg(Color::Rgb(40, 42, 50)))
2396 .title(Span::styled(" Kind (read-only) ", Style::default().fg(c_text_dim())));
2397 let kind_content = Paragraph::new(Line::from(vec![Span::styled(
2398 format!(" {badge} "),
2399 Style::default().bg(color).fg(Color::Black).add_modifier(Modifier::BOLD),
2400 )]))
2401 .block(kind_block);
2402 f.render_widget(kind_content, inner[2]);
2403
2404 let value_block = Block::default()
2405 .borders(Borders::ALL)
2406 .border_type(BorderType::Rounded)
2407 .border_style(Style::default().fg(c_accent()))
2408 .style(Style::default().bg(Color::Rgb(30, 32, 40)))
2409 .title(Span::styled(" New Value ", Style::default().fg(c_accent())));
2410 let value_content = Paragraph::new(&*app.edit_value)
2411 .block(value_block)
2412 .style(Style::default().fg(c_text()))
2413 .wrap(Wrap { trim: true });
2414 f.render_widget(value_content, inner[3]);
2415
2416 let actions = Paragraph::new(Line::from(vec![
2417 Span::styled("[Enter]", Style::default().fg(c_ok()).add_modifier(Modifier::BOLD)),
2418 Span::styled(" Save changes ", Style::default().fg(c_text_dim())),
2419 Span::styled("[Esc]", Style::default().fg(c_err()).add_modifier(Modifier::BOLD)),
2420 Span::styled(" Cancel ", Style::default().fg(c_text_dim())),
2421 Span::styled("Type to edit value", Style::default().fg(c_text_dim())),
2422 ]));
2423 f.render_widget(actions, inner[4]);
2424
2425 if let Some(err) = &app.error {
2426 let error_area = Rect {
2427 x: area.x + 2,
2428 y: area.y + area.height - 3,
2429 width: area.width - 4,
2430 height: 1,
2431 };
2432 let err_p = Paragraph::new(Span::styled(
2433 err.clone(),
2434 Style::default().fg(c_err()).add_modifier(Modifier::BOLD),
2435 ));
2436 f.render_widget(err_p, error_area);
2437 }
2438 }
2439}
2440
2441fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
2442 let v = Layout::default()
2443 .direction(Direction::Vertical)
2444 .constraints([
2445 Constraint::Percentage((100 - percent_y) / 2),
2446 Constraint::Percentage(percent_y),
2447 Constraint::Percentage((100 - percent_y) / 2),
2448 ])
2449 .split(r);
2450 let h = Layout::default()
2451 .direction(Direction::Horizontal)
2452 .constraints([
2453 Constraint::Percentage((100 - percent_x) / 2),
2454 Constraint::Percentage(percent_x),
2455 Constraint::Percentage((100 - percent_x) / 2),
2456 ])
2457 .split(v[1]);
2458 h[1]
2459}
2460
2461const fn pad(r: Rect, x: u16, y: u16) -> Rect {
2462 Rect {
2463 x: r.x.saturating_add(x),
2464 y: r.y.saturating_add(y),
2465 width: r.width.saturating_sub(x.saturating_mul(2)),
2466 height: r.height.saturating_sub(y.saturating_mul(2)),
2467 }
2468}
2469
2470fn field_box(content: &str, focused: bool) -> String {
2471 if focused {
2472 format!("[{content}]")
2473 } else {
2474 format!(" {content} ")
2475 }
2476}
2477
2478fn mask(s: &str) -> String {
2479 if s.is_empty() {
2480 String::new()
2481 } else {
2482 "•".repeat(s.chars().count())
2483 }
2484}