From 27c7f59b0ff1747a63b650c4bb358acecb153467 Mon Sep 17 00:00:00 2001 From: lorenzoberts Date: Sat, 27 Jun 2026 11:18:09 -0300 Subject: [PATCH 1/6] refactor(ui): add UI scene, error, and theme types This commit introduces the paint-ready data model for the UI actor boundary. UiScene and its per-screen scene types describe what the terminal painter needs for one frame, without embedding Ratatui or application state. UiError and UiTheme complete the actor scaffold alongside the existing terminal and input protocols. This commit is part of the architecture's refactoring phase 11. Signed-off-by: lorenzoberts --- src/ui/errors.rs | 12 +++ src/ui/mod.rs | 3 + src/ui/scene.rs | 217 +++++++++++++++++++++++++++++++++++++++++++++++ src/ui/theme.rs | 6 ++ 4 files changed, 238 insertions(+) create mode 100644 src/ui/errors.rs create mode 100644 src/ui/scene.rs create mode 100644 src/ui/theme.rs diff --git a/src/ui/errors.rs b/src/ui/errors.rs new file mode 100644 index 0000000..85d7fd8 --- /dev/null +++ b/src/ui/errors.rs @@ -0,0 +1,12 @@ +/// Errors produced by the UI actor boundary. +#[derive(thiserror::Error, Debug)] +pub enum UiError { + #[error("failed to build scene: {0}")] + Build(String), + + #[error("invalid screen state for UI: {0}")] + InvalidState(String), + + #[error("ui actor unavailable: {0}")] + ActorUnavailable(String), +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 958f0f8..41a034c 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -6,6 +6,9 @@ pub mod loading_screen; mod mail_list; mod navigation_bar; pub mod popup; +pub mod errors; +pub mod scene; +pub mod theme; use ratatui::{ layout::{Alignment, Constraint, Direction, Layout, Rect}, diff --git a/src/ui/scene.rs b/src/ui/scene.rs new file mode 100644 index 0000000..28b345b --- /dev/null +++ b/src/ui/scene.rs @@ -0,0 +1,217 @@ +//! Scene types produced by [`super::core::UiCore`] and consumed by +//! [`super::painter`]. +//! +//! A `UiScene` is a fully projected, paint-ready snapshot of application +//! state. Nothing inside this module reads from `App`, `AppState`, or any +//! actor handle — it is pure presentation data. + +use ratatui::text::{Span, Text}; + +// --------------------------------------------------------------------------- +// Mailing-list selection +// --------------------------------------------------------------------------- + +/// One mailing list row in the selection screen. +#[derive(Clone, Debug)] +pub struct MailingListEntry { + pub name: String, + pub description: String, +} + +/// Validity of the text the user has typed into the mailing-list filter box. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum TargetListStatus { + /// Nothing typed yet. + Empty, + /// The typed string is the name of an existing list. + ExactMatch, + /// The typed string is a prefix of at least one existing list. + PrefixMatch, + /// The typed string matches no list. + NoMatch, +} + +/// Scene for the mailing-list selection screen. +#[derive(Clone, Debug)] +pub struct MailingListScene { + pub entries: Vec, + pub highlighted_index: usize, + pub target_list: String, + pub target_list_status: TargetListStatus, +} + +// --------------------------------------------------------------------------- +// Shared patchset summary row (used by Latest and Bookmarked screens) +// --------------------------------------------------------------------------- + +/// A single row in a patchset list. +#[derive(Clone, Debug)] +pub struct PatchSummaryRow { + pub title: String, + pub author_name: String, + pub version: u32, + pub total_in_series: u32, + /// Absolute index within the full (possibly paginated) list. + pub absolute_index: usize, +} + +// --------------------------------------------------------------------------- +// Bookmarked patchsets +// --------------------------------------------------------------------------- + +/// Scene for the bookmarked-patchsets screen. +#[derive(Clone, Debug)] +pub struct BookmarkedScene { + pub rows: Vec, + pub selected_index: usize, +} + +// --------------------------------------------------------------------------- +// Latest patchsets +// --------------------------------------------------------------------------- + +/// Scene for the latest-patchsets screen. +#[derive(Clone, Debug)] +pub struct LatestScene { + pub rows: Vec, + pub selected_index: usize, + pub page_number: usize, + pub target_list: String, +} + +// --------------------------------------------------------------------------- +// Patchset details +// --------------------------------------------------------------------------- + +/// Pre-computed review-trailer counts for a single patch. +#[derive(Clone, Debug)] +pub struct TagTrailerCounts { + pub reviewed_by: usize, + pub tested_by: usize, + pub acked_by: usize, +} + +/// Scene for the patchset-details-and-actions screen. +#[derive(Clone, Debug)] +pub struct PatchsetDetailsScene { + pub patch_title: String, + pub author_name: String, + pub version: u32, + pub patch_count: u32, + pub last_updated: String, + /// Trailer counts for the currently previewed patch. + pub tag_trailer_counts: TagTrailerCounts, + /// Pre-computed "(0, 2, ...)" string shown when at least one patch is + /// staged for reply. `None` when nothing is staged. + pub staged_to_reply: Option, + /// ANSI-rendered diff/cover text for each patch entry. + pub preview_entries: Vec>, + pub preview_index: usize, + pub preview_scroll_offset: usize, + pub preview_pan: usize, + pub preview_fullscreen: bool, + /// Title shown above the preview pane, including the `[REVIEWED-BY]` or + /// `[REVIEWED-BY]*` suffix when applicable. Pre-computed by `App::present`. + pub preview_title: String, + pub is_bookmarked: bool, + pub is_apply_staged: bool, + /// Whether the patch at `preview_index` is staged for a Reviewed-by reply. + pub is_current_patch_reply_staged: bool, +} + +// --------------------------------------------------------------------------- +// Edit config +// --------------------------------------------------------------------------- + +/// One row in the edit-config form. +#[derive(Clone, Debug)] +pub struct ConfigEntryRow { + pub label: String, + pub value: String, + pub is_highlighted: bool, + /// Whether this row is currently being typed into. + pub is_editing: bool, + /// The in-progress text while editing (only meaningful when `is_editing`). + pub edit_cursor_value: String, +} + +/// Scene for the edit-configuration screen. +#[derive(Clone, Debug)] +pub struct EditConfigScene { + pub entries: Vec, + /// Whether any row is in editing mode. + pub is_editing_mode: bool, +} + +// --------------------------------------------------------------------------- +// Discriminated body +// --------------------------------------------------------------------------- + +/// Which screen's scene the body carries. +#[derive(Clone, Debug)] +pub enum UiBody { + MailingListSelection(MailingListScene), + Bookmarked(BookmarkedScene), + Latest(LatestScene), + PatchsetDetails(PatchsetDetailsScene), + EditConfig(EditConfigScene), +} + +// --------------------------------------------------------------------------- +// Navigation bar +// --------------------------------------------------------------------------- + +/// Pre-computed navigation-bar content. +/// +/// `mode_spans` is a list of styled text spans that together form the left +/// section (mode/context text). `keys_hint` is the right section. +/// Both are built with owned strings so the scene is `'static`-compatible. +#[derive(Clone, Debug)] +pub struct NavigationBarScene { + pub mode_spans: Vec>, + pub keys_hint: Span<'static>, +} + +// --------------------------------------------------------------------------- +// Popup +// --------------------------------------------------------------------------- + +/// Structured body for each popup variant. +#[derive(Clone, Debug)] +pub enum PopupBody { + /// Plain informational text (apply result, bookmark confirmation, …). + Text(String), + /// Help popup with optional description and pre-formatted keybind table. + Keybinds { + description: Option, + formatted_keybinds: String, + }, + /// Code-review-trailer popup with one section per trailer type. + ReviewTrailers { + reviewed_by: String, + tested_by: String, + acked_by: String, + }, +} + +/// Fully projected popup ready to be painted. +#[derive(Clone, Debug)] +pub struct PopupScene { + pub title: String, + pub body: PopupBody, + pub scroll_offset: (u16, u16), + /// `(width_percent, height_percent)` of the terminal area. + pub dimensions: (u16, u16), +} + +// --------------------------------------------------------------------------- +// Top-level scene +// --------------------------------------------------------------------------- + +/// The complete, paint-ready visual snapshot for one TUI frame. +#[derive(Clone, Debug)] +pub struct UiScene { + pub body: UiBody, + pub navigation: NavigationBarScene, + pub popup: Option, +} diff --git a/src/ui/theme.rs b/src/ui/theme.rs new file mode 100644 index 0000000..6d4254b --- /dev/null +++ b/src/ui/theme.rs @@ -0,0 +1,6 @@ +/// Visual theme configuration for the UI actor. +/// +/// Holds styling and layout policy. Kept minimal in phase 11; extended when +/// per-theme rendering is needed. +#[derive(Default, Clone, Debug)] +pub struct UiTheme; From 9eaa6bc48cbfbf6c736f77fd25f23a18a4f3bbb4 Mon Sep 17 00:00:00 2001 From: lorenzoberts Date: Sat, 27 Jun 2026 11:25:11 -0300 Subject: [PATCH 2/6] refactor(ui): replace Box with concrete AppPopup model This commit moves popup state and scroll bounds into an owned AppPopup enum in the application layer, replacing the self-rendering PopUp trait object in AppState. The UI layer now paints popups by matching on AppPopup variants instead of dispatching through dynamic objects, keeping popup data on the app side of the UI boundary. This commit is part of the architecture's refactoring phase 11. Signed-off-by: lorenzoberts --- src/app/mod.rs | 6 +- src/app/popup.rs | 220 ++++++++++++++++++++++++++++++++ src/app/state.rs | 14 +- src/handler/bookmarked.rs | 16 +-- src/handler/details_actions.rs | 13 +- src/handler/edit_config.rs | 12 +- src/handler/latest.rs | 15 +-- src/handler/mail_list.rs | 12 +- src/handler/mod.rs | 2 +- src/ui/errors.rs | 1 + src/ui/mod.rs | 11 +- src/ui/popup/help.rs | 200 ----------------------------- src/ui/popup/info_popup.rs | 103 --------------- src/ui/popup/mod.rs | 206 ++++++++++++++++++++++++------ src/ui/popup/review_trailers.rs | 162 ----------------------- src/ui/scene.rs | 4 + src/ui/theme.rs | 1 + 17 files changed, 441 insertions(+), 557 deletions(-) create mode 100644 src/app/popup.rs delete mode 100644 src/ui/popup/help.rs delete mode 100644 src/ui/popup/info_popup.rs delete mode 100644 src/ui/popup/review_trailers.rs diff --git a/src/app/mod.rs b/src/app/mod.rs index 5ba58de..a51d385 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 popup; pub mod render_snapshot; pub mod screens; pub mod state; @@ -29,7 +30,6 @@ use crate::{ domain::patch::Patch, }, render::{handle::RenderHandle, RenderPatchsetRequest}, - ui::popup::info_popup::InfoPopUp, }; use screens::{ bookmarked::BookmarkedPatchsetsState, @@ -391,8 +391,8 @@ impl App { &*self.services.shell, &self.state.config, ) { - Ok(msg) => InfoPopUp::generate_info_popup("Patchset Apply Success", &msg), - Err(msg) => InfoPopUp::generate_info_popup("Patchset Apply Fail", &msg), + Ok(msg) => popup::AppPopup::info("Patchset Apply Success", msg), + Err(msg) => popup::AppPopup::info("Patchset Apply Fail", msg), }; self.state.popup = Some(popup); diff --git a/src/app/popup.rs b/src/app/popup.rs new file mode 100644 index 0000000..814f6e0 --- /dev/null +++ b/src/app/popup.rs @@ -0,0 +1,220 @@ +//! Application-layer popup state. +//! +//! `AppPopup` replaces the old `Box` trait object in `AppState`. +//! Each variant owns all data required to present the popup and tracks scroll +//! position so the presentation layer receives a read-only snapshot. + +use crate::{app::screens::details_actions::PatchsetDetailsState, input::event::InputEvent}; + +// --------------------------------------------------------------------------- +// Main enum +// --------------------------------------------------------------------------- + +/// Concrete, cloneable popup state stored in `AppState`. +#[derive(Clone, Debug)] +pub enum AppPopup { + Info { + title: String, + body: String, + scroll: (u16, u16), + max_scroll: (u16, u16), + dimensions: (u16, u16), + }, + Help { + title: Option, + description: Option, + formatted_keybinds: String, + scroll: (u16, u16), + max_scroll: (u16, u16), + dimensions: (u16, u16), + }, + ReviewTrailers { + reviewed_by: String, + tested_by: String, + acked_by: String, + scroll: (u16, u16), + max_scroll: (u16, u16), + dimensions: (u16, u16), + }, +} + +impl AppPopup { + // ----------------------------------------------------------------------- + // Factories + // ----------------------------------------------------------------------- + + /// Create an informational text popup. + pub fn info(title: impl Into, body: impl Into) -> Self { + let title = title.into(); + let body = body.into(); + let mut lines = 0u16; + let mut columns = 0u16; + for line in body.lines() { + lines += 1; + let len = line.len() as u16; + if len > columns { + columns = len; + } + } + AppPopup::Info { + title, + body, + scroll: (0, 0), + max_scroll: (lines, columns), + dimensions: (30, 50), + } + } + + /// Start building a help popup using a fluent builder. + pub fn help() -> AppHelpBuilder { + AppHelpBuilder::default() + } + + /// Create a code-review-trailers popup from the current patchset details + /// state. The preview index selects which patch's trailers are shown. + pub fn review_trailers(details: &PatchsetDetailsState) -> Self { + let i = details.preview_index; + let mut columns: usize = 0; + + let mut format_section = + |authors: &std::collections::HashSet| -> String { + let mut text = String::new(); + for author in authors { + let line = format!(" - {author}\n"); + if line.len() > columns { + columns = line.len(); + } + text.push_str(&line); + } + text + }; + + let reviewed_by = format_section(&details.reviewed_by[i]); + let tested_by = format_section(&details.tested_by[i]); + let acked_by = format_section(&details.acked_by[i]); + + let lines = (3 + + details.reviewed_by[i].len() + + details.tested_by[i].len() + + details.acked_by[i].len()) as u16; + + AppPopup::ReviewTrailers { + reviewed_by, + tested_by, + acked_by, + scroll: (0, 0), + max_scroll: (lines, columns as u16), + dimensions: (50, 40), + } + } + + // ----------------------------------------------------------------------- + // Input handling + // ----------------------------------------------------------------------- + + /// Advance scroll position in response to a navigation input. + /// + /// All popup variants share identical two-axis scroll semantics. + pub fn handle_scroll(&mut self, input: InputEvent) { + let (scroll, max_scroll) = match self { + AppPopup::Info { + scroll, max_scroll, .. + } + | AppPopup::Help { + scroll, max_scroll, .. + } + | AppPopup::ReviewTrailers { + scroll, max_scroll, .. + } => (scroll, max_scroll), + }; + + match input { + InputEvent::NavigateUp => { + scroll.0 = scroll.0.saturating_sub(1); + } + InputEvent::NavigateDown => { + if scroll.0 < max_scroll.0 { + scroll.0 += 1; + } + } + InputEvent::NavigateLeft => { + if scroll.1 > 0 { + scroll.1 -= 1; + } + } + InputEvent::NavigateRight => { + if scroll.1 < max_scroll.1 { + scroll.1 += 1; + } + } + _ => {} + } + } + + /// `(width_percent, height_percent)` used for the centred popup rect. + pub fn dimensions(&self) -> (u16, u16) { + match self { + AppPopup::Info { dimensions, .. } + | AppPopup::Help { dimensions, .. } + | AppPopup::ReviewTrailers { dimensions, .. } => *dimensions, + } + } +} + +// --------------------------------------------------------------------------- +// Help builder +// --------------------------------------------------------------------------- + +/// Fluent builder for `AppPopup::Help`. +/// +/// Mirrors the API of the old `HelpPopUpBuilder` so handler call sites change +/// minimally. +#[derive(Default)] +pub struct AppHelpBuilder { + title: Option, + description: Option, + keybinds: Vec<(String, String)>, +} + +impl AppHelpBuilder { + pub fn title(mut self, t: &str) -> Self { + self.title = Some(t.to_string()); + self + } + + pub fn description(mut self, d: &str) -> Self { + self.description = Some(d.to_string()); + self + } + + pub fn keybind(mut self, key: impl Into, help: impl Into) -> Self { + self.keybinds.push((key.into(), help.into())); + self + } + + pub fn build(self) -> AppPopup { + let key_len = self + .keybinds + .iter() + .fold(0usize, |acc, (k, _)| acc.max(k.len())); + + let formatted_keybinds = self.keybinds.iter().fold(String::new(), |acc, (k, v)| { + acc + &format!("{k:>key_len$}: {v}\n") + }); + + let lines = self.keybinds.len() as u16; + let columns = self + .keybinds + .iter() + .fold(0u16, |acc, (k, v)| acc.max((k.len() + v.len()) as u16)); + + AppPopup::Help { + title: self.title, + description: self.description, + formatted_keybinds, + scroll: (0, 0), + max_scroll: (lines, columns), + dimensions: (50, 50), + } + } +} diff --git a/src/app/state.rs b/src/app/state.rs index 061e6a4..0d90dc1 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -1,13 +1,15 @@ use std::collections::{HashMap, HashSet}; use crate::{ - app::screens::{ - bookmarked::BookmarkedPatchsetsState, details_actions::PatchsetDetailsState, - edit_config::EditConfigState, latest::LatestPatchsetsState, - mail_list::MailingListSelectionState, CurrentScreen, + app::{ + popup::AppPopup, + screens::{ + bookmarked::BookmarkedPatchsetsState, details_actions::PatchsetDetailsState, + edit_config::EditConfigState, latest::LatestPatchsetsState, + mail_list::MailingListSelectionState, CurrentScreen, + }, }, config::ConfigSnapshot, - ui::popup::PopUp, }; /// Navigation-only state: which screen is active. @@ -45,5 +47,5 @@ pub struct AppState { pub user_state: UserLoreState, pub config_state: ConfigUiState, pub config: ConfigSnapshot, - pub popup: Option>, + pub popup: Option, } diff --git a/src/handler/bookmarked.rs b/src/handler/bookmarked.rs index b2d725c..47f46ee 100644 --- a/src/handler/bookmarked.rs +++ b/src/handler/bookmarked.rs @@ -1,8 +1,7 @@ use crate::{ - app::{screens::CurrentScreen, App, B4Result}, + app::{popup::AppPopup, screens::CurrentScreen, App, B4Result}, handler::LoadingIndicator, input::event::InputEvent, - ui::popup::{help::HelpPopUpBuilder, info_popup::InfoPopUp, PopUp}, }; pub async fn handle_bookmarked_patchsets( @@ -45,8 +44,9 @@ pub async fn handle_bookmarked_patchsets( 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.state.popup = Some(AppPopup::info( + "Error", + format!("The selected patchset couldn't be retrieved.\nReason: {err_cause}\nPlease choose another patchset."), )); app.set_current_screen(CurrentScreen::BookmarkedPatchsets); } @@ -58,8 +58,8 @@ pub async fn handle_bookmarked_patchsets( Ok(()) } -pub fn generate_help_popup() -> Box { - let popup = HelpPopUpBuilder::new() +pub fn generate_help_popup() -> AppPopup { + AppPopup::help() .title("Bookmarked Patchsets") .description("This screen shows all the patchsets you have bookmarked.\nThis is quite useful to keep track of patchsets you are interested in take a look later.") .keybind("ESC", "Exit") @@ -67,7 +67,5 @@ pub fn generate_help_popup() -> Box { .keybind("?", "Show this help screen") .keybind("j/🡇", "Down") .keybind("k/🡅", "Up") - .build(); - - Box::new(popup) + .build() } diff --git a/src/handler/details_actions.rs b/src/handler/details_actions.rs index 3d48160..7b9cc5c 100644 --- a/src/handler/details_actions.rs +++ b/src/handler/details_actions.rs @@ -3,10 +3,9 @@ use std::time::Duration; use ratatui::crossterm::event::KeyCode; use crate::{ - app::{screens::CurrentScreen, App}, + app::{popup::AppPopup, screens::CurrentScreen, App}, input::event::{InputEvent, ScrollAmount}, terminal::handle::TerminalHandle, - ui::popup::{help::HelpPopUpBuilder, review_trailers::ReviewTrailersPopUp, PopUp}, }; const USER_IO_ENTER_POLL_TIMEOUT: Duration = Duration::from_millis(200); @@ -73,7 +72,7 @@ pub async fn handle_patchset_details( patchset_details_and_actions.toggle_reply_with_reviewed_by_action(true); } InputEvent::ShowReviewTrailers => { - let popup = ReviewTrailersPopUp::generate_trailers_popup(patchset_details_and_actions); + let popup = AppPopup::review_trailers(patchset_details_and_actions); app.state.popup = Some(popup); } InputEvent::ConsolidatePatchsetActions => { @@ -108,8 +107,8 @@ async fn preview_scroll_lines( }) } -pub fn generate_help_popup() -> Box { - let popup = HelpPopUpBuilder::new() +pub fn generate_help_popup() -> AppPopup { + AppPopup::help() .title("Patchset Details and Actions") .description("This screen displays the details of a patchset and allows you to perform actions on it.\nA series of actions are available to you, they are:\n - Bookmark: Save the patchset for later\n - Reply with Reviewed-by: Reply to the patchset with a Reviewed-by tag") .keybind("ESC", "Exit") @@ -129,7 +128,5 @@ pub fn generate_help_popup() -> Box { .keybind("r", "Toggle reply with Reviewed-by action") .keybind("Shift+r", "Toggle reply with Reviewed-by action for all patches") .keybind("Ctrl+t", "Show code-review trailers details") - .build(); - - Box::new(popup) + .build() } diff --git a/src/handler/edit_config.rs b/src/handler/edit_config.rs index 7ede40f..bc3418e 100644 --- a/src/handler/edit_config.rs +++ b/src/handler/edit_config.rs @@ -1,7 +1,6 @@ use crate::{ - app::{screens::CurrentScreen, App}, + app::{popup::AppPopup, screens::CurrentScreen, App}, input::event::InputEvent, - ui::popup::{help::HelpPopUpBuilder, PopUp}, }; pub fn handle_edit_config(app: &mut App, input: InputEvent) -> color_eyre::Result<()> { @@ -51,9 +50,8 @@ pub fn handle_edit_config(app: &mut App, input: InputEvent) -> color_eyre::Resul Ok(()) } -// TODO: Move this to a more appropriate place -pub fn generate_help_popup() -> Box { - let popup = HelpPopUpBuilder::new() +pub fn generate_help_popup() -> AppPopup { + AppPopup::help() .title("Edit Config") .description("This screen allows you to edit the configuration options for patch-hub.\nMore configurations may be available in the configuration file.") .keybind("ESC", "Exit") @@ -62,7 +60,5 @@ pub fn generate_help_popup() -> Box { .keybind("j/🡇", "Down") .keybind("k/🡅", "Up") .keybind("e", "Toggle editing for a configuration option") - .build(); - - Box::new(popup) + .build() } diff --git a/src/handler/latest.rs b/src/handler/latest.rs index ee226d2..48eda23 100644 --- a/src/handler/latest.rs +++ b/src/handler/latest.rs @@ -1,8 +1,7 @@ use crate::{ - app::{screens::CurrentScreen, App, B4Result}, + app::{popup::AppPopup, screens::CurrentScreen, App, B4Result}, handler::LoadingIndicator, input::event::InputEvent, - ui::popup::{help::HelpPopUpBuilder, info_popup::InfoPopUp, PopUp}, }; pub async fn handle_latest_patchsets( @@ -75,8 +74,9 @@ pub async fn handle_latest_patchsets( 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.state.popup = Some(AppPopup::info( + "Error", + format!("The selected patchset couldn't be retrieved.\nReason: {err_cause}\nPlease choose another patchset."), )); app.set_current_screen(CurrentScreen::LatestPatchsets); } @@ -88,8 +88,8 @@ pub async fn handle_latest_patchsets( Ok(()) } -pub fn generate_help_popup() -> Box { - let popup = HelpPopUpBuilder::new() +pub fn generate_help_popup() -> AppPopup { + AppPopup::help() .title("Latest Patchsets") .description("This screen allows you to see a list of the latest patchsets from a mailing list.\nYou might also be able to view the details of a patchset.") .keybind("ESC", "Exit") @@ -99,6 +99,5 @@ pub fn generate_help_popup() -> Box { .keybind("k/🡅", "Up") .keybind("l/🡆", "Next page") .keybind("h/🡄", "Previous page") - .build(); - Box::new(popup) + .build() } diff --git a/src/handler/mail_list.rs b/src/handler/mail_list.rs index 3dd749a..ec67c29 100644 --- a/src/handler/mail_list.rs +++ b/src/handler/mail_list.rs @@ -1,10 +1,9 @@ use std::ops::ControlFlow; use crate::{ - app::{screens::CurrentScreen, App}, + app::{popup::AppPopup, screens::CurrentScreen, App}, handler::LoadingIndicator, input::event::InputEvent, - ui::popup::{help::HelpPopUpBuilder, PopUp}, }; pub async fn handle_mailing_list_selection( @@ -92,9 +91,8 @@ pub async fn handle_mailing_list_selection( Ok(ControlFlow::Continue(())) } -// TODO: Move this to a more appropriate place -pub fn generate_help_popup() -> Box { - let popup = HelpPopUpBuilder::new() +pub fn generate_help_popup() -> AppPopup { + AppPopup::help() .title("Mailing List Selection") .description("This is the mailing list selection screen.\nYou can select a mailing list by typing the name of the list.") .keybind("ESC", "Exit") @@ -105,7 +103,5 @@ pub fn generate_help_popup() -> Box { .keybind("F1", "Show bookmarked patchsets") .keybind("F2", "Edit config options") .keybind("F5", "Refresh lists") - .build(); - - Box::new(popup) + .build() } diff --git a/src/handler/mod.rs b/src/handler/mod.rs index d7a6125..69cf4ba 100644 --- a/src/handler/mod.rs +++ b/src/handler/mod.rs @@ -109,7 +109,7 @@ async fn input_handling( if input == InputEvent::ClosePopup { app.state.popup = None; } else { - popup.handle(input)?; + popup.handle_scroll(input); } } else { match app.state.navigation.current_screen { diff --git a/src/ui/errors.rs b/src/ui/errors.rs index 85d7fd8..c6427bf 100644 --- a/src/ui/errors.rs +++ b/src/ui/errors.rs @@ -1,3 +1,4 @@ +#![allow(dead_code)] /// Errors produced by the UI actor boundary. #[derive(thiserror::Error, Debug)] pub enum UiError { diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 41a034c..f40cfd6 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,12 +1,12 @@ mod bookmarked; mod details_actions; mod edit_config; +pub mod errors; mod latest; pub mod loading_screen; mod mail_list; mod navigation_bar; pub mod popup; -pub mod errors; pub mod scene; pub mod theme; @@ -19,6 +19,7 @@ use ratatui::{ }; use crate::app::{screens::CurrentScreen, AppViewModel}; +use popup::render_popup; pub fn draw_ui(f: &mut Frame, vm: &AppViewModel<'_>) { // Clear the whole screen for sanitizing reasons @@ -47,11 +48,11 @@ pub fn draw_ui(f: &mut Frame, vm: &AppViewModel<'_>) { navigation_bar::render(f, vm, chunks[2]); - vm.state.popup.as_ref().inspect(|p| { - let (x, y) = p.dimensions(); + if let Some(popup) = vm.state.popup.as_ref() { + let (x, y) = popup.dimensions(); let rect = centered_rect(x, y, f.area()); - p.render(f, rect); - }); + render_popup(f, popup, rect); + } } fn render_title(f: &mut Frame, chunk: Rect) { diff --git a/src/ui/popup/help.rs b/src/ui/popup/help.rs deleted file mode 100644 index 9b9efe7..0000000 --- a/src/ui/popup/help.rs +++ /dev/null @@ -1,200 +0,0 @@ -use ratatui::{ - layout::Alignment, - style::{Style, Stylize}, - text::Line, - widgets::{Clear, Paragraph}, -}; - -use std::fmt::Display; - -use crate::input::event::InputEvent; - -use super::PopUp; - -/// A popup that displays a help message -/// -/// This popup is used to display a help message with a title, a description and a list of keybinds. -/// It's meant to produce a new instance for each screen that needs a help popup and then the handler of this screen -/// will be responsible for pushing the popup to the app when the user presses the help key (`?` is the suggested default) -/// -/// The title is displayed at the top center of the 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(Clone, Debug)] -pub struct HelpPopUp { - title: Option, - description: Option, - keybinds: String, - offset: (u16, u16), - max_offset: (u16, u16), - lines: u16, - columns: u16, - dimensions: (u16, u16), -} - -/// A helper struct to build a `HelpPopUp` -#[derive(Debug, Default)] -pub struct HelpPopUpBuilder { - title: Option, - description: Option, - keybinds: Vec<(String, String)>, -} - -impl HelpPopUpBuilder { - /// Creates a new empty `HelpPopUpBuilder` - pub fn new() -> Self { - Self { - title: None, - description: None, - keybinds: Vec::new(), - } - } - - /// Defines the title of the popup - /// - /// The title is displayed at the top center of the popup and - /// it's recommended to be short and be the name of the screen - pub fn title(mut self, title: &str) -> Self { - self.title = Some(title.to_string()); - self - } - - /// Defines the description of the popup - /// - /// The description is displayed below the title and is optional as its meant - /// only to give some extra information about the screen for the user - pub fn description(mut self, description: &str) -> Self { - self.description = Some(description.to_string()); - self - } - - /// Adds a new help entry to the popup - /// - /// A help entry is composed of a key and a help message - /// - /// The keybinds are listed in order of insertion under a `Keybinds` section in the pop-up body - pub fn keybind(mut self, key: K, help: H) -> Self { - self.keybinds.push((key.to_string(), help.to_string())); - self - } - - /// Builds the `HelpPopUp` with the given parameters - pub fn build(self) -> HelpPopUp { - let key_len = self - .keybinds - .iter() - .fold(0, |acc, (k, _)| if k.len() > acc { k.len() } else { acc }); - - let help = self.keybinds.iter().fold(String::new(), |acc, (k, v)| { - acc + &format!("{k:>key_len$}: {v}\n") - }); - - let lines = self.keybinds.len() as u16; - - let columns = self.keybinds.iter().fold(0, |acc, (k, v)| { - let len = (k.len() + v.len()) as u16; - if len > acc { - len - } else { - acc - } - }); - - let dimensions = (50, 50); // TODO: Calculate percentage based on the lines and screen size - - HelpPopUp { - title: self.title, - description: self.description, - keybinds: help, - offset: (0, 0), - max_offset: (lines, columns), - columns: columns + 2, - lines, - dimensions, - } - } -} - -impl PopUp for HelpPopUp { - fn dimensions(&self) -> (u16, u16) { - self.dimensions - } - - fn render(&self, f: &mut ratatui::Frame, chunk: ratatui::prelude::Rect) { - let block = ratatui::widgets::Block::default() - .title(self.to_string()) - .title_alignment(Alignment::Center) - .title_style(ratatui::style::Style::default().bold().blue()) - .title_bottom(Line::styled( - "(ESC / q) Close", - Style::default().bold().blue(), - )) - .borders(ratatui::widgets::Borders::ALL) - .border_type(ratatui::widgets::BorderType::Double) - .style(ratatui::style::Style::default()); - - // Push the description - let text = if let Some(description) = &self.description { - format!("{description}\n\n") - } else { - String::new() - }; - - // Push the help entries - let text = if self.keybinds.is_empty() { - text - } else { - format!("{} \u{1F836} Keybinds\n{}", text, self.keybinds) - }; - - let text = Paragraph::new(text) - .style(ratatui::style::Style::default()) - .block(block) - .alignment(ratatui::layout::Alignment::Left) - .scroll(self.offset); - - f.render_widget(Clear, chunk); - f.render_widget(text, chunk); - } - - fn handle(&mut self, input: InputEvent) -> color_eyre::Result<()> { - match input { - InputEvent::NavigateUp => { - if self.offset.0 > 0 { - self.offset.0 -= 1; - } - } - InputEvent::NavigateDown => { - if self.offset.0 < self.max_offset.0 { - self.offset.0 += 1; - } - } - InputEvent::NavigateLeft => { - if self.offset.1 > 0 { - self.offset.1 -= 1; - } - } - InputEvent::NavigateRight => { - if self.offset.1 < self.max_offset.1 { - self.offset.1 += 1; - } - } - _ => {} - } - - Ok(()) - } -} - -impl Display for HelpPopUp { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - if let Some(title) = &self.title { - write!(f, "{title}")?; - } else { - write!(f, "Help")?; - } - - Ok(()) - } -} diff --git a/src/ui/popup/info_popup.rs b/src/ui/popup/info_popup.rs deleted file mode 100644 index 2691619..0000000 --- a/src/ui/popup/info_popup.rs +++ /dev/null @@ -1,103 +0,0 @@ -use ratatui::{ - layout::{Alignment, Rect}, - style::{Color, Modifier, Style}, - text::Line, - widgets::{Block, BorderType, Borders, Clear, Paragraph, Wrap}, -}; - -use super::PopUp; -use crate::input::event::InputEvent; - -#[derive(Clone, Debug)] -pub struct InfoPopUp { - title: String, - info: String, - offset: (u16, u16), - max_offset: (u16, u16), - dimensions: (u16, u16), -} - -impl InfoPopUp { - /// Generate a pop-up with a title and an arbitrary information. - pub fn generate_info_popup(title: &str, info: &str) -> Box { - let mut lines = 0; - let mut columns = 0; - - for line in info.lines() { - lines += 1; - let line_len = line.len() as u16; - if line_len > columns { - columns = line_len; - } - } - - let dimensions = (30, 50); // TODO: Calculate percentage based on the lines and screen size - - Box::new(InfoPopUp { - title: title.to_string(), - info: info.to_string(), - offset: (0, 0), - max_offset: (lines, columns), - dimensions, - }) - } -} - -impl PopUp for InfoPopUp { - fn dimensions(&self) -> (u16, u16) { - self.dimensions - } - - /// Renders a centered overlaying pop-up with a title and an arbitrary info. - fn render(&self, f: &mut ratatui::Frame, chunk: Rect) { - let bold_blue = Style::default() - .add_modifier(Modifier::BOLD) - .fg(Color::Blue); - let block = Block::default() - .title(self.title.clone()) - .title_alignment(Alignment::Center) - .title_style(bold_blue) - .title_bottom(Line::styled("(ESC / q) Close", bold_blue)) - .borders(Borders::ALL) - .border_type(BorderType::Double) - .style(Style::default()); - - let pop_up = Paragraph::new(self.info.clone()) - .block(block) - .alignment(Alignment::Left) - .wrap(Wrap { trim: true }) - .scroll(self.offset); - - f.render_widget(Clear, chunk); - f.render_widget(pop_up, chunk); - } - - /// Handles simple one-char width navigation. - fn handle(&mut self, input: InputEvent) -> color_eyre::Result<()> { - match input { - InputEvent::NavigateUp => { - if self.offset.0 > 0 { - self.offset.0 -= 1; - } - } - InputEvent::NavigateDown => { - if self.offset.0 < self.max_offset.0 { - self.offset.0 += 1; - } - } - InputEvent::NavigateLeft => { - if self.offset.1 > 0 { - self.offset.1 -= 1; - } - } - InputEvent::NavigateRight => { - if self.offset.1 < self.max_offset.1 { - self.offset.1 += 1; - } - } - _ => {} - } - - Ok(()) - } -} diff --git a/src/ui/popup/mod.rs b/src/ui/popup/mod.rs index d08fbf5..0e42de8 100644 --- a/src/ui/popup/mod.rs +++ b/src/ui/popup/mod.rs @@ -1,47 +1,181 @@ -pub mod help; -pub mod info_popup; -pub mod review_trailers; +//! Popup rendering for the UI layer. +//! +//! The old `PopUp` trait object is gone. Each popup variant is represented +//! in `AppState` as a concrete `AppPopup` enum; this module provides the +//! single `render_popup` function that paints any variant onto a Ratatui +//! frame. The per-variant rendering logic mirrors what the old concrete types +//! did, without the dynamic dispatch overhead. -use ratatui::{layout::Rect, Frame}; +use ratatui::{ + layout::Alignment, + style::{Color, Modifier, Style, Stylize}, + text::Line, + widgets::{Block, BorderType, Borders, Clear, Paragraph, Wrap}, + Frame, +}; -use std::fmt::Debug; +use crate::app::popup::AppPopup; -use crate::input::event::InputEvent; - -pub trait PopUpClone { - fn clone_box(&self) -> Box; +/// Paint `popup` centred inside `chunk`. +pub fn render_popup(f: &mut Frame, popup: &AppPopup, chunk: ratatui::layout::Rect) { + match popup { + AppPopup::Info { + title, + body, + scroll, + .. + } => render_info(f, title, body, *scroll, chunk), + AppPopup::Help { + title, + description, + formatted_keybinds, + scroll, + .. + } => render_help( + f, + title.as_deref(), + description.as_deref(), + formatted_keybinds, + *scroll, + chunk, + ), + AppPopup::ReviewTrailers { + reviewed_by, + tested_by, + acked_by, + scroll, + .. + } => render_review_trailers(f, reviewed_by, tested_by, acked_by, *scroll, chunk), + } } -impl PopUpClone for T -where - T: 'static + PopUp + Clone, -{ - fn clone_box(&self) -> Box { - Box::new(self.clone()) - } +// --------------------------------------------------------------------------- +// Info popup +// --------------------------------------------------------------------------- + +fn render_info( + f: &mut Frame, + title: &str, + body: &str, + scroll: (u16, u16), + chunk: ratatui::layout::Rect, +) { + let bold_blue = Style::default() + .add_modifier(Modifier::BOLD) + .fg(Color::Blue); + let block = Block::default() + .title(title.to_string()) + .title_alignment(Alignment::Center) + .title_style(bold_blue) + .title_bottom(Line::styled("(ESC / q) Close", bold_blue)) + .borders(Borders::ALL) + .border_type(BorderType::Double) + .style(Style::default()); + + let paragraph = Paragraph::new(body.to_string()) + .block(block) + .alignment(Alignment::Left) + .wrap(Wrap { trim: true }) + .scroll(scroll); + + f.render_widget(Clear, chunk); + f.render_widget(paragraph, chunk); } -impl Clone for Box { - fn clone(&self) -> Self { - self.clone_box() +// --------------------------------------------------------------------------- +// Help popup +// --------------------------------------------------------------------------- + +fn render_help( + f: &mut Frame, + title: Option<&str>, + description: Option<&str>, + formatted_keybinds: &str, + scroll: (u16, u16), + chunk: ratatui::layout::Rect, +) { + let title_str = title.unwrap_or("Help").to_string(); + + let block = Block::default() + .title(title_str) + .title_alignment(Alignment::Center) + .title_style(Style::default().bold().blue()) + .title_bottom(Line::styled( + "(ESC / q) Close", + Style::default().bold().blue(), + )) + .borders(Borders::ALL) + .border_type(BorderType::Double) + .style(Style::default()); + + let mut text = description.map_or_else(String::new, |d| format!("{d}\n\n")); + if !formatted_keybinds.is_empty() { + text.push_str(" \u{1F836} Keybinds\n"); + text.push_str(formatted_keybinds); } + + let paragraph = Paragraph::new(text) + .style(Style::default()) + .block(block) + .alignment(Alignment::Left) + .scroll(scroll); + + f.render_widget(Clear, chunk); + f.render_widget(paragraph, chunk); } -/// A trait that represents a popup that can be rendered on top of a screen -pub trait PopUp: Debug + Send + PopUpClone { - /// Returns the dimensions of the popup in percentage of the screen - /// (width, height) - /// - /// Those dimensions are used to create the `chunk` used in the render function - fn dimensions(&self) -> (u16, u16); - - /// Renders the popup on the given frame using the given chunk - /// This chunk is a centered rectangle with the dimensions returned by `dimensions` - fn render(&self, f: &mut Frame, chunk: Rect); - - /// Handles semantic input for 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, input: InputEvent) -> color_eyre::Result<()>; +// --------------------------------------------------------------------------- +// Review-trailers popup +// --------------------------------------------------------------------------- + +fn render_review_trailers( + f: &mut Frame, + reviewed_by: &str, + tested_by: &str, + acked_by: &str, + scroll: (u16, u16), + chunk: ratatui::layout::Rect, +) { + let header_style = Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD) + .add_modifier(Modifier::UNDERLINED); + + let mut contents: Vec> = vec![]; + + let mut push_section = |name: &str, text: &str| { + contents.push(Line::styled(name.to_string(), header_style)); + for line in text.lines() { + contents.push(Line::styled( + line.to_string(), + Style::default().fg(Color::White), + )); + } + contents.push(Line::from("")); + }; + + push_section("Reviewed-by", reviewed_by); + push_section("Tested-by", tested_by); + push_section("Acked-by", acked_by); + + let block = Block::default() + .title("Code-Review Trailers") + .title_alignment(Alignment::Center) + .title_style(Style::default().bold().blue()) + .title_bottom(Line::styled( + "(ESC / q) Close", + Style::default().bold().blue(), + )) + .borders(Borders::ALL) + .border_type(BorderType::Double) + .style(Style::default()); + + let paragraph = Paragraph::new(contents) + .style(Style::default()) + .block(block) + .alignment(Alignment::Left) + .scroll(scroll); + + f.render_widget(Clear, chunk); + f.render_widget(paragraph, chunk); } diff --git a/src/ui/popup/review_trailers.rs b/src/ui/popup/review_trailers.rs deleted file mode 100644 index 06e4d92..0000000 --- a/src/ui/popup/review_trailers.rs +++ /dev/null @@ -1,162 +0,0 @@ -use ratatui::{ - layout::Alignment, - style::{Color, Modifier, Style, Stylize}, - text::Line, - widgets::{Clear, Paragraph}, -}; - -use std::collections::HashSet; - -use crate::{ - app::screens::details_actions::PatchsetDetailsState, input::event::InputEvent, - lore::domain::patch::Author, -}; - -use super::PopUp; - -#[derive(Clone, Debug)] -pub struct ReviewTrailersPopUp { - reviewed_by_text: String, - tested_by_text: String, - acked_by_text: String, - offset: (u16, u16), - max_offset: (u16, u16), - dimensions: (u16, u16), -} - -impl ReviewTrailersPopUp { - /// Generate a pop-up that contains details about the code-review trailers - /// of a specific patch. - /// - /// The specific patch is defined by the currently previewing patch of - /// `@details_actions` and the information about the trailers is stored in - /// the fields `reviewed_by`, `tested_by`, and `acked_by`, which are the - /// tags considered for the generated pop-up. This function succeeds regardless if - /// there are no code-review trailers for the specific patch. - pub fn generate_trailers_popup(details_actions: &PatchsetDetailsState) -> Box { - let i = details_actions.preview_index; - let mut reviewed_by_text = String::new(); - let mut tested_by_text = String::new(); - let mut acked_by_text = String::new(); - let mut columns = 0; - - // Auxiliary routines to avoid code duplications - let mut update_columns = |line_len: usize| { - if line_len > columns { - columns = line_len; - } - }; - let mut fill_text = |text: &mut String, authors: &HashSet| { - for author in authors { - let author = format!(" - {author}\n"); - text.push_str(&author); - update_columns(author.len()); - } - }; - - fill_text(&mut reviewed_by_text, &details_actions.reviewed_by[i]); - fill_text(&mut tested_by_text, &details_actions.tested_by[i]); - fill_text(&mut acked_by_text, &details_actions.acked_by[i]); - - let lines = (3 - + details_actions.reviewed_by[i].len() - + details_actions.tested_by[i].len() - + details_actions.acked_by[i].len()) as u16; - - // TODO: Calculate percentage based on the lines and screen size - let dimensions = (50, 40); - - Box::new(ReviewTrailersPopUp { - reviewed_by_text, - tested_by_text, - acked_by_text, - offset: (0, 0), - max_offset: (lines, columns as u16), - dimensions, - }) - } -} - -impl PopUp for ReviewTrailersPopUp { - fn dimensions(&self) -> (u16, u16) { - self.dimensions - } - - /// Renders a centered overlaying pop-up with entries for code-review - /// trailers of "Reviewed-by", "Tested-by", and "Acked-by". - fn render(&self, f: &mut ratatui::Frame, chunk: ratatui::prelude::Rect) { - let mut contents = vec![]; - - // Auxilliary closure to avoid code duplication - let mut add_entry_to_contents = |name: &str, text: &str| { - contents.push(Line::styled( - name.to_string(), - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD) - .add_modifier(Modifier::UNDERLINED), - )); - for line in text.lines() { - contents.push(Line::styled( - line.to_string(), - Style::default().fg(Color::White), - )); - } - contents.push(Line::from("")); // equivalent to newline - }; - - add_entry_to_contents("Reviewed-by", &self.reviewed_by_text); - add_entry_to_contents("Tested-by", &self.tested_by_text); - add_entry_to_contents("Acked-by", &self.acked_by_text); - - let block = ratatui::widgets::Block::default() - .title("Code-Review Trailers") - .title_alignment(Alignment::Center) - .title_style(ratatui::style::Style::default().bold().blue()) - .title_bottom(Line::styled( - "(ESC / q) Close", - Style::default().bold().blue(), - )) - .borders(ratatui::widgets::Borders::ALL) - .border_type(ratatui::widgets::BorderType::Double) - .style(ratatui::style::Style::default()); - - let pop_up = Paragraph::new(contents) - .style(ratatui::style::Style::default()) - .block(block) - .alignment(ratatui::layout::Alignment::Left) - .scroll(self.offset); - - f.render_widget(Clear, chunk); - f.render_widget(pop_up, chunk); - } - - /// Handles simple one-char width navigation. - fn handle(&mut self, input: InputEvent) -> color_eyre::Result<()> { - match input { - InputEvent::NavigateUp => { - if self.offset.0 > 0 { - self.offset.0 -= 1; - } - } - InputEvent::NavigateDown => { - if self.offset.0 < self.max_offset.0 { - self.offset.0 += 1; - } - } - InputEvent::NavigateLeft => { - if self.offset.1 > 0 { - self.offset.1 -= 1; - } - } - InputEvent::NavigateRight => { - if self.offset.1 < self.max_offset.1 { - self.offset.1 += 1; - } - } - _ => {} - } - - Ok(()) - } -} diff --git a/src/ui/scene.rs b/src/ui/scene.rs index 28b345b..eb433f3 100644 --- a/src/ui/scene.rs +++ b/src/ui/scene.rs @@ -1,6 +1,10 @@ //! Scene types produced by [`super::core::UiCore`] and consumed by //! [`super::painter`]. //! +//! These types are not yet wired into the runtime pipeline; the allow below +//! will be removed once `UiCore` and `painter` are introduced. +#![allow(dead_code)] +//! //! A `UiScene` is a fully projected, paint-ready snapshot of application //! state. Nothing inside this module reads from `App`, `AppState`, or any //! actor handle — it is pure presentation data. diff --git a/src/ui/theme.rs b/src/ui/theme.rs index 6d4254b..ad638d7 100644 --- a/src/ui/theme.rs +++ b/src/ui/theme.rs @@ -1,3 +1,4 @@ +#![allow(dead_code)] /// Visual theme configuration for the UI actor. /// /// Holds styling and layout policy. Kept minimal in phase 11; extended when From bdfe27f1b05cd3b6cbba4e476d6059ad1834f87d Mon Sep 17 00:00:00 2001 From: lorenzoberts Date: Sat, 27 Jun 2026 11:48:09 -0300 Subject: [PATCH 3/6] refactor(ui): replace AppViewModel<'_> with owned typed AppViewModel This commit replaces the thin AppState reference wrapper with an owned AppViewModel that carries a per-screen ScreenViewModel discriminant and an optional PopupViewModel. Projection from AppState is centralized so UI modules receive only the data their screen needs, and cross-cutting display values are pre-computed before rendering. This commit is part of the architecture's refactoring phase 11. Signed-off-by: lorenzoberts --- src/app/mod.rs | 10 + src/app/popup.rs | 1 + src/app/render_snapshot.rs | 6 +- src/app/view_model.rs | 471 ++++++++++++++++++++++++++++++++++++- src/ui/bookmarked.rs | 19 +- src/ui/details_actions.rs | 145 +++--------- src/ui/edit_config.rs | 52 ++-- src/ui/latest.rs | 58 +---- src/ui/mail_list.rs | 61 ++--- src/ui/mod.rs | 24 +- src/ui/navigation_bar.rs | 30 ++- src/ui/popup/mod.rs | 50 ++-- 12 files changed, 617 insertions(+), 310 deletions(-) diff --git a/src/app/mod.rs b/src/app/mod.rs index a51d385..4527d41 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -429,4 +429,14 @@ impl App { pub fn set_current_screen(&mut self, new_current_screen: CurrentScreen) { self.state.navigation.current_screen = new_current_screen; } + + /// Projects the current [`AppState`] into an owned [`AppViewModel`]. + /// + /// This is the primary way for the orchestration layer to hand off + /// presentation data to the UI actor without exposing raw `AppState`. + /// Called by `run_app` once the UI actor is introduced (Commit 11.6). + #[allow(dead_code)] + pub fn present(&self) -> AppViewModel { + view_model::project_state(&self.state) + } } diff --git a/src/app/popup.rs b/src/app/popup.rs index 814f6e0..053389f 100644 --- a/src/app/popup.rs +++ b/src/app/popup.rs @@ -152,6 +152,7 @@ impl AppPopup { } /// `(width_percent, height_percent)` used for the centred popup rect. + #[allow(dead_code)] pub fn dimensions(&self) -> (u16, u16) { match self { AppPopup::Info { dimensions, .. } diff --git a/src/app/render_snapshot.rs b/src/app/render_snapshot.rs index 0ab0f32..1b6bfba 100644 --- a/src/app/render_snapshot.rs +++ b/src/app/render_snapshot.rs @@ -1,4 +1,4 @@ -use super::{App, AppState, AppViewModel}; +use super::{view_model, App, AppState, AppViewModel}; /// Owned application state snapshot for terminal actor drawing. #[derive(Clone)] @@ -11,8 +11,8 @@ impl AppRenderSnapshot { Self { state } } - pub fn to_view_model(&self) -> AppViewModel<'_> { - AppViewModel { state: &self.state } + pub fn to_view_model(&self) -> AppViewModel { + view_model::project_state(&self.state) } } diff --git a/src/app/view_model.rs b/src/app/view_model.rs index 56187a0..8f4d7b3 100644 --- a/src/app/view_model.rs +++ b/src/app/view_model.rs @@ -1,10 +1,469 @@ -//! Read-only projections for rendering: keeps `ui/` independent of the [`super::App`] struct. +//! Owned, typed presentation projections. +//! +//! [`AppViewModel`] is built from [`super::state::AppState`] by +//! [`project_state`], which is called from both +//! [`super::render_snapshot::AppRenderSnapshot::to_view_model`] and +//! [`super::App::present`]. +//! +//! These types sit at the *application* layer: they represent what `App` knows +//! about the presentation before `UiCore` (Commit 11.4) translates them into +//! paint-ready `UiScene` nodes. -use super::state::AppState; +use ratatui::text::Text; -/// References into [`AppState`] for one Ratatui frame. Built via +use super::{ + popup::AppPopup, + screens::{details_actions::PatchsetAction, CurrentScreen}, + state::AppState, +}; + +// --------------------------------------------------------------------------- +// Shared row / helper types +// --------------------------------------------------------------------------- + +/// One mailing list entry shown in the selection list. +#[derive(Clone, Debug)] +pub struct MailingListEntry { + pub name: String, + pub description: String, +} + +/// Match state of the user's mailing-list filter string. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum TargetListStatus { + /// Nothing typed yet. + Empty, + /// The typed string exactly names an existing list. + ExactMatch, + /// The typed string is a prefix of at least one existing list. + PrefixMatch, + /// The typed string matches no list. + NoMatch, +} + +/// A single row in a paginated patchset list (Latest or Bookmarked). +#[derive(Clone, Debug)] +pub struct PatchSummaryRow { + pub title: String, + pub author_name: String, + pub version: usize, + pub total_in_series: usize, + /// Absolute index across pages. + pub absolute_index: usize, +} + +/// Pre-computed counts of code-review trailers for the currently previewed patch. +#[derive(Clone, Debug)] +pub struct TagTrailerCounts { + pub reviewed_by: usize, + pub tested_by: usize, + pub acked_by: usize, +} + +/// One row in the edit-config form. +#[derive(Clone, Debug)] +pub struct ConfigEntryRow { + pub label: String, + pub value: String, + pub is_highlighted: bool, + /// Whether this row is currently being typed into. + pub is_editing: bool, + /// In-progress text while editing; empty when not editing this row. + pub edit_cursor_value: String, +} + +// --------------------------------------------------------------------------- +// Per-screen view models +// --------------------------------------------------------------------------- + +#[derive(Clone, Debug)] +pub struct MailingListSelectionViewModel { + pub entries: Vec, + pub highlighted_index: usize, + pub target_list: String, + pub target_list_status: TargetListStatus, +} + +#[derive(Clone, Debug)] +pub struct BookmarkedViewModel { + pub rows: Vec, + pub selected_index: usize, +} + +#[derive(Clone, Debug)] +pub struct LatestPatchsetsViewModel { + pub rows: Vec, + pub selected_index: usize, + pub page_number: usize, + pub target_list: String, +} + +#[derive(Clone, Debug)] +pub struct PatchsetDetailsViewModel { + pub patch_title: String, + pub author_name: String, + pub version: usize, + pub patch_count: usize, + pub last_updated: String, + pub tag_trailer_counts: TagTrailerCounts, + /// Pre-computed `"(0, 2, …)"` string when patches are staged for reply. + /// `None` when nothing is staged. + pub staged_to_reply: Option, + /// ANSI-rendered diff/cover text for each patch entry. + pub preview_entries: Vec>, + pub preview_index: usize, + pub preview_scroll_offset: usize, + pub preview_pan: usize, + pub preview_fullscreen: bool, + /// Pre-computed title for the preview pane, including the `[REVIEWED-BY]` + /// or `[REVIEWED-BY]*` suffix when applicable. + pub preview_title: String, + pub is_bookmarked: bool, + pub is_apply_staged: bool, + /// Whether the patch at `preview_index` is staged for a Reviewed-by reply. + pub is_current_patch_reply_staged: bool, +} + +#[derive(Clone, Debug)] +pub struct EditConfigViewModel { + pub entries: Vec, + pub is_editing_mode: bool, +} + +// --------------------------------------------------------------------------- +// Popup view model +// --------------------------------------------------------------------------- + +#[derive(Clone, Debug)] +pub enum PopupViewBody { + Text(String), + Keybinds { + description: Option, + formatted_keybinds: String, + }, + ReviewTrailers { + reviewed_by: String, + tested_by: String, + acked_by: String, + }, +} + +#[derive(Clone, Debug)] +pub struct PopupViewModel { + pub title: String, + pub body: PopupViewBody, + pub scroll_offset: (u16, u16), + /// `(width_percent, height_percent)` of the terminal area. + pub dimensions: (u16, u16), +} + +// --------------------------------------------------------------------------- +// Discriminated screen enum +// --------------------------------------------------------------------------- + +#[derive(Clone, Debug)] +pub enum ScreenViewModel { + MailingListSelection(MailingListSelectionViewModel), + Bookmarked(BookmarkedViewModel), + Latest(LatestPatchsetsViewModel), + PatchsetDetails(PatchsetDetailsViewModel), + EditConfig(EditConfigViewModel), +} + +// --------------------------------------------------------------------------- +// Top-level view model +// --------------------------------------------------------------------------- + +/// Owned, typed projection of [`AppState`] for one TUI frame. +/// +/// Built by [`project_state`]; consumed by [`crate::ui::draw_ui`] and — +/// once introduced — by `UiCore::build_scene`. +#[derive(Clone, Debug)] +pub struct AppViewModel { + pub screen: ScreenViewModel, + pub popup: Option, +} + +// --------------------------------------------------------------------------- +// Projection – public entry point +// --------------------------------------------------------------------------- + +/// Projects `state` into an owned [`AppViewModel`]. +/// +/// Called by both [`super::App::present`] and /// [`super::render_snapshot::AppRenderSnapshot::to_view_model`]. -#[derive(Clone, Copy)] -pub struct AppViewModel<'a> { - pub state: &'a AppState, +pub fn project_state(state: &AppState) -> AppViewModel { + let screen = project_screen(state); + let popup = state.popup.as_ref().map(project_popup); + AppViewModel { screen, popup } +} + +// --------------------------------------------------------------------------- +// Private per-screen projectors +// --------------------------------------------------------------------------- + +fn project_screen(state: &AppState) -> ScreenViewModel { + match state.navigation.current_screen { + CurrentScreen::MailingListSelection => { + ScreenViewModel::MailingListSelection(project_mail_list(state)) + } + CurrentScreen::BookmarkedPatchsets => { + ScreenViewModel::Bookmarked(project_bookmarked(state)) + } + CurrentScreen::LatestPatchsets => ScreenViewModel::Latest(project_latest(state)), + CurrentScreen::PatchsetDetails => ScreenViewModel::PatchsetDetails(project_details(state)), + CurrentScreen::EditConfig => ScreenViewModel::EditConfig(project_edit_config(state)), + } +} + +fn project_mail_list(state: &AppState) -> MailingListSelectionViewModel { + let mls = &state.lore.mailing_list_selection; + + let target_list_status = if mls.target_list.is_empty() { + TargetListStatus::Empty + } else { + let mut status = TargetListStatus::NoMatch; + for list in &mls.mailing_lists { + if list.name().eq(&mls.target_list) { + status = TargetListStatus::ExactMatch; + break; + } else if list.name().starts_with(mls.target_list.as_str()) { + status = TargetListStatus::PrefixMatch; + } + } + status + }; + + let entries = mls + .possible_mailing_lists + .iter() + .map(|l| MailingListEntry { + name: l.name().clone(), + description: l.description().clone(), + }) + .collect(); + + MailingListSelectionViewModel { + entries, + highlighted_index: mls.highlighted_list_index, + target_list: mls.target_list.clone(), + target_list_status, + } +} + +fn project_bookmarked(state: &AppState) -> BookmarkedViewModel { + let bs = &state.user_state.bookmarked_patchsets; + let rows = bs + .bookmarked_patchsets + .iter() + .enumerate() + .map(|(i, p)| PatchSummaryRow { + title: p.title().clone(), + author_name: p.author().name.clone(), + version: p.version(), + total_in_series: p.total_in_series(), + absolute_index: i, + }) + .collect(); + BookmarkedViewModel { + rows, + selected_index: bs.patchset_index, + } +} + +fn project_latest(state: &AppState) -> LatestPatchsetsViewModel { + let lps = state + .lore + .latest_patchsets + .as_ref() + .expect("LatestPatchsets must be initialised before projecting"); + + let page_number = lps.page_number(); + let base_index = (page_number - 1) * state.config.page_size(); + + let rows = lps + .get_current_patch_feed_page() + .unwrap_or_default() + .into_iter() + .enumerate() + .map(|(i, p)| PatchSummaryRow { + title: p.title().clone(), + author_name: p.author().name.clone(), + version: p.version(), + total_in_series: p.total_in_series(), + absolute_index: base_index + i, + }) + .collect(); + + LatestPatchsetsViewModel { + rows, + selected_index: lps.patchset_index(), + page_number, + target_list: lps.target_list().to_string(), + } +} + +fn project_details(state: &AppState) -> PatchsetDetailsViewModel { + let details = state + .lore + .details + .as_ref() + .expect("PatchsetDetails must be initialised before projecting"); + + let i = details.preview_index; + let message_id = &details.representative_patch.message_id().href; + + let preview_title = if matches!( + state.user_state.reviewed_patchsets.get(message_id), + Some(set) if set.contains(&i) + ) { + " Preview [REVIEWED-BY] ".to_string() + } else if *details.patches_to_reply.get(i).unwrap_or(&false) { + " Preview [REVIEWED-BY]* ".to_string() + } else { + " Preview ".to_string() + }; + + let staged_to_reply = if *details + .patchset_actions + .get(&PatchsetAction::ReplyWithReviewedBy) + .unwrap_or(&false) + { + let number_offset = if details.has_cover_letter { 0 } else { 1 }; + let numbers: Vec = details + .patches_to_reply + .iter() + .enumerate() + .filter_map(|(j, &val)| { + if val { + Some((j + number_offset).to_string()) + } else { + None + } + }) + .collect(); + if numbers.is_empty() { + None + } else { + Some(format!("({})", numbers.join(", "))) + } + } else { + None + }; + + PatchsetDetailsViewModel { + patch_title: details.representative_patch.title().clone(), + author_name: details.representative_patch.author().name.clone(), + version: details.representative_patch.version(), + patch_count: details.representative_patch.total_in_series(), + last_updated: details.representative_patch.updated().clone(), + tag_trailer_counts: TagTrailerCounts { + reviewed_by: details.reviewed_by[i].len(), + tested_by: details.tested_by[i].len(), + acked_by: details.acked_by[i].len(), + }, + staged_to_reply, + preview_entries: details.patches_preview.clone(), + preview_index: i, + preview_scroll_offset: details.preview_scroll_offset, + preview_pan: details.preview_pan, + preview_fullscreen: details.preview_fullscreen, + preview_title, + is_bookmarked: *details + .patchset_actions + .get(&PatchsetAction::Bookmark) + .unwrap_or(&false), + is_apply_staged: *details + .patchset_actions + .get(&PatchsetAction::Apply) + .unwrap_or(&false), + is_current_patch_reply_staged: *details.patches_to_reply.get(i).unwrap_or(&false), + } +} + +fn project_edit_config(state: &AppState) -> EditConfigViewModel { + let ec = state + .config_state + .edit_config + .as_ref() + .expect("EditConfig must be initialised before projecting"); + + let is_editing_mode = ec.is_editing(); + let highlighted = ec.highlighted(); + + let entries = (0..ec.config_count()) + .filter_map(|i| { + ec.config(i).map(|(label, value)| { + let is_highlighted = i == highlighted; + let is_editing = is_editing_mode && is_highlighted; + let edit_cursor_value = if is_editing { + ec.curr_edit().to_string() + } else { + String::new() + }; + ConfigEntryRow { + label, + value, + is_highlighted, + is_editing, + edit_cursor_value, + } + }) + }) + .collect(); + + EditConfigViewModel { + entries, + is_editing_mode, + } +} + +fn project_popup(popup: &AppPopup) -> PopupViewModel { + match popup { + AppPopup::Info { + title, + body, + scroll, + dimensions, + .. + } => PopupViewModel { + title: title.clone(), + body: PopupViewBody::Text(body.clone()), + scroll_offset: *scroll, + dimensions: *dimensions, + }, + AppPopup::Help { + title, + description, + formatted_keybinds, + scroll, + dimensions, + .. + } => PopupViewModel { + title: title.clone().unwrap_or_else(|| "Help".to_string()), + body: PopupViewBody::Keybinds { + description: description.clone(), + formatted_keybinds: formatted_keybinds.clone(), + }, + scroll_offset: *scroll, + dimensions: *dimensions, + }, + AppPopup::ReviewTrailers { + reviewed_by, + tested_by, + acked_by, + scroll, + dimensions, + .. + } => PopupViewModel { + title: "Code-Review Trailers".to_string(), + body: PopupViewBody::ReviewTrailers { + reviewed_by: reviewed_by.clone(), + tested_by: tested_by.clone(), + acked_by: acked_by.clone(), + }, + scroll_offset: *scroll, + dimensions: *dimensions, + }, + } } diff --git a/src/ui/bookmarked.rs b/src/ui/bookmarked.rs index 0990ac1..bf8e3e7 100644 --- a/src/ui/bookmarked.rs +++ b/src/ui/bookmarked.rs @@ -6,26 +6,21 @@ use ratatui::{ Frame, }; -use crate::app::screens::bookmarked::BookmarkedPatchsetsState; +use crate::app::view_model::BookmarkedViewModel; -pub fn render_main(f: &mut Frame, bookmarked_patchsets: &BookmarkedPatchsetsState, chunk: Rect) { - let patchset_index = bookmarked_patchsets.patchset_index; +pub fn render_main(f: &mut Frame, vm: &BookmarkedViewModel, chunk: Rect) { let mut list_items = Vec::::new(); - for (index, patch) in bookmarked_patchsets.bookmarked_patchsets.iter().enumerate() { - let patch_title = format!("{:width$}", patch.title(), width = 70); + for row in &vm.rows { + let patch_title = format!("{:width$}", row.title, width = 70); let patch_title = format!("{:.width$}", patch_title, width = 70); - let patch_author = format!("{:width$}", patch.author().name, width = 30); + let patch_author = format!("{:width$}", row.author_name, width = 30); let patch_author = format!("{:.width$}", patch_author, width = 30); list_items.push(ListItem::new( Line::from(Span::styled( format!( "{:03}. V{:02} | #{:02} | {} | {}", - index, - patch.version(), - patch.total_in_series(), - patch_title, - patch_author + row.absolute_index, row.version, row.total_in_series, patch_title, patch_author ), Style::default().fg(Color::Yellow), )) @@ -50,7 +45,7 @@ pub fn render_main(f: &mut Frame, bookmarked_patchsets: &BookmarkedPatchsetsStat .highlight_spacing(HighlightSpacing::Always); let mut list_state = ListState::default(); - list_state.select(Some(patchset_index)); + list_state.select(Some(vm.selected_index)); f.render_stateful_widget(list, chunk, &mut list_state); } diff --git a/src/ui/details_actions.rs b/src/ui/details_actions.rs index 8ad22d0..e858a2d 100644 --- a/src/ui/details_actions.rs +++ b/src/ui/details_actions.rs @@ -6,22 +6,13 @@ use ratatui::{ Frame, }; -use crate::app::{ - screens::details_actions::{PatchsetAction, PatchsetDetailsState}, - AppViewModel, -}; - -/// Returns a `Line` type that represents a line containing stats about reply -/// trailers. It currently considers the _Reviewed-by_, _Tested-by_, and -/// _Acked-by_ trailers and colors them depending if they are 0 or not. Example -/// of line returned: -/// -/// _**Reviewed-by: 1 | Tested-by: 0 | Acked-by: 2**_ -fn review_trailers_details(details_actions: &PatchsetDetailsState) -> Line<'static> { - let i = details_actions.preview_index; +use crate::app::view_model::{PatchsetDetailsViewModel, TagTrailerCounts}; - let resolve_color = |n_trailers: usize| -> Style { - if n_trailers == 0 { +/// Returns a `Line` with Reviewed-by / Tested-by / Acked-by trailer counts +/// coloured green when non-zero, white when zero. +fn review_trailers_details(counts: &TagTrailerCounts) -> Line<'static> { + let resolve_color = |n: usize| -> Style { + if n == 0 { Style::default().fg(Color::White) } else { Style::default().fg(Color::Green) @@ -31,97 +22,56 @@ fn review_trailers_details(details_actions: &PatchsetDetailsState) -> Line<'stat Line::from(vec![ Span::styled("Reviewed-by: ", Style::default().fg(Color::Cyan)), Span::styled( - details_actions.reviewed_by[i].len().to_string(), - resolve_color(details_actions.reviewed_by[i].len()), + counts.reviewed_by.to_string(), + resolve_color(counts.reviewed_by), ), Span::styled(" | Tested-by: ", Style::default().fg(Color::Cyan)), Span::styled( - details_actions.tested_by[i].len().to_string(), - resolve_color(details_actions.tested_by[i].len()), + counts.tested_by.to_string(), + resolve_color(counts.tested_by), ), Span::styled(" | Acked-by: ", Style::default().fg(Color::Cyan)), - Span::styled( - details_actions.acked_by[i].len().to_string(), - resolve_color(details_actions.acked_by[i].len()), - ), + Span::styled(counts.acked_by.to_string(), resolve_color(counts.acked_by)), ]) } fn render_details_and_actions( f: &mut Frame, - vm: &AppViewModel<'_>, + vm: &PatchsetDetailsViewModel, details_chunk: Rect, actions_chunk: Rect, ) { - let patchset_details_and_actions = vm.state.lore.details.as_ref().unwrap(); - - let mut staged_to_reply = String::new(); - if let Some(true) = patchset_details_and_actions - .patchset_actions - .get(&PatchsetAction::ReplyWithReviewedBy) - { - staged_to_reply.push('('); - let number_offset = if patchset_details_and_actions.has_cover_letter { - 0 - } else { - 1 - }; - let patches_to_reply_numbers: Vec = patchset_details_and_actions - .patches_to_reply - .iter() - .enumerate() - .filter_map(|(i, &val)| if val { Some(i + number_offset) } else { None }) - .collect(); - for number in patches_to_reply_numbers { - staged_to_reply.push_str(&format!("{number}, ")); - } - staged_to_reply.pop(); - staged_to_reply = format!("{})", &staged_to_reply[..staged_to_reply.len() - 1]); - } - - let patchset_details = &patchset_details_and_actions.representative_patch; let mut patchset_details = vec![ Line::from(vec![ Span::styled(r#" Title: "#, Style::default().fg(Color::Cyan)), - Span::styled( - patchset_details.title().to_string(), - Style::default().fg(Color::White), - ), + Span::styled(vm.patch_title.clone(), Style::default().fg(Color::White)), ]), Line::from(vec![ Span::styled("Author: ", Style::default().fg(Color::Cyan)), - Span::styled( - patchset_details.author().name.to_string(), - Style::default().fg(Color::White), - ), + Span::styled(vm.author_name.clone(), Style::default().fg(Color::White)), ]), Line::from(vec![ Span::styled("Version: ", Style::default().fg(Color::Cyan)), - Span::styled( - format!("{}", patchset_details.version()), - Style::default().fg(Color::White), - ), + Span::styled(format!("{}", vm.version), Style::default().fg(Color::White)), ]), Line::from(vec![ Span::styled("Patch count: ", Style::default().fg(Color::Cyan)), Span::styled( - format!("{}", patchset_details.total_in_series()), + format!("{}", vm.patch_count), Style::default().fg(Color::White), ), ]), Line::from(vec![ Span::styled("Last updated: ", Style::default().fg(Color::Cyan)), - Span::styled( - patchset_details.updated().to_string(), - Style::default().fg(Color::White), - ), + Span::styled(vm.last_updated.clone(), Style::default().fg(Color::White)), ]), - review_trailers_details(patchset_details_and_actions), + review_trailers_details(&vm.tag_trailer_counts), ]; - if !staged_to_reply.is_empty() { + + if let Some(staged) = &vm.staged_to_reply { patchset_details.push(Line::from(vec![ Span::styled("Staged to reply: ", Style::default().fg(Color::Cyan)), - Span::styled(staged_to_reply, Style::default().fg(Color::White)), + Span::styled(staged.clone(), Style::default().fg(Color::White)), ])); } @@ -138,11 +88,10 @@ fn render_details_and_actions( f.render_widget(patchset_details, details_chunk); - let patchset_actions = &patchset_details_and_actions.patchset_actions; // TODO: Create a function to produce new action lines let patchset_actions = vec![ Line::from(vec![ - if *patchset_actions.get(&PatchsetAction::Bookmark).unwrap() { + if vm.is_bookmarked { Span::styled("[x] ", Style::default().fg(Color::Green)) } else { Span::styled("[ ] ", Style::default().fg(Color::Cyan)) @@ -157,7 +106,7 @@ fn render_details_and_actions( Span::styled("ookmark", Style::default().fg(Color::Cyan)), ]), Line::from(vec![ - if *patchset_actions.get(&PatchsetAction::Apply).unwrap() { + if vm.is_apply_staged { Span::styled("[x] ", Style::default().fg(Color::Green)) } else { Span::styled("[ ] ", Style::default().fg(Color::Cyan)) @@ -172,11 +121,7 @@ fn render_details_and_actions( Span::styled("pply", Style::default().fg(Color::Cyan)), ]), Line::from(vec![ - if *patchset_details_and_actions - .patches_to_reply - .get(patchset_details_and_actions.preview_index) - .unwrap() - { + if vm.is_current_patch_reply_staged { Span::styled("[x] ", Style::default().fg(Color::Green)) } else { Span::styled("[ ] ", Style::default().fg(Color::Cyan)) @@ -204,35 +149,8 @@ fn render_details_and_actions( f.render_widget(patchset_actions, actions_chunk); } -fn render_preview(f: &mut Frame, vm: &AppViewModel<'_>, chunk: Rect) { - let patchset_details_and_actions = vm.state.lore.details.as_ref().unwrap(); - - let preview_index = patchset_details_and_actions.preview_index; - - let representative_patch_message_id = &patchset_details_and_actions - .representative_patch - .message_id() - .href; - let mut preview_title = String::from(" Preview "); - if matches!( - vm.state - .user_state - .reviewed_patchsets - .get(representative_patch_message_id), - Some(successful_indexes) if successful_indexes.contains(&preview_index) - ) { - preview_title = " Preview [REVIEWED-BY] ".to_string(); - } else if *patchset_details_and_actions - .patches_to_reply - .get(preview_index) - .unwrap() - { - preview_title = " Preview [REVIEWED-BY]* ".to_string(); - }; - - let preview_offset = patchset_details_and_actions.preview_scroll_offset; - let preview_pan = patchset_details_and_actions.preview_pan; - let patch_preview = patchset_details_and_actions.patches_preview[preview_index].clone(); +fn render_preview(f: &mut Frame, vm: &PatchsetDetailsViewModel, chunk: Rect) { + let patch_preview = vm.preview_entries[vm.preview_index].clone(); let patch_preview = Paragraph::new(patch_preview) .block( @@ -240,20 +158,19 @@ fn render_preview(f: &mut Frame, vm: &AppViewModel<'_>, chunk: Rect) { .borders(Borders::ALL) .border_type(ratatui::widgets::BorderType::Double) .title( - Line::styled(preview_title, Style::default().fg(Color::Green)).left_aligned(), + Line::styled(vm.preview_title.clone(), Style::default().fg(Color::Green)) + .left_aligned(), ) .padding(Padding::vertical(1)), ) .left_aligned() - .scroll((preview_offset as u16, preview_pan as u16)); + .scroll((vm.preview_scroll_offset as u16, vm.preview_pan as u16)); f.render_widget(patch_preview, chunk); } -pub fn render_main(f: &mut Frame, vm: &AppViewModel<'_>, chunk: Rect) { - let patchset_details_and_actions = vm.state.lore.details.as_ref().unwrap(); - - if patchset_details_and_actions.preview_fullscreen { +pub fn render_main(f: &mut Frame, vm: &PatchsetDetailsViewModel, chunk: Rect) { + if vm.preview_fullscreen { render_preview(f, vm, chunk); } else { let chunks = Layout::default() diff --git a/src/ui/edit_config.rs b/src/ui/edit_config.rs index 90803e2..60c0f7f 100644 --- a/src/ui/edit_config.rs +++ b/src/ui/edit_config.rs @@ -5,12 +5,10 @@ use ratatui::{ widgets::{Block, Borders, Paragraph}, Frame, }; -use tracing::{event, Level}; -use crate::app::AppViewModel; +use crate::app::view_model::EditConfigViewModel; -pub fn render_main(f: &mut Frame, vm: &AppViewModel<'_>, chunk: Rect) { - let edit_config = vm.state.config_state.edit_config.as_ref().unwrap(); +pub fn render_main(f: &mut Frame, vm: &EditConfigViewModel, chunk: Rect) { let mut constraints = Vec::new(); for _ in 0..(chunk.height / 3) { @@ -22,37 +20,32 @@ pub fn render_main(f: &mut Frame, vm: &AppViewModel<'_>, chunk: Rect) { .constraints(constraints) .split(chunk); - let highlighted_entry = edit_config.highlighted(); - for i in 0..edit_config.config_count() { + for (i, entry) in vm.entries.iter().enumerate() { if i + 1 > config_chunks.len() { break; } - let (config, value) = match edit_config.config(i) { - Some((cfg, val)) => (cfg, val), - None => { - event!(Level::ERROR, "Invalid configuration index: {}", i); - return; - } - }; - - let value = Line::from(if edit_config.is_editing() && i == highlighted_entry { + let value = Line::from(if entry.is_editing { vec![ - Span::styled(edit_config.curr_edit().to_string(), Style::default()), + Span::styled(entry.edit_cursor_value.clone(), Style::default()), Span::styled(" ", Style::default().bg(Color::White)), ] } else { - vec![Span::from(value)] + vec![Span::from(entry.value.clone())] }); let config_entry = Paragraph::new(value) .centered() - .block(Block::default().borders(Borders::ALL).title(config)) - .style(if i == highlighted_entry && edit_config.is_editing() { + .block( + Block::default() + .borders(Borders::ALL) + .title(entry.label.clone()), + ) + .style(if entry.is_editing { Style::default() .fg(Color::LightYellow) .add_modifier(Modifier::BOLD) - } else if i == highlighted_entry { + } else if entry.is_highlighted { Style::default() .fg(Color::DarkGray) .add_modifier(Modifier::BOLD) @@ -64,25 +57,24 @@ pub fn render_main(f: &mut Frame, vm: &AppViewModel<'_>, chunk: Rect) { } } -pub fn mode_footer_text<'a>(vm: &'a AppViewModel<'a>) -> Vec> { - let edit_config_state = vm.state.config_state.edit_config.as_ref().unwrap(); - vec![if edit_config_state.is_editing() { +pub fn mode_footer_text(vm: &EditConfigViewModel) -> Vec> { + vec![if vm.is_editing_mode { Span::styled("Editing...", Style::default().fg(Color::LightYellow)) } else { Span::styled("Edit Configurations", Style::default().fg(Color::Green)) }] } -pub fn keys_hint<'a>(vm: &'a AppViewModel<'a>) -> Span<'a> { - let edit_config_state = vm.state.config_state.edit_config.as_ref().unwrap(); - match edit_config_state.is_editing() { - true => Span::styled( +pub fn keys_hint(vm: &EditConfigViewModel) -> Span<'static> { + if vm.is_editing_mode { + Span::styled( "(ESC) cancel | (ENTER) confirm", Style::default().fg(Color::Red), - ), - false => Span::styled( + ) + } else { + Span::styled( "(ESC / q) exit | (ENTER) edit | (jk| 🡇 🡅 ) down up", Style::default().fg(Color::Red), - ), + ) } } diff --git a/src/ui/latest.rs b/src/ui/latest.rs index cd0e6f2..8dbab8f 100644 --- a/src/ui/latest.rs +++ b/src/ui/latest.rs @@ -6,55 +6,26 @@ use ratatui::{ Frame, }; -use crate::{app::AppViewModel, lore::domain::patch::Patch}; +use crate::app::view_model::LatestPatchsetsViewModel; -pub fn render_main(f: &mut Frame, vm: &AppViewModel<'_>, chunk: Rect) { - let page_number = vm - .state - .lore - .latest_patchsets - .as_ref() - .unwrap() - .page_number(); - let patchset_index = vm - .state - .lore - .latest_patchsets - .as_ref() - .unwrap() - .patchset_index(); +pub fn render_main(f: &mut Frame, vm: &LatestPatchsetsViewModel, chunk: Rect) { let mut list_items = Vec::::new(); - let patch_feed_page: Vec<&Patch> = vm - .state - .lore - .latest_patchsets - .as_ref() - .unwrap() - .get_current_patch_feed_page() - .unwrap(); - - let mut index: usize = (page_number - 1) * vm.state.config.page_size(); - for patch in patch_feed_page { - let patch_title = format!("{:width$}", patch.title(), width = 70); + for row in &vm.rows { + let patch_title = format!("{:width$}", row.title, width = 70); let patch_title = format!("{:.width$}", patch_title, width = 70); - let patch_author = format!("{:width$}", patch.author().name, width = 30); + let patch_author = format!("{:width$}", row.author_name, width = 30); let patch_author = format!("{:.width$}", patch_author, width = 30); list_items.push(ListItem::new( Line::from(Span::styled( format!( "{:03}. V{:02} | #{:02} | {} | {}", - index, - patch.version(), - patch.total_in_series(), - patch_title, - patch_author + row.absolute_index, row.version, row.total_in_series, patch_title, patch_author ), Style::default().fg(Color::Yellow), )) .centered(), )); - index += 1; } let list_block = Block::default() @@ -74,27 +45,16 @@ pub fn render_main(f: &mut Frame, vm: &AppViewModel<'_>, chunk: Rect) { .highlight_spacing(HighlightSpacing::Always); let mut list_state = ListState::default(); - list_state.select(Some(patchset_index)); + list_state.select(Some(vm.selected_index)); f.render_stateful_widget(list, chunk, &mut list_state); } -pub fn mode_footer_text<'a>(vm: &'a AppViewModel<'a>) -> Vec> { +pub fn mode_footer_text(vm: &LatestPatchsetsViewModel) -> Vec> { vec![Span::styled( format!( "Latest Patchsets from {} (page {})", - &vm.state - .lore - .latest_patchsets - .as_ref() - .unwrap() - .target_list(), - &vm.state - .lore - .latest_patchsets - .as_ref() - .unwrap() - .page_number() + vm.target_list, vm.page_number ), Style::default().fg(Color::Green), )] diff --git a/src/ui/mail_list.rs b/src/ui/mail_list.rs index 758a9c3..2aef997 100644 --- a/src/ui/mail_list.rs +++ b/src/ui/mail_list.rs @@ -6,21 +6,17 @@ use ratatui::{ Frame, }; -use crate::app::AppViewModel; +use crate::app::view_model::{MailingListSelectionViewModel, TargetListStatus}; -pub fn render_main(f: &mut Frame, vm: &AppViewModel<'_>, chunk: Rect) { - let highlighted_list_index = vm.state.lore.mailing_list_selection.highlighted_list_index; +pub fn render_main(f: &mut Frame, vm: &MailingListSelectionViewModel, chunk: Rect) { let mut list_items = Vec::::new(); - for mailing_list in &vm.state.lore.mailing_list_selection.possible_mailing_lists { + for entry in &vm.entries { list_items.push(ListItem::new( Line::from(vec![ + Span::styled(entry.name.clone(), Style::default().fg(Color::Magenta)), Span::styled( - mailing_list.name().to_string(), - Style::default().fg(Color::Magenta), - ), - Span::styled( - format!(" - {}", mailing_list.description()), + format!(" - {}", entry.description), Style::default().fg(Color::White), ), ]) @@ -45,44 +41,27 @@ pub fn render_main(f: &mut Frame, vm: &AppViewModel<'_>, chunk: Rect) { .highlight_spacing(HighlightSpacing::Always); let mut list_state = ListState::default(); - list_state.select(Some(highlighted_list_index)); + list_state.select(Some(vm.highlighted_index)); f.render_stateful_widget(list, chunk, &mut list_state); } -pub fn mode_footer_text<'a>(vm: &'a AppViewModel<'a>) -> Vec> { - let mut text_area = Span::default(); - - if vm.state.lore.mailing_list_selection.target_list.is_empty() { - text_area = Span::styled("type the target list", Style::default().fg(Color::DarkGray)) - } else { - for mailing_list in &vm.state.lore.mailing_list_selection.mailing_lists { - if mailing_list - .name() - .eq(&vm.state.lore.mailing_list_selection.target_list) - { - text_area = Span::styled( - &vm.state.lore.mailing_list_selection.target_list, - Style::default().fg(Color::Green), - ); - break; - } else if mailing_list - .name() - .starts_with(&vm.state.lore.mailing_list_selection.target_list) - { - text_area = Span::styled( - &vm.state.lore.mailing_list_selection.target_list, - Style::default().fg(Color::LightCyan), - ); - } +pub fn mode_footer_text(vm: &MailingListSelectionViewModel) -> Vec> { + let text_area = match vm.target_list_status { + TargetListStatus::Empty => { + Span::styled("type the target list", Style::default().fg(Color::DarkGray)) } - if text_area.content.is_empty() { - text_area = Span::styled( - &vm.state.lore.mailing_list_selection.target_list, - Style::default().fg(Color::Red), - ); + TargetListStatus::ExactMatch => { + Span::styled(vm.target_list.clone(), Style::default().fg(Color::Green)) } - } + TargetListStatus::PrefixMatch => Span::styled( + vm.target_list.clone(), + Style::default().fg(Color::LightCyan), + ), + TargetListStatus::NoMatch => { + Span::styled(vm.target_list.clone(), Style::default().fg(Color::Red)) + } + }; vec![ Span::styled("Target List: ", Style::default().fg(Color::Green)), diff --git a/src/ui/mod.rs b/src/ui/mod.rs index f40cfd6..b60c473 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -18,10 +18,10 @@ use ratatui::{ Frame, }; -use crate::app::{screens::CurrentScreen, AppViewModel}; +use crate::app::view_model::{AppViewModel, ScreenViewModel}; use popup::render_popup; -pub fn draw_ui(f: &mut Frame, vm: &AppViewModel<'_>) { +pub fn draw_ui(f: &mut Frame, vm: &AppViewModel) { // Clear the whole screen for sanitizing reasons f.render_widget(Clear, f.area()); @@ -36,20 +36,22 @@ pub fn draw_ui(f: &mut Frame, vm: &AppViewModel<'_>) { render_title(f, chunks[0]); - match vm.state.navigation.current_screen { - CurrentScreen::MailingListSelection => mail_list::render_main(f, vm, chunks[1]), - CurrentScreen::BookmarkedPatchsets => { - bookmarked::render_main(f, &vm.state.user_state.bookmarked_patchsets, chunks[1]) + match &vm.screen { + ScreenViewModel::MailingListSelection(mls_vm) => { + mail_list::render_main(f, mls_vm, chunks[1]) } - CurrentScreen::LatestPatchsets => latest::render_main(f, vm, chunks[1]), - CurrentScreen::PatchsetDetails => details_actions::render_main(f, vm, chunks[1]), - CurrentScreen::EditConfig => edit_config::render_main(f, vm, chunks[1]), + ScreenViewModel::Bookmarked(b_vm) => bookmarked::render_main(f, b_vm, chunks[1]), + ScreenViewModel::Latest(l_vm) => latest::render_main(f, l_vm, chunks[1]), + ScreenViewModel::PatchsetDetails(pd_vm) => { + details_actions::render_main(f, pd_vm, chunks[1]) + } + ScreenViewModel::EditConfig(ec_vm) => edit_config::render_main(f, ec_vm, chunks[1]), } navigation_bar::render(f, vm, chunks[2]); - if let Some(popup) = vm.state.popup.as_ref() { - let (x, y) = popup.dimensions(); + if let Some(popup) = vm.popup.as_ref() { + let (x, y) = popup.dimensions; let rect = centered_rect(x, y, f.area()); render_popup(f, popup, rect); } diff --git a/src/ui/navigation_bar.rs b/src/ui/navigation_bar.rs index 62af16f..e9c85bf 100644 --- a/src/ui/navigation_bar.rs +++ b/src/ui/navigation_bar.rs @@ -5,30 +5,28 @@ use ratatui::{ Frame, }; -use crate::app::{screens::CurrentScreen, AppViewModel}; +use crate::app::view_model::{AppViewModel, ScreenViewModel}; use super::{bookmarked, details_actions, edit_config, latest, mail_list}; -pub fn render(f: &mut Frame, vm: &AppViewModel<'_>, chunk: Rect) { - let mode_footer_text = match vm.state.navigation.current_screen { - CurrentScreen::MailingListSelection => mail_list::mode_footer_text(vm), - CurrentScreen::BookmarkedPatchsets => bookmarked::mode_footer_text(), - CurrentScreen::LatestPatchsets => latest::mode_footer_text(vm), - CurrentScreen::PatchsetDetails => details_actions::mode_footer_text(), - CurrentScreen::EditConfig => edit_config::mode_footer_text(vm), +pub fn render(f: &mut Frame, vm: &AppViewModel, chunk: Rect) { + let mode_footer_text = match &vm.screen { + ScreenViewModel::MailingListSelection(mls_vm) => mail_list::mode_footer_text(mls_vm), + ScreenViewModel::Bookmarked(_) => bookmarked::mode_footer_text(), + ScreenViewModel::Latest(l_vm) => latest::mode_footer_text(l_vm), + ScreenViewModel::PatchsetDetails(_) => details_actions::mode_footer_text(), + ScreenViewModel::EditConfig(ec_vm) => edit_config::mode_footer_text(ec_vm), }; let mode_footer = Paragraph::new(Line::from(mode_footer_text)) .block(Block::default().borders(Borders::ALL)) .centered(); - let current_keys_hint = { - match vm.state.navigation.current_screen { - CurrentScreen::MailingListSelection => mail_list::keys_hint(), - CurrentScreen::BookmarkedPatchsets => bookmarked::keys_hint(), - CurrentScreen::LatestPatchsets => latest::keys_hint(), - CurrentScreen::PatchsetDetails => details_actions::keys_hint(), - CurrentScreen::EditConfig => edit_config::keys_hint(vm), - } + let current_keys_hint = match &vm.screen { + ScreenViewModel::MailingListSelection(_) => mail_list::keys_hint(), + ScreenViewModel::Bookmarked(_) => bookmarked::keys_hint(), + ScreenViewModel::Latest(_) => latest::keys_hint(), + ScreenViewModel::PatchsetDetails(_) => details_actions::keys_hint(), + ScreenViewModel::EditConfig(ec_vm) => edit_config::keys_hint(ec_vm), }; let keys_hint_footer = Paragraph::new(Line::from(current_keys_hint)) diff --git a/src/ui/popup/mod.rs b/src/ui/popup/mod.rs index 0e42de8..6fad3d2 100644 --- a/src/ui/popup/mod.rs +++ b/src/ui/popup/mod.rs @@ -1,10 +1,9 @@ //! Popup rendering for the UI layer. //! -//! The old `PopUp` trait object is gone. Each popup variant is represented -//! in `AppState` as a concrete `AppPopup` enum; this module provides the -//! single `render_popup` function that paints any variant onto a Ratatui -//! frame. The per-variant rendering logic mirrors what the old concrete types -//! did, without the dynamic dispatch overhead. +//! Each popup variant is represented in the view model as a concrete +//! [`PopupViewModel`]; this module provides the single `render_popup` +//! function that paints any variant onto a Ratatui frame by dispatching on +//! `PopupViewBody`. use ratatui::{ layout::Alignment, @@ -14,38 +13,35 @@ use ratatui::{ Frame, }; -use crate::app::popup::AppPopup; +use crate::app::view_model::{PopupViewBody, PopupViewModel}; /// Paint `popup` centred inside `chunk`. -pub fn render_popup(f: &mut Frame, popup: &AppPopup, chunk: ratatui::layout::Rect) { - match popup { - AppPopup::Info { - title, - body, - scroll, - .. - } => render_info(f, title, body, *scroll, chunk), - AppPopup::Help { - title, +pub fn render_popup(f: &mut Frame, popup: &PopupViewModel, chunk: ratatui::layout::Rect) { + match &popup.body { + PopupViewBody::Text(body) => render_info(f, &popup.title, body, popup.scroll_offset, chunk), + PopupViewBody::Keybinds { description, formatted_keybinds, - scroll, - .. } => render_help( f, - title.as_deref(), + &popup.title, description.as_deref(), formatted_keybinds, - *scroll, + popup.scroll_offset, chunk, ), - AppPopup::ReviewTrailers { + PopupViewBody::ReviewTrailers { reviewed_by, tested_by, acked_by, - scroll, - .. - } => render_review_trailers(f, reviewed_by, tested_by, acked_by, *scroll, chunk), + } => render_review_trailers( + f, + reviewed_by, + tested_by, + acked_by, + popup.scroll_offset, + chunk, + ), } } @@ -88,16 +84,14 @@ fn render_info( fn render_help( f: &mut Frame, - title: Option<&str>, + title: &str, description: Option<&str>, formatted_keybinds: &str, scroll: (u16, u16), chunk: ratatui::layout::Rect, ) { - let title_str = title.unwrap_or("Help").to_string(); - let block = Block::default() - .title(title_str) + .title(title.to_string()) .title_alignment(Alignment::Center) .title_style(Style::default().bold().blue()) .title_bottom(Line::styled( From 946599cdf63c8baaa3eebdaaa06a0b2a5ceddd28 Mon Sep 17 00:00:00 2001 From: lorenzoberts Date: Sat, 27 Jun 2026 12:01:22 -0300 Subject: [PATCH 4/6] refactor(ui): introduce UiCore and painter, restructure screens into subdirectory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit splits UI presentation into composition and painting. UiCore turns an AppViewModel into a UiScene; the painter maps that scene onto a Ratatui frame without making layout or widget decisions. Screen modules move under ui/screens/, each exposing scene building and paint entry points, so the two-stage ViewModel → Scene → terminal draw pipeline is explicit in the module structure. This commit is part of the architecture's refactoring phase 11. Signed-off-by: lorenzoberts --- src/ui/core.rs | 88 ++++++++++++ src/ui/loading_screen.rs | 2 +- src/ui/mod.rs | 100 ++----------- src/ui/navigation_bar.rs | 43 ------ src/ui/painter.rs | 86 +++++++++++ src/ui/scene.rs | 75 ++-------- src/ui/{ => screens}/bookmarked.rs | 31 +++- .../details.rs} | 135 ++++++++++++------ src/ui/{ => screens}/edit_config.rs | 28 +++- src/ui/{ => screens}/latest.rs | 31 +++- .../{mail_list.rs => screens/mailing_list.rs} | 36 ++++- src/ui/screens/mod.rs | 7 + src/ui/screens/navigation_bar.rs | 26 ++++ src/ui/{popup/mod.rs => screens/popup.rs} | 86 ++++++----- 14 files changed, 469 insertions(+), 305 deletions(-) create mode 100644 src/ui/core.rs delete mode 100644 src/ui/navigation_bar.rs create mode 100644 src/ui/painter.rs rename src/ui/{ => screens}/bookmarked.rs (63%) rename src/ui/{details_actions.rs => screens/details.rs} (64%) rename src/ui/{ => screens}/edit_config.rs (67%) rename src/ui/{ => screens}/latest.rs (65%) rename src/ui/{mail_list.rs => screens/mailing_list.rs} (64%) create mode 100644 src/ui/screens/mod.rs create mode 100644 src/ui/screens/navigation_bar.rs rename src/ui/{popup/mod.rs => screens/popup.rs} (75%) diff --git a/src/ui/core.rs b/src/ui/core.rs new file mode 100644 index 0000000..cc06b3a --- /dev/null +++ b/src/ui/core.rs @@ -0,0 +1,88 @@ +//! `UiCore` — the stateful presentation layer. +//! +//! `UiCore` transforms an [`AppViewModel`] into a paint-ready [`UiScene`] by +//! dispatching to per-screen builders and composing the navigation bar and +//! optional popup. It holds a [`UiTheme`] so future styling policy can be +//! applied centrally without touching individual screen painters. + +use crate::app::view_model::{AppViewModel, ScreenViewModel}; +use crate::ui::{ + errors::UiError, + scene::{NavigationBarScene, UiBody, UiScene}, + screens, + theme::UiTheme, +}; + +pub struct UiCore { + #[allow(dead_code)] + theme: UiTheme, +} + +impl UiCore { + pub fn new() -> Self { + Self { theme: UiTheme } + } + + /// Transform `vm` into a fully projected [`UiScene`] ready for painting. + pub fn build_scene(&self, vm: &AppViewModel) -> Result { + let body = match &vm.screen { + ScreenViewModel::MailingListSelection(mls_vm) => { + UiBody::MailingListSelection(screens::mailing_list::build_scene(mls_vm)) + } + ScreenViewModel::Bookmarked(b_vm) => { + UiBody::Bookmarked(screens::bookmarked::build_scene(b_vm)) + } + ScreenViewModel::Latest(l_vm) => UiBody::Latest(screens::latest::build_scene(l_vm)), + ScreenViewModel::PatchsetDetails(pd_vm) => { + UiBody::PatchsetDetails(screens::details::build_scene(pd_vm)) + } + ScreenViewModel::EditConfig(ec_vm) => { + UiBody::EditConfig(screens::edit_config::build_scene(ec_vm)) + } + }; + + let navigation = self.build_navigation(&vm.screen); + let popup = vm.popup.as_ref().map(screens::popup::build_scene); + + Ok(UiScene { + body, + navigation, + popup, + }) + } + + fn build_navigation(&self, screen: &ScreenViewModel) -> NavigationBarScene { + let (mode_spans, keys_hint) = match screen { + ScreenViewModel::MailingListSelection(vm) => ( + screens::mailing_list::mode_spans(vm), + screens::mailing_list::keys_hint_span(), + ), + ScreenViewModel::Bookmarked(_) => ( + screens::bookmarked::mode_spans(), + screens::bookmarked::keys_hint_span(), + ), + ScreenViewModel::Latest(vm) => ( + screens::latest::mode_spans(vm), + screens::latest::keys_hint_span(), + ), + ScreenViewModel::PatchsetDetails(_) => ( + screens::details::mode_spans(), + screens::details::keys_hint_span(), + ), + ScreenViewModel::EditConfig(vm) => ( + screens::edit_config::mode_spans(vm), + screens::edit_config::keys_hint_span(vm), + ), + }; + NavigationBarScene { + mode_spans, + keys_hint, + } + } +} + +impl Default for UiCore { + fn default() -> Self { + Self::new() + } +} diff --git a/src/ui/loading_screen.rs b/src/ui/loading_screen.rs index 79d3613..3a022b5 100644 --- a/src/ui/loading_screen.rs +++ b/src/ui/loading_screen.rs @@ -7,7 +7,7 @@ use ratatui::{ use std::fmt::Display; -use super::centered_rect; +use super::painter::centered_rect; const SPINNER: [char; 8] = [ '\u{1F311}', diff --git a/src/ui/mod.rs b/src/ui/mod.rs index b60c473..12c1ba2 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,99 +1,17 @@ -mod bookmarked; -mod details_actions; -mod edit_config; +mod core; pub mod errors; -mod latest; pub mod loading_screen; -mod mail_list; -mod navigation_bar; -pub mod popup; +mod painter; pub mod scene; +mod screens; pub mod theme; -use ratatui::{ - layout::{Alignment, Constraint, Direction, Layout, Rect}, - style::{Color, Style, Stylize}, - text::Text, - widgets::{Block, Borders, Clear, Paragraph}, - Frame, -}; +use crate::app::view_model::AppViewModel; -use crate::app::view_model::{AppViewModel, ScreenViewModel}; -use popup::render_popup; - -pub fn draw_ui(f: &mut Frame, vm: &AppViewModel) { - // Clear the whole screen for sanitizing reasons - f.render_widget(Clear, f.area()); - - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(3), - Constraint::Min(1), - Constraint::Length(3), - ]) - .split(f.area()); - - render_title(f, chunks[0]); - - match &vm.screen { - ScreenViewModel::MailingListSelection(mls_vm) => { - mail_list::render_main(f, mls_vm, chunks[1]) - } - ScreenViewModel::Bookmarked(b_vm) => bookmarked::render_main(f, b_vm, chunks[1]), - ScreenViewModel::Latest(l_vm) => latest::render_main(f, l_vm, chunks[1]), - ScreenViewModel::PatchsetDetails(pd_vm) => { - details_actions::render_main(f, pd_vm, chunks[1]) - } - ScreenViewModel::EditConfig(ec_vm) => edit_config::render_main(f, ec_vm, chunks[1]), +pub fn draw_ui(f: &mut ratatui::Frame, vm: &AppViewModel) { + let ui_core = core::UiCore::new(); + match ui_core.build_scene(vm) { + Ok(scene) => painter::paint(f, &scene), + Err(e) => tracing::error!("failed to build ui scene: {e}"), } - - navigation_bar::render(f, vm, chunks[2]); - - if let Some(popup) = vm.popup.as_ref() { - let (x, y) = popup.dimensions; - let rect = centered_rect(x, y, f.area()); - render_popup(f, popup, rect); - } -} - -fn render_title(f: &mut Frame, chunk: Rect) { - let title_block = Block::default() - .borders(Borders::ALL) - .style(Style::default()) - .title_alignment(Alignment::Center); - - let title_content: String = "patch-hub".to_string(); - - let title = Paragraph::new(Text::styled( - title_content, - Style::default().fg(Color::Green).bold(), - )) - .centered() - .block(title_block); - - f.render_widget(title, chunk); -} - -/// helper function to create a centered rect using up certain percentage of the available rect `r` -fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect { - // Cut the given rectangle into three vertical pieces - let popup_layout = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Percentage((100 - percent_y) / 2), - Constraint::Percentage(percent_y), - Constraint::Percentage((100 - percent_y) / 2), - ]) - .split(r); - - // Then cut the middle vertical piece into three width-wise pieces - Layout::default() - .direction(Direction::Horizontal) - .constraints([ - Constraint::Percentage((100 - percent_x) / 2), - Constraint::Percentage(percent_x), - Constraint::Percentage((100 - percent_x) / 2), - ]) - .split(popup_layout[1])[1] // Return the middle chunk } diff --git a/src/ui/navigation_bar.rs b/src/ui/navigation_bar.rs deleted file mode 100644 index e9c85bf..0000000 --- a/src/ui/navigation_bar.rs +++ /dev/null @@ -1,43 +0,0 @@ -use ratatui::{ - layout::{Constraint, Direction, Layout, Rect}, - text::Line, - widgets::{Block, Borders, Paragraph}, - Frame, -}; - -use crate::app::view_model::{AppViewModel, ScreenViewModel}; - -use super::{bookmarked, details_actions, edit_config, latest, mail_list}; - -pub fn render(f: &mut Frame, vm: &AppViewModel, chunk: Rect) { - let mode_footer_text = match &vm.screen { - ScreenViewModel::MailingListSelection(mls_vm) => mail_list::mode_footer_text(mls_vm), - ScreenViewModel::Bookmarked(_) => bookmarked::mode_footer_text(), - ScreenViewModel::Latest(l_vm) => latest::mode_footer_text(l_vm), - ScreenViewModel::PatchsetDetails(_) => details_actions::mode_footer_text(), - ScreenViewModel::EditConfig(ec_vm) => edit_config::mode_footer_text(ec_vm), - }; - let mode_footer = Paragraph::new(Line::from(mode_footer_text)) - .block(Block::default().borders(Borders::ALL)) - .centered(); - - let current_keys_hint = match &vm.screen { - ScreenViewModel::MailingListSelection(_) => mail_list::keys_hint(), - ScreenViewModel::Bookmarked(_) => bookmarked::keys_hint(), - ScreenViewModel::Latest(_) => latest::keys_hint(), - ScreenViewModel::PatchsetDetails(_) => details_actions::keys_hint(), - ScreenViewModel::EditConfig(ec_vm) => edit_config::keys_hint(ec_vm), - }; - - let keys_hint_footer = Paragraph::new(Line::from(current_keys_hint)) - .block(Block::default().borders(Borders::ALL)) - .centered(); - - let footer_chunks = Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Percentage(30), Constraint::Percentage(80)]) - .split(chunk); - - f.render_widget(mode_footer, footer_chunks[0]); - f.render_widget(keys_hint_footer, footer_chunks[1]); -} diff --git a/src/ui/painter.rs b/src/ui/painter.rs new file mode 100644 index 0000000..2deaee4 --- /dev/null +++ b/src/ui/painter.rs @@ -0,0 +1,86 @@ +//! Terminal painter — renders a [`UiScene`] onto a Ratatui [`Frame`]. +//! +//! This module is intentionally "dumb": it receives a fully projected scene +//! and maps each node to Ratatui widgets without making any presentation +//! decisions of its own. + +use ratatui::{ + layout::{Alignment, Constraint, Direction, Layout, Rect}, + style::{Color, Style, Stylize}, + text::Text, + widgets::{Block, Borders, Clear, Paragraph}, + Frame, +}; + +use crate::ui::{ + scene::{UiBody, UiScene}, + screens, +}; + +/// Paint `scene` onto `f`, replacing the entire frame contents. +pub fn paint(f: &mut Frame, scene: &UiScene) { + f.render_widget(Clear, f.area()); + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), + Constraint::Min(1), + Constraint::Length(3), + ]) + .split(f.area()); + + paint_title(f, chunks[0]); + + match &scene.body { + UiBody::MailingListSelection(s) => screens::mailing_list::paint(f, s, chunks[1]), + UiBody::Bookmarked(s) => screens::bookmarked::paint(f, s, chunks[1]), + UiBody::Latest(s) => screens::latest::paint(f, s, chunks[1]), + UiBody::PatchsetDetails(s) => screens::details::paint(f, s, chunks[1]), + UiBody::EditConfig(s) => screens::edit_config::paint(f, s, chunks[1]), + } + + screens::navigation_bar::paint(f, &scene.navigation, chunks[2]); + + if let Some(popup) = &scene.popup { + let (x, y) = popup.dimensions; + let rect = centered_rect(x, y, f.area()); + screens::popup::paint(f, popup, rect); + } +} + +fn paint_title(f: &mut Frame, chunk: Rect) { + let title_block = Block::default() + .borders(Borders::ALL) + .style(Style::default()) + .title_alignment(Alignment::Center); + + let title = Paragraph::new(Text::styled( + "patch-hub", + Style::default().fg(Color::Green).bold(), + )) + .centered() + .block(title_block); + + f.render_widget(title, chunk); +} + +pub(super) fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect { + let popup_layout = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage((100 - percent_y) / 2), + Constraint::Percentage(percent_y), + Constraint::Percentage((100 - percent_y) / 2), + ]) + .split(r); + + Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage((100 - percent_x) / 2), + Constraint::Percentage(percent_x), + Constraint::Percentage((100 - percent_x) / 2), + ]) + .split(popup_layout[1])[1] +} diff --git a/src/ui/scene.rs b/src/ui/scene.rs index eb433f3..65483a5 100644 --- a/src/ui/scene.rs +++ b/src/ui/scene.rs @@ -1,62 +1,27 @@ //! Scene types produced by [`super::core::UiCore`] and consumed by //! [`super::painter`]. //! -//! These types are not yet wired into the runtime pipeline; the allow below -//! will be removed once `UiCore` and `painter` are introduced. -#![allow(dead_code)] -//! //! A `UiScene` is a fully projected, paint-ready snapshot of application //! state. Nothing inside this module reads from `App`, `AppState`, or any //! actor handle — it is pure presentation data. use ratatui::text::{Span, Text}; +// Shared presentation-row types live in the app view-model layer. Re-export +// them here so callers within `ui/` only import from `scene`. +pub use crate::app::view_model::{ + ConfigEntryRow, MailingListEntry, PatchSummaryRow, TagTrailerCounts, +}; + // --------------------------------------------------------------------------- // Mailing-list selection // --------------------------------------------------------------------------- -/// One mailing list row in the selection screen. -#[derive(Clone, Debug)] -pub struct MailingListEntry { - pub name: String, - pub description: String, -} - -/// Validity of the text the user has typed into the mailing-list filter box. -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum TargetListStatus { - /// Nothing typed yet. - Empty, - /// The typed string is the name of an existing list. - ExactMatch, - /// The typed string is a prefix of at least one existing list. - PrefixMatch, - /// The typed string matches no list. - NoMatch, -} - /// Scene for the mailing-list selection screen. #[derive(Clone, Debug)] pub struct MailingListScene { pub entries: Vec, pub highlighted_index: usize, - pub target_list: String, - pub target_list_status: TargetListStatus, -} - -// --------------------------------------------------------------------------- -// Shared patchset summary row (used by Latest and Bookmarked screens) -// --------------------------------------------------------------------------- - -/// A single row in a patchset list. -#[derive(Clone, Debug)] -pub struct PatchSummaryRow { - pub title: String, - pub author_name: String, - pub version: u32, - pub total_in_series: u32, - /// Absolute index within the full (possibly paginated) list. - pub absolute_index: usize, } // --------------------------------------------------------------------------- @@ -79,29 +44,19 @@ pub struct BookmarkedScene { pub struct LatestScene { pub rows: Vec, pub selected_index: usize, - pub page_number: usize, - pub target_list: String, } // --------------------------------------------------------------------------- // Patchset details // --------------------------------------------------------------------------- -/// Pre-computed review-trailer counts for a single patch. -#[derive(Clone, Debug)] -pub struct TagTrailerCounts { - pub reviewed_by: usize, - pub tested_by: usize, - pub acked_by: usize, -} - /// Scene for the patchset-details-and-actions screen. #[derive(Clone, Debug)] pub struct PatchsetDetailsScene { pub patch_title: String, pub author_name: String, - pub version: u32, - pub patch_count: u32, + pub version: usize, + pub patch_count: usize, pub last_updated: String, /// Trailer counts for the currently previewed patch. pub tag_trailer_counts: TagTrailerCounts, @@ -127,24 +82,10 @@ pub struct PatchsetDetailsScene { // Edit config // --------------------------------------------------------------------------- -/// One row in the edit-config form. -#[derive(Clone, Debug)] -pub struct ConfigEntryRow { - pub label: String, - pub value: String, - pub is_highlighted: bool, - /// Whether this row is currently being typed into. - pub is_editing: bool, - /// The in-progress text while editing (only meaningful when `is_editing`). - pub edit_cursor_value: String, -} - /// Scene for the edit-configuration screen. #[derive(Clone, Debug)] pub struct EditConfigScene { pub entries: Vec, - /// Whether any row is in editing mode. - pub is_editing_mode: bool, } // --------------------------------------------------------------------------- diff --git a/src/ui/bookmarked.rs b/src/ui/screens/bookmarked.rs similarity index 63% rename from src/ui/bookmarked.rs rename to src/ui/screens/bookmarked.rs index bf8e3e7..0113d58 100644 --- a/src/ui/bookmarked.rs +++ b/src/ui/screens/bookmarked.rs @@ -6,12 +6,27 @@ use ratatui::{ Frame, }; -use crate::app::view_model::BookmarkedViewModel; +use crate::{app::view_model::BookmarkedViewModel, ui::scene::BookmarkedScene}; -pub fn render_main(f: &mut Frame, vm: &BookmarkedViewModel, chunk: Rect) { +// --------------------------------------------------------------------------- +// Builder +// --------------------------------------------------------------------------- + +pub fn build_scene(vm: &BookmarkedViewModel) -> BookmarkedScene { + BookmarkedScene { + rows: vm.rows.clone(), + selected_index: vm.selected_index, + } +} + +// --------------------------------------------------------------------------- +// Painter +// --------------------------------------------------------------------------- + +pub fn paint(f: &mut Frame, scene: &BookmarkedScene, chunk: Rect) { let mut list_items = Vec::::new(); - for row in &vm.rows { + for row in &scene.rows { let patch_title = format!("{:width$}", row.title, width = 70); let patch_title = format!("{:.width$}", patch_title, width = 70); let patch_author = format!("{:width$}", row.author_name, width = 30); @@ -45,19 +60,23 @@ pub fn render_main(f: &mut Frame, vm: &BookmarkedViewModel, chunk: Rect) { .highlight_spacing(HighlightSpacing::Always); let mut list_state = ListState::default(); - list_state.select(Some(vm.selected_index)); + list_state.select(Some(scene.selected_index)); f.render_stateful_widget(list, chunk, &mut list_state); } -pub fn mode_footer_text() -> Vec> { +// --------------------------------------------------------------------------- +// Navigation-bar helpers +// --------------------------------------------------------------------------- + +pub fn mode_spans() -> Vec> { vec![Span::styled( "Bookmarked Patchsets", Style::default().fg(Color::Green), )] } -pub fn keys_hint() -> Span<'static> { +pub fn keys_hint_span() -> Span<'static> { Span::styled( "(ESC / q) to return | (ENTER) to select | (?) help", Style::default().fg(Color::Red), diff --git a/src/ui/details_actions.rs b/src/ui/screens/details.rs similarity index 64% rename from src/ui/details_actions.rs rename to src/ui/screens/details.rs index e858a2d..47bb90c 100644 --- a/src/ui/details_actions.rs +++ b/src/ui/screens/details.rs @@ -6,11 +6,65 @@ use ratatui::{ Frame, }; -use crate::app::view_model::{PatchsetDetailsViewModel, TagTrailerCounts}; +use crate::{ + app::view_model::PatchsetDetailsViewModel, + ui::scene::{PatchsetDetailsScene, TagTrailerCounts}, +}; + +// --------------------------------------------------------------------------- +// Builder +// --------------------------------------------------------------------------- + +pub fn build_scene(vm: &PatchsetDetailsViewModel) -> PatchsetDetailsScene { + PatchsetDetailsScene { + patch_title: vm.patch_title.clone(), + author_name: vm.author_name.clone(), + version: vm.version, + patch_count: vm.patch_count, + last_updated: vm.last_updated.clone(), + tag_trailer_counts: vm.tag_trailer_counts.clone(), + staged_to_reply: vm.staged_to_reply.clone(), + preview_entries: vm.preview_entries.clone(), + preview_index: vm.preview_index, + preview_scroll_offset: vm.preview_scroll_offset, + preview_pan: vm.preview_pan, + preview_fullscreen: vm.preview_fullscreen, + preview_title: vm.preview_title.clone(), + is_bookmarked: vm.is_bookmarked, + is_apply_staged: vm.is_apply_staged, + is_current_patch_reply_staged: vm.is_current_patch_reply_staged, + } +} + +// --------------------------------------------------------------------------- +// Painter +// --------------------------------------------------------------------------- + +pub fn paint(f: &mut Frame, scene: &PatchsetDetailsScene, chunk: Rect) { + if scene.preview_fullscreen { + paint_preview(f, scene, chunk); + } else { + let chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(30), Constraint::Percentage(70)]) + .split(chunk); + + let details_and_actions_chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(chunks[0]); + + paint_details_and_actions( + f, + scene, + details_and_actions_chunks[0], + details_and_actions_chunks[1], + ); + paint_preview(f, scene, chunks[1]); + } +} -/// Returns a `Line` with Reviewed-by / Tested-by / Acked-by trailer counts -/// coloured green when non-zero, white when zero. -fn review_trailers_details(counts: &TagTrailerCounts) -> Line<'static> { +fn review_trailers_line(counts: &TagTrailerCounts) -> Line<'static> { let resolve_color = |n: usize| -> Style { if n == 0 { Style::default().fg(Color::White) @@ -35,40 +89,46 @@ fn review_trailers_details(counts: &TagTrailerCounts) -> Line<'static> { ]) } -fn render_details_and_actions( +fn paint_details_and_actions( f: &mut Frame, - vm: &PatchsetDetailsViewModel, + scene: &PatchsetDetailsScene, details_chunk: Rect, actions_chunk: Rect, ) { let mut patchset_details = vec![ Line::from(vec![ Span::styled(r#" Title: "#, Style::default().fg(Color::Cyan)), - Span::styled(vm.patch_title.clone(), Style::default().fg(Color::White)), + Span::styled(scene.patch_title.clone(), Style::default().fg(Color::White)), ]), Line::from(vec![ Span::styled("Author: ", Style::default().fg(Color::Cyan)), - Span::styled(vm.author_name.clone(), Style::default().fg(Color::White)), + Span::styled(scene.author_name.clone(), Style::default().fg(Color::White)), ]), Line::from(vec![ Span::styled("Version: ", Style::default().fg(Color::Cyan)), - Span::styled(format!("{}", vm.version), Style::default().fg(Color::White)), + Span::styled( + format!("{}", scene.version), + Style::default().fg(Color::White), + ), ]), Line::from(vec![ Span::styled("Patch count: ", Style::default().fg(Color::Cyan)), Span::styled( - format!("{}", vm.patch_count), + format!("{}", scene.patch_count), Style::default().fg(Color::White), ), ]), Line::from(vec![ Span::styled("Last updated: ", Style::default().fg(Color::Cyan)), - Span::styled(vm.last_updated.clone(), Style::default().fg(Color::White)), + Span::styled( + scene.last_updated.clone(), + Style::default().fg(Color::White), + ), ]), - review_trailers_details(&vm.tag_trailer_counts), + review_trailers_line(&scene.tag_trailer_counts), ]; - if let Some(staged) = &vm.staged_to_reply { + if let Some(staged) = &scene.staged_to_reply { patchset_details.push(Line::from(vec![ Span::styled("Staged to reply: ", Style::default().fg(Color::Cyan)), Span::styled(staged.clone(), Style::default().fg(Color::White)), @@ -91,7 +151,7 @@ fn render_details_and_actions( // TODO: Create a function to produce new action lines let patchset_actions = vec![ Line::from(vec![ - if vm.is_bookmarked { + if scene.is_bookmarked { Span::styled("[x] ", Style::default().fg(Color::Green)) } else { Span::styled("[ ] ", Style::default().fg(Color::Cyan)) @@ -106,7 +166,7 @@ fn render_details_and_actions( Span::styled("ookmark", Style::default().fg(Color::Cyan)), ]), Line::from(vec![ - if vm.is_apply_staged { + if scene.is_apply_staged { Span::styled("[x] ", Style::default().fg(Color::Green)) } else { Span::styled("[ ] ", Style::default().fg(Color::Cyan)) @@ -121,7 +181,7 @@ fn render_details_and_actions( Span::styled("pply", Style::default().fg(Color::Cyan)), ]), Line::from(vec![ - if vm.is_current_patch_reply_staged { + if scene.is_current_patch_reply_staged { Span::styled("[x] ", Style::default().fg(Color::Green)) } else { Span::styled("[ ] ", Style::default().fg(Color::Cyan)) @@ -149,8 +209,8 @@ fn render_details_and_actions( f.render_widget(patchset_actions, actions_chunk); } -fn render_preview(f: &mut Frame, vm: &PatchsetDetailsViewModel, chunk: Rect) { - let patch_preview = vm.preview_entries[vm.preview_index].clone(); +fn paint_preview(f: &mut Frame, scene: &PatchsetDetailsScene, chunk: Rect) { + let patch_preview = scene.preview_entries[scene.preview_index].clone(); let patch_preview = Paragraph::new(patch_preview) .block( @@ -158,49 +218,32 @@ fn render_preview(f: &mut Frame, vm: &PatchsetDetailsViewModel, chunk: Rect) { .borders(Borders::ALL) .border_type(ratatui::widgets::BorderType::Double) .title( - Line::styled(vm.preview_title.clone(), Style::default().fg(Color::Green)) - .left_aligned(), + Line::styled( + scene.preview_title.clone(), + Style::default().fg(Color::Green), + ) + .left_aligned(), ) .padding(Padding::vertical(1)), ) .left_aligned() - .scroll((vm.preview_scroll_offset as u16, vm.preview_pan as u16)); + .scroll((scene.preview_scroll_offset as u16, scene.preview_pan as u16)); f.render_widget(patch_preview, chunk); } -pub fn render_main(f: &mut Frame, vm: &PatchsetDetailsViewModel, chunk: Rect) { - if vm.preview_fullscreen { - render_preview(f, vm, chunk); - } else { - let chunks = Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Percentage(30), Constraint::Percentage(70)]) - .split(chunk); - - let details_and_actions_chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) - .split(chunks[0]); - - render_details_and_actions( - f, - vm, - details_and_actions_chunks[0], - details_and_actions_chunks[1], - ); - render_preview(f, vm, chunks[1]); - } -} +// --------------------------------------------------------------------------- +// Navigation-bar helpers +// --------------------------------------------------------------------------- -pub fn mode_footer_text() -> Vec> { +pub fn mode_spans() -> Vec> { vec![Span::styled( "Patchset Details and Actions", Style::default().fg(Color::Green), )] } -pub fn keys_hint() -> Span<'static> { +pub fn keys_hint_span() -> Span<'static> { Span::styled( "(ESC / q) to return | (ENTER) run actions | (?) help", Style::default().fg(Color::Red), diff --git a/src/ui/edit_config.rs b/src/ui/screens/edit_config.rs similarity index 67% rename from src/ui/edit_config.rs rename to src/ui/screens/edit_config.rs index 60c0f7f..4d0c4d5 100644 --- a/src/ui/edit_config.rs +++ b/src/ui/screens/edit_config.rs @@ -6,9 +6,23 @@ use ratatui::{ Frame, }; -use crate::app::view_model::EditConfigViewModel; +use crate::{app::view_model::EditConfigViewModel, ui::scene::EditConfigScene}; -pub fn render_main(f: &mut Frame, vm: &EditConfigViewModel, chunk: Rect) { +// --------------------------------------------------------------------------- +// Builder +// --------------------------------------------------------------------------- + +pub fn build_scene(vm: &EditConfigViewModel) -> EditConfigScene { + EditConfigScene { + entries: vm.entries.clone(), + } +} + +// --------------------------------------------------------------------------- +// Painter +// --------------------------------------------------------------------------- + +pub fn paint(f: &mut Frame, scene: &EditConfigScene, chunk: Rect) { let mut constraints = Vec::new(); for _ in 0..(chunk.height / 3) { @@ -20,7 +34,7 @@ pub fn render_main(f: &mut Frame, vm: &EditConfigViewModel, chunk: Rect) { .constraints(constraints) .split(chunk); - for (i, entry) in vm.entries.iter().enumerate() { + for (i, entry) in scene.entries.iter().enumerate() { if i + 1 > config_chunks.len() { break; } @@ -57,7 +71,11 @@ pub fn render_main(f: &mut Frame, vm: &EditConfigViewModel, chunk: Rect) { } } -pub fn mode_footer_text(vm: &EditConfigViewModel) -> Vec> { +// --------------------------------------------------------------------------- +// Navigation-bar helpers +// --------------------------------------------------------------------------- + +pub fn mode_spans(vm: &EditConfigViewModel) -> Vec> { vec![if vm.is_editing_mode { Span::styled("Editing...", Style::default().fg(Color::LightYellow)) } else { @@ -65,7 +83,7 @@ pub fn mode_footer_text(vm: &EditConfigViewModel) -> Vec> { }] } -pub fn keys_hint(vm: &EditConfigViewModel) -> Span<'static> { +pub fn keys_hint_span(vm: &EditConfigViewModel) -> Span<'static> { if vm.is_editing_mode { Span::styled( "(ESC) cancel | (ENTER) confirm", diff --git a/src/ui/latest.rs b/src/ui/screens/latest.rs similarity index 65% rename from src/ui/latest.rs rename to src/ui/screens/latest.rs index 8dbab8f..ddbb0fa 100644 --- a/src/ui/latest.rs +++ b/src/ui/screens/latest.rs @@ -6,12 +6,27 @@ use ratatui::{ Frame, }; -use crate::app::view_model::LatestPatchsetsViewModel; +use crate::{app::view_model::LatestPatchsetsViewModel, ui::scene::LatestScene}; -pub fn render_main(f: &mut Frame, vm: &LatestPatchsetsViewModel, chunk: Rect) { +// --------------------------------------------------------------------------- +// Builder +// --------------------------------------------------------------------------- + +pub fn build_scene(vm: &LatestPatchsetsViewModel) -> LatestScene { + LatestScene { + rows: vm.rows.clone(), + selected_index: vm.selected_index, + } +} + +// --------------------------------------------------------------------------- +// Painter +// --------------------------------------------------------------------------- + +pub fn paint(f: &mut Frame, scene: &LatestScene, chunk: Rect) { let mut list_items = Vec::::new(); - for row in &vm.rows { + for row in &scene.rows { let patch_title = format!("{:width$}", row.title, width = 70); let patch_title = format!("{:.width$}", patch_title, width = 70); let patch_author = format!("{:width$}", row.author_name, width = 30); @@ -45,12 +60,16 @@ pub fn render_main(f: &mut Frame, vm: &LatestPatchsetsViewModel, chunk: Rect) { .highlight_spacing(HighlightSpacing::Always); let mut list_state = ListState::default(); - list_state.select(Some(vm.selected_index)); + list_state.select(Some(scene.selected_index)); f.render_stateful_widget(list, chunk, &mut list_state); } -pub fn mode_footer_text(vm: &LatestPatchsetsViewModel) -> Vec> { +// --------------------------------------------------------------------------- +// Navigation-bar helpers +// --------------------------------------------------------------------------- + +pub fn mode_spans(vm: &LatestPatchsetsViewModel) -> Vec> { vec![Span::styled( format!( "Latest Patchsets from {} (page {})", @@ -60,7 +79,7 @@ pub fn mode_footer_text(vm: &LatestPatchsetsViewModel) -> Vec> { )] } -pub fn keys_hint() -> Span<'static> { +pub fn keys_hint_span() -> Span<'static> { Span::styled( "(ESC / q) to return | (ENTER) to select | ( h / 🡄 ) previous page | ( l / 🡆 ) next page | (?) help", Style::default().fg(Color::Red), diff --git a/src/ui/mail_list.rs b/src/ui/screens/mailing_list.rs similarity index 64% rename from src/ui/mail_list.rs rename to src/ui/screens/mailing_list.rs index 2aef997..c18c685 100644 --- a/src/ui/mail_list.rs +++ b/src/ui/screens/mailing_list.rs @@ -6,12 +6,30 @@ use ratatui::{ Frame, }; -use crate::app::view_model::{MailingListSelectionViewModel, TargetListStatus}; +use crate::{ + app::view_model::{MailingListSelectionViewModel, TargetListStatus}, + ui::scene::MailingListScene, +}; + +// --------------------------------------------------------------------------- +// Builder +// --------------------------------------------------------------------------- -pub fn render_main(f: &mut Frame, vm: &MailingListSelectionViewModel, chunk: Rect) { +pub fn build_scene(vm: &MailingListSelectionViewModel) -> MailingListScene { + MailingListScene { + entries: vm.entries.clone(), + highlighted_index: vm.highlighted_index, + } +} + +// --------------------------------------------------------------------------- +// Painter +// --------------------------------------------------------------------------- + +pub fn paint(f: &mut Frame, scene: &MailingListScene, chunk: Rect) { let mut list_items = Vec::::new(); - for entry in &vm.entries { + for entry in &scene.entries { list_items.push(ListItem::new( Line::from(vec![ Span::styled(entry.name.clone(), Style::default().fg(Color::Magenta)), @@ -21,7 +39,7 @@ pub fn render_main(f: &mut Frame, vm: &MailingListSelectionViewModel, chunk: Rec ), ]) .centered(), - )) + )); } let list_block = Block::default() @@ -41,12 +59,16 @@ pub fn render_main(f: &mut Frame, vm: &MailingListSelectionViewModel, chunk: Rec .highlight_spacing(HighlightSpacing::Always); let mut list_state = ListState::default(); - list_state.select(Some(vm.highlighted_index)); + list_state.select(Some(scene.highlighted_index)); f.render_stateful_widget(list, chunk, &mut list_state); } -pub fn mode_footer_text(vm: &MailingListSelectionViewModel) -> Vec> { +// --------------------------------------------------------------------------- +// Navigation-bar helpers +// --------------------------------------------------------------------------- + +pub fn mode_spans(vm: &MailingListSelectionViewModel) -> Vec> { let text_area = match vm.target_list_status { TargetListStatus::Empty => { Span::styled("type the target list", Style::default().fg(Color::DarkGray)) @@ -69,7 +91,7 @@ pub fn mode_footer_text(vm: &MailingListSelectionViewModel) -> Vec ] } -pub fn keys_hint() -> Span<'static> { +pub fn keys_hint_span() -> Span<'static> { Span::styled( "(ESC) to quit | (ENTER) to confirm | (?) help", Style::default().fg(Color::Red), diff --git a/src/ui/screens/mod.rs b/src/ui/screens/mod.rs new file mode 100644 index 0000000..b348635 --- /dev/null +++ b/src/ui/screens/mod.rs @@ -0,0 +1,7 @@ +pub mod bookmarked; +pub mod details; +pub mod edit_config; +pub mod latest; +pub mod mailing_list; +pub mod navigation_bar; +pub mod popup; diff --git a/src/ui/screens/navigation_bar.rs b/src/ui/screens/navigation_bar.rs new file mode 100644 index 0000000..40b291b --- /dev/null +++ b/src/ui/screens/navigation_bar.rs @@ -0,0 +1,26 @@ +use ratatui::{ + layout::{Constraint, Direction, Layout, Rect}, + text::Line, + widgets::{Block, Borders, Paragraph}, + Frame, +}; + +use crate::ui::scene::NavigationBarScene; + +pub fn paint(f: &mut Frame, scene: &NavigationBarScene, chunk: Rect) { + let mode_footer = Paragraph::new(Line::from(scene.mode_spans.clone())) + .block(Block::default().borders(Borders::ALL)) + .centered(); + + let keys_hint_footer = Paragraph::new(Line::from(scene.keys_hint.clone())) + .block(Block::default().borders(Borders::ALL)) + .centered(); + + let footer_chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(30), Constraint::Percentage(80)]) + .split(chunk); + + f.render_widget(mode_footer, footer_chunks[0]); + f.render_widget(keys_hint_footer, footer_chunks[1]); +} diff --git a/src/ui/popup/mod.rs b/src/ui/screens/popup.rs similarity index 75% rename from src/ui/popup/mod.rs rename to src/ui/screens/popup.rs index 6fad3d2..23e7be8 100644 --- a/src/ui/popup/mod.rs +++ b/src/ui/screens/popup.rs @@ -1,10 +1,3 @@ -//! Popup rendering for the UI layer. -//! -//! Each popup variant is represented in the view model as a concrete -//! [`PopupViewModel`]; this module provides the single `render_popup` -//! function that paints any variant onto a Ratatui frame by dispatching on -//! `PopupViewBody`. - use ratatui::{ layout::Alignment, style::{Color, Modifier, Style, Stylize}, @@ -13,43 +6,78 @@ use ratatui::{ Frame, }; -use crate::app::view_model::{PopupViewBody, PopupViewModel}; +use crate::{ + app::view_model::{PopupViewBody, PopupViewModel}, + ui::scene::{PopupBody, PopupScene}, +}; + +// --------------------------------------------------------------------------- +// Builder +// --------------------------------------------------------------------------- -/// Paint `popup` centred inside `chunk`. -pub fn render_popup(f: &mut Frame, popup: &PopupViewModel, chunk: ratatui::layout::Rect) { - match &popup.body { - PopupViewBody::Text(body) => render_info(f, &popup.title, body, popup.scroll_offset, chunk), +pub fn build_scene(vm: &PopupViewModel) -> PopupScene { + let body = match &vm.body { + PopupViewBody::Text(text) => PopupBody::Text(text.clone()), PopupViewBody::Keybinds { description, formatted_keybinds, - } => render_help( + } => PopupBody::Keybinds { + description: description.clone(), + formatted_keybinds: formatted_keybinds.clone(), + }, + PopupViewBody::ReviewTrailers { + reviewed_by, + tested_by, + acked_by, + } => PopupBody::ReviewTrailers { + reviewed_by: reviewed_by.clone(), + tested_by: tested_by.clone(), + acked_by: acked_by.clone(), + }, + }; + + PopupScene { + title: vm.title.clone(), + body, + scroll_offset: vm.scroll_offset, + dimensions: vm.dimensions, + } +} + +// --------------------------------------------------------------------------- +// Painter +// --------------------------------------------------------------------------- + +pub fn paint(f: &mut Frame, scene: &PopupScene, chunk: ratatui::layout::Rect) { + match &scene.body { + PopupBody::Text(body) => paint_info(f, &scene.title, body, scene.scroll_offset, chunk), + PopupBody::Keybinds { + description, + formatted_keybinds, + } => paint_help( f, - &popup.title, + &scene.title, description.as_deref(), formatted_keybinds, - popup.scroll_offset, + scene.scroll_offset, chunk, ), - PopupViewBody::ReviewTrailers { + PopupBody::ReviewTrailers { reviewed_by, tested_by, acked_by, - } => render_review_trailers( + } => paint_review_trailers( f, reviewed_by, tested_by, acked_by, - popup.scroll_offset, + scene.scroll_offset, chunk, ), } } -// --------------------------------------------------------------------------- -// Info popup -// --------------------------------------------------------------------------- - -fn render_info( +fn paint_info( f: &mut Frame, title: &str, body: &str, @@ -78,11 +106,7 @@ fn render_info( f.render_widget(paragraph, chunk); } -// --------------------------------------------------------------------------- -// Help popup -// --------------------------------------------------------------------------- - -fn render_help( +fn paint_help( f: &mut Frame, title: &str, description: Option<&str>, @@ -118,11 +142,7 @@ fn render_help( f.render_widget(paragraph, chunk); } -// --------------------------------------------------------------------------- -// Review-trailers popup -// --------------------------------------------------------------------------- - -fn render_review_trailers( +fn paint_review_trailers( f: &mut Frame, reviewed_by: &str, tested_by: &str, From fcb99394c428b9a0ed6175f6f45d58b2eda8b92d Mon Sep 17 00:00:00 2001 From: lorenzoberts Date: Sat, 27 Jun 2026 12:08:52 -0300 Subject: [PATCH 5/6] refactor(terminal): change TerminalFrame::Main payload from AppRenderSnapshot to UiScene This commit narrows the terminal actor boundary to drawing pre-composed UI scenes. TerminalFrame::Main now carries a UiScene instead of an application render snapshot, and the terminal session paints directly through the UI painter. AppRenderSnapshot and the inline draw path inside the terminal layer are removed so scene composition stays entirely on the UI side. This commit is part of the architecture's refactoring phase 11. Signed-off-by: lorenzoberts --- src/app/mod.rs | 3 --- src/app/render_snapshot.rs | 23 ----------------------- src/app/view_model.rs | 14 +++++--------- src/handler/mod.rs | 7 ++++++- src/terminal/messages.rs | 6 ++---- src/terminal/session.rs | 7 +++---- src/ui/mod.rs | 14 ++------------ 7 files changed, 18 insertions(+), 56 deletions(-) delete mode 100644 src/app/render_snapshot.rs diff --git a/src/app/mod.rs b/src/app/mod.rs index 4527d41..e8f05f3 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -2,7 +2,6 @@ pub mod commands; pub mod errors; pub mod input; pub mod popup; -pub mod render_snapshot; pub mod screens; pub mod state; pub mod updates; @@ -434,8 +433,6 @@ impl App { /// /// This is the primary way for the orchestration layer to hand off /// presentation data to the UI actor without exposing raw `AppState`. - /// Called by `run_app` once the UI actor is introduced (Commit 11.6). - #[allow(dead_code)] pub fn present(&self) -> AppViewModel { view_model::project_state(&self.state) } diff --git a/src/app/render_snapshot.rs b/src/app/render_snapshot.rs deleted file mode 100644 index 1b6bfba..0000000 --- a/src/app/render_snapshot.rs +++ /dev/null @@ -1,23 +0,0 @@ -use super::{view_model, 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 { - view_model::project_state(&self.state) - } -} - -impl App { - pub fn render_snapshot(&self) -> AppRenderSnapshot { - AppRenderSnapshot::new(self.state.clone()) - } -} diff --git a/src/app/view_model.rs b/src/app/view_model.rs index 8f4d7b3..78fc84e 100644 --- a/src/app/view_model.rs +++ b/src/app/view_model.rs @@ -1,13 +1,11 @@ //! Owned, typed presentation projections. //! //! [`AppViewModel`] is built from [`super::state::AppState`] by -//! [`project_state`], which is called from both -//! [`super::render_snapshot::AppRenderSnapshot::to_view_model`] and -//! [`super::App::present`]. +//! [`project_state`], which is called via [`super::App::present`]. //! //! These types sit at the *application* layer: they represent what `App` knows -//! about the presentation before `UiCore` (Commit 11.4) translates them into -//! paint-ready `UiScene` nodes. +//! about the presentation before `UiCore` translates them into paint-ready +//! `UiScene` nodes. use ratatui::text::Text; @@ -176,8 +174,7 @@ pub enum ScreenViewModel { /// Owned, typed projection of [`AppState`] for one TUI frame. /// -/// Built by [`project_state`]; consumed by [`crate::ui::draw_ui`] and — -/// once introduced — by `UiCore::build_scene`. +/// Built by [`project_state`]; consumed by `UiCore::build_scene`. #[derive(Clone, Debug)] pub struct AppViewModel { pub screen: ScreenViewModel, @@ -190,8 +187,7 @@ pub struct AppViewModel { /// Projects `state` into an owned [`AppViewModel`]. /// -/// Called by both [`super::App::present`] and -/// [`super::render_snapshot::AppRenderSnapshot::to_view_model`]. +/// Called via [`super::App::present`]. pub fn project_state(state: &AppState) -> AppViewModel { let screen = project_screen(state); let popup = state.popup.as_ref().map(project_popup); diff --git a/src/handler/mod.rs b/src/handler/mod.rs index 69cf4ba..8ae711c 100644 --- a/src/handler/mod.rs +++ b/src/handler/mod.rs @@ -19,6 +19,7 @@ use crate::{ app::{screens::CurrentScreen, App}, input::{event::InputEvent, handle::InputHandle}, terminal::{handle::TerminalHandle, messages::TerminalFrame, TerminalError}, + ui::core::UiCore, }; use bookmarked::handle_bookmarked_patchsets; @@ -147,8 +148,12 @@ pub async fn run_app( loop { app.process_system_updates(&mut loading).await?; + let vm = app.present(); + let scene = UiCore::new() + .build_scene(&vm) + .map_err(|e| color_eyre::eyre::eyre!("{e}"))?; terminal_handle - .draw(TerminalFrame::Main(Box::new(app.render_snapshot()))) + .draw(TerminalFrame::Main(Box::new(scene))) .await .map_err(terminal_error)?; diff --git a/src/terminal/messages.rs b/src/terminal/messages.rs index 879f4c7..72272d4 100644 --- a/src/terminal/messages.rs +++ b/src/terminal/messages.rs @@ -3,16 +3,14 @@ use std::time::Duration; use ratatui::crossterm::event::KeyCode; use tokio::sync::oneshot; -use crate::{ - app::render_snapshot::AppRenderSnapshot, input::event::TerminalEvent, terminal::TerminalError, -}; +use crate::{input::event::TerminalEvent, terminal::TerminalError, ui::scene::UiScene}; pub type TerminalResult = Result; /// Owned payload for a terminal draw request. #[derive(Clone, Default)] pub enum TerminalFrame { - Main(Box), + Main(Box), Loading(String), /// Placeholder frame used in terminal actor tests. #[default] diff --git a/src/terminal/session.rs b/src/terminal/session.rs index 4ca1d20..72c9895 100644 --- a/src/terminal/session.rs +++ b/src/terminal/session.rs @@ -13,7 +13,7 @@ use crate::{ messages::{TerminalFrame, TerminalResult}, TerminalError, }, - ui::{draw_ui, loading_screen::draw_loading_screen}, + ui::{loading_screen::draw_loading_screen, painter}, }; /// Stateful terminal session owned by the terminal actor. @@ -56,9 +56,8 @@ impl CrosstermTerminalSession { 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::Main(scene) => { + self.terminal.draw(|f| painter::paint(f, &scene))?; } TerminalFrame::Loading(title) => { self.terminal.draw(|f| draw_loading_screen(f, &title))?; diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 12c1ba2..5810ea0 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,17 +1,7 @@ -mod core; +pub mod core; pub mod errors; pub mod loading_screen; -mod painter; +pub mod painter; pub mod scene; mod screens; pub mod theme; - -use crate::app::view_model::AppViewModel; - -pub fn draw_ui(f: &mut ratatui::Frame, vm: &AppViewModel) { - let ui_core = core::UiCore::new(); - match ui_core.build_scene(vm) { - Ok(scene) => painter::paint(f, &scene), - Err(e) => tracing::error!("failed to build ui scene: {e}"), - } -} From 04b5f128f3f76b18992129fba6fb23faa29d706f Mon Sep 17 00:00:00 2001 From: lorenzoberts Date: Sat, 27 Jun 2026 12:14:10 -0300 Subject: [PATCH 6/6] refactor(ui): introduce UiActor and wire into runtime This commit promotes the UI layer to a dedicated actor with UiMessage, UiHandle, and a BuildScene request path. run_app now projects application state into a view model and asks the UI actor to build each frame's scene before handing it to the terminal actor, matching the spawn-and-shutdown lifecycle already used by the terminal and input actors. This commit completes the architecture's refactoring phase 11. Signed-off-by: lorenzoberts --- src/handler/mod.rs | 9 +-- src/main.rs | 12 +++- src/ui/actor.rs | 147 +++++++++++++++++++++++++++++++++++++++++++++ src/ui/handle.rs | 49 +++++++++++++++ src/ui/messages.rs | 25 ++++++++ src/ui/mod.rs | 3 + 6 files changed, 240 insertions(+), 5 deletions(-) create mode 100644 src/ui/actor.rs create mode 100644 src/ui/handle.rs create mode 100644 src/ui/messages.rs diff --git a/src/handler/mod.rs b/src/handler/mod.rs index 8ae711c..01022c3 100644 --- a/src/handler/mod.rs +++ b/src/handler/mod.rs @@ -19,7 +19,7 @@ use crate::{ app::{screens::CurrentScreen, App}, input::{event::InputEvent, handle::InputHandle}, terminal::{handle::TerminalHandle, messages::TerminalFrame, TerminalError}, - ui::core::UiCore, + ui::handle::UiHandle, }; use bookmarked::handle_bookmarked_patchsets; @@ -140,6 +140,7 @@ async fn input_handling( pub async fn run_app( mut app: App, terminal_handle: TerminalHandle, + ui_handle: UiHandle, input_handle: InputHandle, mut app_input_rx: mpsc::Receiver, ) -> color_eyre::Result<()> { @@ -148,9 +149,9 @@ pub async fn run_app( loop { app.process_system_updates(&mut loading).await?; - let vm = app.present(); - let scene = UiCore::new() - .build_scene(&vm) + let scene = ui_handle + .build_scene(app.present()) + .await .map_err(|e| color_eyre::eyre::eyre!("{e}"))?; terminal_handle .draw(TerminalFrame::Main(Box::new(scene))) diff --git a/src/main.rs b/src/main.rs index 0dbd02d..527aca8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -41,6 +41,7 @@ use std::{ops::ControlFlow, sync::Arc}; use terminal::{actor::TerminalActor, session::CrosstermTerminalSession}; use tokio::sync::mpsc; use tracing::{event, Level}; +use ui::actor::UiActor; /// Verifies required and optional external binaries before the TUI runs. /// @@ -123,6 +124,7 @@ async fn main() -> color_eyre::Result<()> { } let terminal_handle = TerminalActor::spawn(Box::new(CrosstermTerminalSession::new(init()?))); + let ui_handle = UiActor::spawn(); // Build shared infrastructure dependencies for LoreService let net = Arc::new(UreqNetClient::new()); @@ -183,7 +185,15 @@ async fn main() -> color_eyre::Result<()> { .await .map_err(|e| eyre!("{e}"))?; - run_app(app, terminal_handle.clone(), input_handle, app_input_rx).await?; + run_app( + app, + terminal_handle.clone(), + ui_handle.clone(), + input_handle, + app_input_rx, + ) + .await?; + ui_handle.shutdown().await; terminal_handle .shutdown() .await diff --git a/src/ui/actor.rs b/src/ui/actor.rs new file mode 100644 index 0000000..4aaacea --- /dev/null +++ b/src/ui/actor.rs @@ -0,0 +1,147 @@ +use std::ops::ControlFlow; + +use tokio::sync::{mpsc, oneshot}; + +use crate::ui::{ + core::UiCore, + handle::UiHandle, + messages::{UiMessage, UiResult}, +}; + +pub const DEFAULT_UI_CHANNEL_SIZE: usize = 32; + +pub struct UiActor { + core: UiCore, + rx: mpsc::Receiver, +} + +impl UiActor { + pub fn new(rx: mpsc::Receiver) -> Self { + Self { + core: UiCore::new(), + rx, + } + } + + pub fn spawn() -> UiHandle { + let (tx, rx) = mpsc::channel(DEFAULT_UI_CHANNEL_SIZE); + tracing::debug!(channel_size = DEFAULT_UI_CHANNEL_SIZE, "spawning ui actor"); + tokio::spawn(Self::new(rx).run()); + UiHandle::new(tx) + } + + pub async fn run(mut self) { + tracing::info!("ui actor started"); + while let Some(message) = self.rx.recv().await { + if let ControlFlow::Break(()) = self.handle_message(message) { + break; + } + } + tracing::info!("ui actor stopped"); + } + + fn handle_message(&self, message: UiMessage) -> ControlFlow<()> { + let message_name = message.name(); + tracing::debug!(message = message_name, "ui request received"); + + match message { + UiMessage::BuildScene { app_view, reply_to } => { + tracing::debug!( + screen = ?std::mem::discriminant(&app_view.screen), + has_popup = app_view.popup.is_some(), + "building ui scene" + ); + let result = self.core.build_scene(&app_view); + tracing::debug!(ok = result.is_ok(), "ui scene built"); + send_ui_reply(message_name, reply_to, result); + ControlFlow::Continue(()) + } + UiMessage::Shutdown => { + tracing::debug!("ui actor shutting down"); + ControlFlow::Break(()) + } + } + } +} + +fn send_ui_reply( + message_name: &'static str, + reply: oneshot::Sender>, + result: UiResult, +) { + if let Err(error) = &result { + tracing::warn!( + message = message_name, + error = %error, + "ui request failed" + ); + } + + if reply.send(result).is_err() { + tracing::warn!( + message = message_name, + "ui reply receiver dropped before response" + ); + } +} + +#[cfg(test)] +mod tests { + use tokio::sync::mpsc; + + use crate::{ + app::view_model::{ + AppViewModel, MailingListSelectionViewModel, ScreenViewModel, TargetListStatus, + }, + ui::{errors::UiError, handle::UiHandle}, + }; + + use super::{UiActor, DEFAULT_UI_CHANNEL_SIZE}; + + fn minimal_vm() -> AppViewModel { + AppViewModel { + screen: ScreenViewModel::MailingListSelection(MailingListSelectionViewModel { + entries: vec![], + highlighted_index: 0, + target_list: String::new(), + target_list_status: TargetListStatus::Empty, + }), + popup: None, + } + } + + fn spawn_test_actor() -> UiHandle { + UiActor::spawn() + } + + #[tokio::test] + async fn build_scene_returns_ok_for_valid_view_model() { + let handle = spawn_test_actor(); + + let result = handle.build_scene(minimal_vm()).await; + + assert!(result.is_ok()); + } + + #[tokio::test] + async fn sequential_build_scene_calls_succeed() { + let handle = spawn_test_actor(); + + let result1 = handle.build_scene(minimal_vm()).await; + let result2 = handle.build_scene(minimal_vm()).await; + + assert!(result1.is_ok()); + assert!(result2.is_ok()); + } + + #[tokio::test] + async fn closed_channel_returns_build_error() { + let (tx, rx) = mpsc::channel(DEFAULT_UI_CHANNEL_SIZE); + let handle = UiHandle::new(tx); + drop(rx); + + let result = handle.build_scene(minimal_vm()).await; + + assert!(matches!(result, Err(UiError::Build(_)))); + } +} diff --git a/src/ui/handle.rs b/src/ui/handle.rs new file mode 100644 index 0000000..a8729f0 --- /dev/null +++ b/src/ui/handle.rs @@ -0,0 +1,49 @@ +use tokio::sync::{mpsc, oneshot}; + +use crate::{ + app::view_model::AppViewModel, + ui::{ + errors::UiError, + messages::{UiMessage, UiResult}, + scene::UiScene, + }, +}; + +#[derive(Clone)] +pub struct UiHandle { + tx: mpsc::Sender, +} + +impl UiHandle { + pub fn new(tx: mpsc::Sender) -> Self { + Self { tx } + } + + pub async fn build_scene(&self, app_view: AppViewModel) -> UiResult { + self.request_result(|reply_to| UiMessage::BuildScene { + app_view: Box::new(app_view), + reply_to, + }) + .await + } + + pub async fn shutdown(&self) { + self.tx.send(UiMessage::Shutdown).await.ok(); + } + + async fn request_result( + &self, + build_message: impl FnOnce(oneshot::Sender>) -> UiMessage, + ) -> UiResult + where + T: Send + 'static, + { + let (reply_to, rx) = oneshot::channel(); + self.tx + .send(build_message(reply_to)) + .await + .map_err(|_| UiError::Build("request channel closed".to_string()))?; + rx.await + .map_err(|_| UiError::Build("reply channel closed".to_string()))? + } +} diff --git a/src/ui/messages.rs b/src/ui/messages.rs new file mode 100644 index 0000000..4f6d785 --- /dev/null +++ b/src/ui/messages.rs @@ -0,0 +1,25 @@ +use tokio::sync::oneshot; + +use crate::{ + app::view_model::AppViewModel, + ui::{errors::UiError, scene::UiScene}, +}; + +pub type UiResult = Result; + +pub enum UiMessage { + BuildScene { + app_view: Box, + reply_to: oneshot::Sender>, + }, + Shutdown, +} + +impl UiMessage { + pub fn name(&self) -> &'static str { + match self { + UiMessage::BuildScene { .. } => "BuildScene", + UiMessage::Shutdown => "Shutdown", + } + } +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 5810ea0..01196ba 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,6 +1,9 @@ +pub mod actor; pub mod core; pub mod errors; +pub mod handle; pub mod loading_screen; +pub mod messages; pub mod painter; pub mod scene; mod screens;