metis_docs_tui/
lib.rs

1use anyhow::Result;
2use crossterm::{
3    event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers},
4    execute,
5    terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
6};
7use ratatui::{backend::CrosstermBackend, Terminal};
8use std::io;
9
10pub mod app;
11pub mod error;
12pub mod models;
13pub mod services;
14mod ui;
15
16use app::state::ConfirmationType;
17use app::App;
18use error::AppError;
19use models::AppState;
20
21/// Run the TUI application
22pub async fn run() -> Result<()> {
23    // Setup terminal
24    enable_raw_mode()?;
25    let mut stdout = io::stdout();
26    execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
27    let backend = CrosstermBackend::new(stdout);
28    let mut terminal = Terminal::new(backend)?;
29
30    // Create and initialize app
31    let mut app = App::new();
32
33    // Run the app with initialization
34    let res = run_app(&mut terminal, &mut app).await;
35
36    // Restore terminal
37    disable_raw_mode()?;
38    execute!(
39        terminal.backend_mut(),
40        LeaveAlternateScreen,
41        DisableMouseCapture
42    )?;
43    terminal.show_cursor()?;
44
45    if let Err(err) = res {
46        println!("{err:?}");
47    }
48
49    Ok(())
50}
51
52// Helper functions for keyboard input handling
53
54/// Handle keyboard input for normal state
55async fn handle_normal_state_input(app: &mut App, key: crossterm::event::KeyEvent) -> Result<bool> {
56    // Clear message on any meaningful keystroke (except space which explicitly dismisses)
57    if !matches!(key.code, KeyCode::Char(' ')) {
58        app.clear_messages();
59    }
60
61    match key.code {
62        KeyCode::Char('q') => return Ok(true), // Signal to quit
63        KeyCode::Tab => {
64            if app.is_ready() {
65                app.next_board();
66            }
67        }
68        KeyCode::BackTab => {
69            if app.is_ready() {
70                app.previous_board();
71            }
72        }
73        KeyCode::Left => {
74            if app.is_ready() {
75                app.move_selection_left();
76            }
77        }
78        KeyCode::Right => {
79            if app.is_ready() {
80                app.move_selection_right();
81            }
82        }
83        KeyCode::Up => {
84            if app.is_ready() {
85                app.move_selection_up();
86            }
87        }
88        KeyCode::Down => {
89            if app.is_ready() {
90                app.move_selection_down();
91            }
92        }
93        KeyCode::Char('n') => {
94            if app.is_ready() && key.modifiers.contains(KeyModifiers::CONTROL) {
95                app.start_smart_document_creation();
96            }
97        }
98        KeyCode::Char('d') | KeyCode::Char('D') => {
99            if app.is_ready() && app.get_selected_item().is_some() {
100                app.start_delete_confirmation();
101            }
102        }
103        KeyCode::Char('t') | KeyCode::Char('T') => {
104            if app.is_ready() && app.get_selected_item().is_some() {
105                app.start_transition_confirmation();
106            }
107        }
108        KeyCode::Char('1') => {
109            if app.is_ready() {
110                app.jump_to_strategy_board();
111            }
112        }
113        KeyCode::Char('2') => {
114            if app.is_ready() {
115                app.jump_to_initiative_board();
116            }
117        }
118        KeyCode::Char('3') => {
119            if app.is_ready() {
120                app.jump_to_task_board();
121            }
122        }
123        KeyCode::Char('4') => {
124            if app.is_ready() {
125                app.jump_to_adr_board();
126            }
127        }
128        KeyCode::Char('5') => {
129            if app.is_ready() {
130                app.jump_to_backlog_board();
131            }
132        }
133        KeyCode::Char('v') | KeyCode::Char('V') => {
134            if app.is_ready() {
135                app.view_vision_document();
136            }
137        }
138        KeyCode::Char(' ') => {
139            app.ui_state.message_state.clear_message();
140        }
141        KeyCode::Char('r') | KeyCode::Char('R') => {
142            if app.is_ready() && app.get_selected_item().is_some() {
143                if let Err(e) = app.archive_selected_document().await {
144                    app.error_handler
145                        .handle_with_context(AppError::from(e), "Archive operation");
146                }
147            }
148        }
149        KeyCode::Char('y') | KeyCode::Char('Y') => {
150            if app.is_ready() {
151                if let Err(e) = app.sync_and_reload().await {
152                    app.add_error_message(format!("Sync failed: {}", e));
153                    app.error_handler
154                        .handle_with_context(AppError::from(e), "Sync operation");
155                }
156            }
157        }
158        KeyCode::Enter => {
159            if app.is_ready() {
160                app.view_selected_ticket();
161            }
162        }
163        _ => {}
164    }
165    Ok(false) // Don't quit
166}
167
168/// Handle keyboard input for document creation states
169async fn handle_creation_state_input(
170    app: &mut App,
171    key: crossterm::event::KeyEvent,
172    state: AppState,
173) -> Result<()> {
174    match key.code {
175        KeyCode::Esc => {
176            app.cancel_document_creation();
177        }
178        KeyCode::Enter => {
179            let result = match state {
180                AppState::CreatingDocument => app.create_new_document().await,
181                AppState::CreatingChildDocument => app.create_child_document().await,
182                AppState::CreatingAdr => app.create_adr_from_ticket().await,
183                _ => return Ok(()),
184            };
185
186            if let Err(e) = result {
187                let context = match state {
188                    AppState::CreatingDocument => "Document creation",
189                    AppState::CreatingChildDocument => "Child document creation",
190                    AppState::CreatingAdr => "ADR creation",
191                    _ => "Creation",
192                };
193                app.error_handler
194                    .handle_with_context(AppError::from(e), context);
195            }
196        }
197        _ => {
198            app.handle_key_event(key);
199        }
200    }
201    Ok(())
202}
203
204/// Handle keyboard input for confirmation state
205async fn handle_confirmation_input(app: &mut App, key: crossterm::event::KeyEvent) -> Result<()> {
206    match key.code {
207        KeyCode::Char('y') | KeyCode::Char('Y') => {
208            match app.ui_state.confirmation_type {
209                Some(ConfirmationType::Delete) => {
210                    if let Err(e) = app.delete_selected_document().await {
211                        app.error_handler
212                            .handle_with_context(AppError::from(e), "Document deletion");
213                    }
214                }
215                Some(ConfirmationType::Transition) => {
216                    if let Err(e) = app.transition_selected_document().await {
217                        app.error_handler
218                            .handle_with_context(AppError::from(e), "Document transition");
219                    }
220                }
221                None => {}
222            }
223            app.cancel_confirmation();
224        }
225        KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
226            app.cancel_confirmation();
227        }
228        _ => {}
229    }
230    Ok(())
231}
232
233/// Handle keyboard input for backlog category selection
234fn handle_backlog_category_input(app: &mut App, key: crossterm::event::KeyEvent) {
235    match key.code {
236        KeyCode::Esc => {
237            app.cancel_document_creation();
238        }
239        KeyCode::Up => {
240            app.move_category_selection_up();
241        }
242        KeyCode::Down => {
243            app.move_category_selection_down();
244        }
245        KeyCode::Enter => {
246            app.confirm_category_selection();
247        }
248        _ => {}
249    }
250}
251
252/// Handle keyboard input for content editing
253async fn handle_content_editing_input(
254    app: &mut App,
255    key: crossterm::event::KeyEvent,
256) -> Result<()> {
257    match key.code {
258        KeyCode::Esc => {
259            app.cancel_content_editing();
260        }
261        KeyCode::Char('s') if key.modifiers.contains(KeyModifiers::CONTROL) => {
262            if let Err(e) = app.save_content_edit().await {
263                app.error_handler
264                    .handle_with_context(AppError::from(e), "Document save");
265            }
266            app.cancel_content_editing();
267        }
268        _ => {
269            if let Some(ref mut textarea) = app.ui_state.strategy_editor {
270                use tui_textarea::Input;
271                let input = Input::from(key);
272                textarea.input(input);
273            }
274        }
275    }
276    Ok(())
277}
278
279async fn run_app<B: ratatui::backend::Backend>(
280    terminal: &mut Terminal<B>,
281    app: &mut App,
282) -> Result<()> {
283    // Initialize app before starting the main loop
284    if let Err(e) = app.initialize().await {
285        app.add_error_message("Failed to initialize application".to_string());
286        app.error_handler
287            .handle_with_context(AppError::from(e), "Initialization");
288    }
289
290    loop {
291        // Clear expired messages on each loop iteration
292        app.clear_expired_messages();
293
294        // Draw UI
295        terminal.draw(|f| ui::draw(f, app))?;
296
297        // Handle input events with timeout
298        if crossterm::event::poll(std::time::Duration::from_millis(50))? {
299            if let Event::Key(key) = event::read()? {
300                let current_state = app.app_state();
301
302                match current_state {
303                    AppState::Normal => {
304                        if handle_normal_state_input(app, key).await? {
305                            return Ok(()); // Quit requested
306                        }
307                    }
308                    AppState::CreatingDocument => {
309                        handle_creation_state_input(app, key, AppState::CreatingDocument).await?;
310                    }
311                    AppState::CreatingChildDocument => {
312                        handle_creation_state_input(app, key, AppState::CreatingChildDocument)
313                            .await?;
314                    }
315                    AppState::CreatingAdr => {
316                        handle_creation_state_input(app, key, AppState::CreatingAdr).await?;
317                    }
318                    AppState::Confirming => {
319                        handle_confirmation_input(app, key).await?;
320                    }
321                    AppState::SelectingBacklogCategory => {
322                        handle_backlog_category_input(app, key);
323                    }
324                    AppState::EditingContent => {
325                        handle_content_editing_input(app, key).await?;
326                    }
327                }
328            }
329        }
330    }
331}