Skip to main content

try_rs/
tui.rs

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        // Spawn background thread to calculate folder size
175        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            // Only use git worktree remove if it's actually a worktree (not main working tree)
218            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                // Regular directory or main git repo - just delete it
242                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    // Vertically center the text inside the popup
288    let inner_height = popup_area.height.saturating_sub(2) as usize; // subtract borders
289    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    // Split popup into theme list and transparency option
328    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    // Draw transparency checkbox
359    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            // Render background if not transparent
510            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                    // Build icon list: (flag, icon_str, color)
612                    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; // folder icon
626
627                    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            // Split right area between Preview and Icon Legend
696            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            // Icon legend
753            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        // Poll with 1-second timeout so the screen refreshes periodically
855        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            // Clear status message on any key press so it disappears after one redraw
863            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                            // Save current theme and transparency before opening selector
893                            app.original_theme = Some(app.theme.clone());
894                            app.original_transparent_background = Some(app.transparent_background);
895                            // Find and select current theme in the list
896                            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                        // Toggle transparent background
971                        app.transparent_background = !app.transparent_background;
972                    }
973                    KeyCode::Esc => {
974                        // Restore original theme and transparency
975                        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                        // Restore original theme and transparency
987                        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                        // Apply theme preview
1010                        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                        // Apply theme preview
1027                        if let Some(theme) = app.available_themes.get(i) {
1028                            app.theme = theme.clone();
1029                        }
1030                    }
1031                    KeyCode::Enter => {
1032                        // Clear original theme and transparency (we're confirming the new values)
1033                        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}