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