diff --git a/src/app/input.rs b/src/app/input.rs new file mode 100644 index 0000000..70119a1 --- /dev/null +++ b/src/app/input.rs @@ -0,0 +1,23 @@ +use crate::{app::App, input::context::InputContext}; + +impl App { + /// Projects App state into the context needed by the input mapper. + 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() + .is_some_and(|edit_config| edit_config.is_editing()), + preview_fullscreen: self + .state + .lore + .details + .as_ref() + .is_some_and(|details| details.preview_fullscreen), + } + } +} 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/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/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/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/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/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 3d00151..816ccdf 100644 --- a/src/handler/mod.rs +++ b/src/handler/mod.rs @@ -4,20 +4,17 @@ mod edit_config; mod latest; mod mail_list; -use ratatui::{ - crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind}, - prelude::Backend, - Terminal, -}; +use ratatui::{prelude::Backend, Terminal}; -use std::{ - ops::ControlFlow, - time::{Duration, Instant}, -}; +use std::ops::ControlFlow; use crate::{ app::{screens::CurrentScreen, App}, - loading_screen, + input::{ + event::InputEvent, + mapper::InputMapper, + terminal_source::{CrosstermEventSource, TerminalEventSource}, + }, ui::draw_ui, }; @@ -27,103 +24,51 @@ 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: 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 { - popup.handle(key)?; + popup.handle(input)?; } } else { match app.state.navigation.current_screen { CurrentScreen::MailingListSelection => { - return handle_mailing_list_selection(app, key, terminal).await; + return handle_mailing_list_selection(app, input, terminal).await; } CurrentScreen::BookmarkedPatchsets => { - return handle_bookmarked_patchsets(app, key, terminal).await; + return handle_bookmarked_patchsets(app, input, terminal).await; } CurrentScreen::PatchsetDetails => { - handle_patchset_details(app, key, &mut terminal).await?; + handle_patchset_details(app, input, &mut terminal).await?; } CurrentScreen::EditConfig => { - handle_edit_config(app, key)?; + handle_edit_config(app, input)?; } CurrentScreen::LatestPatchsets => { - return handle_latest_patchsets(app, key, terminal).await; + return handle_latest_patchsets(app, input, terminal).await; } } } 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, { + let mut event_source = CrosstermEventSource; + let mut input_mapper = InputMapper::default(); + 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()))?; @@ -132,34 +77,15 @@ 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; - } - match key_handling(terminal, &mut app, key).await? { - ControlFlow::Continue(t) => terminal = t, - ControlFlow::Break(_) => return Ok(()), - } - } - // } - } -} - -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); + 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(()), } } } + // } } - - Ok(false) } 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..4161876 --- /dev/null +++ b/src/input/context.rs @@ -0,0 +1,34 @@ +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 { + #[cfg(test)] + pub fn new(current_screen: CurrentScreen) -> Self { + Self { + current_screen, + popup_open: false, + edit_config_editing: false, + preview_fullscreen: false, + } + } + + #[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 new file mode 100644 index 0000000..b997f93 --- /dev/null +++ b/src/input/event.rs @@ -0,0 +1,103 @@ +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, + }, + #[allow(dead_code)] // Reserved for future non-blocking refresh loops. + 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 { + #[cfg(test)] + pub fn new(code: KeyCode, modifiers: KeyModifiers, kind: KeyEventKind) -> Self { + Self { + code, + modifiers, + kind, + } + } + + #[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) + } +} + +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..1aca996 --- /dev/null +++ b/src/input/mod.rs @@ -0,0 +1,13 @@ +//! 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; +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..acc59ed --- /dev/null +++ b/src/input/terminal_source.rs @@ -0,0 +1,124 @@ +use ratatui::crossterm::event::{self, Event, KeyCode, 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, + } +} + +pub fn wait_for_enter_press(event_source: &mut dyn TerminalEventSource) -> 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::{ + Event, KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers, + }; + + use crate::input::{ + event::{KeyInput, TerminalEvent}, + 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); + + 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); + } + + #[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()); + } +} 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; 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; }