From 5abd9f1e232209e625f77ed1f95b1bdbf63a0747 Mon Sep 17 00:00:00 2001 From: lorenzoberts Date: Sun, 31 May 2026 13:59:30 -0300 Subject: [PATCH 1/9] refactor(input): add semantic input protocol This commit introduces the initial input protocol types for the Phase 8 refactor. It defines raw terminal events, semantic input events, input context, key binding timing, and a mapper that translates terminal input into application intent while preserving current screen-specific bindings. It also adds mapper coverage for popup handling, edit mode, function keys, release filtering, resize and tick events, details modifiers, and the gg chord. This commit is part of the architecture's refactoring phase 8. Signed-off-by: lorenzoberts --- src/input/bindings.rs | 15 ++ src/input/context.rs | 31 ++++ src/input/event.rs | 96 ++++++++++ src/input/mapper.rs | 395 ++++++++++++++++++++++++++++++++++++++++++ src/input/mod.rs | 6 + src/main.rs | 1 + 6 files changed, 544 insertions(+) create mode 100644 src/input/bindings.rs create mode 100644 src/input/context.rs create mode 100644 src/input/event.rs create mode 100644 src/input/mapper.rs create mode 100644 src/input/mod.rs diff --git a/src/input/bindings.rs b/src/input/bindings.rs new file mode 100644 index 0000000..e19fef1 --- /dev/null +++ b/src/input/bindings.rs @@ -0,0 +1,15 @@ +use std::time::Duration; + +/// Fixed input timing values for the Phase 8 protocol. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct KeyBindings { + pub chord_timeout: Duration, +} + +impl Default for KeyBindings { + fn default() -> Self { + Self { + chord_timeout: Duration::from_millis(500), + } + } +} diff --git a/src/input/context.rs b/src/input/context.rs new file mode 100644 index 0000000..09c4a6b --- /dev/null +++ b/src/input/context.rs @@ -0,0 +1,31 @@ +use crate::app::screens::CurrentScreen; + +/// Snapshot of application state needed to map raw terminal input. +#[derive(Debug, Clone, PartialEq)] +pub struct InputContext { + pub current_screen: CurrentScreen, + pub popup_open: bool, + pub edit_config_editing: bool, + pub preview_fullscreen: bool, +} + +impl InputContext { + pub fn new(current_screen: CurrentScreen) -> Self { + Self { + current_screen, + popup_open: false, + edit_config_editing: false, + preview_fullscreen: false, + } + } + + pub fn with_popup_open(mut self, popup_open: bool) -> Self { + self.popup_open = popup_open; + self + } + + pub fn with_edit_config_editing(mut self, edit_config_editing: bool) -> Self { + self.edit_config_editing = edit_config_editing; + self + } +} diff --git a/src/input/event.rs b/src/input/event.rs new file mode 100644 index 0000000..45d2430 --- /dev/null +++ b/src/input/event.rs @@ -0,0 +1,96 @@ +use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; + +/// Raw terminal event after conversion from the terminal backend. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TerminalEvent { + Key(KeyInput), + Resize { width: u16, height: u16 }, + Tick, +} + +/// Key data kept close to crossterm while avoiding `KeyEvent` outside input. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct KeyInput { + pub code: KeyCode, + pub modifiers: KeyModifiers, + pub kind: KeyEventKind, +} + +impl KeyInput { + pub fn new(code: KeyCode, modifiers: KeyModifiers, kind: KeyEventKind) -> Self { + Self { + code, + modifiers, + kind, + } + } + + pub fn press(code: KeyCode) -> Self { + Self::new(code, KeyModifiers::NONE, KeyEventKind::Press) + } + + pub fn modified_press(code: KeyCode, modifiers: KeyModifiers) -> Self { + Self::new(code, modifiers, KeyEventKind::Press) + } +} + +impl From for KeyInput { + fn from(key: KeyEvent) -> Self { + Self { + code: key.code, + modifiers: key.modifiers, + kind: key.kind, + } + } +} + +/// Semantic input command consumed by application code. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum InputEvent { + Quit, + Back, + ClosePopup, + OpenHelp, + TextInput(char), + Backspace, + NavigateUp, + NavigateDown, + NavigateLeft, + NavigateRight, + RefreshMailingLists, + OpenBookmarkedPatchsets, + OpenEditConfig, + OpenLatestPatchsets, + OpenPatchsetDetails, + PreviousPage, + NextPage, + EditConfigField, + StageConfigEdit, + CancelConfigEdit, + SaveConfig, + ToggleBookmark, + ToggleReplyWithReviewedBy, + ToggleReplyWithReviewedByAll, + ToggleApply, + ConsolidatePatchsetActions, + PreviewNext, + PreviewPrevious, + PreviewScrollUp(ScrollAmount), + PreviewScrollDown(ScrollAmount), + PreviewPanLeft, + PreviewPanRight, + PreviewGoToBeginningOfLine, + PreviewGoToFirstLine, + PreviewGoToLastLine, + TogglePreviewFullscreen, + ShowReviewTrailers, + Resize { width: u16, height: u16 }, + Tick, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ScrollAmount { + Line, + HalfPage, + Page, +} diff --git a/src/input/mapper.rs b/src/input/mapper.rs new file mode 100644 index 0000000..1bba16d --- /dev/null +++ b/src/input/mapper.rs @@ -0,0 +1,395 @@ +use std::time::Instant; + +use ratatui::crossterm::event::{KeyCode, KeyEventKind, KeyModifiers}; + +use crate::{ + app::screens::CurrentScreen, + input::{ + bindings::KeyBindings, + context::InputContext, + event::{InputEvent, KeyInput, ScrollAmount, TerminalEvent}, + }, +}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PendingInput { + DetailsGoToFirstLine { started_at: Instant }, +} + +/// Maps terminal events into semantic input events. +#[derive(Debug, Clone)] +pub struct InputMapper { + bindings: KeyBindings, + pending: Option, +} + +impl InputMapper { + pub fn new(bindings: KeyBindings) -> Self { + Self { + bindings, + pending: None, + } + } + + pub fn map_terminal_event( + &mut self, + event: TerminalEvent, + context: &InputContext, + ) -> Option { + self.clear_expired_pending_input(); + + match event { + TerminalEvent::Key(key) => self.map_key_input(key, context), + TerminalEvent::Resize { width, height } => Some(InputEvent::Resize { width, height }), + TerminalEvent::Tick => Some(InputEvent::Tick), + } + } + + fn map_key_input(&mut self, key: KeyInput, context: &InputContext) -> Option { + if key.kind == KeyEventKind::Release { + return None; + } + + if context.popup_open { + return self.map_popup_key(&key); + } + + match &context.current_screen { + CurrentScreen::MailingListSelection => self.map_mailing_list_key(&key), + CurrentScreen::BookmarkedPatchsets => self.map_bookmarked_key(&key), + CurrentScreen::LatestPatchsets => self.map_latest_key(&key), + CurrentScreen::PatchsetDetails => self.map_details_key(&key), + CurrentScreen::EditConfig => self.map_edit_config_key(&key, context), + } + } + + fn map_popup_key(&mut self, key: &KeyInput) -> Option { + match key.code { + KeyCode::Esc | KeyCode::Char('q') => Some(InputEvent::ClosePopup), + KeyCode::Char('j') | KeyCode::Down => Some(InputEvent::NavigateDown), + KeyCode::Char('k') | KeyCode::Up => Some(InputEvent::NavigateUp), + KeyCode::Char('h') | KeyCode::Left => Some(InputEvent::NavigateLeft), + KeyCode::Char('l') | KeyCode::Right => Some(InputEvent::NavigateRight), + _ => None, + } + } + + fn map_mailing_list_key(&mut self, key: &KeyInput) -> Option { + match key.code { + KeyCode::Char('?') => Some(InputEvent::OpenHelp), + KeyCode::Enter => Some(InputEvent::OpenLatestPatchsets), + KeyCode::F(5) => Some(InputEvent::RefreshMailingLists), + KeyCode::F(2) => Some(InputEvent::OpenEditConfig), + KeyCode::F(1) => Some(InputEvent::OpenBookmarkedPatchsets), + KeyCode::Backspace => Some(InputEvent::Backspace), + KeyCode::Esc => Some(InputEvent::Quit), + KeyCode::Char(ch) => Some(InputEvent::TextInput(ch)), + KeyCode::Down => Some(InputEvent::NavigateDown), + KeyCode::Up => Some(InputEvent::NavigateUp), + _ => None, + } + } + + fn map_bookmarked_key(&mut self, key: &KeyInput) -> Option { + match key.code { + KeyCode::Char('?') => Some(InputEvent::OpenHelp), + KeyCode::Esc | KeyCode::Char('q') => Some(InputEvent::Back), + KeyCode::Char('j') | KeyCode::Down => Some(InputEvent::NavigateDown), + KeyCode::Char('k') | KeyCode::Up => Some(InputEvent::NavigateUp), + KeyCode::Enter => Some(InputEvent::OpenPatchsetDetails), + _ => None, + } + } + + fn map_latest_key(&mut self, key: &KeyInput) -> Option { + match key.code { + KeyCode::Char('?') => Some(InputEvent::OpenHelp), + KeyCode::Esc | KeyCode::Char('q') => Some(InputEvent::Back), + KeyCode::Char('j') | KeyCode::Down => Some(InputEvent::NavigateDown), + KeyCode::Char('k') | KeyCode::Up => Some(InputEvent::NavigateUp), + KeyCode::Char('l') | KeyCode::Right => Some(InputEvent::NextPage), + KeyCode::Char('h') | KeyCode::Left => Some(InputEvent::PreviousPage), + KeyCode::Enter => Some(InputEvent::OpenPatchsetDetails), + _ => None, + } + } + + fn map_edit_config_key( + &mut self, + key: &KeyInput, + context: &InputContext, + ) -> Option { + if context.edit_config_editing { + return match key.code { + KeyCode::Esc => Some(InputEvent::CancelConfigEdit), + KeyCode::Backspace => Some(InputEvent::Backspace), + KeyCode::Char(ch) => Some(InputEvent::TextInput(ch)), + KeyCode::Enter => Some(InputEvent::StageConfigEdit), + _ => None, + }; + } + + match key.code { + KeyCode::Char('?') => Some(InputEvent::OpenHelp), + KeyCode::Esc | KeyCode::Char('q') => Some(InputEvent::SaveConfig), + KeyCode::Enter => Some(InputEvent::EditConfigField), + KeyCode::Char('j') | KeyCode::Down => Some(InputEvent::NavigateDown), + KeyCode::Char('k') | KeyCode::Up => Some(InputEvent::NavigateUp), + _ => None, + } + } + + fn map_details_key(&mut self, key: &KeyInput) -> Option { + if key.modifiers.contains(KeyModifiers::SHIFT) { + return match key.code { + KeyCode::Char('G') => Some(InputEvent::PreviewGoToLastLine), + KeyCode::Char('R') => Some(InputEvent::ToggleReplyWithReviewedByAll), + _ => None, + }; + } + + if key.modifiers.contains(KeyModifiers::CONTROL) { + return match key.code { + KeyCode::Char('b') => Some(InputEvent::PreviewScrollUp(ScrollAmount::Page)), + KeyCode::Char('f') => Some(InputEvent::PreviewScrollDown(ScrollAmount::Page)), + KeyCode::Char('u') => Some(InputEvent::PreviewScrollUp(ScrollAmount::HalfPage)), + KeyCode::Char('d') => Some(InputEvent::PreviewScrollDown(ScrollAmount::HalfPage)), + KeyCode::Char('t') => Some(InputEvent::ShowReviewTrailers), + _ => None, + }; + } + + match key.code { + KeyCode::Char('?') => Some(InputEvent::OpenHelp), + KeyCode::Esc | KeyCode::Char('q') => Some(InputEvent::Back), + KeyCode::Char('a') => Some(InputEvent::ToggleApply), + KeyCode::Char('j') | KeyCode::Down => { + Some(InputEvent::PreviewScrollDown(ScrollAmount::Line)) + } + KeyCode::Char('k') | KeyCode::Up => { + Some(InputEvent::PreviewScrollUp(ScrollAmount::Line)) + } + KeyCode::Char('h') | KeyCode::Left => Some(InputEvent::PreviewPanLeft), + KeyCode::Char('l') | KeyCode::Right => Some(InputEvent::PreviewPanRight), + KeyCode::Char('0') => Some(InputEvent::PreviewGoToBeginningOfLine), + KeyCode::Char('g') => self.map_details_go_to_first_line_chord(), + KeyCode::Char('f') => Some(InputEvent::TogglePreviewFullscreen), + KeyCode::Char('n') => Some(InputEvent::PreviewNext), + KeyCode::Char('p') => Some(InputEvent::PreviewPrevious), + KeyCode::Char('b') => Some(InputEvent::ToggleBookmark), + KeyCode::Char('r') => Some(InputEvent::ToggleReplyWithReviewedBy), + KeyCode::Enter => Some(InputEvent::ConsolidatePatchsetActions), + _ => None, + } + } + + fn map_details_go_to_first_line_chord(&mut self) -> Option { + match self.pending { + Some(PendingInput::DetailsGoToFirstLine { started_at }) + if started_at.elapsed() <= self.bindings.chord_timeout => + { + self.pending = None; + Some(InputEvent::PreviewGoToFirstLine) + } + _ => { + self.pending = Some(PendingInput::DetailsGoToFirstLine { + started_at: Instant::now(), + }); + None + } + } + } + + fn clear_expired_pending_input(&mut self) { + let Some(PendingInput::DetailsGoToFirstLine { started_at }) = self.pending else { + return; + }; + + if started_at.elapsed() > self.bindings.chord_timeout { + self.pending = None; + } + } +} + +impl Default for InputMapper { + fn default() -> Self { + Self::new(KeyBindings::default()) + } +} + +#[cfg(test)] +mod tests { + use ratatui::crossterm::event::{KeyCode, KeyEventKind, KeyModifiers}; + + use crate::{ + app::screens::CurrentScreen, + input::{ + context::InputContext, + event::{InputEvent, KeyInput, ScrollAmount, TerminalEvent}, + mapper::InputMapper, + }, + }; + + fn context(current_screen: CurrentScreen) -> InputContext { + InputContext::new(current_screen) + } + + fn key(code: KeyCode) -> TerminalEvent { + TerminalEvent::Key(KeyInput::press(code)) + } + + fn modified_key(code: KeyCode, modifiers: KeyModifiers) -> TerminalEvent { + TerminalEvent::Key(KeyInput::modified_press(code, modifiers)) + } + + #[test] + fn maps_escape_to_close_popup_when_popup_is_open() { + let mut mapper = InputMapper::default(); + let context = context(CurrentScreen::PatchsetDetails).with_popup_open(true); + + let event = mapper.map_terminal_event(key(KeyCode::Esc), &context); + + assert_eq!(event, Some(InputEvent::ClosePopup)); + } + + #[test] + fn maps_escape_to_back_on_details_without_popup() { + let mut mapper = InputMapper::default(); + let context = context(CurrentScreen::PatchsetDetails); + + let event = mapper.map_terminal_event(key(KeyCode::Esc), &context); + + assert_eq!(event, Some(InputEvent::Back)); + } + + #[test] + fn maps_mailing_list_char_to_text_input() { + let mut mapper = InputMapper::default(); + let context = context(CurrentScreen::MailingListSelection); + + let event = mapper.map_terminal_event(key(KeyCode::Char('n')), &context); + + assert_eq!(event, Some(InputEvent::TextInput('n'))); + } + + #[test] + fn maps_mailing_list_function_keys_to_semantic_events() { + let mut mapper = InputMapper::default(); + let context = context(CurrentScreen::MailingListSelection); + + assert_eq!( + mapper.map_terminal_event(key(KeyCode::F(1)), &context), + Some(InputEvent::OpenBookmarkedPatchsets) + ); + assert_eq!( + mapper.map_terminal_event(key(KeyCode::F(2)), &context), + Some(InputEvent::OpenEditConfig) + ); + assert_eq!( + mapper.map_terminal_event(key(KeyCode::F(5)), &context), + Some(InputEvent::RefreshMailingLists) + ); + } + + #[test] + fn maps_enter_in_edit_mode_to_stage_config_edit() { + let mut mapper = InputMapper::default(); + let context = context(CurrentScreen::EditConfig).with_edit_config_editing(true); + + let event = mapper.map_terminal_event(key(KeyCode::Enter), &context); + + assert_eq!(event, Some(InputEvent::StageConfigEdit)); + } + + #[test] + fn maps_enter_outside_edit_mode_to_edit_config_field() { + let mut mapper = InputMapper::default(); + let context = context(CurrentScreen::EditConfig); + + let event = mapper.map_terminal_event(key(KeyCode::Enter), &context); + + assert_eq!(event, Some(InputEvent::EditConfigField)); + } + + #[test] + fn ignores_key_release_events() { + let mut mapper = InputMapper::default(); + let context = context(CurrentScreen::MailingListSelection); + let event = TerminalEvent::Key(KeyInput::new( + KeyCode::Esc, + KeyModifiers::NONE, + KeyEventKind::Release, + )); + + let mapped = mapper.map_terminal_event(event, &context); + + assert_eq!(mapped, None); + } + + #[test] + fn maps_details_modifier_keys() { + let mut mapper = InputMapper::default(); + let context = context(CurrentScreen::PatchsetDetails); + + assert_eq!( + mapper.map_terminal_event( + modified_key(KeyCode::Char('G'), KeyModifiers::SHIFT), + &context + ), + Some(InputEvent::PreviewGoToLastLine) + ); + assert_eq!( + mapper.map_terminal_event( + modified_key(KeyCode::Char('t'), KeyModifiers::CONTROL), + &context + ), + Some(InputEvent::ShowReviewTrailers) + ); + assert_eq!( + mapper.map_terminal_event( + modified_key(KeyCode::Char('d'), KeyModifiers::CONTROL), + &context + ), + Some(InputEvent::PreviewScrollDown(ScrollAmount::HalfPage)) + ); + } + + #[test] + fn maps_details_gg_sequence_to_go_to_first_line() { + let mut mapper = InputMapper::default(); + let context = context(CurrentScreen::PatchsetDetails); + + assert_eq!( + mapper.map_terminal_event(key(KeyCode::Char('g')), &context), + None + ); + assert_eq!( + mapper.map_terminal_event(key(KeyCode::Char('g')), &context), + Some(InputEvent::PreviewGoToFirstLine) + ); + } + + #[test] + fn maps_resize_and_tick_events() { + let mut mapper = InputMapper::default(); + let context = context(CurrentScreen::MailingListSelection); + + assert_eq!( + mapper.map_terminal_event( + TerminalEvent::Resize { + width: 120, + height: 40, + }, + &context + ), + Some(InputEvent::Resize { + width: 120, + height: 40, + }) + ); + assert_eq!( + mapper.map_terminal_event(TerminalEvent::Tick, &context), + Some(InputEvent::Tick) + ); + } +} diff --git a/src/input/mod.rs b/src/input/mod.rs new file mode 100644 index 0000000..1a80833 --- /dev/null +++ b/src/input/mod.rs @@ -0,0 +1,6 @@ +//! Input protocol types and mapping for terminal-driven application intent. + +pub mod bindings; +pub mod context; +pub mod event; +pub mod mapper; diff --git a/src/main.rs b/src/main.rs index 44299bc..beb615b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,6 +3,7 @@ mod cli; mod config; mod handler; mod infrastructure; +mod input; mod lore; mod macros; mod render; From e3958afdb341442f5fa5f040de878279754cb48b Mon Sep 17 00:00:00 2001 From: lorenzoberts Date: Sun, 31 May 2026 14:01:08 -0300 Subject: [PATCH 2/9] refactor(input): extract terminal event source This commit moves crossterm event reading behind the input protocol boundary. The new terminal event source converts raw crossterm events into TerminalEvent values, filters key release events, and preserves key state so the existing handler flow can continue to receive KeyEvent during the transition. It also adds coverage for key conversion, release filtering, resize events, and ignored terminal events. This commit is part of the architecture's refactoring phase 8. Signed-off-by: lorenzoberts --- src/handler/mod.rs | 12 ++++-- src/input/event.rs | 14 ++++++- src/input/mod.rs | 1 + src/input/terminal_source.rs | 79 ++++++++++++++++++++++++++++++++++++ 4 files changed, 101 insertions(+), 5 deletions(-) create mode 100644 src/input/terminal_source.rs diff --git a/src/handler/mod.rs b/src/handler/mod.rs index 3d00151..ed84b0c 100644 --- a/src/handler/mod.rs +++ b/src/handler/mod.rs @@ -17,6 +17,10 @@ use std::{ use crate::{ app::{screens::CurrentScreen, App}, + input::{ + event::TerminalEvent, + terminal_source::{CrosstermEventSource, TerminalEventSource}, + }, loading_screen, ui::draw_ui, }; @@ -122,6 +126,8 @@ pub async fn run_app(mut terminal: Terminal, mut app: App) -> color_eyre:: where B: Backend + Send + 'static, { + let mut event_source = CrosstermEventSource; + loop { terminal = logic_handling(terminal, &mut app).await?; @@ -132,10 +138,8 @@ where // need to refresh the UI independently of any event as doing so gravely // hinders the performance to below acceptable. // if event::poll(Duration::from_millis(16))? { - if let Event::Key(key) = ratatui::crossterm::event::read()? { - if key.kind == KeyEventKind::Release { - continue; - } + if let Some(TerminalEvent::Key(key)) = event_source.read_event()? { + let key = key.to_key_event(); match key_handling(terminal, &mut app, key).await? { ControlFlow::Continue(t) => terminal = t, ControlFlow::Break(_) => return Ok(()), diff --git a/src/input/event.rs b/src/input/event.rs index 45d2430..943b5a0 100644 --- a/src/input/event.rs +++ b/src/input/event.rs @@ -1,4 +1,4 @@ -use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; +use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers}; /// Raw terminal event after conversion from the terminal backend. #[derive(Debug, Clone, PartialEq, Eq)] @@ -14,6 +14,7 @@ pub struct KeyInput { pub code: KeyCode, pub modifiers: KeyModifiers, pub kind: KeyEventKind, + pub state: KeyEventState, } impl KeyInput { @@ -22,6 +23,7 @@ impl KeyInput { code, modifiers, kind, + state: KeyEventState::NONE, } } @@ -32,6 +34,15 @@ impl KeyInput { pub fn modified_press(code: KeyCode, modifiers: KeyModifiers) -> Self { Self::new(code, modifiers, KeyEventKind::Press) } + + pub fn to_key_event(&self) -> KeyEvent { + KeyEvent { + code: self.code, + modifiers: self.modifiers, + kind: self.kind, + state: self.state, + } + } } impl From for KeyInput { @@ -40,6 +51,7 @@ impl From for KeyInput { code: key.code, modifiers: key.modifiers, kind: key.kind, + state: key.state, } } } diff --git a/src/input/mod.rs b/src/input/mod.rs index 1a80833..ea8523a 100644 --- a/src/input/mod.rs +++ b/src/input/mod.rs @@ -4,3 +4,4 @@ pub mod bindings; pub mod context; pub mod event; pub mod mapper; +pub mod terminal_source; diff --git a/src/input/terminal_source.rs b/src/input/terminal_source.rs new file mode 100644 index 0000000..048f9a3 --- /dev/null +++ b/src/input/terminal_source.rs @@ -0,0 +1,79 @@ +use ratatui::crossterm::event::{self, Event, KeyEventKind}; + +use crate::input::event::{KeyInput, TerminalEvent}; + +pub trait TerminalEventSource { + fn read_event(&mut self) -> color_eyre::Result>; +} + +pub struct CrosstermEventSource; + +impl TerminalEventSource for CrosstermEventSource { + fn read_event(&mut self) -> color_eyre::Result> { + Ok(terminal_event_from_crossterm_event(event::read()?)) + } +} + +pub fn terminal_event_from_crossterm_event(event: Event) -> Option { + match event { + Event::Key(key) if key.kind == KeyEventKind::Release => None, + Event::Key(key) => Some(TerminalEvent::Key(KeyInput::from(key))), + Event::Resize(width, height) => Some(TerminalEvent::Resize { width, height }), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use ratatui::crossterm::event::{ + Event, KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers, + }; + + use crate::input::{ + event::{KeyInput, TerminalEvent}, + terminal_source::terminal_event_from_crossterm_event, + }; + + #[test] + fn converts_key_press_to_terminal_key_event() { + let key = KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE); + + let event = terminal_event_from_crossterm_event(Event::Key(key)); + + assert_eq!(event, Some(TerminalEvent::Key(KeyInput::from(key)))); + } + + #[test] + fn ignores_key_release_events() { + let key = KeyEvent { + code: KeyCode::Char('j'), + modifiers: KeyModifiers::NONE, + kind: KeyEventKind::Release, + state: KeyEventState::NONE, + }; + + let event = terminal_event_from_crossterm_event(Event::Key(key)); + + assert_eq!(event, None); + } + + #[test] + fn converts_resize_events() { + let event = terminal_event_from_crossterm_event(Event::Resize(120, 40)); + + assert_eq!( + event, + Some(TerminalEvent::Resize { + width: 120, + height: 40 + }) + ); + } + + #[test] + fn ignores_non_key_non_resize_events() { + let event = terminal_event_from_crossterm_event(Event::FocusGained); + + assert_eq!(event, None); + } +} From 7024ea54a979543a560840558bf88bbdd8ca852b Mon Sep 17 00:00:00 2001 From: lorenzoberts Date: Sun, 31 May 2026 14:03:24 -0300 Subject: [PATCH 3/9] refactor(app): separate system updates from input handling This commit moves the lazy screen update logic out of the handler loop and into App::process_system_updates. The main loop still preserves the existing input dispatch behavior, but non-input updates such as initial mailing list loading, latest patchset loading, and empty bookmark fallback now live behind an App boundary. It also adds App::input_context as the state projection that will feed the input mapper once the remaining handlers consume semantic InputEvent values. This commit is part of the architecture's refactoring phase 8. Signed-off-by: lorenzoberts --- src/app/input.rs | 26 +++++++++++++++++++ src/app/mod.rs | 2 ++ src/app/updates.rs | 64 ++++++++++++++++++++++++++++++++++++++++++++++ src/handler/mod.rs | 58 +---------------------------------------- 4 files changed, 93 insertions(+), 57 deletions(-) create mode 100644 src/app/input.rs create mode 100644 src/app/updates.rs diff --git a/src/app/input.rs b/src/app/input.rs new file mode 100644 index 0000000..f0e9a64 --- /dev/null +++ b/src/app/input.rs @@ -0,0 +1,26 @@ +use crate::{app::App, input::context::InputContext}; + +impl App { + /// Projects App state into the context needed by the input mapper. + #[allow(dead_code)] // Wired into run_app once handlers consume InputEvent. + pub fn input_context(&self) -> InputContext { + InputContext { + current_screen: self.state.navigation.current_screen.clone(), + popup_open: self.state.popup.is_some(), + edit_config_editing: self + .state + .config_state + .edit_config + .as_ref() + .map(|edit_config| edit_config.is_editing()) + .unwrap_or(false), + preview_fullscreen: self + .state + .lore + .details + .as_ref() + .map(|details| details.preview_fullscreen) + .unwrap_or(false), + } + } +} diff --git a/src/app/mod.rs b/src/app/mod.rs index ea4e5e8..1e30fc7 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1,7 +1,9 @@ pub mod commands; pub mod errors; +pub mod input; pub mod screens; pub mod state; +pub mod updates; pub mod view_model; use color_eyre::eyre::{bail, eyre}; diff --git a/src/app/updates.rs b/src/app/updates.rs new file mode 100644 index 0000000..018eab0 --- /dev/null +++ b/src/app/updates.rs @@ -0,0 +1,64 @@ +use ratatui::{prelude::Backend, Terminal}; + +use crate::{ + app::{screens::CurrentScreen, App}, + loading_screen, +}; + +impl App { + /// Processes app-driven updates that are not direct user input. + pub async fn process_system_updates( + &mut self, + mut terminal: Terminal, + ) -> color_eyre::Result> + where + B: Backend + Send + 'static, + { + match self.state.navigation.current_screen { + CurrentScreen::MailingListSelection => { + if self + .state + .lore + .mailing_list_selection + .mailing_lists + .is_empty() + { + terminal = loading_screen! { + terminal, "Fetching mailing lists" => { + self.refresh_mailing_lists().await + } + }; + } + } + CurrentScreen::LatestPatchsets => { + let patchsets_state = self.state.lore.latest_patchsets.as_ref().unwrap(); + + if patchsets_state.processed_patchsets_count() == 0 { + let target_list = patchsets_state.target_list().to_string(); + terminal = loading_screen! { + terminal, + format!("Fetching patchsets from {}", target_list) => { + self.fetch_latest_current_page().await + } + }; + + self.state.lore.mailing_list_selection.clear_target_list(); + } + } + CurrentScreen::BookmarkedPatchsets => { + if self + .state + .user_state + .bookmarked_patchsets + .bookmarked_patchsets + .is_empty() + { + self.set_current_screen(CurrentScreen::MailingListSelection); + } + } + _ => {} + } + + Ok(terminal) + } +} diff --git a/src/handler/mod.rs b/src/handler/mod.rs index ed84b0c..3c86edd 100644 --- a/src/handler/mod.rs +++ b/src/handler/mod.rs @@ -21,7 +21,6 @@ use crate::{ event::TerminalEvent, terminal_source::{CrosstermEventSource, TerminalEventSource}, }, - loading_screen, ui::draw_ui, }; @@ -67,61 +66,6 @@ where Ok(ControlFlow::Continue(terminal)) } -async fn logic_handling( - mut terminal: Terminal, - app: &mut App, -) -> color_eyre::Result> -where - B: Backend + Send + 'static, -{ - match app.state.navigation.current_screen { - CurrentScreen::MailingListSelection => { - if app - .state - .lore - .mailing_list_selection - .mailing_lists - .is_empty() - { - terminal = loading_screen! { - terminal, "Fetching mailing lists" => { - app.refresh_mailing_lists().await - } - }; - } - } - CurrentScreen::LatestPatchsets => { - let patchsets_state = app.state.lore.latest_patchsets.as_ref().unwrap(); - - if patchsets_state.processed_patchsets_count() == 0 { - let target_list = patchsets_state.target_list().to_string(); - terminal = loading_screen! { - terminal, - format!("Fetching patchsets from {}", target_list) => { - app.fetch_latest_current_page().await - } - }; - - app.state.lore.mailing_list_selection.clear_target_list(); - } - } - CurrentScreen::BookmarkedPatchsets => { - if app - .state - .user_state - .bookmarked_patchsets - .bookmarked_patchsets - .is_empty() - { - app.set_current_screen(CurrentScreen::MailingListSelection); - } - } - _ => {} - } - - Ok(terminal) -} - pub async fn run_app(mut terminal: Terminal, mut app: App) -> color_eyre::Result<()> where B: Backend + Send + 'static, @@ -129,7 +73,7 @@ where let mut event_source = CrosstermEventSource; loop { - terminal = logic_handling(terminal, &mut app).await?; + terminal = app.process_system_updates(terminal).await?; terminal.draw(|f| draw_ui(f, &app.to_view_model()))?; From 59802d7f1b58ae6d6a13a7fb73af8eaab0842a78 Mon Sep 17 00:00:00 2001 From: lorenzoberts Date: Sun, 31 May 2026 14:04:42 -0300 Subject: [PATCH 4/9] refactor(input): route popup handling through InputEvent This commit updates the popup input boundary to consume semantic InputEvent values instead of raw crossterm KeyEvent values. Help, info, and review trailer popups now handle navigation through the shared input protocol while preserving the existing popup scroll behavior. It also adds a temporary raw-key bridge in the handler loop so popup input can move to InputEvent before the screen handlers are migrated. This commit is part of the architecture's refactoring phase 8. Signed-off-by: lorenzoberts --- src/handler/mod.rs | 16 +++++++++++++--- src/ui/popup/help.rs | 15 ++++++++------- src/ui/popup/info_popup.rs | 14 +++++++------- src/ui/popup/mod.rs | 10 ++++++---- src/ui/popup/review_trailers.rs | 18 ++++++++++-------- 5 files changed, 44 insertions(+), 29 deletions(-) diff --git a/src/handler/mod.rs b/src/handler/mod.rs index 3c86edd..f821696 100644 --- a/src/handler/mod.rs +++ b/src/handler/mod.rs @@ -18,7 +18,7 @@ use std::{ use crate::{ app::{screens::CurrentScreen, App}, input::{ - event::TerminalEvent, + event::{InputEvent, TerminalEvent}, terminal_source::{CrosstermEventSource, TerminalEventSource}, }, ui::draw_ui, @@ -41,8 +41,8 @@ where if let Some(popup) = app.state.popup.as_mut() { if matches!(key.code, KeyCode::Esc | KeyCode::Char('q')) { app.state.popup = None; - } else { - popup.handle(key)?; + } else if let Some(input) = popup_input_from_key(key) { + popup.handle(input)?; } } else { match app.state.navigation.current_screen { @@ -66,6 +66,16 @@ where Ok(ControlFlow::Continue(terminal)) } +fn popup_input_from_key(key: KeyEvent) -> Option { + match key.code { + KeyCode::Char('j') | KeyCode::Down => Some(InputEvent::NavigateDown), + KeyCode::Char('k') | KeyCode::Up => Some(InputEvent::NavigateUp), + KeyCode::Char('h') | KeyCode::Left => Some(InputEvent::NavigateLeft), + KeyCode::Char('l') | KeyCode::Right => Some(InputEvent::NavigateRight), + _ => None, + } +} + pub async fn run_app(mut terminal: Terminal, mut app: App) -> color_eyre::Result<()> where B: Backend + Send + 'static, diff --git a/src/ui/popup/help.rs b/src/ui/popup/help.rs index 8e90109..b3ea975 100644 --- a/src/ui/popup/help.rs +++ b/src/ui/popup/help.rs @@ -1,5 +1,4 @@ use ratatui::{ - crossterm::event::KeyCode, layout::Alignment, style::{Style, Stylize}, text::Line, @@ -8,6 +7,8 @@ use ratatui::{ use std::fmt::Display; +use crate::input::event::InputEvent; + use super::PopUp; /// A popup that displays a help message @@ -157,24 +158,24 @@ impl PopUp for HelpPopUp { f.render_widget(text, chunk); } - fn handle(&mut self, key: ratatui::crossterm::event::KeyEvent) -> color_eyre::Result<()> { - match key.code { - KeyCode::Up | KeyCode::Char('k') => { + fn handle(&mut self, input: InputEvent) -> color_eyre::Result<()> { + match input { + InputEvent::NavigateUp => { if self.offset.0 > 0 { self.offset.0 -= 1; } } - KeyCode::Down | KeyCode::Char('j') => { + InputEvent::NavigateDown => { if self.offset.0 < self.max_offset.0 { self.offset.0 += 1; } } - KeyCode::Left | KeyCode::Char('h') => { + InputEvent::NavigateLeft => { if self.offset.1 > 0 { self.offset.1 -= 1; } } - KeyCode::Right | KeyCode::Char('l') => { + InputEvent::NavigateRight => { if self.offset.1 < self.max_offset.1 { self.offset.1 += 1; } diff --git a/src/ui/popup/info_popup.rs b/src/ui/popup/info_popup.rs index 1a7a865..17fc393 100644 --- a/src/ui/popup/info_popup.rs +++ b/src/ui/popup/info_popup.rs @@ -1,5 +1,4 @@ use ratatui::{ - crossterm::event::KeyCode, layout::{Alignment, Rect}, style::{Color, Modifier, Style}, text::Line, @@ -7,6 +6,7 @@ use ratatui::{ }; use super::PopUp; +use crate::input::event::InputEvent; #[derive(Debug)] pub struct InfoPopUp { @@ -73,24 +73,24 @@ impl PopUp for InfoPopUp { } /// Handles simple one-char width navigation. - fn handle(&mut self, key: ratatui::crossterm::event::KeyEvent) -> color_eyre::Result<()> { - match key.code { - KeyCode::Up | KeyCode::Char('k') => { + fn handle(&mut self, input: InputEvent) -> color_eyre::Result<()> { + match input { + InputEvent::NavigateUp => { if self.offset.0 > 0 { self.offset.0 -= 1; } } - KeyCode::Down | KeyCode::Char('j') => { + InputEvent::NavigateDown => { if self.offset.0 < self.max_offset.0 { self.offset.0 += 1; } } - KeyCode::Left | KeyCode::Char('h') => { + InputEvent::NavigateLeft => { if self.offset.1 > 0 { self.offset.1 -= 1; } } - KeyCode::Right | KeyCode::Char('l') => { + InputEvent::NavigateRight => { if self.offset.1 < self.max_offset.1 { self.offset.1 += 1; } diff --git a/src/ui/popup/mod.rs b/src/ui/popup/mod.rs index f249612..8901514 100644 --- a/src/ui/popup/mod.rs +++ b/src/ui/popup/mod.rs @@ -2,10 +2,12 @@ pub mod help; pub mod info_popup; pub mod review_trailers; -use ratatui::{crossterm::event::KeyEvent, layout::Rect, Frame}; +use ratatui::{layout::Rect, Frame}; use std::fmt::Debug; +use crate::input::event::InputEvent; + /// A trait that represents a popup that can be rendered on top of a screen pub trait PopUp: Debug { /// Returns the dimensions of the popup in percentage of the screen @@ -18,9 +20,9 @@ pub trait PopUp: Debug { /// This chunk is a centered rectangle with the dimensions returned by `dimensions` fn render(&self, f: &mut Frame, chunk: Rect); - /// Handles the key event for the popup + /// Handles semantic input for the popup. /// - /// Is important to notice that except for the 'ESC' key, all other keys are hijacked by the popup + /// Is important to notice that except for close events, all other keys are hijacked by the popup /// So the screens handlers won't be called - fn handle(&mut self, key: KeyEvent) -> color_eyre::Result<()>; + fn handle(&mut self, input: InputEvent) -> color_eyre::Result<()>; } diff --git a/src/ui/popup/review_trailers.rs b/src/ui/popup/review_trailers.rs index b579405..88466a3 100644 --- a/src/ui/popup/review_trailers.rs +++ b/src/ui/popup/review_trailers.rs @@ -1,5 +1,4 @@ use ratatui::{ - crossterm::event::KeyCode, layout::Alignment, style::{Color, Modifier, Style, Stylize}, text::Line, @@ -8,7 +7,10 @@ use ratatui::{ use std::collections::HashSet; -use crate::{app::screens::details_actions::PatchsetDetailsState, lore::domain::patch::Author}; +use crate::{ + app::screens::details_actions::PatchsetDetailsState, input::event::InputEvent, + lore::domain::patch::Author, +}; use super::PopUp; @@ -130,24 +132,24 @@ impl PopUp for ReviewTrailersPopUp { } /// Handles simple one-char width navigation. - fn handle(&mut self, key: ratatui::crossterm::event::KeyEvent) -> color_eyre::Result<()> { - match key.code { - KeyCode::Up | KeyCode::Char('k') => { + fn handle(&mut self, input: InputEvent) -> color_eyre::Result<()> { + match input { + InputEvent::NavigateUp => { if self.offset.0 > 0 { self.offset.0 -= 1; } } - KeyCode::Down | KeyCode::Char('j') => { + InputEvent::NavigateDown => { if self.offset.0 < self.max_offset.0 { self.offset.0 += 1; } } - KeyCode::Left | KeyCode::Char('h') => { + InputEvent::NavigateLeft => { if self.offset.1 > 0 { self.offset.1 -= 1; } } - KeyCode::Right | KeyCode::Char('l') => { + InputEvent::NavigateRight => { if self.offset.1 < self.max_offset.1 { self.offset.1 += 1; } From 4b7b76161da67db54ece6176c399b0418b6ffb6c Mon Sep 17 00:00:00 2001 From: lorenzoberts Date: Sun, 31 May 2026 14:06:35 -0300 Subject: [PATCH 5/9] refactor(input): migrate simple handlers to InputEvent This commit updates the mailing list, bookmarked patchsets, and edit config handlers to consume semantic InputEvent values instead of raw crossterm KeyEvent values. The existing handler loop now uses the input mapper for these screens while preserving the current behavior and leaving the more complex latest and details screens on the temporary raw-key path. This commit is part of the architecture's refactoring phase 8. Signed-off-by: lorenzoberts --- src/handler/bookmarked.rs | 21 +++++++++------------ src/handler/edit_config.rs | 27 +++++++++++++-------------- src/handler/mail_list.rs | 31 ++++++++++++++----------------- src/handler/mod.rs | 30 +++++++++++++++++++++++++----- 4 files changed, 61 insertions(+), 48 deletions(-) diff --git a/src/handler/bookmarked.rs b/src/handler/bookmarked.rs index b370e06..6bc87e6 100644 --- a/src/handler/bookmarked.rs +++ b/src/handler/bookmarked.rs @@ -1,47 +1,44 @@ -use ratatui::{ - crossterm::event::{KeyCode, KeyEvent}, - prelude::Backend, - Terminal, -}; +use ratatui::{prelude::Backend, Terminal}; use std::ops::ControlFlow; use crate::{ app::{screens::CurrentScreen, App, B4Result}, + input::event::InputEvent, loading_screen, ui::popup::{help::HelpPopUpBuilder, info_popup::InfoPopUp, PopUp}, }; pub async fn handle_bookmarked_patchsets( app: &mut App, - key: KeyEvent, + input: InputEvent, mut terminal: Terminal, ) -> color_eyre::Result>> where B: Backend + Send + 'static, { - match key.code { - KeyCode::Char('?') => { + match input { + InputEvent::OpenHelp => { let popup = generate_help_popup(); app.state.popup = Some(popup); } - KeyCode::Esc | KeyCode::Char('q') => { + InputEvent::Back => { app.state.user_state.bookmarked_patchsets.patchset_index = 0; app.set_current_screen(CurrentScreen::MailingListSelection); } - KeyCode::Char('j') | KeyCode::Down => { + InputEvent::NavigateDown => { app.state .user_state .bookmarked_patchsets .select_below_patchset(); } - KeyCode::Char('k') | KeyCode::Up => { + InputEvent::NavigateUp => { app.state .user_state .bookmarked_patchsets .select_above_patchset(); } - KeyCode::Enter => { + InputEvent::OpenPatchsetDetails => { terminal = loading_screen! { terminal, "Loading patchset" => { diff --git a/src/handler/edit_config.rs b/src/handler/edit_config.rs index ba3611b..7ede40f 100644 --- a/src/handler/edit_config.rs +++ b/src/handler/edit_config.rs @@ -1,48 +1,47 @@ -use ratatui::crossterm::event::{KeyCode, KeyEvent}; - use crate::{ app::{screens::CurrentScreen, App}, + input::event::InputEvent, ui::popup::{help::HelpPopUpBuilder, PopUp}, }; -pub fn handle_edit_config(app: &mut App, key: KeyEvent) -> color_eyre::Result<()> { +pub fn handle_edit_config(app: &mut App, input: InputEvent) -> color_eyre::Result<()> { if let Some(edit_config_state) = app.state.config_state.edit_config.as_mut() { match edit_config_state.is_editing() { - true => match key.code { - KeyCode::Esc => { + true => match input { + InputEvent::CancelConfigEdit => { edit_config_state.clear_edit(); edit_config_state.toggle_editing(); } - KeyCode::Backspace => { + InputEvent::Backspace => { edit_config_state.backspace_edit(); } - KeyCode::Char(ch) => { + InputEvent::TextInput(ch) => { edit_config_state.append_edit(ch); } - KeyCode::Enter => { + InputEvent::StageConfigEdit => { edit_config_state.stage_edit(); edit_config_state.clear_edit(); edit_config_state.toggle_editing(); } _ => {} }, - false => match key.code { - KeyCode::Char('?') => { + false => match input { + InputEvent::OpenHelp => { let popup = generate_help_popup(); app.state.popup = Some(popup); } - KeyCode::Esc | KeyCode::Char('q') => { + InputEvent::SaveConfig => { app.consolidate_edit_config()?; app.reset_edit_config(); app.set_current_screen(CurrentScreen::MailingListSelection); } - KeyCode::Enter => { + InputEvent::EditConfigField => { edit_config_state.toggle_editing(); } - KeyCode::Char('j') | KeyCode::Down => { + InputEvent::NavigateDown => { edit_config_state.highlight_next(); } - KeyCode::Char('k') | KeyCode::Up => { + InputEvent::NavigateUp => { edit_config_state.highlight_prev(); } _ => {} diff --git a/src/handler/mail_list.rs b/src/handler/mail_list.rs index 97fd188..1dc3174 100644 --- a/src/handler/mail_list.rs +++ b/src/handler/mail_list.rs @@ -1,31 +1,28 @@ -use ratatui::{ - crossterm::event::{KeyCode, KeyEvent}, - prelude::Backend, - Terminal, -}; +use ratatui::{prelude::Backend, Terminal}; use std::ops::ControlFlow; use crate::{ app::{screens::CurrentScreen, App}, + input::event::InputEvent, loading_screen, ui::popup::{help::HelpPopUpBuilder, PopUp}, }; pub async fn handle_mailing_list_selection( app: &mut App, - key: KeyEvent, + input: InputEvent, mut terminal: Terminal, ) -> color_eyre::Result>> where B: Backend + Send + 'static, { - match key.code { - KeyCode::Char('?') => { + match input { + InputEvent::OpenHelp => { let popup = generate_help_popup(); app.state.popup = Some(popup); } - KeyCode::Enter => { + InputEvent::OpenLatestPatchsets => { if app .state .lore @@ -55,7 +52,7 @@ where }; } } - KeyCode::F(5) => { + InputEvent::RefreshMailingLists => { terminal = loading_screen! { terminal, "Refreshing lists" => { @@ -63,11 +60,11 @@ where } }; } - KeyCode::F(2) => { + InputEvent::OpenEditConfig => { app.init_edit_config(); app.set_current_screen(CurrentScreen::EditConfig); } - KeyCode::F(1) => { + InputEvent::OpenBookmarkedPatchsets => { if !app .state .user_state @@ -79,25 +76,25 @@ where app.set_current_screen(CurrentScreen::BookmarkedPatchsets); } } - KeyCode::Backspace => { + InputEvent::Backspace => { app.state .lore .mailing_list_selection .remove_last_target_list_char(); } - KeyCode::Esc => { + InputEvent::Quit => { return Ok(ControlFlow::Break(())); } - KeyCode::Char(ch) => { + InputEvent::TextInput(ch) => { app.state .lore .mailing_list_selection .push_char_to_target_list(ch); } - KeyCode::Down => { + InputEvent::NavigateDown => { app.state.lore.mailing_list_selection.highlight_below_list(); } - KeyCode::Up => { + InputEvent::NavigateUp => { app.state.lore.mailing_list_selection.highlight_above_list(); } _ => {} diff --git a/src/handler/mod.rs b/src/handler/mod.rs index f821696..1a37852 100644 --- a/src/handler/mod.rs +++ b/src/handler/mod.rs @@ -18,7 +18,8 @@ use std::{ use crate::{ app::{screens::CurrentScreen, App}, input::{ - event::{InputEvent, TerminalEvent}, + event::{InputEvent, KeyInput, TerminalEvent}, + mapper::InputMapper, terminal_source::{CrosstermEventSource, TerminalEventSource}, }, ui::draw_ui, @@ -34,6 +35,7 @@ async fn key_handling( mut terminal: Terminal, app: &mut App, key: KeyEvent, + input_mapper: &mut InputMapper, ) -> color_eyre::Result>> where B: Backend + Send + 'static, @@ -47,16 +49,22 @@ where } else { match app.state.navigation.current_screen { CurrentScreen::MailingListSelection => { - return handle_mailing_list_selection(app, key, terminal).await; + if let Some(input) = map_key_to_input(app, key, input_mapper) { + return handle_mailing_list_selection(app, input, terminal).await; + } } CurrentScreen::BookmarkedPatchsets => { - return handle_bookmarked_patchsets(app, key, terminal).await; + if let Some(input) = map_key_to_input(app, key, input_mapper) { + return handle_bookmarked_patchsets(app, input, terminal).await; + } } CurrentScreen::PatchsetDetails => { handle_patchset_details(app, key, &mut terminal).await?; } CurrentScreen::EditConfig => { - handle_edit_config(app, key)?; + if let Some(input) = map_key_to_input(app, key, input_mapper) { + handle_edit_config(app, input)?; + } } CurrentScreen::LatestPatchsets => { return handle_latest_patchsets(app, key, terminal).await; @@ -66,6 +74,17 @@ where Ok(ControlFlow::Continue(terminal)) } +fn map_key_to_input( + app: &App, + key: KeyEvent, + input_mapper: &mut InputMapper, +) -> Option { + input_mapper.map_terminal_event( + TerminalEvent::Key(KeyInput::from(key)), + &app.input_context(), + ) +} + fn popup_input_from_key(key: KeyEvent) -> Option { match key.code { KeyCode::Char('j') | KeyCode::Down => Some(InputEvent::NavigateDown), @@ -81,6 +100,7 @@ where B: Backend + Send + 'static, { let mut event_source = CrosstermEventSource; + let mut input_mapper = InputMapper::default(); loop { terminal = app.process_system_updates(terminal).await?; @@ -94,7 +114,7 @@ where // if event::poll(Duration::from_millis(16))? { if let Some(TerminalEvent::Key(key)) = event_source.read_event()? { let key = key.to_key_event(); - match key_handling(terminal, &mut app, key).await? { + match key_handling(terminal, &mut app, key, &mut input_mapper).await? { ControlFlow::Continue(t) => terminal = t, ControlFlow::Break(_) => return Ok(()), } From d36c694143e5fb8a52cef886a4c0550959261201 Mon Sep 17 00:00:00 2001 From: lorenzoberts Date: Sun, 31 May 2026 14:07:37 -0300 Subject: [PATCH 6/9] refactor(input): migrate latest handler to InputEvent This commit updates the latest patchsets handler to consume semantic InputEvent values instead of raw crossterm KeyEvent values. The handler loop now routes the latest screen through the input mapper while preserving existing navigation, pagination, loading, and patchset error handling behavior. This commit is part of the architecture's refactoring phase 8. Signed-off-by: lorenzoberts --- src/handler/latest.rs | 25 +++++++++++-------------- src/handler/mod.rs | 4 +++- 2 files changed, 14 insertions(+), 15 deletions(-) diff --git a/src/handler/latest.rs b/src/handler/latest.rs index f83c066..2934781 100644 --- a/src/handler/latest.rs +++ b/src/handler/latest.rs @@ -1,35 +1,32 @@ -use ratatui::{ - crossterm::event::{KeyCode, KeyEvent}, - prelude::Backend, - Terminal, -}; +use ratatui::{prelude::Backend, Terminal}; use std::ops::ControlFlow; use crate::{ app::{screens::CurrentScreen, App, B4Result}, + input::event::InputEvent, loading_screen, ui::popup::{help::HelpPopUpBuilder, info_popup::InfoPopUp, PopUp}, }; pub async fn handle_latest_patchsets( app: &mut App, - key: KeyEvent, + input: InputEvent, mut terminal: Terminal, ) -> color_eyre::Result>> where B: Backend + Send + 'static, { - match key.code { - KeyCode::Char('?') => { + match input { + InputEvent::OpenHelp => { let popup = generate_help_popup(); app.state.popup = Some(popup); } - KeyCode::Esc | KeyCode::Char('q') => { + InputEvent::Back => { app.reset_latest_patchsets(); app.set_current_screen(CurrentScreen::MailingListSelection); } - KeyCode::Char('j') | KeyCode::Down => { + InputEvent::NavigateDown => { app.state .lore .latest_patchsets @@ -37,7 +34,7 @@ where .unwrap() .select_below_patchset(); } - KeyCode::Char('k') | KeyCode::Up => { + InputEvent::NavigateUp => { app.state .lore .latest_patchsets @@ -45,7 +42,7 @@ where .unwrap() .select_above_patchset(); } - KeyCode::Char('l') | KeyCode::Right => { + InputEvent::NextPage => { let list_name = app .state .lore @@ -67,7 +64,7 @@ where } }; } - KeyCode::Char('h') | KeyCode::Left => { + InputEvent::PreviousPage => { app.state .lore .latest_patchsets @@ -77,7 +74,7 @@ where // Reload from cache (no network call since LoreAPI caches all pages) app.fetch_latest_current_page().await?; } - KeyCode::Enter => { + InputEvent::OpenPatchsetDetails => { terminal = loading_screen! { terminal, "Loading patchset" => { diff --git a/src/handler/mod.rs b/src/handler/mod.rs index 1a37852..5fd3f7c 100644 --- a/src/handler/mod.rs +++ b/src/handler/mod.rs @@ -67,7 +67,9 @@ where } } CurrentScreen::LatestPatchsets => { - return handle_latest_patchsets(app, key, terminal).await; + if let Some(input) = map_key_to_input(app, key, input_mapper) { + return handle_latest_patchsets(app, input, terminal).await; + } } } } From 9fc1f547e211490486e9ebad245eb8906336e5b4 Mon Sep 17 00:00:00 2001 From: lorenzoberts Date: Sun, 31 May 2026 14:09:18 -0300 Subject: [PATCH 7/9] refactor(input): migrate details handler to InputEvent This commit updates the patchset details handler to consume semantic InputEvent values instead of raw crossterm KeyEvent values. Details-specific input such as preview scrolling, panning, toggles, modifiers, review trailers, and action consolidation now flows through the shared input protocol. It also removes the nested gg key polling from the handler, relying on the stateful input mapper instead, and moves the post-user-IO Enter wait behind the terminal event source boundary. This commit is part of the architecture's refactoring phase 8. Signed-off-by: lorenzoberts --- src/handler/details_actions.rs | 122 +++++++++++++-------------------- src/handler/mod.rs | 30 ++------ src/input/terminal_source.rs | 49 ++++++++++++- 3 files changed, 99 insertions(+), 102 deletions(-) diff --git a/src/handler/details_actions.rs b/src/handler/details_actions.rs index 4d55c11..9c20447 100644 --- a/src/handler/details_actions.rs +++ b/src/handler/details_actions.rs @@ -1,123 +1,87 @@ -use ratatui::{ - backend::Backend, - crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}, - Terminal, -}; - -use std::time::Duration; +use ratatui::{backend::Backend, Terminal}; use crate::{ app::{screens::CurrentScreen, App}, infrastructure::terminal::{setup_user_io, teardown_user_io}, + input::{ + event::{InputEvent, ScrollAmount}, + terminal_source::{wait_for_enter_press, CrosstermEventSource}, + }, ui::popup::{help::HelpPopUpBuilder, review_trailers::ReviewTrailersPopUp, PopUp}, }; -use super::wait_key_press; - pub async fn handle_patchset_details( app: &mut App, - key: KeyEvent, + input: InputEvent, terminal: &mut Terminal, ) -> color_eyre::Result<()> { let patchset_details_and_actions = app.state.lore.details.as_mut().unwrap(); - if key.modifiers.contains(KeyModifiers::SHIFT) { - match key.code { - KeyCode::Char('G') => patchset_details_and_actions.go_to_last_line(), - KeyCode::Char('R') => { - patchset_details_and_actions.toggle_reply_with_reviewed_by_action(true); - } - _ => {} - } - return Ok(()); - } - - if key.modifiers.contains(KeyModifiers::CONTROL) { - // TODO: Get preview sub-window height w/out coupling it to UI - let terminal_height = terminal.size().unwrap().height as usize; - match key.code { - KeyCode::Char('b') => { - patchset_details_and_actions.preview_scroll_up(terminal_height); - } - KeyCode::Char('f') => { - patchset_details_and_actions.preview_scroll_down(terminal_height); - } - KeyCode::Char('u') => { - patchset_details_and_actions.preview_scroll_up(terminal_height / 2); - } - KeyCode::Char('d') => { - patchset_details_and_actions.preview_scroll_down(terminal_height / 2); - } - KeyCode::Char('t') => { - let popup = - ReviewTrailersPopUp::generate_trailers_popup(patchset_details_and_actions); - app.state.popup = Some(popup); - } - _ => {} - } - return Ok(()); - } - - match key.code { - KeyCode::Char('?') => { + match input { + InputEvent::OpenHelp => { let popup = generate_help_popup(); app.state.popup = Some(popup); } - KeyCode::Esc | KeyCode::Char('q') => { + InputEvent::Back => { let ps_da_clone = patchset_details_and_actions.last_screen.clone(); app.set_current_screen(ps_da_clone); app.reset_details_actions(); } - KeyCode::Char('a') => { + InputEvent::ToggleApply => { patchset_details_and_actions.toggle_apply_action(); } - KeyCode::Char('j') | KeyCode::Down => { - patchset_details_and_actions.preview_scroll_down(1); + InputEvent::PreviewScrollDown(amount) => { + let lines = preview_scroll_lines(amount, terminal); + patchset_details_and_actions.preview_scroll_down(lines); } - KeyCode::Char('k') | KeyCode::Up => { - patchset_details_and_actions.preview_scroll_up(1); + InputEvent::PreviewScrollUp(amount) => { + let lines = preview_scroll_lines(amount, terminal); + patchset_details_and_actions.preview_scroll_up(lines); } - KeyCode::Char('h') | KeyCode::Left => { + InputEvent::PreviewPanLeft => { patchset_details_and_actions.preview_pan_left(); } - KeyCode::Char('l') | KeyCode::Right => { + InputEvent::PreviewPanRight => { patchset_details_and_actions.preview_pan_right(); } - KeyCode::Char('0') => { + InputEvent::PreviewGoToBeginningOfLine => { patchset_details_and_actions.go_to_beg_of_line(); } - KeyCode::Char('g') => { - if let Ok(true) = wait_key_press('g', Duration::from_millis(500)) { - patchset_details_and_actions.go_to_first_line(); - } + InputEvent::PreviewGoToFirstLine => { + patchset_details_and_actions.go_to_first_line(); + } + InputEvent::PreviewGoToLastLine => { + patchset_details_and_actions.go_to_last_line(); } - KeyCode::Char('f') => { + InputEvent::TogglePreviewFullscreen => { patchset_details_and_actions.toggle_preview_fullscreen(); } - KeyCode::Char('n') => { + InputEvent::PreviewNext => { patchset_details_and_actions.preview_next_patch(); } - KeyCode::Char('p') => { + InputEvent::PreviewPrevious => { patchset_details_and_actions.preview_previous_patch(); } - KeyCode::Char('b') => { + InputEvent::ToggleBookmark => { patchset_details_and_actions.toggle_bookmark_action(); } - KeyCode::Char('r') => { + InputEvent::ToggleReplyWithReviewedBy => { patchset_details_and_actions.toggle_reply_with_reviewed_by_action(false); } - KeyCode::Enter => { + InputEvent::ToggleReplyWithReviewedByAll => { + patchset_details_and_actions.toggle_reply_with_reviewed_by_action(true); + } + InputEvent::ShowReviewTrailers => { + let popup = ReviewTrailersPopUp::generate_trailers_popup(patchset_details_and_actions); + app.state.popup = Some(popup); + } + InputEvent::ConsolidatePatchsetActions => { if patchset_details_and_actions.actions_require_user_io() { setup_user_io(terminal)?; app.consolidate_patchset_actions().await?; println!("\nPress ENTER continue..."); - loop { - if let Event::Key(key) = event::read()? { - if key.kind == KeyEventKind::Press && key.code == KeyCode::Enter { - break; - } - } - } + let mut event_source = CrosstermEventSource; + wait_for_enter_press(&mut event_source)?; teardown_user_io(terminal)?; } else { app.consolidate_patchset_actions().await?; @@ -129,6 +93,14 @@ pub async fn handle_patchset_details( Ok(()) } +fn preview_scroll_lines(amount: ScrollAmount, terminal: &Terminal) -> usize { + match amount { + ScrollAmount::Line => 1, + ScrollAmount::HalfPage => terminal.size().unwrap().height as usize / 2, + ScrollAmount::Page => terminal.size().unwrap().height as usize, + } +} + pub fn generate_help_popup() -> Box { let popup = HelpPopUpBuilder::new() .title("Patchset Details and Actions") diff --git a/src/handler/mod.rs b/src/handler/mod.rs index 5fd3f7c..68f2c3e 100644 --- a/src/handler/mod.rs +++ b/src/handler/mod.rs @@ -5,15 +5,12 @@ mod latest; mod mail_list; use ratatui::{ - crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind}, + crossterm::event::{KeyCode, KeyEvent}, prelude::Backend, Terminal, }; -use std::{ - ops::ControlFlow, - time::{Duration, Instant}, -}; +use std::ops::ControlFlow; use crate::{ app::{screens::CurrentScreen, App}, @@ -59,7 +56,9 @@ where } } CurrentScreen::PatchsetDetails => { - handle_patchset_details(app, key, &mut terminal).await?; + if let Some(input) = map_key_to_input(app, key, input_mapper) { + handle_patchset_details(app, input, &mut terminal).await?; + } } CurrentScreen::EditConfig => { if let Some(input) = map_key_to_input(app, key, input_mapper) { @@ -124,22 +123,3 @@ where // } } } - -fn wait_key_press(ch: char, wait_time: Duration) -> color_eyre::Result { - let start = Instant::now(); - - while Instant::now() - start < wait_time { - if ratatui::crossterm::event::poll(Duration::from_millis(16))? { - if let Event::Key(key) = ratatui::crossterm::event::read()? { - if key.kind == KeyEventKind::Release { - continue; - } - if key.code == KeyCode::Char(ch) { - return Ok(true); - } - } - } - } - - Ok(false) -} diff --git a/src/input/terminal_source.rs b/src/input/terminal_source.rs index 048f9a3..acc59ed 100644 --- a/src/input/terminal_source.rs +++ b/src/input/terminal_source.rs @@ -1,4 +1,4 @@ -use ratatui::crossterm::event::{self, Event, KeyEventKind}; +use ratatui::crossterm::event::{self, Event, KeyCode, KeyEventKind}; use crate::input::event::{KeyInput, TerminalEvent}; @@ -23,6 +23,16 @@ pub fn terminal_event_from_crossterm_event(event: Event) -> Option color_eyre::Result<()> { + loop { + if let Some(TerminalEvent::Key(key)) = event_source.read_event()? { + if key.code == KeyCode::Enter { + return Ok(()); + } + } + } +} + #[cfg(test)] mod tests { use ratatui::crossterm::event::{ @@ -31,9 +41,27 @@ mod tests { use crate::input::{ event::{KeyInput, TerminalEvent}, - terminal_source::terminal_event_from_crossterm_event, + terminal_source::{ + terminal_event_from_crossterm_event, wait_for_enter_press, TerminalEventSource, + }, }; + struct FakeTerminalEventSource { + events: Vec>, + } + + impl FakeTerminalEventSource { + fn new(events: Vec>) -> Self { + Self { events } + } + } + + impl TerminalEventSource for FakeTerminalEventSource { + fn read_event(&mut self) -> color_eyre::Result> { + Ok(self.events.remove(0)) + } + } + #[test] fn converts_key_press_to_terminal_key_event() { let key = KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE); @@ -76,4 +104,21 @@ mod tests { assert_eq!(event, None); } + + #[test] + fn wait_for_enter_press_ignores_non_enter_events() { + let mut event_source = FakeTerminalEventSource::new(vec![ + None, + Some(TerminalEvent::Resize { + width: 120, + height: 40, + }), + Some(TerminalEvent::Key(KeyInput::press(KeyCode::Char('x')))), + Some(TerminalEvent::Key(KeyInput::press(KeyCode::Enter))), + ]); + + let result = wait_for_enter_press(&mut event_source); + + assert!(result.is_ok()); + } } From 6f8e3e23b9e00e9762bd38017062aba5675b2631 Mon Sep 17 00:00:00 2001 From: lorenzoberts Date: Sun, 31 May 2026 14:10:38 -0300 Subject: [PATCH 8/9] refactor(input): wire main loop through InputEvent This commit updates the main handler loop to translate TerminalEvent values into semantic InputEvent values before dispatching to popups or screen handlers. The old raw-key bridge is removed, so run_app now uses the input context and mapper as the single path between terminal events and application input handling. It also removes the temporary KeyEvent reconstruction helper from KeyInput now that handlers no longer consume raw crossterm input. This commit is part of the architecture's refactoring phase 8. Signed-off-by: lorenzoberts --- src/app/input.rs | 1 - src/handler/mod.rs | 70 ++++++++++++---------------------------------- src/input/event.rs | 9 ------ 3 files changed, 18 insertions(+), 62 deletions(-) diff --git a/src/app/input.rs b/src/app/input.rs index f0e9a64..bf2a6db 100644 --- a/src/app/input.rs +++ b/src/app/input.rs @@ -2,7 +2,6 @@ use crate::{app::App, input::context::InputContext}; impl App { /// Projects App state into the context needed by the input mapper. - #[allow(dead_code)] // Wired into run_app once handlers consume InputEvent. pub fn input_context(&self) -> InputContext { InputContext { current_screen: self.state.navigation.current_screen.clone(), diff --git a/src/handler/mod.rs b/src/handler/mod.rs index 68f2c3e..816ccdf 100644 --- a/src/handler/mod.rs +++ b/src/handler/mod.rs @@ -4,18 +4,14 @@ mod edit_config; mod latest; mod mail_list; -use ratatui::{ - crossterm::event::{KeyCode, KeyEvent}, - prelude::Backend, - Terminal, -}; +use ratatui::{prelude::Backend, Terminal}; use std::ops::ControlFlow; use crate::{ app::{screens::CurrentScreen, App}, input::{ - event::{InputEvent, KeyInput, TerminalEvent}, + event::InputEvent, mapper::InputMapper, terminal_source::{CrosstermEventSource, TerminalEventSource}, }, @@ -28,74 +24,42 @@ use edit_config::handle_edit_config; use latest::handle_latest_patchsets; use mail_list::handle_mailing_list_selection; -async fn key_handling( +async fn input_handling( mut terminal: Terminal, app: &mut App, - key: KeyEvent, - input_mapper: &mut InputMapper, + input: InputEvent, ) -> color_eyre::Result>> where B: Backend + Send + 'static, { if let Some(popup) = app.state.popup.as_mut() { - if matches!(key.code, KeyCode::Esc | KeyCode::Char('q')) { + if input == InputEvent::ClosePopup { app.state.popup = None; - } else if let Some(input) = popup_input_from_key(key) { + } else { popup.handle(input)?; } } else { match app.state.navigation.current_screen { CurrentScreen::MailingListSelection => { - if let Some(input) = map_key_to_input(app, key, input_mapper) { - return handle_mailing_list_selection(app, input, terminal).await; - } + return handle_mailing_list_selection(app, input, terminal).await; } CurrentScreen::BookmarkedPatchsets => { - if let Some(input) = map_key_to_input(app, key, input_mapper) { - return handle_bookmarked_patchsets(app, input, terminal).await; - } + return handle_bookmarked_patchsets(app, input, terminal).await; } CurrentScreen::PatchsetDetails => { - if let Some(input) = map_key_to_input(app, key, input_mapper) { - handle_patchset_details(app, input, &mut terminal).await?; - } + handle_patchset_details(app, input, &mut terminal).await?; } CurrentScreen::EditConfig => { - if let Some(input) = map_key_to_input(app, key, input_mapper) { - handle_edit_config(app, input)?; - } + handle_edit_config(app, input)?; } CurrentScreen::LatestPatchsets => { - if let Some(input) = map_key_to_input(app, key, input_mapper) { - return handle_latest_patchsets(app, input, terminal).await; - } + return handle_latest_patchsets(app, input, terminal).await; } } } Ok(ControlFlow::Continue(terminal)) } -fn map_key_to_input( - app: &App, - key: KeyEvent, - input_mapper: &mut InputMapper, -) -> Option { - input_mapper.map_terminal_event( - TerminalEvent::Key(KeyInput::from(key)), - &app.input_context(), - ) -} - -fn popup_input_from_key(key: KeyEvent) -> Option { - match key.code { - KeyCode::Char('j') | KeyCode::Down => Some(InputEvent::NavigateDown), - KeyCode::Char('k') | KeyCode::Up => Some(InputEvent::NavigateUp), - KeyCode::Char('h') | KeyCode::Left => Some(InputEvent::NavigateLeft), - KeyCode::Char('l') | KeyCode::Right => Some(InputEvent::NavigateRight), - _ => None, - } -} - pub async fn run_app(mut terminal: Terminal, mut app: App) -> color_eyre::Result<()> where B: Backend + Send + 'static, @@ -113,11 +77,13 @@ where // need to refresh the UI independently of any event as doing so gravely // hinders the performance to below acceptable. // if event::poll(Duration::from_millis(16))? { - if let Some(TerminalEvent::Key(key)) = event_source.read_event()? { - let key = key.to_key_event(); - match key_handling(terminal, &mut app, key, &mut input_mapper).await? { - ControlFlow::Continue(t) => terminal = t, - ControlFlow::Break(_) => return Ok(()), + if let Some(terminal_event) = event_source.read_event()? { + let input = input_mapper.map_terminal_event(terminal_event, &app.input_context()); + if let Some(input) = input { + match input_handling(terminal, &mut app, input).await? { + ControlFlow::Continue(t) => terminal = t, + ControlFlow::Break(_) => return Ok(()), + } } } // } diff --git a/src/input/event.rs b/src/input/event.rs index 943b5a0..7ec8c52 100644 --- a/src/input/event.rs +++ b/src/input/event.rs @@ -34,15 +34,6 @@ impl KeyInput { pub fn modified_press(code: KeyCode, modifiers: KeyModifiers) -> Self { Self::new(code, modifiers, KeyEventKind::Press) } - - pub fn to_key_event(&self) -> KeyEvent { - KeyEvent { - code: self.code, - modifiers: self.modifiers, - kind: self.kind, - state: self.state, - } - } } impl From for KeyInput { From 7dbd5c8af42b9d9b03f0493a7e7765173d08412a Mon Sep 17 00:00:00 2001 From: lorenzoberts Date: Sun, 31 May 2026 14:12:30 -0300 Subject: [PATCH 9/9] refactor(input): clean up input protocol boundary This commit removes transitional input helpers that are no longer needed after the main loop was wired through InputEvent. Test-only constructors are limited to test builds, KeyInput no longer preserves unused crossterm key state, and the input module now documents the actor-free protocol boundary introduced in Phase 8. It also adjusts App::input_context to satisfy the repository clippy rules. This commit completes the architecture's refactoring phase 8. Signed-off-by: lorenzoberts --- src/app/input.rs | 6 ++---- src/input/context.rs | 3 +++ src/input/event.rs | 14 +++++++++----- src/input/mod.rs | 8 +++++++- 4 files changed, 21 insertions(+), 10 deletions(-) diff --git a/src/app/input.rs b/src/app/input.rs index bf2a6db..70119a1 100644 --- a/src/app/input.rs +++ b/src/app/input.rs @@ -11,15 +11,13 @@ impl App { .config_state .edit_config .as_ref() - .map(|edit_config| edit_config.is_editing()) - .unwrap_or(false), + .is_some_and(|edit_config| edit_config.is_editing()), preview_fullscreen: self .state .lore .details .as_ref() - .map(|details| details.preview_fullscreen) - .unwrap_or(false), + .is_some_and(|details| details.preview_fullscreen), } } } diff --git a/src/input/context.rs b/src/input/context.rs index 09c4a6b..4161876 100644 --- a/src/input/context.rs +++ b/src/input/context.rs @@ -10,6 +10,7 @@ pub struct InputContext { } impl InputContext { + #[cfg(test)] pub fn new(current_screen: CurrentScreen) -> Self { Self { current_screen, @@ -19,11 +20,13 @@ impl InputContext { } } + #[cfg(test)] pub fn with_popup_open(mut self, popup_open: bool) -> Self { self.popup_open = popup_open; self } + #[cfg(test)] pub fn with_edit_config_editing(mut self, edit_config_editing: bool) -> Self { self.edit_config_editing = edit_config_editing; self diff --git a/src/input/event.rs b/src/input/event.rs index 7ec8c52..b997f93 100644 --- a/src/input/event.rs +++ b/src/input/event.rs @@ -1,10 +1,14 @@ -use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers}; +use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; /// Raw terminal event after conversion from the terminal backend. #[derive(Debug, Clone, PartialEq, Eq)] pub enum TerminalEvent { Key(KeyInput), - Resize { width: u16, height: u16 }, + Resize { + width: u16, + height: u16, + }, + #[allow(dead_code)] // Reserved for future non-blocking refresh loops. Tick, } @@ -14,23 +18,24 @@ pub struct KeyInput { pub code: KeyCode, pub modifiers: KeyModifiers, pub kind: KeyEventKind, - pub state: KeyEventState, } impl KeyInput { + #[cfg(test)] pub fn new(code: KeyCode, modifiers: KeyModifiers, kind: KeyEventKind) -> Self { Self { code, modifiers, kind, - state: KeyEventState::NONE, } } + #[cfg(test)] pub fn press(code: KeyCode) -> Self { Self::new(code, KeyModifiers::NONE, KeyEventKind::Press) } + #[cfg(test)] pub fn modified_press(code: KeyCode, modifiers: KeyModifiers) -> Self { Self::new(code, modifiers, KeyEventKind::Press) } @@ -42,7 +47,6 @@ impl From for KeyInput { code: key.code, modifiers: key.modifiers, kind: key.kind, - state: key.state, } } } diff --git a/src/input/mod.rs b/src/input/mod.rs index ea8523a..1aca996 100644 --- a/src/input/mod.rs +++ b/src/input/mod.rs @@ -1,4 +1,10 @@ -//! Input protocol types and mapping for terminal-driven application intent. +//! Protocol boundary between terminal input and application intent. +//! +//! Phase 8 keeps this module actor-free: terminal backends produce +//! [`event::TerminalEvent`] values, [`mapper::InputMapper`] translates them +//! using [`context::InputContext`], and handlers consume semantic +//! [`event::InputEvent`] values. The Input actor and broadcast handle are +//! introduced later once Terminal is also actorized. pub mod bindings; pub mod context;