diff --git a/src/app/mod.rs b/src/app/mod.rs index 1e30fc7..5ba58de 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1,6 +1,7 @@ pub mod commands; pub mod errors; pub mod input; +pub mod render_snapshot; pub mod screens; pub mod state; pub mod updates; @@ -428,10 +429,4 @@ impl App { pub fn set_current_screen(&mut self, new_current_screen: CurrentScreen) { self.state.navigation.current_screen = new_current_screen; } - - /// Borrows state for one UI frame without passing [`App`] into `ui/`. - #[must_use] - pub fn to_view_model(&self) -> AppViewModel<'_> { - AppViewModel { state: &self.state } - } } diff --git a/src/app/render_snapshot.rs b/src/app/render_snapshot.rs new file mode 100644 index 0000000..0ab0f32 --- /dev/null +++ b/src/app/render_snapshot.rs @@ -0,0 +1,23 @@ +use super::{App, AppState, AppViewModel}; + +/// Owned application state snapshot for terminal actor drawing. +#[derive(Clone)] +pub struct AppRenderSnapshot { + state: AppState, +} + +impl AppRenderSnapshot { + pub fn new(state: AppState) -> Self { + Self { state } + } + + pub fn to_view_model(&self) -> AppViewModel<'_> { + AppViewModel { state: &self.state } + } +} + +impl App { + pub fn render_snapshot(&self) -> AppRenderSnapshot { + AppRenderSnapshot::new(self.state.clone()) + } +} diff --git a/src/app/screens/bookmarked.rs b/src/app/screens/bookmarked.rs index 5f07539..88a2531 100644 --- a/src/app/screens/bookmarked.rs +++ b/src/app/screens/bookmarked.rs @@ -1,5 +1,6 @@ use crate::lore::domain::patch::Patch; +#[derive(Clone)] pub struct BookmarkedPatchsetsState { pub bookmarked_patchsets: Vec, pub patchset_index: usize, diff --git a/src/app/screens/details_actions.rs b/src/app/screens/details_actions.rs index c0ccc04..e9be519 100644 --- a/src/app/screens/details_actions.rs +++ b/src/app/screens/details_actions.rs @@ -19,6 +19,7 @@ use crate::{ use super::CurrentScreen; +#[derive(Clone)] pub struct PatchsetDetailsState { pub representative_patch: Patch, /// Raw patches as plain text files diff --git a/src/app/screens/edit_config.rs b/src/app/screens/edit_config.rs index 742a34b..c56d5cd 100644 --- a/src/app/screens/edit_config.rs +++ b/src/app/screens/edit_config.rs @@ -5,7 +5,7 @@ use std::{collections::HashMap, fmt::Display}; use crate::config::{ConfigSnapshot, ConfigUpdateDraft}; -#[derive(Debug, Getters)] +#[derive(Clone, Debug, Getters)] pub struct EditConfigState { #[getter(skip)] config_buffer: HashMap, @@ -137,7 +137,7 @@ impl EditConfigState { } } -#[derive(Debug, Hash, Eq, PartialEq)] +#[derive(Clone, Debug, Hash, Eq, PartialEq)] enum EditableConfig { PageSize, CacheDir, diff --git a/src/app/screens/latest.rs b/src/app/screens/latest.rs index 4a5140d..53c003d 100644 --- a/src/app/screens/latest.rs +++ b/src/app/screens/latest.rs @@ -5,6 +5,7 @@ use crate::lore::{ domain::patch::Patch, }; +#[derive(Clone)] pub struct LatestPatchsetsState { target_list: String, page_number: usize, diff --git a/src/app/screens/mail_list.rs b/src/app/screens/mail_list.rs index 0074d71..e6dff7d 100644 --- a/src/app/screens/mail_list.rs +++ b/src/app/screens/mail_list.rs @@ -5,6 +5,7 @@ use crate::lore::{ domain::mailing_list::MailingList, }; +#[derive(Clone)] pub struct MailingListSelectionState { pub mailing_lists: Vec, pub target_list: String, diff --git a/src/app/state.rs b/src/app/state.rs index da76a53..061e6a4 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -11,11 +11,13 @@ use crate::{ }; /// Navigation-only state: which screen is active. +#[derive(Clone)] pub struct NavigationState { pub current_screen: CurrentScreen, } /// Lore-related UI: mailing list picker, feed, patchset details. +#[derive(Clone)] pub struct LoreUiState { pub mailing_list_selection: MailingListSelectionState, pub latest_patchsets: Option, @@ -23,17 +25,20 @@ pub struct LoreUiState { } /// User-owned Lore data (bookmarks and review markers). +#[derive(Clone)] pub struct UserLoreState { pub bookmarked_patchsets: BookmarkedPatchsetsState, pub reviewed_patchsets: HashMap>, } /// Edit-config screen state (transient form). +#[derive(Clone)] pub struct ConfigUiState { pub edit_config: Option, } /// All application state grouped for the future App actor. +#[derive(Clone)] pub struct AppState { pub navigation: NavigationState, pub lore: LoreUiState, diff --git a/src/app/updates.rs b/src/app/updates.rs index 018eab0..a382b68 100644 --- a/src/app/updates.rs +++ b/src/app/updates.rs @@ -1,19 +1,14 @@ -use ratatui::{prelude::Backend, Terminal}; - use crate::{ app::{screens::CurrentScreen, App}, - loading_screen, + handler::LoadingIndicator, }; impl App { /// Processes app-driven updates that are not direct user input. - pub async fn process_system_updates( + pub async fn process_system_updates( &mut self, - mut terminal: Terminal, - ) -> color_eyre::Result> - where - B: Backend + Send + 'static, - { + loading: &mut dyn LoadingIndicator, + ) -> color_eyre::Result<()> { match self.state.navigation.current_screen { CurrentScreen::MailingListSelection => { if self @@ -23,11 +18,10 @@ impl App { .mailing_lists .is_empty() { - terminal = loading_screen! { - terminal, "Fetching mailing lists" => { - self.refresh_mailing_lists().await - } - }; + loading.start("Fetching mailing lists".to_string()); + let result = self.refresh_mailing_lists().await; + loading.stop()?; + result?; } } CurrentScreen::LatestPatchsets => { @@ -35,12 +29,10 @@ impl App { 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 - } - }; + loading.start(format!("Fetching patchsets from {target_list}")); + let result = self.fetch_latest_current_page().await; + loading.stop()?; + result?; self.state.lore.mailing_list_selection.clear_target_list(); } @@ -59,6 +51,6 @@ impl App { _ => {} } - Ok(terminal) + Ok(()) } } diff --git a/src/app/view_model.rs b/src/app/view_model.rs index e36b1da..56187a0 100644 --- a/src/app/view_model.rs +++ b/src/app/view_model.rs @@ -2,7 +2,8 @@ use super::state::AppState; -/// References into [`AppState`] for one Ratatui frame. Built via [`super::App::to_view_model`]. +/// References into [`AppState`] for one Ratatui frame. Built via +/// [`super::render_snapshot::AppRenderSnapshot::to_view_model`]. #[derive(Clone, Copy)] pub struct AppViewModel<'a> { pub state: &'a AppState, diff --git a/src/cli.rs b/src/cli.rs index dd2d078..59515f7 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,10 +1,9 @@ use clap::Parser; use color_eyre::eyre::eyre; -use ratatui::{prelude::Backend, Terminal}; use std::ops::ControlFlow; -use crate::{config::ConfigSnapshot, infrastructure::terminal::restore}; +use crate::config::ConfigSnapshot; #[derive(Debug, Parser)] #[command(version, about)] @@ -15,19 +14,11 @@ pub struct Cli { } impl Cli { - /// Resolves the command line arguments and applies the necessary changes to the terminal and app + /// Resolves command line arguments that may finish before the TUI starts. /// /// Some arguments may finish the program early (returning `ControlFlow::Break`) - pub fn resolve( - &self, - terminal: Terminal, - config: &ConfigSnapshot, - ) -> ControlFlow, Terminal> { + pub fn resolve(&self, config: &ConfigSnapshot) -> ControlFlow, ()> { if self.show_configs { - drop(terminal); - if let Err(err) = restore() { - return ControlFlow::Break(Err(eyre!(err))); - } match serde_json::to_string_pretty(&config) { Err(err) => return ControlFlow::Break(Err(eyre!(err))), Ok(config) => println!("patch-hub configurations:\n{config}"), @@ -36,6 +27,34 @@ impl Cli { return ControlFlow::Break(Ok(())); } - ControlFlow::Continue(terminal) + ControlFlow::Continue(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::ConfigState; + + #[test] + fn resolve_continues_when_no_early_cli_action_is_requested() { + let cli = Cli { + show_configs: false, + }; + let config = ConfigState::default().to_snapshot(); + + let result = cli.resolve(&config); + + assert!(matches!(result, ControlFlow::Continue(()))); + } + + #[test] + fn resolve_finishes_after_printing_configs() { + let cli = Cli { show_configs: true }; + let config = ConfigState::default().to_snapshot(); + + let result = cli.resolve(&config); + + assert!(matches!(result, ControlFlow::Break(Ok(())))); } } diff --git a/src/handler/bookmarked.rs b/src/handler/bookmarked.rs index 6bc87e6..b2d725c 100644 --- a/src/handler/bookmarked.rs +++ b/src/handler/bookmarked.rs @@ -1,22 +1,15 @@ -use ratatui::{prelude::Backend, Terminal}; - -use std::ops::ControlFlow; - use crate::{ app::{screens::CurrentScreen, App, B4Result}, + handler::LoadingIndicator, input::event::InputEvent, - loading_screen, ui::popup::{help::HelpPopUpBuilder, info_popup::InfoPopUp, PopUp}, }; -pub async fn handle_bookmarked_patchsets( +pub async fn handle_bookmarked_patchsets( app: &mut App, input: InputEvent, - mut terminal: Terminal, -) -> color_eyre::Result>> -where - B: Backend + Send + 'static, -{ + loading: &mut dyn LoadingIndicator, +) -> color_eyre::Result<()> { match input { InputEvent::OpenHelp => { let popup = generate_help_popup(); @@ -39,34 +32,30 @@ where .select_above_patchset(); } InputEvent::OpenPatchsetDetails => { - terminal = loading_screen! { - terminal, - "Loading patchset" => { - let result = app.open_patchset_details().await; - if result.is_ok() { - // If a patchset has been bookmarked UI, this means that - // b4 was successful in fetching it, so it shouldn't be - // necessary to handle this, but we can't assume that a - // patchset in this list was bookmarked through the UI - match result.unwrap() { - B4Result::PatchFound => { - app.set_current_screen(CurrentScreen::PatchsetDetails); - } - B4Result::PatchNotFound(err_cause) => { - app.state.popup = Some(InfoPopUp::generate_info_popup( - "Error",&format!("The selected patchset couldn't be retrieved.\nReason: {err_cause}\nPlease choose another patchset.") - )); - app.set_current_screen(CurrentScreen::BookmarkedPatchsets); - } - } + loading.start("Loading patchset".to_string()); + let result = app.open_patchset_details().await; + loading.stop()?; + if result.is_ok() { + // If a patchset has been bookmarked UI, this means that + // b4 was successful in fetching it, so it shouldn't be + // necessary to handle this, but we can't assume that a + // patchset in this list was bookmarked through the UI + match result.unwrap() { + B4Result::PatchFound => { + app.set_current_screen(CurrentScreen::PatchsetDetails); + } + B4Result::PatchNotFound(err_cause) => { + app.state.popup = Some(InfoPopUp::generate_info_popup( + "Error",&format!("The selected patchset couldn't be retrieved.\nReason: {err_cause}\nPlease choose another patchset.") + )); + app.set_current_screen(CurrentScreen::BookmarkedPatchsets); } - color_eyre::eyre::Ok(()) } - }; + } } _ => {} } - Ok(ControlFlow::Continue(terminal)) + Ok(()) } pub fn generate_help_popup() -> Box { diff --git a/src/handler/details_actions.rs b/src/handler/details_actions.rs index 9c20447..3d48160 100644 --- a/src/handler/details_actions.rs +++ b/src/handler/details_actions.rs @@ -1,19 +1,20 @@ -use ratatui::{backend::Backend, Terminal}; +use std::time::Duration; + +use ratatui::crossterm::event::KeyCode; 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}, - }, + input::event::{InputEvent, ScrollAmount}, + terminal::handle::TerminalHandle, ui::popup::{help::HelpPopUpBuilder, review_trailers::ReviewTrailersPopUp, PopUp}, }; -pub async fn handle_patchset_details( +const USER_IO_ENTER_POLL_TIMEOUT: Duration = Duration::from_millis(200); + +pub async fn handle_patchset_details( app: &mut App, input: InputEvent, - terminal: &mut Terminal, + terminal_handle: &TerminalHandle, ) -> color_eyre::Result<()> { let patchset_details_and_actions = app.state.lore.details.as_mut().unwrap(); @@ -31,11 +32,11 @@ pub async fn handle_patchset_details( patchset_details_and_actions.toggle_apply_action(); } InputEvent::PreviewScrollDown(amount) => { - let lines = preview_scroll_lines(amount, terminal); + let lines = preview_scroll_lines(amount, terminal_handle).await?; patchset_details_and_actions.preview_scroll_down(lines); } InputEvent::PreviewScrollUp(amount) => { - let lines = preview_scroll_lines(amount, terminal); + let lines = preview_scroll_lines(amount, terminal_handle).await?; patchset_details_and_actions.preview_scroll_up(lines); } InputEvent::PreviewPanLeft => { @@ -77,12 +78,14 @@ pub async fn handle_patchset_details( } InputEvent::ConsolidatePatchsetActions => { if patchset_details_and_actions.actions_require_user_io() { - setup_user_io(terminal)?; + terminal_handle.setup_user_io().await?; app.consolidate_patchset_actions().await?; println!("\nPress ENTER continue..."); - let mut event_source = CrosstermEventSource; - wait_for_enter_press(&mut event_source)?; - teardown_user_io(terminal)?; + while !terminal_handle + .wait_for_key_press(KeyCode::Enter, USER_IO_ENTER_POLL_TIMEOUT) + .await? + {} + terminal_handle.teardown_user_io().await?; } else { app.consolidate_patchset_actions().await?; } @@ -93,12 +96,16 @@ pub async fn handle_patchset_details( Ok(()) } -fn preview_scroll_lines(amount: ScrollAmount, terminal: &Terminal) -> usize { - match amount { +async fn preview_scroll_lines( + amount: ScrollAmount, + terminal_handle: &TerminalHandle, +) -> color_eyre::Result { + let (_, height) = terminal_handle.size().await?; + Ok(match amount { ScrollAmount::Line => 1, - ScrollAmount::HalfPage => terminal.size().unwrap().height as usize / 2, - ScrollAmount::Page => terminal.size().unwrap().height as usize, - } + ScrollAmount::HalfPage => height as usize / 2, + ScrollAmount::Page => height as usize, + }) } pub fn generate_help_popup() -> Box { diff --git a/src/handler/latest.rs b/src/handler/latest.rs index 2934781..ee226d2 100644 --- a/src/handler/latest.rs +++ b/src/handler/latest.rs @@ -1,22 +1,15 @@ -use ratatui::{prelude::Backend, Terminal}; - -use std::ops::ControlFlow; - use crate::{ app::{screens::CurrentScreen, App, B4Result}, + handler::LoadingIndicator, input::event::InputEvent, - loading_screen, ui::popup::{help::HelpPopUpBuilder, info_popup::InfoPopUp, PopUp}, }; -pub async fn handle_latest_patchsets( +pub async fn handle_latest_patchsets( app: &mut App, input: InputEvent, - mut terminal: Terminal, -) -> color_eyre::Result>> -where - B: Backend + Send + 'static, -{ + loading: &mut dyn LoadingIndicator, +) -> color_eyre::Result<()> { match input { InputEvent::OpenHelp => { let popup = generate_help_popup(); @@ -51,18 +44,16 @@ where .unwrap() .target_list() .to_string(); - terminal = loading_screen! { - terminal, - format!("Fetching patchsets from {}", list_name) => { - app.state - .lore - .latest_patchsets - .as_mut() - .unwrap() - .increment_page(); - app.fetch_latest_current_page().await - } - }; + loading.start(format!("Fetching patchsets from {list_name}")); + app.state + .lore + .latest_patchsets + .as_mut() + .unwrap() + .increment_page(); + let result = app.fetch_latest_current_page().await; + loading.stop()?; + result?; } InputEvent::PreviousPage => { app.state @@ -75,30 +66,26 @@ where app.fetch_latest_current_page().await?; } InputEvent::OpenPatchsetDetails => { - terminal = loading_screen! { - terminal, - "Loading patchset" => { - let result = app.open_patchset_details().await; - if result.is_ok() { - match result.unwrap() { - B4Result::PatchFound => { - app.set_current_screen(CurrentScreen::PatchsetDetails); - } - B4Result::PatchNotFound(err_cause) => { - app.state.popup = Some(InfoPopUp::generate_info_popup( - "Error",&format!("The selected patchset couldn't be retrieved.\nReason: {err_cause}\nPlease choose another patchset.") - )); - app.set_current_screen(CurrentScreen::LatestPatchsets); - } - } + loading.start("Loading patchset".to_string()); + let result = app.open_patchset_details().await; + loading.stop()?; + if result.is_ok() { + match result.unwrap() { + B4Result::PatchFound => { + app.set_current_screen(CurrentScreen::PatchsetDetails); + } + B4Result::PatchNotFound(err_cause) => { + app.state.popup = Some(InfoPopUp::generate_info_popup( + "Error",&format!("The selected patchset couldn't be retrieved.\nReason: {err_cause}\nPlease choose another patchset.") + )); + app.set_current_screen(CurrentScreen::LatestPatchsets); } - color_eyre::eyre::Ok(()) } - }; + } } _ => {} } - Ok(ControlFlow::Continue(terminal)) + Ok(()) } pub fn generate_help_popup() -> Box { diff --git a/src/handler/mail_list.rs b/src/handler/mail_list.rs index 1dc3174..3dd749a 100644 --- a/src/handler/mail_list.rs +++ b/src/handler/mail_list.rs @@ -1,22 +1,17 @@ -use ratatui::{prelude::Backend, Terminal}; - use std::ops::ControlFlow; use crate::{ app::{screens::CurrentScreen, App}, + handler::LoadingIndicator, input::event::InputEvent, - loading_screen, ui::popup::{help::HelpPopUpBuilder, PopUp}, }; -pub async fn handle_mailing_list_selection( +pub async fn handle_mailing_list_selection( app: &mut App, input: InputEvent, - mut terminal: Terminal, -) -> color_eyre::Result>> -where - B: Backend + Send + 'static, -{ + loading: &mut dyn LoadingIndicator, +) -> color_eyre::Result> { match input { InputEvent::OpenHelp => { let popup = generate_help_popup(); @@ -39,26 +34,21 @@ where .target_list() .to_string(); - terminal = loading_screen! { - terminal, - format!("Fetching patchsets from {}", list_name) => { - let result = app.fetch_latest_current_page().await; - if result.is_ok() { - app.state.lore.mailing_list_selection.clear_target_list(); - app.set_current_screen(CurrentScreen::LatestPatchsets); - } - result - } - }; + loading.start(format!("Fetching patchsets from {list_name}")); + let result = app.fetch_latest_current_page().await; + loading.stop()?; + if result.is_ok() { + app.state.lore.mailing_list_selection.clear_target_list(); + app.set_current_screen(CurrentScreen::LatestPatchsets); + } + result?; } } InputEvent::RefreshMailingLists => { - terminal = loading_screen! { - terminal, - "Refreshing lists" => { - app.refresh_mailing_lists().await - } - }; + loading.start("Refreshing lists".to_string()); + let result = app.refresh_mailing_lists().await; + loading.stop()?; + result?; } InputEvent::OpenEditConfig => { app.init_edit_config(); @@ -99,7 +89,7 @@ where } _ => {} } - Ok(ControlFlow::Continue(terminal)) + Ok(ControlFlow::Continue(())) } // TODO: Move this to a more appropriate place diff --git a/src/handler/mod.rs b/src/handler/mod.rs index 816ccdf..2521c57 100644 --- a/src/handler/mod.rs +++ b/src/handler/mod.rs @@ -4,18 +4,21 @@ mod edit_config; mod latest; mod mail_list; -use ratatui::{prelude::Backend, Terminal}; +use std::{ + ops::ControlFlow, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, + }, + time::Duration, +}; -use std::ops::ControlFlow; +use tokio::task::JoinHandle; use crate::{ app::{screens::CurrentScreen, App}, - input::{ - event::InputEvent, - mapper::InputMapper, - terminal_source::{CrosstermEventSource, TerminalEventSource}, - }, - ui::draw_ui, + input::{event::InputEvent, mapper::InputMapper}, + terminal::{handle::TerminalHandle, messages::TerminalFrame, TerminalError}, }; use bookmarked::handle_bookmarked_patchsets; @@ -24,14 +27,84 @@ use edit_config::handle_edit_config; use latest::handle_latest_patchsets; use mail_list::handle_mailing_list_selection; -async fn input_handling( - mut terminal: Terminal, +const LOADING_FRAME_INTERVAL: Duration = Duration::from_millis(200); + +pub(crate) trait LoadingIndicator { + fn start(&mut self, title: String); + fn stop(&mut self) -> color_eyre::Result<()>; +} + +struct TerminalLoadingIndicator { + terminal_handle: TerminalHandle, + running: Option>, + spinner_task: Option>, +} + +impl TerminalLoadingIndicator { + fn new(terminal_handle: TerminalHandle) -> Self { + Self { + terminal_handle, + running: None, + spinner_task: None, + } + } +} + +impl LoadingIndicator for TerminalLoadingIndicator { + fn start(&mut self, title: String) { + if self.spinner_task.is_some() { + return; + } + + let running = Arc::new(AtomicBool::new(true)); + let running_clone = Arc::clone(&running); + let terminal_handle = self.terminal_handle.clone(); + + self.running = Some(running); + self.spinner_task = Some(tokio::spawn(async move { + while running_clone.load(Ordering::Relaxed) { + if terminal_handle + .draw(TerminalFrame::Loading(title.clone())) + .await + .is_err() + { + break; + } + + std::thread::sleep(LOADING_FRAME_INTERVAL); + } + })); + + std::thread::sleep(LOADING_FRAME_INTERVAL); + } + + fn stop(&mut self) -> color_eyre::Result<()> { + let Some(spinner_task) = self.spinner_task.take() else { + return Ok(()); + }; + + if let Some(running) = self.running.take() { + running.store(false, Ordering::Relaxed); + } + + tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(async { spinner_task.await.ok() }); + }); + + Ok(()) + } +} + +fn terminal_error(error: TerminalError) -> color_eyre::Report { + color_eyre::eyre::eyre!("{error}") +} + +async fn input_handling( app: &mut App, input: InputEvent, -) -> color_eyre::Result>> -where - B: Backend + Send + 'static, -{ + terminal_handle: &TerminalHandle, + loading: &mut TerminalLoadingIndicator, +) -> color_eyre::Result> { if let Some(popup) = app.state.popup.as_mut() { if input == InputEvent::ClosePopup { app.state.popup = None; @@ -41,51 +114,71 @@ where } else { match app.state.navigation.current_screen { CurrentScreen::MailingListSelection => { - return handle_mailing_list_selection(app, input, terminal).await; + match handle_mailing_list_selection(app, input, loading).await? { + ControlFlow::Continue(()) => {} + ControlFlow::Break(()) => return Ok(ControlFlow::Break(())), + } } CurrentScreen::BookmarkedPatchsets => { - return handle_bookmarked_patchsets(app, input, terminal).await; + handle_bookmarked_patchsets(app, input, loading).await?; } CurrentScreen::PatchsetDetails => { - handle_patchset_details(app, input, &mut terminal).await?; + handle_patchset_details(app, input, terminal_handle).await?; } CurrentScreen::EditConfig => { handle_edit_config(app, input)?; } CurrentScreen::LatestPatchsets => { - return handle_latest_patchsets(app, input, terminal).await; + handle_latest_patchsets(app, input, loading).await?; } } } - Ok(ControlFlow::Continue(terminal)) + Ok(ControlFlow::Continue(())) } -pub async fn run_app(mut terminal: Terminal, mut app: App) -> color_eyre::Result<()> -where - B: Backend + Send + 'static, -{ - let mut event_source = CrosstermEventSource; +pub async fn run_app(mut app: App, terminal_handle: TerminalHandle) -> color_eyre::Result<()> { let mut input_mapper = InputMapper::default(); + let mut loading = TerminalLoadingIndicator::new(terminal_handle.clone()); loop { - terminal = app.process_system_updates(terminal).await?; + app.process_system_updates(&mut loading).await?; - terminal.draw(|f| draw_ui(f, &app.to_view_model()))?; + terminal_handle + .draw(TerminalFrame::Main(Box::new(app.render_snapshot()))) + .await + .map_err(terminal_error)?; - // *IMPORTANT*: Uncommenting the if below makes `patch-hub` not block - // until an event is captured. We should only do it when (if ever) we - // 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(terminal_event) = event_source.read_event()? { + if let Some(terminal_event) = terminal_handle.read_event().await.map_err(terminal_error)? { 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(()), + match input_handling(&mut app, input, &terminal_handle, &mut loading).await? { + ControlFlow::Continue(()) => {} + ControlFlow::Break(()) => return Ok(()), } } } - // } + } +} + +#[cfg(test)] +mod tests { + use crate::terminal::{actor::TerminalActor, session::MockTerminalSessionApi}; + + use super::*; + + #[tokio::test(flavor = "multi_thread")] + async fn loading_indicator_draws_loading_frame_through_terminal_handle() { + let mut session = MockTerminalSessionApi::new(); + session + .expect_draw() + .withf(|frame| matches!(frame, TerminalFrame::Loading(_))) + .times(1..) + .returning(|_| Ok(())); + let handle = TerminalActor::spawn(Box::new(session)); + let mut loading = TerminalLoadingIndicator::new(handle); + + loading.start("Fetching mailing lists".to_string()); + std::thread::sleep(LOADING_FRAME_INTERVAL); + loading.stop().unwrap(); } } diff --git a/src/infrastructure/errors.rs b/src/infrastructure/errors.rs index 12d2138..d07356d 100644 --- a/src/infrastructure/errors.rs +++ b/src/infrastructure/errors.rs @@ -4,6 +4,10 @@ use super::terminal::restore; /// This replaces the standard color_eyre panic and error hooks with hooks that /// restore the terminal before printing the panic or error. +/// +/// Normal application shutdown restores the terminal through +/// [`crate::terminal::handle::TerminalHandle::shutdown`]. These hooks keep a +/// direct [`super::terminal::restore`] fallback for panics and fatal errors. pub fn install_hooks() -> color_eyre::Result<()> { let (panic_hook, eyre_hook) = color_eyre::config::HookBuilder::default().into_hooks(); diff --git a/src/infrastructure/terminal.rs b/src/infrastructure/terminal.rs index c8bf6db..ec42680 100644 --- a/src/infrastructure/terminal.rs +++ b/src/infrastructure/terminal.rs @@ -1,3 +1,10 @@ +//! Low-level Crossterm/Ratatui helpers for the terminal actor session and +//! emergency restore hooks. +//! +//! Normal runtime startup uses [`init`] from `main`, session operations go +//! through [`crate::terminal::session::CrosstermTerminalSession`], and fatal +//! error hooks call [`restore`] directly. + use ratatui::{ crossterm::{ execute, @@ -27,7 +34,7 @@ pub fn restore() -> io::Result<()> { Ok(()) } -pub fn setup_user_io(terminal: &mut Terminal) -> color_eyre::Result<()> { +pub(crate) fn setup_user_io(terminal: &mut Terminal) -> color_eyre::Result<()> { terminal.clear()?; terminal.set_cursor_position(Position::new(0, 0))?; terminal.show_cursor()?; @@ -35,7 +42,7 @@ pub fn setup_user_io(terminal: &mut Terminal) -> color_eyre::Resu Ok(()) } -pub fn teardown_user_io(terminal: &mut Terminal) -> color_eyre::Result<()> { +pub(crate) fn teardown_user_io(terminal: &mut Terminal) -> color_eyre::Result<()> { enable_raw_mode()?; terminal.clear()?; Ok(()) diff --git a/src/input/mod.rs b/src/input/mod.rs index 1aca996..7d7d3a5 100644 --- a/src/input/mod.rs +++ b/src/input/mod.rs @@ -1,13 +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. +//! Phase 9 uses a pull loop: `handler::run_app` reads raw +//! [`event::TerminalEvent`] values through the terminal actor, then +//! [`mapper::InputMapper`] translates them using [`context::InputContext`] +//! before handlers consume semantic [`event::InputEvent`] values. Phase 10 +//! will introduce an `InputActor` that broadcasts terminal events instead of +//! this direct pull loop. 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 deleted file mode 100644 index acc59ed..0000000 --- a/src/input/terminal_source.rs +++ /dev/null @@ -1,124 +0,0 @@ -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/macros.rs b/src/macros.rs index dae1a5e..bf88b0c 100644 --- a/src/macros.rs +++ b/src/macros.rs @@ -1,53 +1,3 @@ -#[macro_export] -/// Macro that encapsulates a piece of code that takes long to run and displays a loading screen while it runs. -/// -/// This macro takes two arguments: the terminal and the title of the loading screen (anything that implements `Display`). -/// After a `=>` token, you can pass the code that takes long to run. -/// -/// When the execution finishes, the macro will return the terminal. -/// -/// Important to notice that the code block will run in the same scope as the rest of the macro. -/// Be aware that in Rust, when using `?` or `return` inside a closure, they apply to the outer function, -/// not the closure itself. This can lead to unexpected behavior if you expect the closure to handle -/// errors or return values independently of the enclosing function. -/// -/// # Example -/// ```rust norun -/// terminal = loading_screen! { terminal, "Loading stuff" => { -/// // code that takes long to run -/// }}; -/// ``` -macro_rules! loading_screen { - { $terminal:expr, $title:expr => $inst:expr} => { - { - let loading = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(true)); - let loading_clone = std::sync::Arc::clone(&loading); - let mut terminal = $terminal; - - let handle = std::thread::spawn(move || { - while loading_clone.load(std::sync::atomic::Ordering::Relaxed) { - terminal = $crate::ui::loading_screen::render(terminal, $title); - std::thread::sleep(std::time::Duration::from_millis(200)); - } - - terminal - }); - - // we have to sleep so the loading thread completes at least one render - std::thread::sleep(std::time::Duration::from_millis(200)); - let inst_result = $inst; - - loading.store(false, std::sync::atomic::Ordering::Relaxed); - - let terminal = handle.join().unwrap(); - - inst_result?; - - terminal - } - }; -} - #[macro_export] macro_rules! log_on_error { ($result:expr) => { diff --git a/src/main.rs b/src/main.rs index beb615b..a46ca8e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,6 +8,7 @@ mod lore; mod macros; mod render; mod render_prefs; +mod terminal; mod ui; use app::App; @@ -22,7 +23,7 @@ use infrastructure::{ monitoring::{init_monitoring, InitMonitoringProduct}, net::UreqNetClient, shell::OsShell, - terminal::{init, restore}, + terminal::init, }; use lore::{ application::{actor::LoreApiActor, cache::CacheTtl, service::LoreService}, @@ -36,6 +37,7 @@ use lore::{ use render::{actor::RenderActor, ShellRenderService}; use render_prefs::PatchRenderer; use std::{ops::ControlFlow, sync::Arc}; +use terminal::{actor::TerminalActor, session::CrosstermTerminalSession}; use tracing::{event, Level}; /// Verifies required and optional external binaries before the TUI runs. @@ -100,7 +102,6 @@ async fn main() -> color_eyre::Result<()> { let args = Cli::parse(); infrastructure::errors::install_hooks()?; - let mut terminal = init()?; let env = OsEnv; let config_service: Box = @@ -114,11 +115,13 @@ async fn main() -> color_eyre::Result<()> { logging_reload_handle, ); - match args.resolve(terminal, &config) { + match args.resolve(&config) { ControlFlow::Break(b) => return b, - ControlFlow::Continue(t) => terminal = t, + ControlFlow::Continue(()) => {} } + let terminal_handle = TerminalActor::spawn(Box::new(CrosstermTerminalSession::new(init()?))); + // Build shared infrastructure dependencies for LoreService let net = Arc::new(UreqNetClient::new()); let fs_arc: Arc = Arc::new(OsFileSystem); @@ -171,8 +174,11 @@ async fn main() -> color_eyre::Result<()> { bail!("patch-hub cannot be executed because some dependencies are missing, check logs for more information"); } - run_app(terminal, app).await?; - restore()?; + run_app(app, terminal_handle.clone()).await?; + terminal_handle + .shutdown() + .await + .map_err(|error| eyre!("{error}"))?; event!(Level::INFO, "patch-hub finished"); diff --git a/src/terminal/actor.rs b/src/terminal/actor.rs new file mode 100644 index 0000000..919fb02 --- /dev/null +++ b/src/terminal/actor.rs @@ -0,0 +1,282 @@ +use tokio::{ + sync::{mpsc, oneshot}, + task, +}; + +use crate::terminal::{ + handle::TerminalHandle, + messages::{TerminalMessage, TerminalResult}, + session::TerminalSessionApi, + TerminalError, +}; + +pub const DEFAULT_TERMINAL_CHANNEL_SIZE: usize = 32; + +pub struct TerminalActor { + session: Option>, + rx: mpsc::Receiver, +} + +impl TerminalActor { + pub fn new(session: Box, rx: mpsc::Receiver) -> Self { + Self { + session: Some(session), + rx, + } + } + + pub fn spawn(session: Box) -> TerminalHandle { + let (tx, rx) = mpsc::channel(DEFAULT_TERMINAL_CHANNEL_SIZE); + tracing::debug!( + channel_size = DEFAULT_TERMINAL_CHANNEL_SIZE, + "spawning terminal actor" + ); + tokio::spawn(Self::new(session, rx).run()); + TerminalHandle::new(tx) + } + + pub async fn run(mut self) { + tracing::info!("terminal actor started"); + while let Some(message) = self.rx.recv().await { + self.handle_message(message).await; + } + tracing::info!("terminal actor stopped"); + } + + async fn handle_message(&mut self, message: TerminalMessage) { + let message_name = message.name(); + tracing::debug!(message = message_name, "terminal request received"); + + match message { + TerminalMessage::Draw { frame, reply } => { + let result = self + .with_session(move |session| session.draw(frame)) + .await + .and_then(|result| result); + send_terminal_reply(message_name, reply, result); + } + TerminalMessage::ReadEvent { reply } => { + let result = self + .with_session(|session| session.read_event()) + .await + .and_then(|result| result); + send_terminal_reply(message_name, reply, result); + } + TerminalMessage::PollEvent { timeout, reply } => { + let result = self + .with_session(move |session| session.poll_event(timeout)) + .await + .and_then(|result| result); + send_terminal_reply(message_name, reply, result); + } + TerminalMessage::SetupUserIo { reply } => { + let result = self + .with_session(|session| session.setup_user_io()) + .await + .and_then(|result| result); + send_terminal_reply(message_name, reply, result); + } + TerminalMessage::TeardownUserIo { reply } => { + let result = self + .with_session(|session| session.teardown_user_io()) + .await + .and_then(|result| result); + send_terminal_reply(message_name, reply, result); + } + TerminalMessage::WaitForKeyPress { + key, + timeout, + reply, + } => { + let result = self + .with_session(move |session| session.wait_for_key_press(key, timeout)) + .await + .and_then(|result| result); + send_terminal_reply(message_name, reply, result); + } + TerminalMessage::GetSize { reply } => { + let result = self + .with_session(|session| session.size()) + .await + .and_then(|result| result); + send_terminal_reply(message_name, reply, result); + } + TerminalMessage::Shutdown { reply } => { + let result = self + .with_session(|session| session.shutdown()) + .await + .and_then(|result| result); + send_terminal_reply(message_name, reply, result); + } + } + } + + async fn with_session(&mut self, operation: F) -> TerminalResult + where + T: Send + 'static, + F: FnOnce(&mut dyn TerminalSessionApi) -> T + Send + 'static, + { + let mut session = self + .session + .take() + .ok_or_else(|| TerminalError::ActorUnavailable("session unavailable".to_string()))?; + let (session, result) = task::spawn_blocking(move || { + let result = operation(session.as_mut()); + (session, result) + }) + .await + .map_err(|e| TerminalError::ActorUnavailable(e.to_string()))?; + self.session = Some(session); + Ok(result) + } +} + +fn send_terminal_reply( + message_name: &'static str, + reply: oneshot::Sender>, + result: TerminalResult, +) { + if let Err(error) = &result { + tracing::warn!( + message = message_name, + error = %error, + "terminal request failed" + ); + } + + if reply.send(result).is_err() { + tracing::warn!( + message = message_name, + "terminal reply receiver dropped before response" + ); + } +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use ratatui::crossterm::event::KeyCode; + + use crate::{ + input::event::{KeyInput, TerminalEvent}, + terminal::messages::TerminalFrame, + terminal::session::MockTerminalSessionApi, + }; + + use super::*; + + fn spawn_test_actor(session: MockTerminalSessionApi) -> TerminalHandle { + TerminalActor::spawn(Box::new(session)) + } + + #[tokio::test] + async fn draw_returns_ok_from_actor() { + let mut session = MockTerminalSessionApi::new(); + session + .expect_draw() + .withf(|frame| matches!(frame, TerminalFrame::Empty)) + .times(1) + .returning(|_| Ok(())); + let handle = spawn_test_actor(session); + + let result = handle.draw(TerminalFrame::Empty).await; + + assert!(result.is_ok()); + } + + #[tokio::test] + async fn read_event_returns_terminal_event_from_actor() { + let expected = TerminalEvent::Key(KeyInput::press(KeyCode::Char('j'))); + let mut session = MockTerminalSessionApi::new(); + session + .expect_read_event() + .times(1) + .returning(move || Ok(Some(expected.clone()))); + let handle = spawn_test_actor(session); + + let result = handle.read_event().await.unwrap(); + + assert_eq!( + result, + Some(TerminalEvent::Key(KeyInput::press(KeyCode::Char('j')))) + ); + } + + #[tokio::test] + async fn sequential_requests_preserve_session() { + let mut session = MockTerminalSessionApi::new(); + session + .expect_draw() + .withf(|frame| matches!(frame, TerminalFrame::Empty)) + .times(1) + .returning(|_| Ok(())); + session.expect_size().times(1).returning(|| Ok((120, 40))); + let handle = spawn_test_actor(session); + + handle.draw(TerminalFrame::Empty).await.unwrap(); + let size = handle.size().await.unwrap(); + + assert_eq!(size, (120, 40)); + } + + #[tokio::test] + async fn wait_for_key_press_uses_requested_key_and_timeout() { + let mut session = MockTerminalSessionApi::new(); + session + .expect_wait_for_key_press() + .withf(|key, timeout| *key == KeyCode::Enter && *timeout == Duration::from_millis(50)) + .times(1) + .returning(|_, _| Ok(true)); + let handle = spawn_test_actor(session); + + let pressed = handle + .wait_for_key_press(KeyCode::Enter, Duration::from_millis(50)) + .await + .unwrap(); + + assert!(pressed); + } + + #[tokio::test] + async fn setup_user_io_delegates_to_session() { + let mut session = MockTerminalSessionApi::new(); + session.expect_setup_user_io().times(1).returning(|| Ok(())); + let handle = spawn_test_actor(session); + + handle.setup_user_io().await.unwrap(); + } + + #[tokio::test] + async fn teardown_user_io_delegates_to_session() { + let mut session = MockTerminalSessionApi::new(); + session + .expect_teardown_user_io() + .times(1) + .returning(|| Ok(())); + let handle = spawn_test_actor(session); + + handle.teardown_user_io().await.unwrap(); + } + + #[tokio::test] + async fn shutdown_delegates_to_session_and_is_safe_to_repeat() { + let mut session = MockTerminalSessionApi::new(); + session.expect_shutdown().times(2).returning(|| Ok(())); + let handle = spawn_test_actor(session); + + handle.shutdown().await.unwrap(); + handle.shutdown().await.unwrap(); + } + + #[tokio::test] + async fn closed_channel_returns_actor_unavailable() { + let (tx, rx) = mpsc::channel(DEFAULT_TERMINAL_CHANNEL_SIZE); + let handle = TerminalHandle::new(tx); + drop(rx); + + let result = handle.draw(TerminalFrame::Empty).await; + + assert!(matches!(result, Err(TerminalError::ActorUnavailable(_)))); + } +} diff --git a/src/terminal/errors.rs b/src/terminal/errors.rs new file mode 100644 index 0000000..673256f --- /dev/null +++ b/src/terminal/errors.rs @@ -0,0 +1,12 @@ +use thiserror::Error; + +/// Failures at the terminal actor/session boundary. +#[derive(Debug, Error)] +pub enum TerminalError { + #[error("terminal I/O error: {0}")] + Io(#[from] std::io::Error), + #[error("terminal session error: {0}")] + Session(String), + #[error("terminal actor unavailable: {0}")] + ActorUnavailable(String), +} diff --git a/src/terminal/handle.rs b/src/terminal/handle.rs new file mode 100644 index 0000000..83973bb --- /dev/null +++ b/src/terminal/handle.rs @@ -0,0 +1,88 @@ +use std::time::Duration; + +use ratatui::crossterm::event::KeyCode; +use tokio::sync::{mpsc, oneshot}; + +use crate::{ + input::event::TerminalEvent, + terminal::{ + messages::{TerminalFrame, TerminalMessage, TerminalResult}, + TerminalError, + }, +}; + +#[derive(Clone)] +pub struct TerminalHandle { + tx: mpsc::Sender, +} + +impl TerminalHandle { + pub fn new(tx: mpsc::Sender) -> Self { + Self { tx } + } + + pub async fn draw(&self, frame: TerminalFrame) -> TerminalResult<()> { + self.request_result(|reply| TerminalMessage::Draw { frame, reply }) + .await + } + + pub async fn read_event(&self) -> TerminalResult> { + self.request_result(|reply| TerminalMessage::ReadEvent { reply }) + .await + } + + #[allow(dead_code)] // Reserved for Phase 10 non-blocking input polling. + pub async fn poll_event(&self, timeout: Duration) -> TerminalResult> { + self.request_result(|reply| TerminalMessage::PollEvent { timeout, reply }) + .await + } + + pub async fn setup_user_io(&self) -> TerminalResult<()> { + self.request_result(|reply| TerminalMessage::SetupUserIo { reply }) + .await + } + + pub async fn teardown_user_io(&self) -> TerminalResult<()> { + self.request_result(|reply| TerminalMessage::TeardownUserIo { reply }) + .await + } + + pub async fn wait_for_key_press( + &self, + key: KeyCode, + timeout: Duration, + ) -> TerminalResult { + self.request_result(|reply| TerminalMessage::WaitForKeyPress { + key, + timeout, + reply, + }) + .await + } + + pub async fn size(&self) -> TerminalResult<(u16, u16)> { + self.request_result(|reply| TerminalMessage::GetSize { reply }) + .await + } + + pub async fn shutdown(&self) -> TerminalResult<()> { + self.request_result(|reply| TerminalMessage::Shutdown { reply }) + .await + } + + async fn request_result( + &self, + build_message: impl FnOnce(oneshot::Sender>) -> TerminalMessage, + ) -> TerminalResult + where + T: Send + 'static, + { + let (reply, rx) = oneshot::channel(); + self.tx + .send(build_message(reply)) + .await + .map_err(|_| TerminalError::ActorUnavailable("request channel closed".to_string()))?; + rx.await + .map_err(|_| TerminalError::ActorUnavailable("reply channel closed".to_string()))? + } +} diff --git a/src/terminal/messages.rs b/src/terminal/messages.rs new file mode 100644 index 0000000..879f4c7 --- /dev/null +++ b/src/terminal/messages.rs @@ -0,0 +1,66 @@ +use std::time::Duration; + +use ratatui::crossterm::event::KeyCode; +use tokio::sync::oneshot; + +use crate::{ + app::render_snapshot::AppRenderSnapshot, input::event::TerminalEvent, terminal::TerminalError, +}; + +pub type TerminalResult = Result; + +/// Owned payload for a terminal draw request. +#[derive(Clone, Default)] +pub enum TerminalFrame { + Main(Box), + Loading(String), + /// Placeholder frame used in terminal actor tests. + #[default] + Empty, +} + +pub enum TerminalMessage { + Draw { + frame: TerminalFrame, + reply: oneshot::Sender>, + }, + ReadEvent { + reply: oneshot::Sender>>, + }, + PollEvent { + timeout: Duration, + reply: oneshot::Sender>>, + }, + SetupUserIo { + reply: oneshot::Sender>, + }, + TeardownUserIo { + reply: oneshot::Sender>, + }, + WaitForKeyPress { + key: KeyCode, + timeout: Duration, + reply: oneshot::Sender>, + }, + GetSize { + reply: oneshot::Sender>, + }, + Shutdown { + reply: oneshot::Sender>, + }, +} + +impl TerminalMessage { + pub fn name(&self) -> &'static str { + match self { + TerminalMessage::Draw { .. } => "Draw", + TerminalMessage::ReadEvent { .. } => "ReadEvent", + TerminalMessage::PollEvent { .. } => "PollEvent", + TerminalMessage::SetupUserIo { .. } => "SetupUserIo", + TerminalMessage::TeardownUserIo { .. } => "TeardownUserIo", + TerminalMessage::WaitForKeyPress { .. } => "WaitForKeyPress", + TerminalMessage::GetSize { .. } => "GetSize", + TerminalMessage::Shutdown { .. } => "Shutdown", + } + } +} diff --git a/src/terminal/mod.rs b/src/terminal/mod.rs new file mode 100644 index 0000000..3d14168 --- /dev/null +++ b/src/terminal/mod.rs @@ -0,0 +1,16 @@ +//! Actor boundary for the Ratatui/Crossterm terminal session. +//! +//! Phase 9 makes this module the single owner of terminal session operations. +//! The runtime pull loop in `handler::run_app` draws through +//! [`handle::TerminalHandle::draw`] and reads input through +//! [`handle::TerminalHandle::read_event`]. Phase 10 will introduce an +//! `InputActor` that broadcasts terminal events instead of the current +//! direct pull loop. + +pub mod actor; +pub mod errors; +pub mod handle; +pub mod messages; +pub mod session; + +pub use errors::TerminalError; diff --git a/src/terminal/session.rs b/src/terminal/session.rs new file mode 100644 index 0000000..4ca1d20 --- /dev/null +++ b/src/terminal/session.rs @@ -0,0 +1,191 @@ +use std::time::Duration; + +use mockall::automock; +use ratatui::crossterm::event::{self, Event, KeyCode, KeyEventKind}; + +use crate::{ + infrastructure::terminal::{ + restore, setup_user_io as setup_terminal_user_io, + teardown_user_io as teardown_terminal_user_io, Tui, + }, + input::event::{KeyInput, TerminalEvent}, + terminal::{ + messages::{TerminalFrame, TerminalResult}, + TerminalError, + }, + ui::{draw_ui, loading_screen::draw_loading_screen}, +}; + +/// Stateful terminal session owned by the terminal actor. +#[automock] +pub trait TerminalSessionApi: Send { + fn draw(&mut self, frame: TerminalFrame) -> TerminalResult<()>; + fn read_event(&mut self) -> TerminalResult>; + fn poll_event(&mut self, timeout: Duration) -> TerminalResult>; + fn setup_user_io(&mut self) -> TerminalResult<()>; + fn teardown_user_io(&mut self) -> TerminalResult<()>; + fn wait_for_key_press(&mut self, key: KeyCode, timeout: Duration) -> TerminalResult; + fn size(&self) -> TerminalResult<(u16, u16)>; + fn shutdown(&mut self) -> TerminalResult<()>; +} + +/// Ratatui/Crossterm-backed terminal session. +pub struct CrosstermTerminalSession { + terminal: Tui, + shutdown: bool, +} + +impl CrosstermTerminalSession { + pub fn new(terminal: Tui) -> Self { + Self { + terminal, + shutdown: false, + } + } + + 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, + } + } +} + +impl TerminalSessionApi for CrosstermTerminalSession { + fn draw(&mut self, frame: TerminalFrame) -> TerminalResult<()> { + match frame { + TerminalFrame::Main(snapshot) => { + self.terminal + .draw(|f| draw_ui(f, &snapshot.to_view_model()))?; + } + TerminalFrame::Loading(title) => { + self.terminal.draw(|f| draw_loading_screen(f, &title))?; + } + TerminalFrame::Empty => { + self.terminal.draw(|_| {})?; + } + } + Ok(()) + } + + fn read_event(&mut self) -> TerminalResult> { + Ok(Self::terminal_event_from_crossterm_event(event::read()?)) + } + + fn poll_event(&mut self, timeout: Duration) -> TerminalResult> { + if !event::poll(timeout)? { + return Ok(None); + } + + self.read_event() + } + + fn setup_user_io(&mut self) -> TerminalResult<()> { + setup_terminal_user_io(&mut self.terminal) + .map_err(|err| TerminalError::Session(err.to_string())) + } + + fn teardown_user_io(&mut self) -> TerminalResult<()> { + teardown_terminal_user_io(&mut self.terminal) + .map_err(|err| TerminalError::Session(err.to_string())) + } + + fn wait_for_key_press(&mut self, key: KeyCode, timeout: Duration) -> TerminalResult { + wait_for_key_press_from_session(self, key, timeout) + } + + fn size(&self) -> TerminalResult<(u16, u16)> { + let size = self.terminal.size()?; + Ok((size.width, size.height)) + } + + fn shutdown(&mut self) -> TerminalResult<()> { + if self.shutdown { + return Ok(()); + } + + restore()?; + self.shutdown = true; + Ok(()) + } +} + +fn wait_for_key_press_from_session( + session: &mut dyn TerminalSessionApi, + key: KeyCode, + timeout: Duration, +) -> TerminalResult { + let started_at = std::time::Instant::now(); + + while started_at.elapsed() < timeout { + let elapsed = started_at.elapsed(); + let remaining = timeout.saturating_sub(elapsed); + let poll_timeout = remaining.min(Duration::from_millis(16)); + + if let Some(TerminalEvent::Key(input)) = session.poll_event(poll_timeout)? { + if input.code == key { + return Ok(true); + } + } + } + + Ok(false) +} + +#[cfg(test)] +mod tests { + use ratatui::crossterm::event::{ + Event, KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers, + }; + + use crate::input::event::{KeyInput, TerminalEvent}; + + use super::CrosstermTerminalSession; + + #[test] + fn converts_key_press_to_terminal_key_event() { + let key = KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE); + + let event = CrosstermTerminalSession::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 = CrosstermTerminalSession::terminal_event_from_crossterm_event(Event::Key(key)); + + assert_eq!(event, None); + } + + #[test] + fn converts_resize_events() { + let event = + CrosstermTerminalSession::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 = + CrosstermTerminalSession::terminal_event_from_crossterm_event(Event::FocusGained); + + assert_eq!(event, None); + } +} diff --git a/src/ui/loading_screen.rs b/src/ui/loading_screen.rs index 99343b4..79d3613 100644 --- a/src/ui/loading_screen.rs +++ b/src/ui/loading_screen.rs @@ -1,9 +1,8 @@ use ratatui::{ - prelude::Backend, style::{Color, Style}, text::{Line, Span}, widgets::{Block, Borders, Paragraph, Wrap}, - Frame, Terminal, + Frame, }; use std::fmt::Display; @@ -25,13 +24,6 @@ static mut SPINNER_TICK: usize = 1; const LOADING_AREA_EXTRA_FACTOR_WIDTH: f32 = 1.3; const LOADING_AREA_EXTRA_LINES: u16 = 2; -/// This function renders a loading screen taking a `terminal` instance and a -/// `title`. -pub fn render(mut terminal: Terminal, title: impl Display) -> Terminal { - let _ = terminal.draw(|f| draw_loading_screen(f, title)); - terminal -} - /// Gets the current spinner state and updates the tick. fn spinner() -> char { let char_to_ret = SPINNER[unsafe { SPINNER_TICK }]; @@ -43,7 +35,7 @@ fn spinner() -> char { /// The actual implementation of the loading screen rendering. Currently the /// loading notification is static. -fn draw_loading_screen(f: &mut Frame, title: impl Display) { +pub(crate) fn draw_loading_screen(f: &mut Frame, title: impl Display) { let frame_area = f.area(); let loading_text = format!("{} {}", title, spinner()); diff --git a/src/ui/popup/help.rs b/src/ui/popup/help.rs index b3ea975..9b9efe7 100644 --- a/src/ui/popup/help.rs +++ b/src/ui/popup/help.rs @@ -21,7 +21,7 @@ use super::PopUp; /// The description is displayed below the title and is optional /// The keybinds (also optional) are displayed in a table format with the key on the left and the help message on the right #[allow(dead_code)] -#[derive(Debug)] +#[derive(Clone, Debug)] pub struct HelpPopUp { title: Option, description: Option, diff --git a/src/ui/popup/info_popup.rs b/src/ui/popup/info_popup.rs index 17fc393..2691619 100644 --- a/src/ui/popup/info_popup.rs +++ b/src/ui/popup/info_popup.rs @@ -8,7 +8,7 @@ use ratatui::{ use super::PopUp; use crate::input::event::InputEvent; -#[derive(Debug)] +#[derive(Clone, Debug)] pub struct InfoPopUp { title: String, info: String, diff --git a/src/ui/popup/mod.rs b/src/ui/popup/mod.rs index 8901514..d08fbf5 100644 --- a/src/ui/popup/mod.rs +++ b/src/ui/popup/mod.rs @@ -8,8 +8,27 @@ use std::fmt::Debug; use crate::input::event::InputEvent; +pub trait PopUpClone { + fn clone_box(&self) -> Box; +} + +impl PopUpClone for T +where + T: 'static + PopUp + Clone, +{ + fn clone_box(&self) -> Box { + Box::new(self.clone()) + } +} + +impl Clone for Box { + fn clone(&self) -> Self { + self.clone_box() + } +} + /// A trait that represents a popup that can be rendered on top of a screen -pub trait PopUp: Debug { +pub trait PopUp: Debug + Send + PopUpClone { /// Returns the dimensions of the popup in percentage of the screen /// (width, height) /// diff --git a/src/ui/popup/review_trailers.rs b/src/ui/popup/review_trailers.rs index 88466a3..06e4d92 100644 --- a/src/ui/popup/review_trailers.rs +++ b/src/ui/popup/review_trailers.rs @@ -14,7 +14,7 @@ use crate::{ use super::PopUp; -#[derive(Debug)] +#[derive(Clone, Debug)] pub struct ReviewTrailersPopUp { reviewed_by_text: String, tested_by_text: String,