diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 00000000..5f67e08b --- /dev/null +++ b/src/app.rs @@ -0,0 +1,72 @@ +use crate::{infrastructure::terminal::Tui, viewmodels::ViewModel, views::View}; +use std::collections::HashMap; + +use color_eyre::eyre::Result; +use ratatui::crossterm::event::{self, Event, KeyEventKind}; + +use crate::model::Model; + +#[allow(dead_code)] +pub struct App { + model: Model, + current_view: View, + viewmodels: HashMap>, + terminal: Tui, +} + +impl App { + #[allow(dead_code)] + pub fn new(model: Model, terminal: Tui) -> color_eyre::Result { + Ok(App { + model, + current_view: View::MailingLists, + viewmodels: HashMap::new(), + terminal, + }) + } + + #[allow(dead_code)] + pub fn get_current_view(&self) -> View { + self.current_view + } + + #[allow(dead_code)] + pub fn get_current_viewmodel(&mut self) -> &mut Box { + self.viewmodels + .entry(self.current_view) + .or_insert_with(|| match self.current_view { + View::MailingLists => { + todo!() + } + View::BookmarkedPatchsets => { + todo!() + } + View::LatestPatchsets => { + todo!() + } + View::PatchsetDetails => { + todo!() + } + View::EditConfig => { + todo!() + } + }) + } + + #[allow(dead_code, unreachable_code, unused_variables)] + pub fn run(&mut self) -> Result<()> { + loop { + let state = &self.get_current_viewmodel().state(); + let current_view = self.get_current_view(); + current_view.render_screen(&mut self.terminal, state)?; + + if let Event::Key(key) = event::read()? { + if key.kind == KeyEventKind::Release { + continue; + } + + self.get_current_viewmodel().handle_key(key); + } + } + } +} diff --git a/src/app/screens/mod.rs b/src/app/screens/mod.rs deleted file mode 100644 index 4fb235bf..00000000 --- a/src/app/screens/mod.rs +++ /dev/null @@ -1,14 +0,0 @@ -pub mod bookmarked; -pub mod details_actions; -pub mod edit_config; -pub mod latest; -pub mod mail_list; - -#[derive(Debug, Clone, PartialEq)] -pub enum CurrentScreen { - MailingListSelection, - BookmarkedPatchsets, - LatestPatchsets, - PatchsetDetails, - EditConfig, -} diff --git a/src/cli.rs b/src/cli.rs index 39deb5d9..88fdd076 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -4,7 +4,7 @@ use ratatui::{prelude::Backend, Terminal}; use std::ops::ControlFlow; -use crate::{app::config::Config, infrastructure::terminal::restore}; +use crate::{infrastructure::terminal::restore, model::config::Config}; #[derive(Debug, Parser)] #[command(version, about)] diff --git a/src/handler/bookmarked.rs b/src/handler/bookmarked.rs index 54ddd75c..60a20026 100644 --- a/src/handler/bookmarked.rs +++ b/src/handler/bookmarked.rs @@ -7,14 +7,17 @@ use ratatui::{ use std::ops::ControlFlow; use crate::{ - app::{screens::CurrentScreen, App}, loading_screen, lore::lore_session::B4Result, - ui::popup::{help::HelpPopUpBuilder, info_popup::InfoPopUp, PopUp}, + model::Model, + views::{ + popup::{help::HelpPopUpBuilder, info_popup::InfoPopUp, PopUp}, + View, + }, }; pub fn handle_bookmarked_patchsets( - app: &mut App, + model: &mut Model, key: KeyEvent, mut terminal: Terminal, ) -> color_eyre::Result>> @@ -24,23 +27,23 @@ where match key.code { KeyCode::Char('?') => { let popup = generate_help_popup(); - app.popup = Some(popup); + model.popup = Some(popup); } KeyCode::Esc | KeyCode::Char('q') => { - app.bookmarked_patchsets.patchset_index = 0; - app.set_current_screen(CurrentScreen::MailingListSelection); + model.bookmarked_patchsets.patchset_index = 0; + model.set_current_screen(View::MailingLists); } KeyCode::Char('j') | KeyCode::Down => { - app.bookmarked_patchsets.select_below_patchset(); + model.bookmarked_patchsets.select_below_patchset(); } KeyCode::Char('k') | KeyCode::Up => { - app.bookmarked_patchsets.select_above_patchset(); + model.bookmarked_patchsets.select_above_patchset(); } KeyCode::Enter => { terminal = loading_screen! { terminal, "Loading patchset" => { - let result = app.init_details_actions(); + let result = model.init_details_actions(); if result.is_ok() { // If a patchset has been bookmarked UI, this means that // b4 was successful in fetching it, so it shouldn't be @@ -48,13 +51,13 @@ where // patchset in this list was bookmarked through the UI match result.unwrap() { B4Result::PatchFound(_) => { - app.set_current_screen(CurrentScreen::PatchsetDetails); + model.set_current_screen(View::PatchsetDetails); } B4Result::PatchNotFound(err_cause) => { - app.popup = Some(InfoPopUp::generate_info_popup( + model.popup = Some(InfoPopUp::generate_info_popup( "Error",&format!("The selected patchset couldn't be retrieved.\nReason: {err_cause}\nPlease choose another patchset.") )); - app.set_current_screen(CurrentScreen::BookmarkedPatchsets); + model.set_current_screen(View::BookmarkedPatchsets); } } } diff --git a/src/handler/details_actions.rs b/src/handler/details_actions.rs index 26a072b2..97e8a33d 100644 --- a/src/handler/details_actions.rs +++ b/src/handler/details_actions.rs @@ -7,19 +7,22 @@ use ratatui::{ use std::time::Duration; use crate::{ - app::{screens::CurrentScreen, App}, infrastructure::terminal::{setup_user_io, teardown_user_io}, - ui::popup::{help::HelpPopUpBuilder, review_trailers::ReviewTrailersPopUp, PopUp}, + model::Model, + views::{ + popup::{help::HelpPopUpBuilder, review_trailers::ReviewTrailersPopUp, PopUp}, + View, + }, }; use super::wait_key_press; pub fn handle_patchset_details( - app: &mut App, + model: &mut Model, key: KeyEvent, terminal: &mut Terminal, ) -> color_eyre::Result<()> { - let patchset_details_and_actions = app.details_actions.as_mut().unwrap(); + let patchset_details_and_actions = model.details_actions.as_mut().unwrap(); if key.modifiers.contains(KeyModifiers::SHIFT) { match key.code { @@ -51,7 +54,7 @@ pub fn handle_patchset_details( KeyCode::Char('t') => { let popup = ReviewTrailersPopUp::generate_trailers_popup(patchset_details_and_actions); - app.popup = Some(popup); + model.popup = Some(popup); } _ => {} } @@ -61,12 +64,12 @@ pub fn handle_patchset_details( match key.code { KeyCode::Char('?') => { let popup = generate_help_popup(); - app.popup = Some(popup); + model.popup = Some(popup); } KeyCode::Esc | KeyCode::Char('q') => { - let ps_da_clone = patchset_details_and_actions.last_screen.clone(); - app.set_current_screen(ps_da_clone); - app.reset_details_actions(); + let ps_da_clone = patchset_details_and_actions.last_screen; + model.set_current_screen(ps_da_clone); + model.reset_details_actions(); } KeyCode::Char('a') => { patchset_details_and_actions.toggle_apply_action(); @@ -109,7 +112,7 @@ pub fn handle_patchset_details( KeyCode::Enter => { if patchset_details_and_actions.actions_require_user_io() { setup_user_io(terminal)?; - app.consolidate_patchset_actions()?; + model.consolidate_patchset_actions()?; println!("\nPress ENTER continue..."); loop { if let Event::Key(key) = event::read()? { @@ -120,9 +123,9 @@ pub fn handle_patchset_details( } teardown_user_io(terminal)?; } else { - app.consolidate_patchset_actions()?; + model.consolidate_patchset_actions()?; } - app.set_current_screen(CurrentScreen::PatchsetDetails); + model.set_current_screen(View::PatchsetDetails); } _ => {} } diff --git a/src/handler/edit_config.rs b/src/handler/edit_config.rs index f0f2f158..a10ec3f7 100644 --- a/src/handler/edit_config.rs +++ b/src/handler/edit_config.rs @@ -1,12 +1,15 @@ use ratatui::crossterm::event::{KeyCode, KeyEvent}; use crate::{ - app::{screens::CurrentScreen, App}, - ui::popup::{help::HelpPopUpBuilder, PopUp}, + model::Model, + views::{ + popup::{help::HelpPopUpBuilder, PopUp}, + View, + }, }; -pub fn handle_edit_config(app: &mut App, key: KeyEvent) -> color_eyre::Result<()> { - if let Some(edit_config_state) = app.edit_config.as_mut() { +pub fn handle_edit_config(model: &mut Model, key: KeyEvent) -> color_eyre::Result<()> { + if let Some(edit_config_state) = model.edit_config.as_mut() { match edit_config_state.is_editing() { true => match key.code { KeyCode::Esc => { @@ -29,13 +32,13 @@ pub fn handle_edit_config(app: &mut App, key: KeyEvent) -> color_eyre::Result<() false => match key.code { KeyCode::Char('?') => { let popup = generate_help_popup(); - app.popup = Some(popup); + model.popup = Some(popup); } KeyCode::Esc | KeyCode::Char('q') => { - app.consolidate_edit_config(); - app.config.save_patch_hub_config()?; - app.reset_edit_config(); - app.set_current_screen(CurrentScreen::MailingListSelection); + model.consolidate_edit_config(); + model.config.save_patch_hub_config()?; + model.reset_edit_config(); + model.set_current_screen(View::MailingLists); } KeyCode::Enter => { edit_config_state.toggle_editing(); diff --git a/src/handler/latest.rs b/src/handler/latest.rs index 74a035da..9f09290d 100644 --- a/src/handler/latest.rs +++ b/src/handler/latest.rs @@ -7,30 +7,33 @@ use ratatui::{ use std::ops::ControlFlow; use crate::{ - app::{screens::CurrentScreen, App}, loading_screen, lore::lore_session::B4Result, - ui::popup::{help::HelpPopUpBuilder, info_popup::InfoPopUp, PopUp}, + model::Model, + views::{ + popup::{help::HelpPopUpBuilder, info_popup::InfoPopUp, PopUp}, + View, + }, }; pub fn handle_latest_patchsets( - app: &mut App, + model: &mut Model, key: KeyEvent, mut terminal: Terminal, ) -> color_eyre::Result>> where B: Backend + Send + 'static, { - let latest_patchsets = app.latest_patchsets.as_mut().unwrap(); + let latest_patchsets = model.latest_patchsets.as_mut().unwrap(); match key.code { KeyCode::Char('?') => { let popup = generate_help_popup(); - app.popup = Some(popup); + model.popup = Some(popup); } KeyCode::Esc | KeyCode::Char('q') => { - app.reset_latest_patchsets(); - app.set_current_screen(CurrentScreen::MailingListSelection); + model.reset_latest_patchsets(); + model.set_current_screen(View::MailingLists); } KeyCode::Char('j') | KeyCode::Down => { latest_patchsets.select_below_patchset(); @@ -55,17 +58,17 @@ where terminal = loading_screen! { terminal, "Loading patchset" => { - let result = app.init_details_actions(); + let result = model.init_details_actions(); if result.is_ok() { match result.unwrap() { B4Result::PatchFound(_) => { - app.set_current_screen(CurrentScreen::PatchsetDetails); + model.set_current_screen(View::PatchsetDetails); } B4Result::PatchNotFound(err_cause) => { - app.popup = Some(InfoPopUp::generate_info_popup( + model.popup = Some(InfoPopUp::generate_info_popup( "Error",&format!("The selected patchset couldn't be retrieved.\nReason: {err_cause}\nPlease choose another patchset.") )); - app.set_current_screen(CurrentScreen::LatestPatchsets); + model.set_current_screen(View::LatestPatchsets); } } } diff --git a/src/handler/mail_list.rs b/src/handler/mail_list.rs index d5a0ffc4..863c7586 100644 --- a/src/handler/mail_list.rs +++ b/src/handler/mail_list.rs @@ -1,3 +1,4 @@ +use crate::views::View; use ratatui::{ crossterm::event::{KeyCode, KeyEvent}, prelude::Backend, @@ -7,13 +8,13 @@ use ratatui::{ use std::ops::ControlFlow; use crate::{ - app::{screens::CurrentScreen, App}, loading_screen, - ui::popup::{help::HelpPopUpBuilder, PopUp}, + model::Model, + views::popup::{help::HelpPopUpBuilder, PopUp}, }; pub fn handle_mailing_list_selection( - app: &mut App, + model: &mut Model, key: KeyEvent, mut terminal: Terminal, ) -> color_eyre::Result>> @@ -23,12 +24,12 @@ where match key.code { KeyCode::Char('?') => { let popup = generate_help_popup(); - app.popup = Some(popup); + model.popup = Some(popup); } KeyCode::Enter => { - if app.mailing_list_selection.has_valid_target_list() { - app.init_latest_patchsets(); - let list_name = app + if model.mailing_list_selection.has_valid_target_list() { + model.init_latest_patchsets(); + let list_name = model .latest_patchsets .as_ref() .unwrap() @@ -39,11 +40,11 @@ where terminal, format!("Fetching patchsets from {}", list_name) => { let result = - app.latest_patchsets.as_mut().unwrap() + model.latest_patchsets.as_mut().unwrap() .fetch_current_page(); if result.is_ok() { - app.mailing_list_selection.clear_target_list(); - app.set_current_screen(CurrentScreen::LatestPatchsets); + model.mailing_list_selection.clear_target_list(); + model.set_current_screen(View::LatestPatchsets); } result } @@ -54,35 +55,35 @@ where terminal = loading_screen! { terminal, "Refreshing lists" => { - app.mailing_list_selection + model.mailing_list_selection .refresh_available_mailing_lists() } }; } KeyCode::F(2) => { - app.init_edit_config(); - app.set_current_screen(CurrentScreen::EditConfig); + model.init_edit_config(); + model.set_current_screen(View::EditConfig); } KeyCode::F(1) => { - if !app.bookmarked_patchsets.bookmarked_patchsets.is_empty() { - app.mailing_list_selection.clear_target_list(); - app.set_current_screen(CurrentScreen::BookmarkedPatchsets); + if !model.bookmarked_patchsets.bookmarked_patchsets.is_empty() { + model.mailing_list_selection.clear_target_list(); + model.set_current_screen(View::BookmarkedPatchsets); } } KeyCode::Backspace => { - app.mailing_list_selection.remove_last_target_list_char(); + model.mailing_list_selection.remove_last_target_list_char(); } KeyCode::Esc => { return Ok(ControlFlow::Break(())); } KeyCode::Char(ch) => { - app.mailing_list_selection.push_char_to_target_list(ch); + model.mailing_list_selection.push_char_to_target_list(ch); } KeyCode::Down => { - app.mailing_list_selection.highlight_below_list(); + model.mailing_list_selection.highlight_below_list(); } KeyCode::Up => { - app.mailing_list_selection.highlight_above_list(); + model.mailing_list_selection.highlight_above_list(); } _ => {} } diff --git a/src/handler/mod.rs b/src/handler/mod.rs index feaf6658..86b4e799 100644 --- a/src/handler/mod.rs +++ b/src/handler/mod.rs @@ -16,9 +16,9 @@ use std::{ }; use crate::{ - app::{screens::CurrentScreen, App}, loading_screen, - ui::draw_ui, + model::Model, + views::{draw_ui, View}, }; use bookmarked::handle_bookmarked_patchsets; @@ -29,56 +29,59 @@ use mail_list::handle_mailing_list_selection; fn key_handling( mut terminal: Terminal, - app: &mut App, + model: &mut Model, key: KeyEvent, ) -> color_eyre::Result>> where B: Backend + Send + 'static, { - if let Some(popup) = app.popup.as_mut() { + if let Some(popup) = model.popup.as_mut() { if matches!(key.code, KeyCode::Esc | KeyCode::Char('q')) { - app.popup = None; + model.popup = None; } else { popup.handle(key)?; } } else { - match app.current_screen { - CurrentScreen::MailingListSelection => { - return handle_mailing_list_selection(app, key, terminal); + match model.current_screen { + View::MailingLists => { + return handle_mailing_list_selection(model, key, terminal); } - CurrentScreen::BookmarkedPatchsets => { - return handle_bookmarked_patchsets(app, key, terminal); + View::BookmarkedPatchsets => { + return handle_bookmarked_patchsets(model, key, terminal); } - CurrentScreen::PatchsetDetails => { - handle_patchset_details(app, key, &mut terminal)?; + View::PatchsetDetails => { + handle_patchset_details(model, key, &mut terminal)?; } - CurrentScreen::EditConfig => { - handle_edit_config(app, key)?; + View::EditConfig => { + handle_edit_config(model, key)?; } - CurrentScreen::LatestPatchsets => { - return handle_latest_patchsets(app, key, terminal); + View::LatestPatchsets => { + return handle_latest_patchsets(model, key, terminal); } } } Ok(ControlFlow::Continue(terminal)) } -fn logic_handling(mut terminal: Terminal, app: &mut App) -> color_eyre::Result> +fn logic_handling( + mut terminal: Terminal, + model: &mut Model, +) -> color_eyre::Result> where B: Backend + Send + 'static, { - match app.current_screen { - CurrentScreen::MailingListSelection => { - if app.mailing_list_selection.mailing_lists.is_empty() { + match model.current_screen { + View::MailingLists => { + if model.mailing_list_selection.mailing_lists.is_empty() { terminal = loading_screen! { terminal, "Fetching mailing lists" => { - app.mailing_list_selection.refresh_available_mailing_lists() + model.mailing_list_selection.refresh_available_mailing_lists() } }; } } - CurrentScreen::LatestPatchsets => { - let patchsets_state = app.latest_patchsets.as_mut().unwrap(); + View::LatestPatchsets => { + let patchsets_state = model.latest_patchsets.as_mut().unwrap(); let target_list = patchsets_state.target_list().to_string(); if patchsets_state.processed_patchsets_count() == 0 { terminal = loading_screen! { @@ -88,12 +91,12 @@ where } }; - app.mailing_list_selection.clear_target_list(); + model.mailing_list_selection.clear_target_list(); } } - CurrentScreen::BookmarkedPatchsets => { - if app.bookmarked_patchsets.bookmarked_patchsets.is_empty() { - app.set_current_screen(CurrentScreen::MailingListSelection); + View::BookmarkedPatchsets => { + if model.bookmarked_patchsets.bookmarked_patchsets.is_empty() { + model.set_current_screen(View::MailingLists); } } _ => {} @@ -102,14 +105,14 @@ where Ok(terminal) } -pub fn run_app(mut terminal: Terminal, mut app: App) -> color_eyre::Result<()> +pub fn run_app(mut terminal: Terminal, mut model: Model) -> color_eyre::Result<()> where B: Backend + Send + 'static, { loop { - terminal = logic_handling(terminal, &mut app)?; + terminal = logic_handling(terminal, &mut model)?; - terminal.draw(|f| draw_ui(f, &app))?; + terminal.draw(|f| draw_ui(f, &model))?; // *IMPORTANT*: Uncommenting the if below makes `patch-hub` not block // until an event is captured. We should only do it when (if ever) we @@ -120,7 +123,7 @@ where if key.kind == KeyEventKind::Release { continue; } - match key_handling(terminal, &mut app, key)? { + match key_handling(terminal, &mut model, key)? { ControlFlow::Continue(t) => terminal = t, ControlFlow::Break(_) => return Ok(()), } diff --git a/src/infrastructure/logging/garbage_collector.rs b/src/infrastructure/logging/garbage_collector.rs index e06892b4..be02368b 100644 --- a/src/infrastructure/logging/garbage_collector.rs +++ b/src/infrastructure/logging/garbage_collector.rs @@ -2,7 +2,7 @@ //! //! This module is responsible for cleaning up the log files. -use crate::app::config::Config; +use crate::model::config::Config; use super::Logger; diff --git a/src/infrastructure/logging/mod.rs b/src/infrastructure/logging/mod.rs index e7ceb67e..e7bccac8 100644 --- a/src/infrastructure/logging/mod.rs +++ b/src/infrastructure/logging/mod.rs @@ -8,7 +8,7 @@ use std::{ io::Write, }; -use crate::app::config::Config; +use crate::model::config::Config; const LATEST_LOG_FILENAME: &str = "latest.log"; diff --git a/src/macros.rs b/src/macros.rs index 9664121a..1862b308 100644 --- a/src/macros.rs +++ b/src/macros.rs @@ -26,7 +26,7 @@ macro_rules! loading_screen { let handle = std::thread::spawn(move || { while loading_clone.load(std::sync::atomic::Ordering::Relaxed) { - terminal = $crate::ui::loading_screen::render(terminal, $title); + terminal = $crate::views::loading_screen::render(terminal, $title); std::thread::sleep(std::time::Duration::from_millis(200)); } diff --git a/src/main.rs b/src/main.rs index 1db14403..5b2f68bb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,9 +4,10 @@ mod handler; mod infrastructure; mod lore; mod macros; -mod ui; +mod model; +mod viewmodels; +mod views; -use app::{config::Config, App}; use clap::Parser; use cli::Cli; use color_eyre::eyre::bail; @@ -15,6 +16,7 @@ use infrastructure::{ logging::Logger, terminal::{init, restore}, }; +use model::{config::Config, Model}; use std::ops::ControlFlow; fn main() -> color_eyre::Result<()> { @@ -31,13 +33,13 @@ fn main() -> color_eyre::Result<()> { ControlFlow::Continue(t) => terminal = t, } - let app = App::new(config)?; - if !app.check_external_deps() { + let model = Model::new(config)?; + if !model.check_external_deps() { Logger::error("patch-hub cannot be executed because some dependencies are missing"); bail!("patch-hub cannot be executed because some dependencies are missing, check logs for more information"); } - run_app(terminal, app)?; + run_app(terminal, model)?; restore()?; Logger::info("patch-hub finished"); diff --git a/src/app/config/mod.rs b/src/model/config/mod.rs similarity index 100% rename from src/app/config/mod.rs rename to src/model/config/mod.rs diff --git a/src/app/config/tests.rs b/src/model/config/tests.rs similarity index 100% rename from src/app/config/tests.rs rename to src/model/config/tests.rs diff --git a/src/app/cover_renderer.rs b/src/model/cover_renderer.rs similarity index 100% rename from src/app/cover_renderer.rs rename to src/model/cover_renderer.rs diff --git a/src/app/mod.rs b/src/model/mod.rs similarity index 97% rename from src/app/mod.rs rename to src/model/mod.rs index 23a64389..561c04f1 100644 --- a/src/app/mod.rs +++ b/src/model/mod.rs @@ -17,7 +17,10 @@ use crate::{ lore_session::{self, B4Result}, patch::{Author, Patch}, }, - ui::popup::{info_popup::InfoPopUp, PopUp}, + views::{ + popup::{info_popup::InfoPopUp, PopUp}, + View, + }, }; use config::Config; @@ -29,14 +32,13 @@ use screens::{ edit_config::EditConfig, latest::LatestPatchsets, mail_list::MailingListSelection, - CurrentScreen, }; /// Type that represents the overall state of the application. It can be viewed /// as the **Model** component of `patch-hub`. -pub struct App { +pub struct Model { /// The current active screen - pub current_screen: CurrentScreen, + pub current_screen: View, /// Screen to navigate and select the mailing lists archived on Lore pub mailing_list_selection: MailingListSelection, /// Screen with listing patchsets that were previously bookmarked @@ -56,7 +58,7 @@ pub struct App { pub popup: Option>, } -impl App { +impl Model { /// Creates a new instance of `App`. It dynamically loads configurations /// based on precedence (see [crate::app::Config::build]), app data /// (available mailing lists, bookmarked patchsets, reviewed patchsets), and @@ -84,8 +86,8 @@ impl App { Logger::info("patch-hub started"); garbage_collector::collect_garbage(&config); - Ok(App { - current_screen: CurrentScreen::MailingListSelection, + Ok(Model { + current_screen: View::MailingLists, mailing_list_selection: MailingListSelection { mailing_lists: mailing_lists.clone(), target_list: String::new(), @@ -147,10 +149,10 @@ impl App { let mut acked_by = Vec::new(); match &self.current_screen { - CurrentScreen::BookmarkedPatchsets => { + View::BookmarkedPatchsets => { representative_patch = self.bookmarked_patchsets.get_selected_patchset(); } - CurrentScreen::LatestPatchsets => { + View::LatestPatchsets => { representative_patch = self .latest_patchsets .as_ref() @@ -255,7 +257,7 @@ impl App { reviewed_by, tested_by, acked_by, - last_screen: self.current_screen.clone(), + last_screen: self.current_screen, lore_api_client: self.lore_api_client.clone(), patchset_path, }); @@ -392,7 +394,7 @@ impl App { } /// Change the current active screen in [App::current_screen]. - pub fn set_current_screen(&mut self, new_current_screen: CurrentScreen) { + pub fn set_current_screen(&mut self, new_current_screen: View) { self.current_screen = new_current_screen; } diff --git a/src/app/patch_renderer.rs b/src/model/patch_renderer.rs similarity index 100% rename from src/app/patch_renderer.rs rename to src/model/patch_renderer.rs diff --git a/src/app/screens/bookmarked.rs b/src/model/screens/bookmarked.rs similarity index 100% rename from src/app/screens/bookmarked.rs rename to src/model/screens/bookmarked.rs diff --git a/src/app/screens/details_actions.rs b/src/model/screens/details_actions.rs similarity index 99% rename from src/app/screens/details_actions.rs rename to src/model/screens/details_actions.rs index a3807cdd..e1c8de4c 100644 --- a/src/app/screens/details_actions.rs +++ b/src/model/screens/details_actions.rs @@ -8,16 +8,15 @@ use std::{ }; use crate::{ - app::config::{Config, KernelTree}, lore::{ lore_api_client::BlockingLoreAPIClient, lore_session, patch::{Author, Patch}, }, + model::config::{Config, KernelTree}, + views::View, }; -use super::CurrentScreen; - pub struct DetailsActions { pub representative_patch: Patch, /// Raw patches as plain text files @@ -44,7 +43,7 @@ pub struct DetailsActions { pub tested_by: Vec>, /// For each patch, a set of `Authors` that appear in `Acked-by` trailers pub acked_by: Vec>, - pub last_screen: CurrentScreen, + pub last_screen: View, pub lore_api_client: BlockingLoreAPIClient, } diff --git a/src/app/screens/edit_config.rs b/src/model/screens/edit_config.rs similarity index 99% rename from src/app/screens/edit_config.rs rename to src/model/screens/edit_config.rs index 8714f1ca..64316865 100644 --- a/src/app/screens/edit_config.rs +++ b/src/model/screens/edit_config.rs @@ -3,7 +3,7 @@ use derive_getters::Getters; use std::{collections::HashMap, fmt::Display, path::Path}; -use crate::app::config::Config; +use crate::model::config::Config; #[derive(Debug, Getters)] pub struct EditConfig { diff --git a/src/app/screens/latest.rs b/src/model/screens/latest.rs similarity index 100% rename from src/app/screens/latest.rs rename to src/model/screens/latest.rs diff --git a/src/app/screens/mail_list.rs b/src/model/screens/mail_list.rs similarity index 100% rename from src/app/screens/mail_list.rs rename to src/model/screens/mail_list.rs diff --git a/src/model/screens/mod.rs b/src/model/screens/mod.rs new file mode 100644 index 00000000..ead9434c --- /dev/null +++ b/src/model/screens/mod.rs @@ -0,0 +1,5 @@ +pub mod bookmarked; +pub mod details_actions; +pub mod edit_config; +pub mod latest; +pub mod mail_list; diff --git a/src/ui/mail_list.rs b/src/ui/mail_list.rs deleted file mode 100644 index 16686db1..00000000 --- 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::App; - -pub fn render_main(f: &mut Frame, app: &App, chunk: Rect) { - let highlighted_list_index = app.mailing_list_selection.highlighted_list_index; - let mut list_items = Vec::::new(); - - for mailing_list in &app.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(app: &App) -> Vec { - let mut text_area = Span::default(); - - if app.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 &app.mailing_list_selection.mailing_lists { - if mailing_list - .name() - .eq(&app.mailing_list_selection.target_list) - { - text_area = Span::styled( - &app.mailing_list_selection.target_list, - Style::default().fg(Color::Green), - ); - break; - } else if mailing_list - .name() - .starts_with(&app.mailing_list_selection.target_list) - { - text_area = Span::styled( - &app.mailing_list_selection.target_list, - Style::default().fg(Color::LightCyan), - ); - } - } - if text_area.content.is_empty() { - text_area = Span::styled( - &app.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/mod.rs b/src/ui/mod.rs deleted file mode 100644 index ed528754..00000000 --- a/src/ui/mod.rs +++ /dev/null @@ -1,93 +0,0 @@ -mod bookmarked; -mod details_actions; -mod edit_config; -mod latest; -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, App}; - -pub fn draw_ui(f: &mut Frame, app: &App) { - // 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 app.current_screen { - CurrentScreen::MailingListSelection => mail_list::render_main(f, app, chunks[1]), - CurrentScreen::BookmarkedPatchsets => { - bookmarked::render_main(f, &app.bookmarked_patchsets, chunks[1]) - } - CurrentScreen::LatestPatchsets => latest::render_main(f, app, chunks[1]), - CurrentScreen::PatchsetDetails => details_actions::render_main(f, app, chunks[1]), - CurrentScreen::EditConfig => edit_config::render_main(f, app, chunks[1]), - } - - navigation_bar::render(f, app, chunks[2]); - - app.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 -} diff --git a/src/ui/navigation_bar.rs b/src/ui/navigation_bar.rs deleted file mode 100644 index 47dd6a10..00000000 --- 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, App}; - -use super::{bookmarked, details_actions, edit_config, latest, mail_list}; - -pub fn render(f: &mut Frame, app: &App, chunk: Rect) { - let mode_footer_text = match app.current_screen { - CurrentScreen::MailingListSelection => mail_list::mode_footer_text(app), - CurrentScreen::BookmarkedPatchsets => bookmarked::mode_footer_text(), - CurrentScreen::LatestPatchsets => latest::mode_footer_text(app), - CurrentScreen::PatchsetDetails => details_actions::mode_footer_text(), - CurrentScreen::EditConfig => edit_config::mode_footer_text(app), - }; - let mode_footer = Paragraph::new(Line::from(mode_footer_text)) - .block(Block::default().borders(Borders::ALL)) - .centered(); - - let current_keys_hint = { - match app.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(app), - } - }; - - 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/viewmodels/mailing_list_viewmodel.rs b/src/viewmodels/mailing_list_viewmodel.rs new file mode 100644 index 00000000..84ba55c7 --- /dev/null +++ b/src/viewmodels/mailing_list_viewmodel.rs @@ -0,0 +1,8 @@ +use crate::lore::mailing_list::MailingList; + +pub struct MailingListsState { + pub highlighted_list_index: usize, + pub filtered_mailing_lists: Vec, + pub mailing_lists: Vec, + pub search_string: String, +} diff --git a/src/viewmodels/mod.rs b/src/viewmodels/mod.rs new file mode 100644 index 00000000..523dd0f2 --- /dev/null +++ b/src/viewmodels/mod.rs @@ -0,0 +1,21 @@ +pub mod mailing_list_viewmodel; + +use ratatui::crossterm::event::KeyEvent; + +use crate::viewmodels::mailing_list_viewmodel::MailingListsState; + +#[allow(dead_code)] +pub trait ViewModel { + fn handle_key(&self, event: KeyEvent); + + fn state(&self) -> ViewModelState; +} + +#[allow(dead_code)] +pub enum ViewModelState { + MailingLists(MailingListsState), + BookmarkedPatchsets, + LatestPatchsets, + PatchsetDetails, + EditConfig, +} diff --git a/src/ui/bookmarked.rs b/src/views/bookmarked.rs similarity index 97% rename from src/ui/bookmarked.rs rename to src/views/bookmarked.rs index a6a9ccfe..f194c78b 100644 --- a/src/ui/bookmarked.rs +++ b/src/views/bookmarked.rs @@ -6,7 +6,7 @@ use ratatui::{ Frame, }; -use crate::app::screens::bookmarked::BookmarkedPatchsets; +use crate::model::screens::bookmarked::BookmarkedPatchsets; pub fn render_main(f: &mut Frame, bookmarked_patchsets: &BookmarkedPatchsets, chunk: Rect) { let patchset_index = bookmarked_patchsets.patchset_index; diff --git a/src/ui/details_actions.rs b/src/views/details_actions.rs similarity index 93% rename from src/ui/details_actions.rs rename to src/views/details_actions.rs index 92a4020d..75b75937 100644 --- a/src/ui/details_actions.rs +++ b/src/views/details_actions.rs @@ -6,9 +6,9 @@ use ratatui::{ Frame, }; -use crate::app::{ +use crate::model::{ screens::details_actions::{DetailsActions, PatchsetAction}, - App, + Model, }; /// Returns a `Line` type that represents a line containing stats about reply @@ -47,8 +47,13 @@ fn review_trailers_details(details_actions: &DetailsActions) -> Line<'static> { ]) } -fn render_details_and_actions(f: &mut Frame, app: &App, details_chunk: Rect, actions_chunk: Rect) { - let patchset_details_and_actions = app.details_actions.as_ref().unwrap(); +fn render_details_and_actions( + f: &mut Frame, + model: &Model, + details_chunk: Rect, + actions_chunk: Rect, +) { + let patchset_details_and_actions = model.details_actions.as_ref().unwrap(); let mut staged_to_reply = String::new(); if let Some(true) = patchset_details_and_actions @@ -199,8 +204,8 @@ fn render_details_and_actions(f: &mut Frame, app: &App, details_chunk: Rect, act f.render_widget(patchset_actions, actions_chunk); } -fn render_preview(f: &mut Frame, app: &App, chunk: Rect) { - let patchset_details_and_actions = app.details_actions.as_ref().unwrap(); +fn render_preview(f: &mut Frame, model: &Model, chunk: Rect) { + let patchset_details_and_actions = model.details_actions.as_ref().unwrap(); let preview_index = patchset_details_and_actions.preview_index; @@ -210,7 +215,7 @@ fn render_preview(f: &mut Frame, app: &App, chunk: Rect) { .href; let mut preview_title = String::from(" Preview "); if matches!( - app.reviewed_patchsets.get(representative_patch_message_id), + model.reviewed_patchsets.get(representative_patch_message_id), Some(successful_indexes) if successful_indexes.contains(&preview_index) ) { preview_title = " Preview [REVIEWED-BY] ".to_string(); @@ -242,11 +247,11 @@ fn render_preview(f: &mut Frame, app: &App, chunk: Rect) { f.render_widget(patch_preview, chunk); } -pub fn render_main(f: &mut Frame, app: &App, chunk: Rect) { - let patchset_details_and_actions = app.details_actions.as_ref().unwrap(); +pub fn render_main(f: &mut Frame, model: &Model, chunk: Rect) { + let patchset_details_and_actions = model.details_actions.as_ref().unwrap(); if patchset_details_and_actions.preview_fullscreen { - render_preview(f, app, chunk); + render_preview(f, model, chunk); } else { let chunks = Layout::default() .direction(Direction::Horizontal) @@ -260,11 +265,11 @@ pub fn render_main(f: &mut Frame, app: &App, chunk: Rect) { render_details_and_actions( f, - app, + model, details_and_actions_chunks[0], details_and_actions_chunks[1], ); - render_preview(f, app, chunks[1]); + render_preview(f, model, chunks[1]); } } diff --git a/src/ui/edit_config.rs b/src/views/edit_config.rs similarity index 85% rename from src/ui/edit_config.rs rename to src/views/edit_config.rs index 3dbb2867..799b936d 100644 --- a/src/ui/edit_config.rs +++ b/src/views/edit_config.rs @@ -6,10 +6,10 @@ use ratatui::{ Frame, }; -use crate::{app::App, infrastructure::logging::Logger}; +use crate::{infrastructure::logging::Logger, model::Model}; -pub fn render_main(f: &mut Frame, app: &App, chunk: Rect) { - let edit_config = app.edit_config.as_ref().unwrap(); +pub fn render_main(f: &mut Frame, model: &Model, chunk: Rect) { + let edit_config = model.edit_config.as_ref().unwrap(); let mut constraints = Vec::new(); for _ in 0..(chunk.height / 3) { @@ -63,8 +63,8 @@ pub fn render_main(f: &mut Frame, app: &App, chunk: Rect) { } } -pub fn mode_footer_text(app: &App) -> Vec { - let edit_config_state = app.edit_config.as_ref().unwrap(); +pub fn mode_footer_text(model: &Model) -> Vec { + let edit_config_state = model.edit_config.as_ref().unwrap(); vec![if edit_config_state.is_editing() { Span::styled("Editing...", Style::default().fg(Color::LightYellow)) } else { @@ -72,8 +72,8 @@ pub fn mode_footer_text(app: &App) -> Vec { }] } -pub fn keys_hint(app: &App) -> Span { - let edit_config_state = app.edit_config.as_ref().unwrap(); +pub fn keys_hint(model: &Model) -> Span { + let edit_config_state = model.edit_config.as_ref().unwrap(); match edit_config_state.is_editing() { true => Span::styled( "(ESC) cancel | (ENTER) confirm", diff --git a/src/ui/latest.rs b/src/views/latest.rs similarity index 77% rename from src/ui/latest.rs rename to src/views/latest.rs index e6776366..3d1b1a64 100644 --- a/src/ui/latest.rs +++ b/src/views/latest.rs @@ -6,21 +6,21 @@ use ratatui::{ Frame, }; -use crate::{app::App, lore::patch::Patch}; +use crate::{lore::patch::Patch, model::Model}; -pub fn render_main(f: &mut Frame, app: &App, chunk: Rect) { - let page_number = app.latest_patchsets.as_ref().unwrap().page_number(); - let patchset_index = app.latest_patchsets.as_ref().unwrap().patchset_index(); +pub fn render_main(f: &mut Frame, model: &Model, chunk: Rect) { + let page_number = model.latest_patchsets.as_ref().unwrap().page_number(); + let patchset_index = model.latest_patchsets.as_ref().unwrap().patchset_index(); let mut list_items = Vec::::new(); - let patch_feed_page: Vec<&Patch> = app + let patch_feed_page: Vec<&Patch> = model .latest_patchsets .as_ref() .unwrap() .get_current_patch_feed_page() .unwrap(); - let mut index: usize = (page_number - 1) * app.config.page_size(); + let mut index: usize = (page_number - 1) * model.config.page_size(); for patch in patch_feed_page { let patch_title = format!("{:width$}", patch.title(), width = 70); let patch_title = format!("{:.width$}", patch_title, width = 70); @@ -61,18 +61,18 @@ pub fn render_main(f: &mut Frame, app: &App, chunk: Rect) { let mut list_state = ListState::default(); list_state.select(Some( - patchset_index - (page_number - 1) * app.config.page_size(), + patchset_index - (page_number - 1) * model.config.page_size(), )); f.render_stateful_widget(list, chunk, &mut list_state); } -pub fn mode_footer_text(app: &App) -> Vec { +pub fn mode_footer_text(model: &Model) -> Vec { vec![Span::styled( format!( "Latest Patchsets from {} (page {})", - &app.latest_patchsets.as_ref().unwrap().target_list(), - &app.latest_patchsets.as_ref().unwrap().page_number() + &model.latest_patchsets.as_ref().unwrap().target_list(), + &model.latest_patchsets.as_ref().unwrap().page_number() ), Style::default().fg(Color::Green), )] diff --git a/src/ui/loading_screen.rs b/src/views/loading_screen.rs similarity index 100% rename from src/ui/loading_screen.rs rename to src/views/loading_screen.rs diff --git a/src/views/mail_list.rs b/src/views/mail_list.rs new file mode 100644 index 00000000..f7dde805 --- /dev/null +++ b/src/views/mail_list.rs @@ -0,0 +1,189 @@ +use std::rc::Rc; + +use ratatui::{ + layout::Rect, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, HighlightSpacing, List, ListItem, ListState}, + Frame, +}; + +use crate::{ + model::Model, viewmodels::mailing_list_viewmodel::MailingListsState, + views::widgets::centered_text_widget, +}; + +pub fn render_navigation_bar(frame: &mut Frame, state: &MailingListsState, chunks: Rc<[Rect]>) { + let target_list_text = target_list_text(state); + let target_list_widget = centered_text_widget(target_list_text); + + let keys_hint_text = keys_hint_text(); + let keys_hint_widget = centered_text_widget(vec![keys_hint_text]); + + frame.render_widget(target_list_widget, chunks[0]); + frame.render_widget(keys_hint_widget, chunks[1]); +} + +pub fn render_main_content(f: &mut Frame, state: &MailingListsState, chunk: Rect) { + let highlighted_list_index = state.highlighted_list_index; + let mut list_items = Vec::::new(); + + for mailing_list in &state.filtered_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); +} + +fn target_list_text(state: &MailingListsState) -> Vec { + let mut text_area = Span::default(); + + if state.search_string.is_empty() { + text_area = Span::styled("type the target list", Style::default().fg(Color::DarkGray)) + } else { + for mailing_list in &state.mailing_lists { + if mailing_list.name().eq(&state.search_string) { + text_area = Span::styled(&state.search_string, Style::default().fg(Color::Green)); + break; + } else if mailing_list.name().starts_with(&state.search_string) { + text_area = + Span::styled(&state.search_string, Style::default().fg(Color::LightCyan)); + } + } + if text_area.content.is_empty() { + text_area = Span::styled(&state.search_string, Style::default().fg(Color::Red)); + } + } + + vec![ + Span::styled("Target List: ", Style::default().fg(Color::Green)), + text_area, + ] +} + +pub fn keys_hint_text() -> Span<'static> { + Span::styled( + "(ESC) to quit | (ENTER) to confirm | (?) help", + Style::default().fg(Color::Red), + ) +} +pub fn render_main(f: &mut Frame, model: &Model, chunk: Rect) { + let highlighted_list_index = model.mailing_list_selection.highlighted_list_index; + let mut list_items = Vec::::new(); + + for mailing_list in &model.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(model: &Model) -> Vec { + let mut text_area = Span::default(); + + if model.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 &model.mailing_list_selection.mailing_lists { + if mailing_list + .name() + .eq(&model.mailing_list_selection.target_list) + { + text_area = Span::styled( + &model.mailing_list_selection.target_list, + Style::default().fg(Color::Green), + ); + break; + } else if mailing_list + .name() + .starts_with(&model.mailing_list_selection.target_list) + { + text_area = Span::styled( + &model.mailing_list_selection.target_list, + Style::default().fg(Color::LightCyan), + ); + } + } + if text_area.content.is_empty() { + text_area = Span::styled( + &model.mailing_list_selection.target_list, + Style::default().fg(Color::Red), + ); + } + } + + vec![ + Span::styled("Target List: ", Style::default().fg(Color::Green)), + text_area, + ] +} + +#[allow(dead_code)] +pub fn keys_hint() -> Span<'static> { + Span::styled( + "(ESC) to quit | (ENTER) to confirm | (?) help", + Style::default().fg(Color::Red), + ) +} diff --git a/src/views/mod.rs b/src/views/mod.rs new file mode 100644 index 00000000..6a0ba1b5 --- /dev/null +++ b/src/views/mod.rs @@ -0,0 +1,181 @@ +mod bookmarked; +mod details_actions; +mod edit_config; +mod latest; +pub mod loading_screen; +mod mail_list; +mod navigation_bar; +pub mod popup; +mod widgets; + +use std::rc::Rc; + +use ratatui::{ + layout::{Alignment, Constraint, Direction, Layout, Rect}, + style::{Color, Style, Stylize}, + text::Text, + widgets::{Block, Borders, Clear, Paragraph}, + Frame, +}; + +use crate::{infrastructure::terminal::Tui, model::Model}; + +use crate::viewmodels::ViewModelState; + +#[derive(Copy, Debug, Clone, PartialEq, Eq, Hash)] +pub enum View { + MailingLists, + BookmarkedPatchsets, + LatestPatchsets, + PatchsetDetails, + EditConfig, +} + +impl View { + #[allow(dead_code, unused_variables)] + pub fn render_screen( + &self, + terminal: &mut Tui, + state: &ViewModelState, + ) -> color_eyre::Result<()> { + terminal.draw(|frame| { + View::clear_frame(frame); + + let main_layout = View::main_layout(); + let main_layout_chunks = View::chunk_layout(&main_layout, frame); + + View::render_title_on_chunk(frame, main_layout_chunks[0]); + self.render_main_content(frame, state, main_layout_chunks[1]); + + let footer_layout = View::footer_layout(); + let footer_layout_chunks = View::chunk_layout(&footer_layout, frame); + self.render_navigation_bar(frame, state, footer_layout_chunks); + })?; + + Ok(()) + } + + fn render_navigation_bar(&self, frame: &mut Frame, state: &ViewModelState, chunk: Rc<[Rect]>) { + match (self, state) { + (View::MailingLists, ViewModelState::MailingLists(s)) => { + mail_list::render_navigation_bar(frame, s, chunk) + } + (View::BookmarkedPatchsets, _) => todo!(), + (View::LatestPatchsets, _) => todo!(), + (View::PatchsetDetails, _) => todo!(), + (View::EditConfig, _) => todo!(), + _ => todo!(), + }; + } + + fn render_main_content(&self, frame: &mut Frame, state: &ViewModelState, chunk: Rect) { + match (self, state) { + (View::MailingLists, ViewModelState::MailingLists(s)) => { + mail_list::render_main_content(frame, s, chunk) + } + (View::BookmarkedPatchsets, _) => todo!(), + (View::LatestPatchsets, _) => todo!(), + (View::PatchsetDetails, _) => todo!(), + (View::EditConfig, _) => todo!(), + _ => todo!(), + }; + } + + fn render_title_on_chunk(frame: &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); + + frame.render_widget(title, chunk); + } + + fn clear_frame(frame: &mut Frame) { + frame.render_widget(Clear, frame.area()); + } + + fn main_layout() -> Layout { + Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), + Constraint::Min(1), + Constraint::Length(3), + ]) + } + + fn footer_layout() -> Layout { + Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(30), Constraint::Percentage(80)]) + } + + fn chunk_layout(layout: &Layout, frame: &mut Frame) -> Rc<[Rect]> { + layout.split(frame.area()) + } +} + +pub fn draw_ui(f: &mut Frame, model: &Model) { + // 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()); + + View::render_title_on_chunk(f, chunks[0]); + match model.current_screen { + View::MailingLists => mail_list::render_main(f, model, chunks[1]), + View::BookmarkedPatchsets => { + bookmarked::render_main(f, &model.bookmarked_patchsets, chunks[1]) + } + View::LatestPatchsets => latest::render_main(f, model, chunks[1]), + View::PatchsetDetails => details_actions::render_main(f, model, chunks[1]), + View::EditConfig => edit_config::render_main(f, model, chunks[1]), + } + + navigation_bar::render(f, model, chunks[2]); + + model.popup.as_ref().inspect(|p| { + let (x, y) = p.dimensions(); + let rect = centered_rect(x, y, f.area()); + p.render(f, rect); + }); +} + +/// helper function to create a centered rect using up certain percentage of the available rect `r` +fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect { + // Cut the given rectangle into three vertical pieces + let popup_layout = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage((100 - percent_y) / 2), + Constraint::Percentage(percent_y), + Constraint::Percentage((100 - percent_y) / 2), + ]) + .split(r); + + // Then cut the middle vertical piece into three width-wise pieces + Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage((100 - percent_x) / 2), + Constraint::Percentage(percent_x), + Constraint::Percentage((100 - percent_x) / 2), + ]) + .split(popup_layout[1])[1] // Return the middle chunk +} diff --git a/src/views/navigation_bar.rs b/src/views/navigation_bar.rs new file mode 100644 index 00000000..e94c9db6 --- /dev/null +++ b/src/views/navigation_bar.rs @@ -0,0 +1,45 @@ +use ratatui::{ + layout::{Constraint, Direction, Layout, Rect}, + text::Line, + widgets::{Block, Borders, Paragraph}, + Frame, +}; + +use crate::{model::Model, views::View}; + +use super::{bookmarked, details_actions, edit_config, latest, mail_list}; + +pub fn render(f: &mut Frame, model: &Model, chunk: Rect) { + let mode_footer_text = match model.current_screen { + View::MailingLists => mail_list::mode_footer_text(model), + View::BookmarkedPatchsets => bookmarked::mode_footer_text(), + View::LatestPatchsets => latest::mode_footer_text(model), + View::PatchsetDetails => details_actions::mode_footer_text(), + View::EditConfig => edit_config::mode_footer_text(model), + }; + let mode_footer = Paragraph::new(Line::from(mode_footer_text)) + .block(Block::default().borders(Borders::ALL)) + .centered(); + + let current_keys_hint = { + match model.current_screen { + View::MailingLists => mail_list::keys_hint_text(), + View::BookmarkedPatchsets => bookmarked::keys_hint(), + View::LatestPatchsets => latest::keys_hint(), + View::PatchsetDetails => details_actions::keys_hint(), + View::EditConfig => edit_config::keys_hint(model), + } + }; + + 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/popup/help.rs b/src/views/popup/help.rs similarity index 100% rename from src/ui/popup/help.rs rename to src/views/popup/help.rs diff --git a/src/ui/popup/info_popup.rs b/src/views/popup/info_popup.rs similarity index 100% rename from src/ui/popup/info_popup.rs rename to src/views/popup/info_popup.rs diff --git a/src/ui/popup/mod.rs b/src/views/popup/mod.rs similarity index 100% rename from src/ui/popup/mod.rs rename to src/views/popup/mod.rs diff --git a/src/ui/popup/review_trailers.rs b/src/views/popup/review_trailers.rs similarity index 98% rename from src/ui/popup/review_trailers.rs rename to src/views/popup/review_trailers.rs index 5123e3e5..af919ad6 100644 --- a/src/ui/popup/review_trailers.rs +++ b/src/views/popup/review_trailers.rs @@ -8,7 +8,7 @@ use ratatui::{ use std::collections::HashSet; -use crate::{app::screens::details_actions::DetailsActions, lore::patch::Author}; +use crate::{lore::patch::Author, model::screens::details_actions::DetailsActions}; use super::PopUp; diff --git a/src/views/widgets.rs b/src/views/widgets.rs new file mode 100644 index 00000000..deb124d1 --- /dev/null +++ b/src/views/widgets.rs @@ -0,0 +1,10 @@ +use ratatui::{ + text::{Line, Span}, + widgets::{Block, Borders, Paragraph}, +}; + +pub fn centered_text_widget(text: Vec) -> Paragraph<'_> { + Paragraph::new(Line::from(text)) + .block(Block::default().borders(Borders::ALL)) + .centered() +}