//! Model trait impl and key dispatch for LoreApp. use chrono::TimeDelta; use ftui::{Cmd, Event, Frame, KeyCode, KeyEvent, Model, Modifiers}; use crate::crash_context::CrashEvent; use crate::message::{InputMode, Msg, Screen}; use crate::state::LoadState; use crate::task_supervisor::TaskKey; use super::LoreApp; /// Timeout for the g-prefix key sequence. const GO_PREFIX_TIMEOUT: TimeDelta = TimeDelta::milliseconds(500); impl LoreApp { // ----------------------------------------------------------------------- // Key dispatch // ----------------------------------------------------------------------- /// Normalize terminal key variants for cross-terminal consistency. fn normalize_key(key: &mut KeyEvent) { // BackTab -> Shift+Tab canonical form. if key.code == KeyCode::BackTab { key.code = KeyCode::Tab; key.modifiers |= Modifiers::SHIFT; } } /// 5-stage key dispatch pipeline. /// /// Returns the Cmd to execute (Quit, None, or a task command). pub(crate) fn interpret_key(&mut self, mut key: KeyEvent) -> Cmd { Self::normalize_key(&mut key); let screen = self.navigation.current().clone(); // Record key press in crash context. self.crash_context.push(CrashEvent::KeyPress { key: format!("{:?}", key.code), mode: format!("{:?}", self.input_mode), screen: screen.label().to_string(), }); // --- Stage 1: Quit check --- // Ctrl+C always quits regardless of mode. if key.code == KeyCode::Char('c') && key.modifiers.contains(Modifiers::CTRL) { return Cmd::quit(); } // --- Stage 2: InputMode routing --- match &self.input_mode { InputMode::Text => { return self.handle_text_mode_key(&key, &screen); } InputMode::Palette => { return self.handle_palette_mode_key(&key, &screen); } InputMode::GoPrefix { started_at } => { let elapsed = self.clock.now().signed_duration_since(*started_at); if elapsed > GO_PREFIX_TIMEOUT { // Timeout expired — cancel prefix and re-process as normal. self.input_mode = InputMode::Normal; } else { return self.handle_go_prefix_key(&key, &screen); } } InputMode::Normal => {} } // --- Stage 3: Global shortcuts (Normal mode) --- // 'q' quits. if key.code == KeyCode::Char('q') && key.modifiers == Modifiers::NONE { return Cmd::quit(); } // 'g' starts prefix sequence. if self .command_registry .is_sequence_starter(&key.code, &key.modifiers) { self.input_mode = InputMode::GoPrefix { started_at: self.clock.now(), }; return Cmd::none(); } // Registry-based single-key lookup. if let Some(cmd_def) = self.command_registry .lookup_key(&key.code, &key.modifiers, &screen, &self.input_mode) { return self.execute_command(cmd_def.id, &screen); } // --- Stage 4: Screen-local keys --- // Delegated to AppState::interpret_screen_key in future phases. // --- Stage 5: Fallback (unhandled) --- Cmd::none() } /// Handle keys in Text input mode. /// /// Only Esc and Ctrl+P pass through; everything else is consumed by /// the focused text widget (handled in future phases). fn handle_text_mode_key(&mut self, key: &KeyEvent, screen: &Screen) -> Cmd { // Esc blurs the text input. if key.code == KeyCode::Escape { self.state.blur_text_focus(); self.input_mode = InputMode::Normal; return Cmd::none(); } // Ctrl+P opens palette even in text mode. if let Some(cmd_def) = self.command_registry .lookup_key(&key.code, &key.modifiers, screen, &InputMode::Text) { return self.execute_command(cmd_def.id, screen); } // All other keys consumed by text widget (future). Cmd::none() } /// Handle keys in Palette mode. fn handle_palette_mode_key(&mut self, key: &KeyEvent, _screen: &Screen) -> Cmd { if key.code == KeyCode::Escape { self.input_mode = InputMode::Normal; return Cmd::none(); } // Palette key dispatch will be expanded in the palette widget phase. Cmd::none() } /// Handle the second key of a g-prefix sequence. fn handle_go_prefix_key(&mut self, key: &KeyEvent, screen: &Screen) -> Cmd { self.input_mode = InputMode::Normal; if let Some(cmd_def) = self.command_registry.complete_sequence( &KeyCode::Char('g'), &Modifiers::NONE, &key.code, &key.modifiers, screen, ) { return self.execute_command(cmd_def.id, screen); } // Invalid second key — cancel prefix silently. Cmd::none() } /// Execute a command by ID. fn execute_command(&mut self, id: &str, _screen: &Screen) -> Cmd { match id { "quit" => Cmd::quit(), "go_back" => { self.navigation.pop(); Cmd::none() } "show_help" => { self.state.show_help = !self.state.show_help; Cmd::none() } "command_palette" => { self.input_mode = InputMode::Palette; self.state.command_palette.query_focused = true; Cmd::none() } "open_in_browser" => { // Will dispatch OpenInBrowser msg in future phase. Cmd::none() } "show_cli" => { // Will show CLI equivalent in future phase. Cmd::none() } "go_home" => self.navigate_to(Screen::Dashboard), "go_issues" => self.navigate_to(Screen::IssueList), "go_mrs" => self.navigate_to(Screen::MrList), "go_search" => self.navigate_to(Screen::Search), "go_timeline" => self.navigate_to(Screen::Timeline), "go_who" => self.navigate_to(Screen::Who), "go_sync" => self.navigate_to(Screen::Sync), "jump_back" => { self.navigation.jump_back(); Cmd::none() } "jump_forward" => { self.navigation.jump_forward(); Cmd::none() } "move_down" | "move_up" | "select_item" | "focus_filter" | "scroll_to_top" => { // Screen-specific actions — delegated in future phases. Cmd::none() } _ => Cmd::none(), } } // ----------------------------------------------------------------------- // Navigation helpers // ----------------------------------------------------------------------- /// Navigate to a screen, pushing the nav stack and starting a data load. fn navigate_to(&mut self, screen: Screen) -> Cmd { let screen_label = screen.label().to_string(); let current_label = self.navigation.current().label().to_string(); self.crash_context.push(CrashEvent::StateTransition { from: current_label, to: screen_label, }); self.navigation.push(screen.clone()); // First visit → full-screen spinner; revisit → corner spinner over stale data. let load_state = if self.state.load_state.was_visited(&screen) { LoadState::Refreshing } else { LoadState::LoadingInitial }; self.state.set_loading(screen.clone(), load_state); // Spawn supervised task for data loading (placeholder — actual DB // query dispatch comes in Phase 2 screen implementations). let _handle = self.supervisor.submit(TaskKey::LoadScreen(screen)); Cmd::none() } // ----------------------------------------------------------------------- // Message dispatch (non-key) // ----------------------------------------------------------------------- /// Handle non-key messages. pub(crate) fn handle_msg(&mut self, msg: Msg) -> Cmd { // Record in crash context. self.crash_context.push(CrashEvent::MsgDispatched { msg_name: format!("{msg:?}") .split('(') .next() .unwrap_or("?") .to_string(), screen: self.navigation.current().label().to_string(), }); match msg { Msg::Quit => Cmd::quit(), // --- Navigation --- Msg::NavigateTo(screen) => self.navigate_to(screen), Msg::GoBack => { self.navigation.pop(); Cmd::none() } Msg::GoForward => { self.navigation.go_forward(); Cmd::none() } Msg::GoHome => self.navigate_to(Screen::Dashboard), Msg::JumpBack(_) => { self.navigation.jump_back(); Cmd::none() } Msg::JumpForward(_) => { self.navigation.jump_forward(); Cmd::none() } // --- Error --- Msg::Error(err) => { self.state.set_error(err.to_string()); Cmd::none() } // --- Help / UI --- Msg::ShowHelp => { self.state.show_help = !self.state.show_help; Cmd::none() } Msg::BlurTextInput => { self.state.blur_text_focus(); self.input_mode = InputMode::Normal; Cmd::none() } // --- Terminal --- Msg::Resize { width, height } => { self.state.terminal_size = (width, height); Cmd::none() } Msg::Tick => Cmd::none(), // --- Loaded results (stale guard) --- Msg::IssueListLoaded { generation, page } => { if self .supervisor .is_current(&TaskKey::LoadScreen(Screen::IssueList), generation) { self.state.issue_list.apply_page(page); self.state.set_loading(Screen::IssueList, LoadState::Idle); self.supervisor .complete(&TaskKey::LoadScreen(Screen::IssueList), generation); } Cmd::none() } Msg::MrListLoaded { generation, page } => { if self .supervisor .is_current(&TaskKey::LoadScreen(Screen::MrList), generation) { self.state.mr_list.apply_page(page); self.state.set_loading(Screen::MrList, LoadState::Idle); self.supervisor .complete(&TaskKey::LoadScreen(Screen::MrList), generation); } Cmd::none() } Msg::DashboardLoaded { generation, data } => { if self .supervisor .is_current(&TaskKey::LoadScreen(Screen::Dashboard), generation) { self.state.dashboard.update(*data); self.state.set_loading(Screen::Dashboard, LoadState::Idle); self.supervisor .complete(&TaskKey::LoadScreen(Screen::Dashboard), generation); } Cmd::none() } // All other message variants: no-op for now. // Future phases will fill these in as screens are implemented. _ => Cmd::none(), } } } impl Model for LoreApp { type Message = Msg; fn init(&mut self) -> Cmd { // Install crash context panic hook. crate::crash_context::CrashContext::install_panic_hook(&self.crash_context); crate::crash_context::CrashContext::prune_crash_files(); // Navigate to dashboard (will trigger data load in future phase). Cmd::none() } fn update(&mut self, msg: Self::Message) -> Cmd { // Route raw key events through the 5-stage pipeline. if let Msg::RawEvent(Event::Key(key)) = msg { return self.interpret_key(key); } // Everything else goes through message dispatch. self.handle_msg(msg) } fn view(&self, frame: &mut Frame) { crate::view::render_screen(frame, self); } }