1use anyhow::Result;
2use chrono::Local;
3use crossterm::event::{self, Event, KeyCode};
4use fuzzy_matcher::FuzzyMatcher;
5use fuzzy_matcher::skim::SkimMatcherV2;
6use ratatui::{prelude::*, widgets::*};
7
8use std::{
9 fs,
10 io::{self},
11 path::PathBuf,
12 sync::{
13 Arc,
14 atomic::{AtomicU64, Ordering},
15 },
16 thread,
17 time::SystemTime,
18};
19
20pub use crate::themes::Theme;
21use crate::{
22 config::{get_file_config_toml_name, save_config},
23 utils::{self, SelectionResult},
24};
25
26#[derive(Clone, Copy, PartialEq)]
27pub enum AppMode {
28 Normal,
29 DeleteConfirm,
30 ThemeSelect,
31 ConfigSavePrompt,
32 ConfigSaveLocationSelect,
33 About,
34}
35
36#[derive(Clone)]
37pub struct TryEntry {
38 pub name: String,
39 pub display_name: String,
40 pub modified: SystemTime,
41 pub created: SystemTime,
42 pub score: i64,
43 pub is_git: bool,
44 pub is_worktree: bool,
45 pub is_worktree_locked: bool,
46 pub is_gitmodules: bool,
47 pub is_mise: bool,
48 pub is_cargo: bool,
49 pub is_maven: bool,
50 pub is_flutter: bool,
51 pub is_go: bool,
52 pub is_python: bool,
53}
54
55pub struct App {
56 pub query: String,
57 pub all_entries: Vec<TryEntry>,
58 pub filtered_entries: Vec<TryEntry>,
59 pub selected_index: usize,
60 pub should_quit: bool,
61 pub final_selection: SelectionResult,
62 pub mode: AppMode,
63 pub status_message: Option<String>,
64 pub base_path: PathBuf,
65 pub theme: Theme,
66 pub editor_cmd: Option<String>,
67 pub wants_editor: bool,
68 pub apply_date_prefix: Option<bool>,
69 pub transparent_background: bool,
70
71 pub available_themes: Vec<Theme>,
72 pub theme_list_state: ListState,
73 pub original_theme: Option<Theme>,
74 pub original_transparent_background: Option<bool>,
75
76 pub config_path: Option<PathBuf>,
77 pub config_location_state: ListState,
78
79 pub cached_free_space_mb: Option<u64>,
80 pub folder_size_mb: Arc<AtomicU64>,
81}
82
83impl App {
84 pub fn new(
85 path: PathBuf,
86 theme: Theme,
87 editor_cmd: Option<String>,
88 config_path: Option<PathBuf>,
89 apply_date_prefix: Option<bool>,
90 transparent_background: bool,
91 query: Option<String>,
92 ) -> Self {
93 let mut entries = Vec::new();
94 if let Ok(read_dir) = fs::read_dir(&path) {
95 for entry in read_dir.flatten() {
96 if let Ok(metadata) = entry.metadata()
97 && metadata.is_dir()
98 {
99 let name = entry.file_name().to_string_lossy().to_string();
100 let git_path = entry.path().join(".git");
101 let is_git = git_path.exists();
102 let is_worktree = git_path.is_file();
103 let is_worktree_locked = utils::is_git_worktree_locked(&entry.path());
104 let is_gitmodules = entry.path().join(".gitmodules").exists();
105 let is_mise = entry.path().join("mise.toml").exists();
106 let is_cargo = entry.path().join("Cargo.toml").exists();
107 let is_maven = entry.path().join("pom.xml").exists();
108
109 let created;
110 let display_name;
111 if let Some((date_prefix, remainder)) = utils::extract_prefix_date(&name) {
112 created = date_prefix;
113 display_name = remainder;
114 } else {
115 created = metadata.created().unwrap_or(SystemTime::UNIX_EPOCH);
116 display_name = name.clone();
117 }
118 let is_flutter = entry.path().join("pubspec.yaml").exists();
119 let is_go = entry.path().join("go.mod").exists();
120 let is_python = entry.path().join("pyproject.toml").exists()
121 || entry.path().join("requirements.txt").exists();
122 entries.push(TryEntry {
123 name,
124 display_name,
125 modified: metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH),
126 created,
127 score: 0,
128 is_git,
129 is_worktree,
130 is_worktree_locked,
131 is_gitmodules,
132 is_mise,
133 is_cargo,
134 is_maven,
135 is_flutter,
136 is_go,
137 is_python,
138 });
139 }
140 }
141 }
142 entries.sort_by(|a, b| b.modified.cmp(&a.modified));
143
144 let themes = Theme::all();
145
146 let mut theme_state = ListState::default();
147 theme_state.select(Some(0));
148
149 let mut app = Self {
150 query: query.unwrap_or_else(|| String::new()),
151 all_entries: entries.clone(),
152 filtered_entries: entries,
153 selected_index: 0,
154 should_quit: false,
155 final_selection: SelectionResult::None,
156 mode: AppMode::Normal,
157 status_message: None,
158 base_path: path.clone(),
159 theme,
160 editor_cmd,
161 wants_editor: false,
162 apply_date_prefix,
163 transparent_background,
164 available_themes: themes,
165 theme_list_state: theme_state,
166 original_theme: None,
167 original_transparent_background: None,
168 config_path,
169 config_location_state: ListState::default(),
170 cached_free_space_mb: utils::get_free_disk_space_mb(&path),
171 folder_size_mb: Arc::new(AtomicU64::new(0)),
172 };
173
174 let folder_size_arc = Arc::clone(&app.folder_size_mb);
176 let path_clone = path.clone();
177 thread::spawn(move || {
178 let size = utils::get_folder_size_mb(&path_clone);
179 folder_size_arc.store(size, Ordering::Relaxed);
180 });
181
182 app.update_search();
183 app
184 }
185
186 pub fn update_search(&mut self) {
187 let matcher = SkimMatcherV2::default();
188
189 if self.query.is_empty() {
190 self.filtered_entries = self.all_entries.clone();
191 } else {
192 self.filtered_entries = self
193 .all_entries
194 .iter()
195 .filter_map(|entry| {
196 matcher.fuzzy_match(&entry.name, &self.query).map(|score| {
197 let mut e = entry.clone();
198 e.score = score;
199 e
200 })
201 })
202 .collect();
203
204 self.filtered_entries.sort_by(|a, b| b.score.cmp(&a.score));
205 }
206 self.selected_index = 0;
207 }
208
209 pub fn delete_selected(&mut self) {
210 if let Some(entry_name) = self
211 .filtered_entries
212 .get(self.selected_index)
213 .map(|e| e.name.clone())
214 {
215 let path_to_remove = self.base_path.join(&entry_name);
216
217 if utils::is_git_worktree(&path_to_remove) {
219 match utils::remove_git_worktree(&path_to_remove) {
220 Ok(output) => {
221 if output.status.success() {
222 self.all_entries.retain(|e| e.name != entry_name);
223 self.update_search();
224 self.status_message =
225 Some(format!("Worktree removed: {path_to_remove:?}"));
226 } else {
227 self.status_message = Some(format!(
228 "Error deleting: {}",
229 String::from_utf8_lossy(&output.stderr)
230 .lines()
231 .take(1)
232 .collect::<String>()
233 ));
234 }
235 }
236 Err(e) => {
237 self.status_message = Some(format!("Error removing worktree: {}", e));
238 }
239 };
240 } else {
241 match fs::remove_dir_all(&path_to_remove) {
243 Ok(_) => {
244 self.all_entries.retain(|e| e.name != entry_name);
245 self.update_search();
246 self.status_message =
247 Some(format!("Deleted: {}", path_to_remove.display()));
248 }
249 Err(e) => {
250 self.status_message = Some(format!("Error deleting: {}", e));
251 }
252 }
253 };
254 }
255 self.mode = AppMode::Normal;
256 }
257}
258
259fn draw_popup(f: &mut Frame, title: &str, message: &str, theme: &Theme) {
260 let area = f.area();
261
262 let popup_layout = Layout::default()
263 .direction(Direction::Vertical)
264 .constraints([
265 Constraint::Percentage(40),
266 Constraint::Length(8),
267 Constraint::Percentage(40),
268 ])
269 .split(area);
270
271 let popup_area = Layout::default()
272 .direction(Direction::Horizontal)
273 .constraints([
274 Constraint::Percentage(35),
275 Constraint::Percentage(30),
276 Constraint::Percentage(35),
277 ])
278 .split(popup_layout[1])[1];
279
280 f.render_widget(Clear, popup_area);
281
282 let block = Block::default()
283 .title(title)
284 .borders(Borders::ALL)
285 .style(Style::default().bg(theme.popup_bg));
286
287 let inner_height = popup_area.height.saturating_sub(2) as usize; let text_lines = message.lines().count();
290 let top_padding = inner_height.saturating_sub(text_lines) / 2;
291 let padded_message = format!("{}{}", "\n".repeat(top_padding), message);
292
293 let paragraph = Paragraph::new(padded_message)
294 .block(block)
295 .style(
296 Style::default()
297 .fg(theme.popup_text)
298 .add_modifier(Modifier::BOLD),
299 )
300 .alignment(Alignment::Center);
301
302 f.render_widget(paragraph, popup_area);
303}
304
305fn draw_theme_select(f: &mut Frame, app: &mut App) {
306 let area = f.area();
307 let popup_layout = Layout::default()
308 .direction(Direction::Vertical)
309 .constraints([
310 Constraint::Percentage(25),
311 Constraint::Percentage(50),
312 Constraint::Percentage(25),
313 ])
314 .split(area);
315
316 let popup_area = Layout::default()
317 .direction(Direction::Horizontal)
318 .constraints([
319 Constraint::Percentage(25),
320 Constraint::Percentage(50),
321 Constraint::Percentage(25),
322 ])
323 .split(popup_layout[1])[1];
324
325 f.render_widget(Clear, popup_area);
326
327 let inner_layout = Layout::default()
329 .direction(Direction::Vertical)
330 .constraints([Constraint::Min(3), Constraint::Length(3)])
331 .split(popup_area);
332
333 let block = Block::default()
334 .title(" Select Theme ")
335 .borders(Borders::ALL)
336 .style(Style::default().bg(app.theme.popup_bg));
337
338 let items: Vec<ListItem> = app
339 .available_themes
340 .iter()
341 .map(|t| {
342 ListItem::new(t.name.clone()).style(Style::default().fg(app.theme.list_highlight_fg))
343 })
344 .collect();
345
346 let list = List::new(items)
347 .block(block)
348 .highlight_style(
349 Style::default()
350 .bg(app.theme.list_highlight_bg)
351 .fg(app.theme.list_selected_fg)
352 .add_modifier(Modifier::BOLD),
353 )
354 .highlight_symbol(">> ");
355
356 f.render_stateful_widget(list, inner_layout[0], &mut app.theme_list_state);
357
358 let checkbox = if app.transparent_background {
360 "[x]"
361 } else {
362 "[ ]"
363 };
364 let transparency_text = format!(" {} Transparent Background (Space to toggle)", checkbox);
365 let transparency_block = Block::default()
366 .borders(Borders::ALL)
367 .style(Style::default().bg(app.theme.popup_bg));
368 let transparency_paragraph = Paragraph::new(transparency_text)
369 .style(Style::default().fg(app.theme.list_highlight_fg))
370 .block(transparency_block);
371 f.render_widget(transparency_paragraph, inner_layout[1]);
372}
373
374fn draw_config_location_select(f: &mut Frame, app: &mut App) {
375 let area = f.area();
376 let popup_layout = Layout::default()
377 .direction(Direction::Vertical)
378 .constraints([
379 Constraint::Percentage(40),
380 Constraint::Length(8),
381 Constraint::Percentage(40),
382 ])
383 .split(area);
384
385 let popup_area = Layout::default()
386 .direction(Direction::Horizontal)
387 .constraints([
388 Constraint::Percentage(20),
389 Constraint::Percentage(60),
390 Constraint::Percentage(20),
391 ])
392 .split(popup_layout[1])[1];
393
394 f.render_widget(Clear, popup_area);
395
396 let block = Block::default()
397 .title(" Select Config Location ")
398 .borders(Borders::ALL)
399 .style(Style::default().bg(app.theme.popup_bg));
400
401 let config_name = get_file_config_toml_name();
402 let items = vec![
403 ListItem::new(format!("System Config (~/.config/try-rs/{})", config_name))
404 .style(Style::default().fg(app.theme.list_highlight_fg)),
405 ListItem::new(format!("Home Directory (~/{})", config_name))
406 .style(Style::default().fg(app.theme.list_highlight_fg)),
407 ];
408
409 let list = List::new(items)
410 .block(block)
411 .highlight_style(
412 Style::default()
413 .bg(app.theme.list_highlight_bg)
414 .fg(app.theme.list_selected_fg)
415 .add_modifier(Modifier::BOLD),
416 )
417 .highlight_symbol(">> ");
418
419 f.render_stateful_widget(list, popup_area, &mut app.config_location_state);
420}
421
422fn draw_about_popup(f: &mut Frame, theme: &Theme) {
423 let area = f.area();
424 let popup_layout = Layout::default()
425 .direction(Direction::Vertical)
426 .constraints([
427 Constraint::Percentage(25),
428 Constraint::Length(12),
429 Constraint::Percentage(25),
430 ])
431 .split(area);
432
433 let popup_area = Layout::default()
434 .direction(Direction::Horizontal)
435 .constraints([
436 Constraint::Percentage(30),
437 Constraint::Percentage(40),
438 Constraint::Percentage(30),
439 ])
440 .split(popup_layout[1])[1];
441
442 f.render_widget(Clear, popup_area);
443
444 let block = Block::default()
445 .title(" About ")
446 .borders(Borders::ALL)
447 .style(Style::default().bg(theme.popup_bg));
448
449 let text = vec![
450 Line::from(vec![
451 Span::styled(
452 "🦀 try",
453 Style::default()
454 .fg(theme.title_try)
455 .add_modifier(Modifier::BOLD),
456 ),
457 Span::styled("-", Style::default().fg(Color::DarkGray)),
458 Span::styled(
459 "rs",
460 Style::default()
461 .fg(theme.title_rs)
462 .add_modifier(Modifier::BOLD),
463 ),
464 Span::styled(
465 format!(" v{}", env!("CARGO_PKG_VERSION")),
466 Style::default().fg(Color::DarkGray),
467 ),
468 ]),
469 Line::from(""),
470 Line::from(Span::styled(
471 "try-rs.org",
472 Style::default().fg(theme.search_title),
473 )),
474 Line::from(""),
475 Line::from(Span::styled(
476 "github.com/tassiovirginio/try-rs",
477 Style::default().fg(theme.search_title),
478 )),
479 Line::from(""),
480 Line::from(vec![
481 Span::styled(" License: ", Style::default().fg(theme.helpers_colors)),
482 Span::styled(
483 "MIT",
484 Style::default()
485 .fg(theme.status_message)
486 .add_modifier(Modifier::BOLD),
487 ),
488 ]),
489 Line::from(""),
490 Line::from(Span::styled(
491 "Press Esc to close",
492 Style::default().fg(theme.helpers_colors),
493 )),
494 ];
495
496 let paragraph = Paragraph::new(text)
497 .block(block)
498 .alignment(Alignment::Center);
499
500 f.render_widget(paragraph, popup_area);
501}
502
503pub fn run_app(
504 terminal: &mut Terminal<CrosstermBackend<io::Stderr>>,
505 mut app: App,
506) -> Result<(SelectionResult, bool)> {
507 while !app.should_quit {
508 terminal.draw(|f| {
509 if !app.transparent_background {
511 if let Some(bg_color) = app.theme.background {
512 let background = Block::default().style(Style::default().bg(bg_color));
513 f.render_widget(background, f.area());
514 }
515 }
516
517 let chunks = Layout::default()
518 .direction(Direction::Vertical)
519 .constraints([
520 Constraint::Length(3),
521 Constraint::Min(1),
522 Constraint::Length(1),
523 ])
524 .split(f.area());
525
526 let content_chunks = Layout::default()
527 .direction(Direction::Horizontal)
528 .constraints([Constraint::Percentage(70), Constraint::Percentage(30)])
529 .split(chunks[1]);
530
531 let search_chunks = Layout::default()
532 .direction(Direction::Horizontal)
533 .constraints([Constraint::Min(20), Constraint::Length(45)])
534 .split(chunks[0]);
535
536 let search_text = Paragraph::new(app.query.clone())
537 .style(Style::default().fg(app.theme.search_title))
538 .block(
539 Block::default()
540 .borders(Borders::ALL)
541 .title(Span::styled(
542 " Search/New ",
543 Style::default().fg(app.theme.search_title),
544 ))
545 .border_style(Style::default().fg(app.theme.search_border)),
546 );
547 f.render_widget(search_text, search_chunks[0]);
548
549 let free_space = app
550 .cached_free_space_mb
551 .map(|s| {
552 if s >= 1000 {
553 format!("{:.1} GB", s as f64 / 1024.0)
554 } else {
555 format!("{} MB", s)
556 }
557 })
558 .unwrap_or_else(|| "N/A".to_string());
559
560 let folder_size = app.folder_size_mb.load(Ordering::Relaxed);
561 let folder_size_str = if folder_size == 0 {
562 "---".to_string()
563 } else if folder_size >= 1000 {
564 format!("{:.1} GB", folder_size as f64 / 1024.0)
565 } else {
566 format!("{} MB", folder_size)
567 };
568
569 let memory_info = Paragraph::new(Line::from(vec![
570 Span::styled(" ", Style::default().fg(app.theme.title_rs)),
571 Span::styled("Used: ", Style::default().fg(app.theme.helpers_colors)),
572 Span::styled(
573 folder_size_str,
574 Style::default().fg(app.theme.status_message),
575 ),
576 Span::styled(" | ", Style::default().fg(app.theme.helpers_colors)),
577 Span::styled("Free: ", Style::default().fg(app.theme.helpers_colors)),
578 Span::styled(free_space, Style::default().fg(app.theme.status_message)),
579 ]))
580 .block(
581 Block::default()
582 .borders(Borders::ALL)
583 .title(Span::styled(
584 " Disk ",
585 Style::default().fg(app.theme.disk_title),
586 ))
587 .border_style(Style::default().fg(app.theme.disk_border)),
588 )
589 .alignment(Alignment::Center);
590 f.render_widget(memory_info, search_chunks[1]);
591
592 let items: Vec<ListItem> = app
593 .filtered_entries
594 .iter()
595 .map(|entry| {
596 let now = SystemTime::now();
597 let elapsed = now
598 .duration_since(entry.modified)
599 .unwrap_or(std::time::Duration::ZERO);
600 let secs = elapsed.as_secs();
601 let days = secs / 86400;
602 let hours = (secs % 86400) / 3600;
603 let minutes = (secs % 3600) / 60;
604 let date_str = format!("({:02}d {:02}h {:02}m)", days, hours, minutes);
605
606 let width = content_chunks[0].width.saturating_sub(5) as usize;
607
608 let date_text = date_str.to_string();
609 let date_width = date_text.chars().count();
610
611 let icons: &[(bool, &str, Color)] = &[
613 (entry.is_cargo, " ", app.theme.icon_rust),
614 (entry.is_maven, " ", app.theme.icon_maven),
615 (entry.is_flutter, " ", app.theme.icon_flutter),
616 (entry.is_go, " ", app.theme.icon_go),
617 (entry.is_python, " ", app.theme.icon_python),
618 (entry.is_mise, " ", app.theme.icon_mise),
619 (entry.is_worktree, " ", app.theme.icon_worktree),
620 (entry.is_worktree_locked, " ", app.theme.icon_worktree_lock),
621 (entry.is_gitmodules, " ", app.theme.icon_gitmodules),
622 (entry.is_git, " ", app.theme.icon_git),
623 ];
624 let icons_width: usize = icons.iter().filter(|(f, _, _)| *f).count() * 2;
625 let icon_width = 2; let created_dt: chrono::DateTime<Local> = entry.created.into();
628 let created_text = created_dt.format("%Y-%m-%d").to_string();
629 let created_width = created_text.chars().count();
630
631 let reserved = date_width + icons_width + icon_width + created_width + 2;
632 let available_for_name = width.saturating_sub(reserved);
633 let name_len = entry.display_name.chars().count();
634
635 let (display_name, padding) = if name_len > available_for_name {
636 let safe_len = available_for_name.saturating_sub(3);
637 let truncated: String = entry.display_name.chars().take(safe_len).collect();
638 (format!("{}...", truncated), 1)
639 } else {
640 (
641 entry.display_name.clone(),
642 width.saturating_sub(
643 icon_width
644 + created_width
645 + 1
646 + name_len
647 + date_width
648 + icons_width,
649 ),
650 )
651 };
652
653 let mut spans = vec![
654 Span::styled(" ", Style::default().fg(app.theme.icon_folder)),
655 Span::styled(created_text, Style::default().fg(app.theme.list_date)),
656 Span::raw(format!(" {}", display_name)),
657 Span::raw(" ".repeat(padding)),
658 ];
659 for &(flag, icon, color) in icons {
660 if flag {
661 spans.push(Span::styled(icon, Style::default().fg(color)));
662 }
663 }
664 spans.push(Span::styled(
665 date_text,
666 Style::default().fg(app.theme.list_date),
667 ));
668
669 ListItem::new(Line::from(spans))
670 })
671 .collect();
672
673 let list = List::new(items)
674 .block(
675 Block::default()
676 .borders(Borders::ALL)
677 .title(Span::styled(
678 " Folders ",
679 Style::default().fg(app.theme.folder_title),
680 ))
681 .border_style(Style::default().fg(app.theme.folder_border)),
682 )
683 .highlight_style(
684 Style::default()
685 .bg(app.theme.list_highlight_bg)
686 .fg(app.theme.list_selected_fg)
687 .add_modifier(Modifier::BOLD),
688 )
689 .highlight_symbol("→ ");
690
691 let mut state = ListState::default();
692 state.select(Some(app.selected_index));
693 f.render_stateful_widget(list, content_chunks[0], &mut state);
694
695 let right_chunks = Layout::default()
697 .direction(Direction::Vertical)
698 .constraints([Constraint::Min(1), Constraint::Length(4)])
699 .split(content_chunks[1]);
700
701 if let Some(selected) = app.filtered_entries.get(app.selected_index) {
702 let preview_path = app.base_path.join(&selected.name);
703 let mut preview_lines = Vec::new();
704
705 if let Ok(entries) = fs::read_dir(&preview_path) {
706 for e in entries
707 .take(right_chunks[0].height.saturating_sub(2) as usize)
708 .flatten()
709 {
710 let file_name = e.file_name().to_string_lossy().to_string();
711 let is_dir = e.file_type().map(|t| t.is_dir()).unwrap_or(false);
712 let (icon, color) = if is_dir {
713 (" ", app.theme.icon_folder)
714 } else {
715 (" ", app.theme.icon_file)
716 };
717 preview_lines.push(Line::from(vec![
718 Span::styled(icon, Style::default().fg(color)),
719 Span::raw(file_name),
720 ]));
721 }
722 }
723
724 if preview_lines.is_empty() {
725 preview_lines.push(Line::from(Span::styled(
726 " (empty) ",
727 Style::default().fg(Color::DarkGray),
728 )));
729 }
730
731 let preview = Paragraph::new(preview_lines).block(
732 Block::default()
733 .borders(Borders::ALL)
734 .title(Span::styled(
735 " Preview ",
736 Style::default().fg(app.theme.preview_title),
737 ))
738 .border_style(Style::default().fg(app.theme.preview_border)),
739 );
740 f.render_widget(preview, right_chunks[0]);
741 } else {
742 let preview = Block::default()
743 .borders(Borders::ALL)
744 .title(Span::styled(
745 " Preview ",
746 Style::default().fg(app.theme.preview_title),
747 ))
748 .border_style(Style::default().fg(app.theme.preview_border));
749 f.render_widget(preview, right_chunks[0]);
750 }
751
752 let legend_lines = vec![Line::from(vec![
754 Span::styled(" ", Style::default().fg(app.theme.icon_rust)),
755 Span::styled("Rust ", Style::default().fg(app.theme.helpers_colors)),
756 Span::styled(" ", Style::default().fg(app.theme.icon_maven)),
757 Span::styled("Maven ", Style::default().fg(app.theme.helpers_colors)),
758 Span::styled(" ", Style::default().fg(app.theme.icon_flutter)),
759 Span::styled("Flutter ", Style::default().fg(app.theme.helpers_colors)),
760 Span::styled(" ", Style::default().fg(app.theme.icon_go)),
761 Span::styled("Go ", Style::default().fg(app.theme.helpers_colors)),
762 Span::styled(" ", Style::default().fg(app.theme.icon_python)),
763 Span::styled("Python ", Style::default().fg(app.theme.helpers_colors)),
764 Span::styled(" ", Style::default().fg(app.theme.icon_mise)),
765 Span::styled("Mise ", Style::default().fg(app.theme.helpers_colors)),
766 Span::styled(" ", Style::default().fg(app.theme.icon_worktree_lock)),
767 Span::styled("Locked ", Style::default().fg(app.theme.helpers_colors)),
768 Span::styled(" ", Style::default().fg(app.theme.icon_worktree)),
769 Span::styled(
770 "Git-Worktree ",
771 Style::default().fg(app.theme.helpers_colors),
772 ),
773 Span::styled(" ", Style::default().fg(app.theme.icon_gitmodules)),
774 Span::styled("Git-Submod ", Style::default().fg(app.theme.helpers_colors)),
775 Span::styled(" ", Style::default().fg(app.theme.icon_git)),
776 Span::styled("Git ", Style::default().fg(app.theme.helpers_colors)),
777 ])];
778
779 let legend = Paragraph::new(legend_lines)
780 .block(
781 Block::default()
782 .borders(Borders::ALL)
783 .title(Span::styled(
784 " Legends ",
785 Style::default().fg(app.theme.legends_title),
786 ))
787 .border_style(Style::default().fg(app.theme.legends_border)),
788 )
789 .alignment(Alignment::Left)
790 .wrap(Wrap { trim: true });
791 f.render_widget(legend, right_chunks[1]);
792
793 let help_text = if let Some(msg) = &app.status_message {
794 Line::from(vec![Span::styled(
795 msg,
796 Style::default()
797 .fg(app.theme.status_message)
798 .add_modifier(Modifier::BOLD),
799 )])
800 } else {
801 Line::from(vec![
802 Span::styled("↑↓", Style::default().add_modifier(Modifier::BOLD)),
803 Span::raw(" Nav | "),
804 Span::styled("Enter", Style::default().add_modifier(Modifier::BOLD)),
805 Span::raw(" Select | "),
806 Span::styled("Ctrl-D", Style::default().add_modifier(Modifier::BOLD)),
807 Span::raw(" Del | "),
808 Span::styled("Ctrl-E", Style::default().add_modifier(Modifier::BOLD)),
809 Span::raw(" Edit | "),
810 Span::styled("Ctrl-T", Style::default().add_modifier(Modifier::BOLD)),
811 Span::raw(" Theme | "),
812 Span::styled("Ctrl-A", Style::default().add_modifier(Modifier::BOLD)),
813 Span::raw(" About | "),
814 Span::styled("Esc/Ctrl+C", Style::default().add_modifier(Modifier::BOLD)),
815 Span::raw(" Quit"),
816 ])
817 };
818
819 let help_message = Paragraph::new(help_text)
820 .style(Style::default().fg(app.theme.helpers_colors))
821 .alignment(Alignment::Center);
822
823 f.render_widget(help_message, chunks[2]);
824
825 if app.mode == AppMode::DeleteConfirm
826 && let Some(selected) = app.filtered_entries.get(app.selected_index)
827 {
828 let msg = format!("Delete '{}'?\n(y/n)", selected.name);
829 draw_popup(f, " WARNING ", &msg, &app.theme);
830 }
831
832 if app.mode == AppMode::ThemeSelect {
833 draw_theme_select(f, &mut app);
834 }
835
836 if app.mode == AppMode::ConfigSavePrompt {
837 draw_popup(
838 f,
839 " Create Config? ",
840 "Config file not found.\nCreate one now to save theme? (y/n)",
841 &app.theme,
842 );
843 }
844
845 if app.mode == AppMode::ConfigSaveLocationSelect {
846 draw_config_location_select(f, &mut app);
847 }
848
849 if app.mode == AppMode::About {
850 draw_about_popup(f, &app.theme);
851 }
852 })?;
853
854 if !event::poll(std::time::Duration::from_secs(1))? {
856 continue;
857 }
858 if let Event::Key(key) = event::read()? {
859 if !key.is_press() {
860 continue;
861 }
862 app.status_message = None;
864 match app.mode {
865 AppMode::Normal => match key.code {
866 KeyCode::Char(c) => {
867 if c == 'c' && key.modifiers.contains(event::KeyModifiers::CONTROL) {
868 app.should_quit = true;
869 } else if c == 'd' && key.modifiers.contains(event::KeyModifiers::CONTROL) {
870 if !app.filtered_entries.is_empty() {
871 app.mode = AppMode::DeleteConfirm;
872 }
873 } else if c == 'e' && key.modifiers.contains(event::KeyModifiers::CONTROL) {
874 if app.editor_cmd.is_some() {
875 if !app.filtered_entries.is_empty() {
876 app.final_selection = SelectionResult::Folder(
877 app.filtered_entries[app.selected_index].name.clone(),
878 );
879 app.wants_editor = true;
880 app.should_quit = true;
881 } else if !app.query.is_empty() {
882 app.final_selection =
883 SelectionResult::Folder(app.query.clone());
884 app.wants_editor = true;
885 app.should_quit = true;
886 }
887 } else {
888 app.status_message =
889 Some("No editor configured in config.toml".to_string());
890 }
891 } else if c == 't' && key.modifiers.contains(event::KeyModifiers::CONTROL) {
892 app.original_theme = Some(app.theme.clone());
894 app.original_transparent_background = Some(app.transparent_background);
895 let current_idx = app
897 .available_themes
898 .iter()
899 .position(|t| t.name == app.theme.name)
900 .unwrap_or(0);
901 app.theme_list_state.select(Some(current_idx));
902 app.mode = AppMode::ThemeSelect;
903 } else if c == 'a' && key.modifiers.contains(event::KeyModifiers::CONTROL) {
904 app.mode = AppMode::About;
905 } else if matches!(c, 'k' | 'p')
906 && key.modifiers.contains(event::KeyModifiers::CONTROL)
907 {
908 if app.selected_index > 0 {
909 app.selected_index -= 1;
910 }
911 } else if matches!(c, 'j' | 'n')
912 && key.modifiers.contains(event::KeyModifiers::CONTROL)
913 {
914 if app.selected_index < app.filtered_entries.len().saturating_sub(1) {
915 app.selected_index += 1;
916 }
917 } else if c == 'u' && key.modifiers.contains(event::KeyModifiers::CONTROL) {
918 app.query.clear();
919 app.update_search();
920 } else if key.modifiers.is_empty()
921 || key.modifiers == event::KeyModifiers::SHIFT
922 {
923 app.query.push(c);
924 app.update_search();
925 }
926 }
927 KeyCode::Backspace => {
928 app.query.pop();
929 app.update_search();
930 }
931 KeyCode::Up => {
932 if app.selected_index > 0 {
933 app.selected_index -= 1;
934 }
935 }
936 KeyCode::Down => {
937 if app.selected_index < app.filtered_entries.len().saturating_sub(1) {
938 app.selected_index += 1;
939 }
940 }
941 KeyCode::Enter => {
942 if !app.filtered_entries.is_empty() {
943 app.final_selection = SelectionResult::Folder(
944 app.filtered_entries[app.selected_index].name.clone(),
945 );
946 } else if !app.query.is_empty() {
947 app.final_selection = SelectionResult::New(app.query.clone());
948 }
949 app.should_quit = true;
950 }
951 KeyCode::Esc => app.should_quit = true,
952 _ => {}
953 },
954
955 AppMode::DeleteConfirm => match key.code {
956 KeyCode::Char('y') | KeyCode::Char('Y') => {
957 app.delete_selected();
958 }
959 KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
960 app.mode = AppMode::Normal;
961 }
962 KeyCode::Char('c') if key.modifiers.contains(event::KeyModifiers::CONTROL) => {
963 app.should_quit = true;
964 }
965 _ => {}
966 },
967
968 AppMode::ThemeSelect => match key.code {
969 KeyCode::Char(' ') => {
970 app.transparent_background = !app.transparent_background;
972 }
973 KeyCode::Esc => {
974 if let Some(original) = app.original_theme.take() {
976 app.theme = original;
977 }
978 if let Some(original_transparent) =
979 app.original_transparent_background.take()
980 {
981 app.transparent_background = original_transparent;
982 }
983 app.mode = AppMode::Normal;
984 }
985 KeyCode::Char('c') if key.modifiers.contains(event::KeyModifiers::CONTROL) => {
986 if let Some(original) = app.original_theme.take() {
988 app.theme = original;
989 }
990 if let Some(original_transparent) =
991 app.original_transparent_background.take()
992 {
993 app.transparent_background = original_transparent;
994 }
995 app.mode = AppMode::Normal;
996 }
997 KeyCode::Up | KeyCode::Char('k' | 'p') => {
998 let i = match app.theme_list_state.selected() {
999 Some(i) => {
1000 if i > 0 {
1001 i - 1
1002 } else {
1003 i
1004 }
1005 }
1006 None => 0,
1007 };
1008 app.theme_list_state.select(Some(i));
1009 if let Some(theme) = app.available_themes.get(i) {
1011 app.theme = theme.clone();
1012 }
1013 }
1014 KeyCode::Down | KeyCode::Char('j' | 'n') => {
1015 let i = match app.theme_list_state.selected() {
1016 Some(i) => {
1017 if i < app.available_themes.len() - 1 {
1018 i + 1
1019 } else {
1020 i
1021 }
1022 }
1023 None => 0,
1024 };
1025 app.theme_list_state.select(Some(i));
1026 if let Some(theme) = app.available_themes.get(i) {
1028 app.theme = theme.clone();
1029 }
1030 }
1031 KeyCode::Enter => {
1032 app.original_theme = None;
1034 app.original_transparent_background = None;
1035 if let Some(i) = app.theme_list_state.selected() {
1036 if let Some(theme) = app.available_themes.get(i) {
1037 app.theme = theme.clone();
1038
1039 if let Some(ref path) = app.config_path {
1040 if let Err(e) = save_config(
1041 path,
1042 &app.theme,
1043 &app.base_path,
1044 &app.editor_cmd,
1045 app.apply_date_prefix,
1046 Some(app.transparent_background),
1047 ) {
1048 app.status_message = Some(format!("Error saving: {}", e));
1049 } else {
1050 app.status_message = Some("Theme saved.".to_string());
1051 }
1052 app.mode = AppMode::Normal;
1053 } else {
1054 app.mode = AppMode::ConfigSavePrompt;
1055 }
1056 } else {
1057 app.mode = AppMode::Normal;
1058 }
1059 } else {
1060 app.mode = AppMode::Normal;
1061 }
1062 }
1063 _ => {}
1064 },
1065 AppMode::ConfigSavePrompt => match key.code {
1066 KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => {
1067 app.mode = AppMode::ConfigSaveLocationSelect;
1068 app.config_location_state.select(Some(0));
1069 }
1070 KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
1071 app.mode = AppMode::Normal;
1072 }
1073 _ => {}
1074 },
1075
1076 AppMode::ConfigSaveLocationSelect => match key.code {
1077 KeyCode::Esc | KeyCode::Char('c')
1078 if key.modifiers.contains(event::KeyModifiers::CONTROL) =>
1079 {
1080 app.mode = AppMode::Normal;
1081 }
1082 KeyCode::Up | KeyCode::Char('k' | 'p') => {
1083 let i = match app.config_location_state.selected() {
1084 Some(i) => {
1085 if i > 0 {
1086 i - 1
1087 } else {
1088 i
1089 }
1090 }
1091 None => 0,
1092 };
1093 app.config_location_state.select(Some(i));
1094 }
1095 KeyCode::Down | KeyCode::Char('j' | 'n') => {
1096 let i = match app.config_location_state.selected() {
1097 Some(i) => {
1098 if i < 1 {
1099 i + 1
1100 } else {
1101 i
1102 }
1103 }
1104 None => 0,
1105 };
1106 app.config_location_state.select(Some(i));
1107 }
1108 KeyCode::Enter => {
1109 if let Some(i) = app.config_location_state.selected() {
1110 let config_name = get_file_config_toml_name();
1111 let path = if i == 0 {
1112 dirs::config_dir()
1113 .unwrap_or_else(|| {
1114 dirs::home_dir().expect("Folder not found").join(".config")
1115 })
1116 .join("try-rs")
1117 .join(&config_name)
1118 } else {
1119 dirs::home_dir()
1120 .expect("Folder not found")
1121 .join(&config_name)
1122 };
1123
1124 if let Err(e) = save_config(
1125 &path,
1126 &app.theme,
1127 &app.base_path,
1128 &app.editor_cmd,
1129 app.apply_date_prefix,
1130 Some(app.transparent_background),
1131 ) {
1132 app.status_message = Some(format!("Error saving config: {}", e));
1133 } else {
1134 app.config_path = Some(path);
1135 app.status_message = Some("Theme saved!".to_string());
1136 }
1137 }
1138 app.mode = AppMode::Normal;
1139 }
1140 _ => {}
1141 },
1142 AppMode::About => match key.code {
1143 KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q') | KeyCode::Char(' ') => {
1144 app.mode = AppMode::Normal;
1145 }
1146 KeyCode::Char('c') if key.modifiers.contains(event::KeyModifiers::CONTROL) => {
1147 app.mode = AppMode::Normal;
1148 }
1149 _ => {}
1150 },
1151 }
1152 }
1153 }
1154
1155 Ok((app.final_selection, app.wants_editor))
1156}