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_highlight_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_highlight_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 + created_width + 1 + name_len + date_width + icons_width,
644                            ),
645                        )
646                    };
647
648                    let mut spans = vec![
649                        Span::styled(" 󰝰 ", Style::default().fg(app.theme.icon_folder)),
650                        Span::styled(created_text, Style::default().fg(app.theme.list_date)),
651                        Span::raw(format!(" {}", display_name)),
652                        Span::raw(" ".repeat(padding)),
653                    ];
654                    for &(flag, icon, color) in icons {
655                        if flag {
656                            spans.push(Span::styled(icon, Style::default().fg(color)));
657                        }
658                    }
659                    spans.push(Span::styled(date_text, Style::default().fg(app.theme.list_date)));
660
661                    ListItem::new(Line::from(spans))
662                })
663                .collect();
664
665            let list = List::new(items)
666                .block(
667                    Block::default()
668                        .borders(Borders::ALL)
669                        .title(Span::styled(
670                            " Folders ",
671                            Style::default().fg(app.theme.folder_title),
672                        ))
673                        .border_style(Style::default().fg(app.theme.folder_border)),
674                )
675                .highlight_style(
676                    Style::default()
677                        .bg(app.theme.list_highlight_bg)
678                        .fg(app.theme.list_highlight_fg)
679                        .add_modifier(Modifier::BOLD),
680                )
681                .highlight_symbol("→ ");
682
683            let mut state = ListState::default();
684            state.select(Some(app.selected_index));
685            f.render_stateful_widget(list, content_chunks[0], &mut state);
686
687            // Split right area between Preview and Icon Legend
688            let right_chunks = Layout::default()
689                .direction(Direction::Vertical)
690                .constraints([Constraint::Min(1), Constraint::Length(4)])
691                .split(content_chunks[1]);
692
693            if let Some(selected) = app.filtered_entries.get(app.selected_index) {
694                let preview_path = app.base_path.join(&selected.name);
695                let mut preview_lines = Vec::new();
696
697                if let Ok(entries) = fs::read_dir(&preview_path) {
698                    for e in entries
699                        .take(right_chunks[0].height.saturating_sub(2) as usize)
700                        .flatten()
701                    {
702                        let file_name = e.file_name().to_string_lossy().to_string();
703                        let is_dir = e.file_type().map(|t| t.is_dir()).unwrap_or(false);
704                        let (icon, color) = if is_dir {
705                            ("󰝰 ", app.theme.icon_folder)
706                        } else {
707                            ("󰈙 ", app.theme.icon_file)
708                        };
709                        preview_lines.push(Line::from(vec![
710                            Span::styled(icon, Style::default().fg(color)),
711                            Span::raw(file_name),
712                        ]));
713                    }
714                }
715
716                if preview_lines.is_empty() {
717                    preview_lines.push(Line::from(Span::styled(
718                        " (empty) ",
719                        Style::default().fg(Color::DarkGray),
720                    )));
721                }
722
723                let preview = Paragraph::new(preview_lines).block(
724                    Block::default()
725                        .borders(Borders::ALL)
726                        .title(Span::styled(
727                            " Preview ",
728                            Style::default().fg(app.theme.preview_title),
729                        ))
730                        .border_style(Style::default().fg(app.theme.preview_border)),
731                );
732                f.render_widget(preview, right_chunks[0]);
733            } else {
734                let preview = Block::default()
735                    .borders(Borders::ALL)
736                    .title(Span::styled(
737                        " Preview ",
738                        Style::default().fg(app.theme.preview_title),
739                    ))
740                    .border_style(Style::default().fg(app.theme.preview_border));
741                f.render_widget(preview, right_chunks[0]);
742            }
743
744            // Icon legend
745            let legend_lines = vec![Line::from(vec![
746                Span::styled(" ", Style::default().fg(app.theme.icon_rust)),
747                Span::styled("Rust ", Style::default().fg(app.theme.helpers_colors)),
748                Span::styled(" ", Style::default().fg(app.theme.icon_maven)),
749                Span::styled("Maven ", Style::default().fg(app.theme.helpers_colors)),
750                Span::styled(" ", Style::default().fg(app.theme.icon_flutter)),
751                Span::styled("Flutter ", Style::default().fg(app.theme.helpers_colors)),
752                Span::styled(" ", Style::default().fg(app.theme.icon_go)),
753                Span::styled("Go ", Style::default().fg(app.theme.helpers_colors)),
754                Span::styled(" ", Style::default().fg(app.theme.icon_python)),
755                Span::styled("Python ", Style::default().fg(app.theme.helpers_colors)),
756                Span::styled("󰬔 ", Style::default().fg(app.theme.icon_mise)),
757                Span::styled("Mise ", Style::default().fg(app.theme.helpers_colors)),
758                Span::styled(" ", Style::default().fg(app.theme.icon_worktree_lock)),
759                Span::styled("Locked ", Style::default().fg(app.theme.helpers_colors)),
760                Span::styled("󰙅 ", Style::default().fg(app.theme.icon_worktree)),
761                Span::styled(
762                    "Git-Worktree ",
763                    Style::default().fg(app.theme.helpers_colors),
764                ),
765                Span::styled(" ", Style::default().fg(app.theme.icon_gitmodules)),
766                Span::styled("Git-Submod ", Style::default().fg(app.theme.helpers_colors)),
767                Span::styled(" ", Style::default().fg(app.theme.icon_git)),
768                Span::styled("Git ", Style::default().fg(app.theme.helpers_colors)),
769            ])];
770
771            let legend = Paragraph::new(legend_lines)
772                .block(
773                    Block::default()
774                        .borders(Borders::ALL)
775                        .title(Span::styled(
776                            " Legends ",
777                            Style::default().fg(app.theme.legends_title),
778                        ))
779                        .border_style(Style::default().fg(app.theme.legends_border)),
780                )
781                .alignment(Alignment::Left)
782                .wrap(Wrap { trim: true });
783            f.render_widget(legend, right_chunks[1]);
784
785            let help_text = if let Some(msg) = &app.status_message {
786                Line::from(vec![Span::styled(
787                    msg,
788                    Style::default()
789                        .fg(app.theme.status_message)
790                        .add_modifier(Modifier::BOLD),
791                )])
792            } else {
793                Line::from(vec![
794                    Span::styled("↑↓", Style::default().add_modifier(Modifier::BOLD)),
795                    Span::raw(" Nav | "),
796                    Span::styled("Enter", Style::default().add_modifier(Modifier::BOLD)),
797                    Span::raw(" Select | "),
798                    Span::styled("Ctrl-D", Style::default().add_modifier(Modifier::BOLD)),
799                    Span::raw(" Del | "),
800                    Span::styled("Ctrl-E", Style::default().add_modifier(Modifier::BOLD)),
801                    Span::raw(" Edit | "),
802                    Span::styled("Ctrl-T", Style::default().add_modifier(Modifier::BOLD)),
803                    Span::raw(" Theme | "),
804                    Span::styled("Ctrl-A", Style::default().add_modifier(Modifier::BOLD)),
805                    Span::raw(" About | "),
806                    Span::styled("Esc/Ctrl+C", Style::default().add_modifier(Modifier::BOLD)),
807                    Span::raw(" Quit"),
808                ])
809            };
810
811            let help_message = Paragraph::new(help_text)
812                .style(Style::default().fg(app.theme.helpers_colors))
813                .alignment(Alignment::Center);
814
815            f.render_widget(help_message, chunks[2]);
816
817            if app.mode == AppMode::DeleteConfirm
818                && let Some(selected) = app.filtered_entries.get(app.selected_index)
819            {
820                let msg = format!("Delete '{}'?\n(y/n)", selected.name);
821                draw_popup(f, " WARNING ", &msg, &app.theme);
822            }
823
824            if app.mode == AppMode::ThemeSelect {
825                draw_theme_select(f, &mut app);
826            }
827
828            if app.mode == AppMode::ConfigSavePrompt {
829                draw_popup(
830                    f,
831                    " Create Config? ",
832                    "Config file not found.\nCreate one now to save theme? (y/n)",
833                    &app.theme,
834                );
835            }
836
837            if app.mode == AppMode::ConfigSaveLocationSelect {
838                draw_config_location_select(f, &mut app);
839            }
840
841            if app.mode == AppMode::About {
842                draw_about_popup(f, &app.theme);
843            }
844        })?;
845
846        // Poll with 1-second timeout so the screen refreshes periodically
847        if !event::poll(std::time::Duration::from_secs(1))? {
848            continue;
849        }
850        if let Event::Key(key) = event::read()? {
851            if !key.is_press() {
852                continue;
853            }
854            // Clear status message on any key press so it disappears after one redraw
855            app.status_message = None;
856            match app.mode {
857                AppMode::Normal => match key.code {
858                    KeyCode::Char(c) => {
859                        if c == 'c' && key.modifiers.contains(event::KeyModifiers::CONTROL) {
860                            app.should_quit = true;
861                        } else if c == 'd' && key.modifiers.contains(event::KeyModifiers::CONTROL) {
862                            if !app.filtered_entries.is_empty() {
863                                app.mode = AppMode::DeleteConfirm;
864                            }
865                        } else if c == 'e' && key.modifiers.contains(event::KeyModifiers::CONTROL) {
866                            if app.editor_cmd.is_some() {
867                                if !app.filtered_entries.is_empty() {
868                                    app.final_selection = SelectionResult::Folder(
869                                        app.filtered_entries[app.selected_index].name.clone(),
870                                    );
871                                    app.wants_editor = true;
872                                    app.should_quit = true;
873                                } else if !app.query.is_empty() {
874                                    app.final_selection =
875                                        SelectionResult::Folder(app.query.clone());
876                                    app.wants_editor = true;
877                                    app.should_quit = true;
878                                }
879                            } else {
880                                app.status_message =
881                                    Some("No editor configured in config.toml".to_string());
882                            }
883                        } else if c == 't' && key.modifiers.contains(event::KeyModifiers::CONTROL) {
884                            // Save current theme and transparency before opening selector
885                            app.original_theme = Some(app.theme.clone());
886                            app.original_transparent_background = Some(app.transparent_background);
887                            // Find and select current theme in the list
888                            let current_idx = app
889                                .available_themes
890                                .iter()
891                                .position(|t| t.name == app.theme.name)
892                                .unwrap_or(0);
893                            app.theme_list_state.select(Some(current_idx));
894                            app.mode = AppMode::ThemeSelect;
895                        } else if c == 'a' && key.modifiers.contains(event::KeyModifiers::CONTROL) {
896                            app.mode = AppMode::About;
897                        } else if matches!(c, 'k' | 'p')
898                            && key.modifiers.contains(event::KeyModifiers::CONTROL)
899                        {
900                            if app.selected_index > 0 {
901                                app.selected_index -= 1;
902                            }
903                        } else if matches!(c, 'j' | 'n')
904                            && key.modifiers.contains(event::KeyModifiers::CONTROL)
905                        {
906                            if app.selected_index < app.filtered_entries.len().saturating_sub(1) {
907                                app.selected_index += 1;
908                            }
909                        } else if c == 'u' && key.modifiers.contains(event::KeyModifiers::CONTROL) {
910                            app.query.clear();
911                            app.update_search();
912                        } else if key.modifiers.is_empty()
913                            || key.modifiers == event::KeyModifiers::SHIFT
914                        {
915                            app.query.push(c);
916                            app.update_search();
917                        }
918                    }
919                    KeyCode::Backspace => {
920                        app.query.pop();
921                        app.update_search();
922                    }
923                    KeyCode::Up => {
924                        if app.selected_index > 0 {
925                            app.selected_index -= 1;
926                        }
927                    }
928                    KeyCode::Down => {
929                        if app.selected_index < app.filtered_entries.len().saturating_sub(1) {
930                            app.selected_index += 1;
931                        }
932                    }
933                    KeyCode::Enter => {
934                        if !app.filtered_entries.is_empty() {
935                            app.final_selection = SelectionResult::Folder(
936                                app.filtered_entries[app.selected_index].name.clone(),
937                            );
938                        } else if !app.query.is_empty() {
939                            app.final_selection = SelectionResult::New(app.query.clone());
940                        }
941                        app.should_quit = true;
942                    }
943                    KeyCode::Esc => app.should_quit = true,
944                    _ => {}
945                },
946
947                AppMode::DeleteConfirm => match key.code {
948                    KeyCode::Char('y') | KeyCode::Char('Y') => {
949                        app.delete_selected();
950                    }
951                    KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
952                        app.mode = AppMode::Normal;
953                    }
954                    KeyCode::Char('c') if key.modifiers.contains(event::KeyModifiers::CONTROL) => {
955                        app.should_quit = true;
956                    }
957                    _ => {}
958                },
959
960                AppMode::ThemeSelect => match key.code {
961                    KeyCode::Char(' ') => {
962                        // Toggle transparent background
963                        app.transparent_background = !app.transparent_background;
964                    }
965                    KeyCode::Esc => {
966                        // Restore original theme and transparency
967                        if let Some(original) = app.original_theme.take() {
968                            app.theme = original;
969                        }
970                        if let Some(original_transparent) = app.original_transparent_background.take() {
971                            app.transparent_background = original_transparent;
972                        }
973                        app.mode = AppMode::Normal;
974                    }
975                    KeyCode::Char('c') if key.modifiers.contains(event::KeyModifiers::CONTROL) => {
976                        // Restore original theme and transparency
977                        if let Some(original) = app.original_theme.take() {
978                            app.theme = original;
979                        }
980                        if let Some(original_transparent) = app.original_transparent_background.take() {
981                            app.transparent_background = original_transparent;
982                        }
983                        app.mode = AppMode::Normal;
984                    }
985                    KeyCode::Up | KeyCode::Char('k' | 'p') => {
986                        let i = match app.theme_list_state.selected() {
987                            Some(i) => {
988                                if i > 0 {
989                                    i - 1
990                                } else {
991                                    i
992                                }
993                            }
994                            None => 0,
995                        };
996                        app.theme_list_state.select(Some(i));
997                        // Apply theme preview
998                        if let Some(theme) = app.available_themes.get(i) {
999                            app.theme = theme.clone();
1000                        }
1001                    }
1002                    KeyCode::Down | KeyCode::Char('j' | 'n') => {
1003                        let i = match app.theme_list_state.selected() {
1004                            Some(i) => {
1005                                if i < app.available_themes.len() - 1 {
1006                                    i + 1
1007                                } else {
1008                                    i
1009                                }
1010                            }
1011                            None => 0,
1012                        };
1013                        app.theme_list_state.select(Some(i));
1014                        // Apply theme preview
1015                        if let Some(theme) = app.available_themes.get(i) {
1016                            app.theme = theme.clone();
1017                        }
1018                    }
1019                    KeyCode::Enter => {
1020                        // Clear original theme and transparency (we're confirming the new values)
1021                        app.original_theme = None;
1022                        app.original_transparent_background = None;
1023                        if let Some(i) = app.theme_list_state.selected() {
1024                            if let Some(theme) = app.available_themes.get(i) {
1025                                app.theme = theme.clone();
1026
1027                                if let Some(ref path) = app.config_path {
1028                                    if let Err(e) = save_config(
1029                                        path,
1030                                        &app.theme,
1031                                        &app.base_path,
1032                                        &app.editor_cmd,
1033                                        app.apply_date_prefix,
1034                                        Some(app.transparent_background),
1035                                    ) {
1036                                        app.status_message = Some(format!("Error saving: {}", e));
1037                                    } else {
1038                                        app.status_message = Some("Theme saved.".to_string());
1039                                    }
1040                                    app.mode = AppMode::Normal;
1041                                } else {
1042                                    app.mode = AppMode::ConfigSavePrompt;
1043                                }
1044                            } else {
1045                                app.mode = AppMode::Normal;
1046                            }
1047                        } else {
1048                            app.mode = AppMode::Normal;
1049                        }
1050                    }
1051                    _ => {}
1052                },
1053                AppMode::ConfigSavePrompt => match key.code {
1054                    KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => {
1055                        app.mode = AppMode::ConfigSaveLocationSelect;
1056                        app.config_location_state.select(Some(0));
1057                    }
1058                    KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
1059                        app.mode = AppMode::Normal;
1060                    }
1061                    _ => {}
1062                },
1063
1064                AppMode::ConfigSaveLocationSelect => match key.code {
1065                    KeyCode::Esc | KeyCode::Char('c')
1066                        if key.modifiers.contains(event::KeyModifiers::CONTROL) =>
1067                    {
1068                        app.mode = AppMode::Normal;
1069                    }
1070                    KeyCode::Up | KeyCode::Char('k' | 'p') => {
1071                        let i = match app.config_location_state.selected() {
1072                            Some(i) => {
1073                                if i > 0 {
1074                                    i - 1
1075                                } else {
1076                                    i
1077                                }
1078                            }
1079                            None => 0,
1080                        };
1081                        app.config_location_state.select(Some(i));
1082                    }
1083                    KeyCode::Down | KeyCode::Char('j' | 'n') => {
1084                        let i = match app.config_location_state.selected() {
1085                            Some(i) => {
1086                                if i < 1 {
1087                                    i + 1
1088                                } else {
1089                                    i
1090                                }
1091                            }
1092                            None => 0,
1093                        };
1094                        app.config_location_state.select(Some(i));
1095                    }
1096                    KeyCode::Enter => {
1097                        if let Some(i) = app.config_location_state.selected() {
1098                            let config_name = get_file_config_toml_name();
1099                            let path = if i == 0 {
1100                                dirs::config_dir()
1101                                    .unwrap_or_else(|| {
1102                                        dirs::home_dir().expect("Folder not found").join(".config")
1103                                    })
1104                                    .join("try-rs")
1105                                    .join(&config_name)
1106                            } else {
1107                                dirs::home_dir()
1108                                    .expect("Folder not found")
1109                                    .join(&config_name)
1110                            };
1111
1112                            if let Err(e) = save_config(
1113                                &path,
1114                                &app.theme,
1115                                &app.base_path,
1116                                &app.editor_cmd,
1117                                app.apply_date_prefix,
1118                                Some(app.transparent_background),
1119                            ) {
1120                                app.status_message = Some(format!("Error saving config: {}", e));
1121                            } else {
1122                                app.config_path = Some(path);
1123                                app.status_message = Some("Theme saved!".to_string());
1124                            }
1125                        }
1126                        app.mode = AppMode::Normal;
1127                    }
1128                    _ => {}
1129                },
1130                AppMode::About => match key.code {
1131                    KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q') | KeyCode::Char(' ') => {
1132                        app.mode = AppMode::Normal;
1133                    }
1134                    KeyCode::Char('c') if key.modifiers.contains(event::KeyModifiers::CONTROL) => {
1135                        app.mode = AppMode::Normal;
1136                    }
1137                    _ => {}
1138                },
1139            }
1140        }
1141    }
1142
1143    Ok((app.final_selection, app.wants_editor))
1144}