From 19161cdea67efa20c1640b0cc914f63212f81902 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 00000000..e19fef18 --- /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 00000000..09c4a6b1 --- /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 00000000..45d24300 --- /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 00000000..1bba16d7 --- /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 00000000..1a80833d --- /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 44299bc3..beb615bc 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 81d5915ecb587912af30b29c377606d1c3f83529 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 3d001514..ed84b0cc 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 45d24300..943b5a01 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 1a80833d..ea8523af 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 00000000..048f9a37 --- /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 4b439bd0c879ba5de6060f4c6773b8df545146e3 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 00000000..f0e9a64b --- /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 ea4e5e8a..1e30fc74 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 00000000..018eab0e --- /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 ed84b0cc..3c86edd8 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 628d9e69be5368913f5abc91814fbef0c12b9ec2 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 3c86edd8..f8216969 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 8e901095..b3ea9756 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 1a7a8659..17fc393a 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 f2496123..89015146 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 b579405e..88466a3c 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 193cf6f97556c56de0542eb7981298124368578c 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 b370e063..6bc87e60 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 ba3611b0..7ede40fd 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 97fd1885..1dc31743 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 f8216969..1a378529 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 b4ca896444009aca91dc5e0606a60cf056648b48 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 f83c0662..29347819 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 1a378529..5fd3f7c0 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 02e5274bf77c313da06dcfd23b6da528f1c7184c 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 4d55c111..9c20447e 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 5fd3f7c0..68f2c3e4 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 048f9a37..acc59ed8 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 43f25c72631bd989c7bc97c61104bf0c6aef6f0b 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 f0e9a64b..bf2a6dbb 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 68f2c3e4..816ccdf5 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 943b5a01..7ec8c522 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 faca636ce310ab3b35ddf5716f201384b69f4178 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 bf2a6dbb..70119a1a 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 09c4a6b1..41618767 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 7ec8c522..b997f93b 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 ea8523af..1aca9969 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;