Skip to main content

chasm/tui/
events.rs

1// Copyright (c) 2024-2026 Nervosys LLC
2// SPDX-License-Identifier: AGPL-3.0-only
3//! Event handling and main TUI loop
4
5use std::io;
6
7use anyhow::Result;
8use crossterm::{
9    event::{
10        self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind, KeyModifiers,
11    },
12    execute,
13    terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
14};
15use ratatui::{backend::CrosstermBackend, Terminal};
16
17use super::app::{App, AppMode, ExportFormat};
18use super::ui;
19
20/// Run the TUI application
21pub fn run_tui() -> Result<()> {
22    // Setup terminal
23    enable_raw_mode()?;
24    let mut stdout = io::stdout();
25    execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
26    let backend = CrosstermBackend::new(stdout);
27    let mut terminal = Terminal::new(backend)?;
28
29    // Create app state
30    let mut app = App::new()?;
31
32    // Main loop
33    let res = run_app(&mut terminal, &mut app);
34
35    // Restore terminal
36    disable_raw_mode()?;
37    execute!(
38        terminal.backend_mut(),
39        LeaveAlternateScreen,
40        DisableMouseCapture
41    )?;
42    terminal.show_cursor()?;
43
44    if let Err(err) = res {
45        eprintln!("Error: {}", err);
46    }
47
48    Ok(())
49}
50
51/// Main application loop
52fn run_app<B: ratatui::backend::Backend>(terminal: &mut Terminal<B>, app: &mut App) -> Result<()> {
53    // Initial draw
54    terminal.draw(|f| ui::render(f, app))?;
55
56    loop {
57        // Block waiting for input - no polling delay, instant response
58        if let Event::Key(key) = event::read()? {
59            // Only handle key press events, ignore release/repeat to prevent double-triggering
60            if key.kind != KeyEventKind::Press {
61                continue;
62            }
63
64            // Clear status message on any keypress (unless in confirm flow)
65            if !app.confirm_delete {
66                app.status_message = None;
67            }
68
69            // --- Export format picker ---
70            if app.export_picker_active {
71                match key.code {
72                    KeyCode::Char('j') | KeyCode::Down => {
73                        let max = ExportFormat::all().len();
74                        if app.export_format_index + 1 < max {
75                            app.export_format_index += 1;
76                        }
77                    }
78                    KeyCode::Char('k') | KeyCode::Up => {
79                        if app.export_format_index > 0 {
80                            app.export_format_index -= 1;
81                        }
82                    }
83                    KeyCode::Enter => app.confirm_export(),
84                    KeyCode::Esc => app.cancel_export(),
85                    KeyCode::Char('1') => {
86                        app.export_format_index = 0;
87                        app.confirm_export();
88                    }
89                    KeyCode::Char('2') => {
90                        app.export_format_index = 1;
91                        app.confirm_export();
92                    }
93                    KeyCode::Char('3') => {
94                        app.export_format_index = 2;
95                        app.confirm_export();
96                    }
97                    KeyCode::Char('4') => {
98                        app.export_format_index = 3;
99                        app.confirm_export();
100                    }
101                    _ => {}
102                }
103                terminal.draw(|f| ui::render(f, app))?;
104                continue;
105            }
106
107            // --- Global search input ---
108            if app.search_active {
109                match key.code {
110                    KeyCode::Enter => app.execute_search(),
111                    KeyCode::Esc => app.cancel_search(),
112                    KeyCode::Backspace => app.search_backspace(),
113                    KeyCode::Char(c) => app.search_input(c),
114                    _ => {}
115                }
116                terminal.draw(|f| ui::render(f, app))?;
117                continue;
118            }
119
120            // --- Workspace filter input ---
121            if app.filter_active {
122                match key.code {
123                    KeyCode::Enter => app.confirm_filter(),
124                    KeyCode::Esc => app.cancel_filter(),
125                    KeyCode::Backspace => app.filter_backspace(),
126                    KeyCode::Char(c) => app.filter_input(c),
127                    _ => {}
128                }
129                terminal.draw(|f| ui::render(f, app))?;
130                continue;
131            }
132
133            // --- Session filter input ---
134            if app.session_filter_active {
135                match key.code {
136                    KeyCode::Enter => app.confirm_session_filter(),
137                    KeyCode::Esc => app.cancel_session_filter(),
138                    KeyCode::Backspace => app.session_filter_backspace(),
139                    KeyCode::Char(c) => app.session_filter_input(c),
140                    _ => {}
141                }
142                terminal.draw(|f| ui::render(f, app))?;
143                continue;
144            }
145
146            // Help mode - any key closes it
147            if app.mode == AppMode::Help {
148                app.back();
149                terminal.draw(|f| ui::render(f, app))?;
150                continue;
151            }
152
153            // Normal key handling
154            match key.code {
155                KeyCode::Char('q') => {
156                    return Ok(());
157                }
158                KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
159                    return Ok(());
160                }
161                KeyCode::Char('?') => {
162                    app.toggle_help();
163                }
164                // Navigation
165                KeyCode::Char('j') | KeyCode::Down => {
166                    app.navigate_down();
167                }
168                KeyCode::Char('k') | KeyCode::Up => {
169                    app.navigate_up();
170                }
171                KeyCode::Char('g') => {
172                    app.go_to_top();
173                }
174                KeyCode::Char('G') => {
175                    app.go_to_bottom();
176                }
177                KeyCode::PageUp => {
178                    app.page_up();
179                }
180                KeyCode::PageDown => {
181                    app.page_down();
182                }
183                KeyCode::Enter => {
184                    app.enter();
185                }
186                KeyCode::Esc | KeyCode::Backspace => {
187                    if app.confirm_delete {
188                        app.cancel_delete();
189                    } else {
190                        app.back();
191                    }
192                }
193                // Filter (/ works in workspaces and sessions)
194                KeyCode::Char('/') if matches!(app.mode, AppMode::Workspaces | AppMode::Sessions) => {
195                    app.start_filter();
196                }
197                // Global search
198                KeyCode::Char('s') if !matches!(app.mode, AppMode::SessionDetail) => {
199                    app.start_search();
200                }
201                // Refresh
202                KeyCode::Char('r') => {
203                    app.refresh();
204                }
205                // Export
206                KeyCode::Char('e') if matches!(app.mode, AppMode::Sessions | AppMode::SessionDetail | AppMode::SearchResults) => {
207                    app.export_current_session();
208                }
209                // Sort (sessions view)
210                KeyCode::Char('o') if app.mode == AppMode::Sessions => {
211                    app.cycle_sort();
212                }
213                // Delete (sessions view)
214                KeyCode::Char('d') if matches!(app.mode, AppMode::Sessions | AppMode::SessionDetail) => {
215                    app.delete_current_session();
216                }
217                // Yank (copy) session content
218                KeyCode::Char('y') if matches!(app.mode, AppMode::Sessions | AppMode::SessionDetail | AppMode::SearchResults) => {
219                    app.yank_session();
220                }
221                _ => continue, // No redraw needed for unhandled keys
222            }
223
224            // Redraw after handling input
225            terminal.draw(|f| ui::render(f, app))?;
226        }
227    }
228}