diff --git a/src/app/mod.rs b/src/app/mod.rs index 5ba58de..e8f05f3 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1,7 +1,7 @@ pub mod commands; pub mod errors; pub mod input; -pub mod render_snapshot; +pub mod popup; pub mod screens; pub mod state; pub mod updates; @@ -29,7 +29,6 @@ use crate::{ domain::patch::Patch, }, render::{handle::RenderHandle, RenderPatchsetRequest}, - ui::popup::info_popup::InfoPopUp, }; use screens::{ bookmarked::BookmarkedPatchsetsState, @@ -391,8 +390,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); @@ -429,4 +428,12 @@ 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`. + pub fn present(&self) -> AppViewModel { + view_model::project_state(&self.state) + } } diff --git a/src/app/popup.rs b/src/app/popup.rs new file mode 100644 index 0000000..053389f --- /dev/null +++ b/src/app/popup.rs @@ -0,0 +1,221 @@ +//! 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. + #[allow(dead_code)] + 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/render_snapshot.rs b/src/app/render_snapshot.rs deleted file mode 100644 index 0ab0f32..0000000 --- a/src/app/render_snapshot.rs +++ /dev/null @@ -1,23 +0,0 @@ -use super::{App, AppState, AppViewModel}; - -/// Owned application state snapshot for terminal actor drawing. -#[derive(Clone)] -pub struct AppRenderSnapshot { - state: AppState, -} - -impl AppRenderSnapshot { - pub fn new(state: AppState) -> Self { - Self { state } - } - - pub fn to_view_model(&self) -> AppViewModel<'_> { - AppViewModel { state: &self.state } - } -} - -impl App { - pub fn render_snapshot(&self) -> AppRenderSnapshot { - AppRenderSnapshot::new(self.state.clone()) - } -} diff --git a/src/app/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/app/view_model.rs b/src/app/view_model.rs index 56187a0..78fc84e 100644 --- a/src/app/view_model.rs +++ b/src/app/view_model.rs @@ -1,10 +1,465 @@ -//! 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 via [`super::App::present`]. +//! +//! These types sit at the *application* layer: they represent what `App` knows +//! about the presentation before `UiCore` translates them into paint-ready +//! `UiScene` nodes. -use super::state::AppState; +use ratatui::text::Text; -/// References into [`AppState`] for one Ratatui frame. Built via -/// [`super::render_snapshot::AppRenderSnapshot::to_view_model`]. -#[derive(Clone, Copy)] -pub struct AppViewModel<'a> { - pub state: &'a AppState, +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 `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 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); + 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/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..01022c3 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::handle::UiHandle, }; use bookmarked::handle_bookmarked_patchsets; @@ -109,7 +110,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 { @@ -139,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<()> { @@ -147,8 +149,12 @@ pub async fn run_app( loop { app.process_system_updates(&mut loading).await?; + let scene = ui_handle + .build_scene(app.present()) + .await + .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/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/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/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/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/edit_config.rs b/src/ui/edit_config.rs deleted file mode 100644 index 90803e2..0000000 --- a/src/ui/edit_config.rs +++ /dev/null @@ -1,88 +0,0 @@ -use ratatui::{ - layout::{Constraint, Direction, Layout, Rect}, - style::{Color, Modifier, Style}, - text::{Line, Span}, - widgets::{Block, Borders, Paragraph}, - Frame, -}; -use tracing::{event, Level}; - -use crate::app::AppViewModel; - -pub fn render_main(f: &mut Frame, vm: &AppViewModel<'_>, chunk: Rect) { - let edit_config = vm.state.config_state.edit_config.as_ref().unwrap(); - let mut constraints = Vec::new(); - - for _ in 0..(chunk.height / 3) { - constraints.push(Constraint::Length(3)); - } - - let config_chunks = Layout::default() - .direction(Direction::Vertical) - .constraints(constraints) - .split(chunk); - - let highlighted_entry = edit_config.highlighted(); - for i in 0..edit_config.config_count() { - 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 { - vec![ - Span::styled(edit_config.curr_edit().to_string(), Style::default()), - Span::styled(" ", Style::default().bg(Color::White)), - ] - } else { - vec![Span::from(value)] - }); - - let config_entry = Paragraph::new(value) - .centered() - .block(Block::default().borders(Borders::ALL).title(config)) - .style(if i == highlighted_entry && edit_config.is_editing() { - Style::default() - .fg(Color::LightYellow) - .add_modifier(Modifier::BOLD) - } else if i == highlighted_entry { - Style::default() - .fg(Color::DarkGray) - .add_modifier(Modifier::BOLD) - } else { - Style::default() - }); - - f.render_widget(config_entry, config_chunks[i]); - } -} - -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() { - 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( - "(ESC) cancel | (ENTER) confirm", - Style::default().fg(Color::Red), - ), - false => Span::styled( - "(ESC / q) exit | (ENTER) edit | (jk| 🡇 🡅 ) down up", - Style::default().fg(Color::Red), - ), - } -} diff --git a/src/ui/errors.rs b/src/ui/errors.rs new file mode 100644 index 0000000..c6427bf --- /dev/null +++ b/src/ui/errors.rs @@ -0,0 +1,13 @@ +#![allow(dead_code)] +/// 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/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/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/mail_list.rs b/src/ui/mail_list.rs deleted file mode 100644 index 758a9c3..0000000 --- a/src/ui/mail_list.rs +++ /dev/null @@ -1,98 +0,0 @@ -use ratatui::{ - layout::Rect, - style::{Color, Modifier, Style}, - text::{Line, Span}, - widgets::{Block, Borders, HighlightSpacing, List, ListItem, ListState}, - Frame, -}; - -use crate::app::AppViewModel; - -pub fn render_main(f: &mut Frame, vm: &AppViewModel<'_>, chunk: Rect) { - let highlighted_list_index = vm.state.lore.mailing_list_selection.highlighted_list_index; - let mut list_items = Vec::::new(); - - for mailing_list in &vm.state.lore.mailing_list_selection.possible_mailing_lists { - list_items.push(ListItem::new( - Line::from(vec![ - Span::styled( - mailing_list.name().to_string(), - Style::default().fg(Color::Magenta), - ), - Span::styled( - format!(" - {}", mailing_list.description()), - Style::default().fg(Color::White), - ), - ]) - .centered(), - )) - } - - let list_block = Block::default() - .borders(Borders::ALL) - .border_type(ratatui::widgets::BorderType::Double) - .style(Style::default()); - - let list = List::new(list_items) - .block(list_block) - .highlight_style( - Style::default() - .add_modifier(Modifier::BOLD) - .add_modifier(Modifier::REVERSED) - .fg(Color::Cyan), - ) - .highlight_symbol(">") - .highlight_spacing(HighlightSpacing::Always); - - let mut list_state = ListState::default(); - list_state.select(Some(highlighted_list_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), - ); - } - } - if text_area.content.is_empty() { - text_area = Span::styled( - &vm.state.lore.mailing_list_selection.target_list, - Style::default().fg(Color::Red), - ); - } - } - - vec![ - Span::styled("Target List: ", Style::default().fg(Color::Green)), - text_area, - ] -} - -pub fn keys_hint() -> Span<'static> { - Span::styled( - "(ESC) to quit | (ENTER) to confirm | (?) help", - Style::default().fg(Color::Red), - ) -} 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 958f0f8..01196ba 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,93 +1,10 @@ -mod bookmarked; -mod details_actions; -mod edit_config; -mod latest; +pub mod actor; +pub mod core; +pub mod errors; +pub mod handle; pub mod loading_screen; -mod mail_list; -mod navigation_bar; -pub mod popup; - -use ratatui::{ - layout::{Alignment, Constraint, Direction, Layout, Rect}, - style::{Color, Style, Stylize}, - text::Text, - widgets::{Block, Borders, Clear, Paragraph}, - Frame, -}; - -use crate::app::{screens::CurrentScreen, AppViewModel}; - -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.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]) - } - 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]), - } - - navigation_bar::render(f, vm, chunks[2]); - - vm.state.popup.as_ref().inspect(|p| { - let (x, y) = p.dimensions(); - let rect = centered_rect(x, y, f.area()); - p.render(f, 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 -} +pub mod messages; +pub mod painter; +pub mod scene; +mod screens; +pub mod theme; diff --git a/src/ui/navigation_bar.rs b/src/ui/navigation_bar.rs deleted file mode 100644 index 62af16f..0000000 --- a/src/ui/navigation_bar.rs +++ /dev/null @@ -1,45 +0,0 @@ -use ratatui::{ - layout::{Constraint, Direction, Layout, Rect}, - text::Line, - widgets::{Block, Borders, Paragraph}, - Frame, -}; - -use crate::app::{screens::CurrentScreen, AppViewModel}; - -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), - }; - 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 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/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 deleted file mode 100644 index d08fbf5..0000000 --- a/src/ui/popup/mod.rs +++ /dev/null @@ -1,47 +0,0 @@ -pub mod help; -pub mod info_popup; -pub mod review_trailers; - -use ratatui::{layout::Rect, Frame}; - -use std::fmt::Debug; - -use crate::input::event::InputEvent; - -pub trait PopUpClone { - fn clone_box(&self) -> Box; -} - -impl PopUpClone for T -where - T: 'static + PopUp + Clone, -{ - fn clone_box(&self) -> Box { - Box::new(self.clone()) - } -} - -impl Clone for Box { - fn clone(&self) -> Self { - self.clone_box() - } -} - -/// A trait that represents a popup that can be rendered on top of a screen -pub trait PopUp: Debug + 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<()>; -} 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 new file mode 100644 index 0000000..65483a5 --- /dev/null +++ b/src/ui/scene.rs @@ -0,0 +1,162 @@ +//! 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}; + +// 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 +// --------------------------------------------------------------------------- + +/// Scene for the mailing-list selection screen. +#[derive(Clone, Debug)] +pub struct MailingListScene { + pub entries: Vec, + pub highlighted_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, +} + +// --------------------------------------------------------------------------- +// Patchset details +// --------------------------------------------------------------------------- + +/// Scene for the patchset-details-and-actions screen. +#[derive(Clone, Debug)] +pub struct PatchsetDetailsScene { + pub patch_title: String, + pub author_name: String, + pub version: usize, + pub patch_count: usize, + 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 +// --------------------------------------------------------------------------- + +/// Scene for the edit-configuration screen. +#[derive(Clone, Debug)] +pub struct EditConfigScene { + pub entries: Vec, +} + +// --------------------------------------------------------------------------- +// 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/bookmarked.rs b/src/ui/screens/bookmarked.rs similarity index 54% rename from src/ui/bookmarked.rs rename to src/ui/screens/bookmarked.rs index 0990ac1..0113d58 100644 --- a/src/ui/bookmarked.rs +++ b/src/ui/screens/bookmarked.rs @@ -6,26 +6,36 @@ use ratatui::{ Frame, }; -use crate::app::screens::bookmarked::BookmarkedPatchsetsState; +use crate::{app::view_model::BookmarkedViewModel, ui::scene::BookmarkedScene}; -pub fn render_main(f: &mut Frame, bookmarked_patchsets: &BookmarkedPatchsetsState, chunk: Rect) { - let patchset_index = bookmarked_patchsets.patchset_index; +// --------------------------------------------------------------------------- +// 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 (index, patch) in bookmarked_patchsets.bookmarked_patchsets.iter().enumerate() { - let patch_title = format!("{:width$}", patch.title(), width = 70); + 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$}", 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,19 +60,23 @@ 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(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 53% rename from src/ui/details_actions.rs rename to src/ui/screens/details.rs index 8ad22d0..47bb90c 100644 --- a/src/ui/details_actions.rs +++ b/src/ui/screens/details.rs @@ -6,22 +6,67 @@ use ratatui::{ Frame, }; -use crate::app::{ - screens::details_actions::{PatchsetAction, PatchsetDetailsState}, - AppViewModel, +use crate::{ + app::view_model::PatchsetDetailsViewModel, + ui::scene::{PatchsetDetailsScene, TagTrailerCounts}, }; -/// 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; +// --------------------------------------------------------------------------- +// 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, + } +} - let resolve_color = |n_trailers: usize| -> Style { - if n_trailers == 0 { +// --------------------------------------------------------------------------- +// 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]); + } +} + +fn review_trailers_line(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 +76,62 @@ 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( +fn paint_details_and_actions( f: &mut Frame, - vm: &AppViewModel<'_>, + scene: &PatchsetDetailsScene, 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(scene.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(scene.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()), + format!("{}", scene.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!("{}", scene.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(), + scene.last_updated.clone(), Style::default().fg(Color::White), ), ]), - review_trailers_details(patchset_details_and_actions), + review_trailers_line(&scene.tag_trailer_counts), ]; - if !staged_to_reply.is_empty() { + + 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_to_reply, Style::default().fg(Color::White)), + Span::styled(staged.clone(), Style::default().fg(Color::White)), ])); } @@ -138,11 +148,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 scene.is_bookmarked { Span::styled("[x] ", Style::default().fg(Color::Green)) } else { Span::styled("[ ] ", Style::default().fg(Color::Cyan)) @@ -157,7 +166,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 scene.is_apply_staged { Span::styled("[x] ", Style::default().fg(Color::Green)) } else { Span::styled("[ ] ", Style::default().fg(Color::Cyan)) @@ -172,11 +181,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 scene.is_current_patch_reply_staged { Span::styled("[x] ", Style::default().fg(Color::Green)) } else { Span::styled("[ ] ", Style::default().fg(Color::Cyan)) @@ -204,35 +209,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 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( @@ -240,50 +218,32 @@ 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( + scene.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((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: &AppViewModel<'_>, chunk: Rect) { - let patchset_details_and_actions = vm.state.lore.details.as_ref().unwrap(); - - if patchset_details_and_actions.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/screens/edit_config.rs b/src/ui/screens/edit_config.rs new file mode 100644 index 0000000..4d0c4d5 --- /dev/null +++ b/src/ui/screens/edit_config.rs @@ -0,0 +1,98 @@ +use ratatui::{ + layout::{Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph}, + Frame, +}; + +use crate::{app::view_model::EditConfigViewModel, ui::scene::EditConfigScene}; + +// --------------------------------------------------------------------------- +// 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) { + constraints.push(Constraint::Length(3)); + } + + let config_chunks = Layout::default() + .direction(Direction::Vertical) + .constraints(constraints) + .split(chunk); + + for (i, entry) in scene.entries.iter().enumerate() { + if i + 1 > config_chunks.len() { + break; + } + + let value = Line::from(if entry.is_editing { + vec![ + Span::styled(entry.edit_cursor_value.clone(), Style::default()), + Span::styled(" ", Style::default().bg(Color::White)), + ] + } else { + vec![Span::from(entry.value.clone())] + }); + + let config_entry = Paragraph::new(value) + .centered() + .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 entry.is_highlighted { + Style::default() + .fg(Color::DarkGray) + .add_modifier(Modifier::BOLD) + } else { + Style::default() + }); + + f.render_widget(config_entry, config_chunks[i]); + } +} + +// --------------------------------------------------------------------------- +// 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 { + Span::styled("Edit Configurations", Style::default().fg(Color::Green)) + }] +} + +pub fn keys_hint_span(vm: &EditConfigViewModel) -> Span<'static> { + if vm.is_editing_mode { + Span::styled( + "(ESC) cancel | (ENTER) confirm", + Style::default().fg(Color::Red), + ) + } 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/screens/latest.rs similarity index 51% rename from src/ui/latest.rs rename to src/ui/screens/latest.rs index cd0e6f2..ddbb0fa 100644 --- a/src/ui/latest.rs +++ b/src/ui/screens/latest.rs @@ -6,55 +6,41 @@ use ratatui::{ Frame, }; -use crate::{app::AppViewModel, lore::domain::patch::Patch}; +use crate::{app::view_model::LatestPatchsetsViewModel, ui::scene::LatestScene}; -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(); - let mut list_items = Vec::::new(); +// --------------------------------------------------------------------------- +// Builder +// --------------------------------------------------------------------------- + +pub fn build_scene(vm: &LatestPatchsetsViewModel) -> LatestScene { + LatestScene { + rows: vm.rows.clone(), + selected_index: vm.selected_index, + } +} - let patch_feed_page: Vec<&Patch> = vm - .state - .lore - .latest_patchsets - .as_ref() - .unwrap() - .get_current_patch_feed_page() - .unwrap(); +// --------------------------------------------------------------------------- +// Painter +// --------------------------------------------------------------------------- - 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); +pub fn paint(f: &mut Frame, scene: &LatestScene, chunk: Rect) { + let mut list_items = Vec::::new(); + + 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$}", 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,33 +60,26 @@ 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(scene.selected_index)); f.render_stateful_widget(list, chunk, &mut list_state); } -pub fn mode_footer_text<'a>(vm: &'a AppViewModel<'a>) -> Vec> { +// --------------------------------------------------------------------------- +// Navigation-bar helpers +// --------------------------------------------------------------------------- + +pub fn mode_spans(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), )] } -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/screens/mailing_list.rs b/src/ui/screens/mailing_list.rs new file mode 100644 index 0000000..c18c685 --- /dev/null +++ b/src/ui/screens/mailing_list.rs @@ -0,0 +1,99 @@ +use ratatui::{ + layout::Rect, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, HighlightSpacing, List, ListItem, ListState}, + Frame, +}; + +use crate::{ + app::view_model::{MailingListSelectionViewModel, TargetListStatus}, + ui::scene::MailingListScene, +}; + +// --------------------------------------------------------------------------- +// Builder +// --------------------------------------------------------------------------- + +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 &scene.entries { + list_items.push(ListItem::new( + Line::from(vec![ + Span::styled(entry.name.clone(), Style::default().fg(Color::Magenta)), + Span::styled( + format!(" - {}", entry.description), + Style::default().fg(Color::White), + ), + ]) + .centered(), + )); + } + + let list_block = Block::default() + .borders(Borders::ALL) + .border_type(ratatui::widgets::BorderType::Double) + .style(Style::default()); + + let list = List::new(list_items) + .block(list_block) + .highlight_style( + Style::default() + .add_modifier(Modifier::BOLD) + .add_modifier(Modifier::REVERSED) + .fg(Color::Cyan), + ) + .highlight_symbol(">") + .highlight_spacing(HighlightSpacing::Always); + + let mut list_state = ListState::default(); + list_state.select(Some(scene.highlighted_index)); + + f.render_stateful_widget(list, chunk, &mut list_state); +} + +// --------------------------------------------------------------------------- +// 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)) + } + 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)), + text_area, + ] +} + +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/screens/popup.rs b/src/ui/screens/popup.rs new file mode 100644 index 0000000..23e7be8 --- /dev/null +++ b/src/ui/screens/popup.rs @@ -0,0 +1,195 @@ +use ratatui::{ + layout::Alignment, + style::{Color, Modifier, Style, Stylize}, + text::Line, + widgets::{Block, BorderType, Borders, Clear, Paragraph, Wrap}, + Frame, +}; + +use crate::{ + app::view_model::{PopupViewBody, PopupViewModel}, + ui::scene::{PopupBody, PopupScene}, +}; + +// --------------------------------------------------------------------------- +// Builder +// --------------------------------------------------------------------------- + +pub fn build_scene(vm: &PopupViewModel) -> PopupScene { + let body = match &vm.body { + PopupViewBody::Text(text) => PopupBody::Text(text.clone()), + PopupViewBody::Keybinds { + description, + formatted_keybinds, + } => 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, + &scene.title, + description.as_deref(), + formatted_keybinds, + scene.scroll_offset, + chunk, + ), + PopupBody::ReviewTrailers { + reviewed_by, + tested_by, + acked_by, + } => paint_review_trailers( + f, + reviewed_by, + tested_by, + acked_by, + scene.scroll_offset, + chunk, + ), + } +} + +fn paint_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); +} + +fn paint_help( + f: &mut Frame, + title: &str, + description: Option<&str>, + formatted_keybinds: &str, + scroll: (u16, u16), + chunk: ratatui::layout::Rect, +) { + let block = Block::default() + .title(title.to_string()) + .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); +} + +fn paint_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/theme.rs b/src/ui/theme.rs new file mode 100644 index 0000000..ad638d7 --- /dev/null +++ b/src/ui/theme.rs @@ -0,0 +1,7 @@ +#![allow(dead_code)] +/// 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;