From 4142d85178d6b79b4d5255e3560a85e937ca8f82 Mon Sep 17 00:00:00 2001 From: Erik Kundt Date: Wed, 26 Apr 2023 13:08:55 +0200 Subject: [PATCH] tui: Implement page stack and page state --- radicle-tui/src/app.rs | 72 +++++----- radicle-tui/src/app/event.rs | 27 ++-- radicle-tui/src/app/page.rs | 248 ++++++++++++++++++++++++++++------- radicle-tui/src/lib.rs | 6 +- 4 files changed, 261 insertions(+), 92 deletions(-) diff --git a/radicle-tui/src/app.rs b/radicle-tui/src/app.rs index daa8a189..5f99f87a 100644 --- a/radicle-tui/src/app.rs +++ b/radicle-tui/src/app.rs @@ -4,7 +4,6 @@ pub mod subscription; use anyhow::Result; -use radicle::cob::patch::{Patch, PatchId}; use radicle::identity::{Id, Project}; use radicle::profile::Profile; @@ -17,6 +16,10 @@ use radicle_tui::ui; use radicle_tui::ui::theme::{self, Theme}; use radicle_tui::Tui; +use page::{HomeView, PatchView}; + +use self::page::PageStack; + #[derive(Debug, Eq, PartialEq, Clone, Hash)] pub enum HomeCid { Navigation, @@ -43,12 +46,12 @@ pub enum Cid { /// Messages handled by this application. #[derive(Debug, Eq, PartialEq)] pub enum HomeMessage { - Show, + PatchChanged(usize), } #[derive(Debug, Eq, PartialEq)] pub enum PatchMessage { - Show(usize), + Show, Leave, } @@ -65,14 +68,12 @@ pub struct Context { profile: Profile, id: Id, project: Project, - selected_patch: usize, - patches: Vec<(PatchId, Patch)>, } #[allow(dead_code)] pub struct App { context: Context, - active_page: Box, + pages: PageStack, theme: Theme, quit: bool, } @@ -81,49 +82,63 @@ pub struct App { /// components and sets focus to a default one. impl App { pub fn new(profile: Profile, id: Id, project: Project) -> Self { - let patches = patch::load_all(&profile, id); Self { context: Context { id, profile, project, - selected_patch: 0, - patches, }, + pages: PageStack::default(), theme: theme::default_dark(), - active_page: Box::::default(), quit: false, } } - fn mount_home_view( + fn view_home( &mut self, app: &mut Application, theme: &Theme, ) -> Result<()> { - self.active_page = Box::::default(); - self.active_page.mount(app, &self.context, theme)?; - self.active_page.activate(app)?; + let patches = patch::load_all(&self.context.profile, self.context.id); + let home = Box::new(HomeView::new(patches)); + self.pages.push(home, app, &self.context, theme)?; Ok(()) } - fn mount_patch_view( + fn view_patch( &mut self, app: &mut Application, theme: &Theme, ) -> Result<()> { - self.active_page = Box::::default(); - self.active_page.mount(app, &self.context, theme)?; - self.active_page.activate(app)?; + let page = self.pages.peek_mut()?; + let state = page.state().unwrap_map(); + let patches = state + .and_then(|mut values| values.remove("patches")) + .and_then(|value| value.unwrap_patches()); - Ok(()) + match patches { + Some((patches, selection)) => match patches.get(selection) { + Some((id, patch)) => { + let view = Box::new(PatchView::new((*id, patch.clone()))); + self.pages.push(view, app, &self.context, theme)?; + + Ok(()) + } + None => Err(anyhow::anyhow!( + "Could not mount 'page::PatchView'. Patch not found." + )), + }, + None => Err(anyhow::anyhow!( + "Could not mount 'page::PatchView'. No state value for 'patches' found." + )), + } } } impl Tui for App { fn init(&mut self, app: &mut Application) -> Result<()> { - self.mount_home_view(app, &self.theme.clone())?; + self.view_home(app, &self.theme.clone())?; // Add global key listener and subscribe to key events let global = ui::widget::common::global_listener().to_boxed(); @@ -133,7 +148,9 @@ impl Tui for App { } fn view(&mut self, app: &mut Application, frame: &mut Frame) { - self.active_page.as_mut().view(app, frame); + if let Ok(page) = self.pages.peek_mut() { + page.view(app, frame); + } } fn update(&mut self, app: &mut Application) -> Result { @@ -142,20 +159,15 @@ impl Tui for App { let theme = theme::default_dark(); for message in messages { match message { - Message::Home(HomeMessage::Show) => { - self.mount_home_view(app, &theme)?; - } - Message::Patch(PatchMessage::Show(index)) => { - self.context.selected_patch = index; - self.mount_patch_view(app, &theme)?; + Message::Patch(PatchMessage::Show) => { + self.view_patch(app, &theme)?; } Message::Patch(PatchMessage::Leave) => { - self.mount_home_view(app, &theme)?; + self.pages.pop(app)?; } Message::Quit => self.quit = true, _ => { - self.active_page.update(message); - self.active_page.activate(app)?; + self.pages.peek_mut()?.update(app, message)?; } } } diff --git a/radicle-tui/src/app/event.rs b/radicle-tui/src/app/event.rs index 610dd278..f003f05f 100644 --- a/radicle-tui/src/app/event.rs +++ b/radicle-tui/src/app/event.rs @@ -13,7 +13,7 @@ use radicle_tui::ui::components::patch; use radicle_tui::ui::widget::Widget; -use super::{Message, PatchMessage}; +use super::{HomeMessage, Message, PatchMessage}; /// Since the framework does not know the type of messages that are being /// passed around in the app, the following handlers need to be implemented for @@ -51,23 +51,24 @@ impl tuirealm::Component for Widget) -> Option { match event { Event::Keyboard(KeyEvent { code: Key::Up, .. }) => { - self.perform(Cmd::Move(MoveDirection::Up)); - Some(Message::Tick) + match self.perform(Cmd::Move(MoveDirection::Up)) { + CmdResult::Changed(State::One(StateValue::Usize(index))) => { + Some(Message::Home(HomeMessage::PatchChanged(index))) + } + _ => Some(Message::Tick), + } } Event::Keyboard(KeyEvent { code: Key::Down, .. - }) => { - self.perform(Cmd::Move(MoveDirection::Down)); - Some(Message::Tick) - } + }) => match self.perform(Cmd::Move(MoveDirection::Down)) { + CmdResult::Changed(State::One(StateValue::Usize(index))) => { + Some(Message::Home(HomeMessage::PatchChanged(index))) + } + _ => Some(Message::Tick), + }, Event::Keyboard(KeyEvent { code: Key::Enter, .. - }) => match self.perform(Cmd::Submit) { - CmdResult::Submit(State::One(StateValue::Usize(index))) => { - Some(Message::Patch(PatchMessage::Show(index))) - } - _ => None, - }, + }) => Some(Message::Patch(PatchMessage::Show)), _ => None, } } diff --git a/radicle-tui/src/app/page.rs b/radicle-tui/src/app/page.rs index 89be30b9..5005a3b4 100644 --- a/radicle-tui/src/app/page.rs +++ b/radicle-tui/src/app/page.rs @@ -1,8 +1,15 @@ +use std::collections::HashMap; + use anyhow::Result; -use radicle_tui::ui::{layout, theme::Theme, widget}; + +use radicle::cob::patch::{Patch, PatchId}; use tuirealm::{Frame, NoUserEvent}; -use super::{subscription, Application, Cid, Context, HomeCid, Message, PatchCid}; +use radicle_tui::ui::layout; +use radicle_tui::ui::theme::Theme; +use radicle_tui::ui::widget; + +use super::{subscription, Application, Cid, Context, HomeCid, HomeMessage, Message, PatchCid}; /// `tuirealm`'s event and prop system is designed to work with flat component hierarchies. /// Building deep nested component hierarchies would need a lot more additional effort to @@ -12,6 +19,7 @@ use super::{subscription, Application, Cid, Context, HomeCid, Message, PatchCid} /// View pages take into account these flat component hierarchies, and provide /// switchable sets of components. pub trait ViewPage { + /// Will be called whenever a view page is pushed onto the page stack. Should create and mount all widgets. fn mount( &self, app: &mut Application, @@ -19,41 +27,54 @@ pub trait ViewPage { theme: &Theme, ) -> Result<()>; - fn update(&mut self, message: Message); + /// Will be called whenever a view page is popped from the page stack. Should unmount all widgets. + fn unmount(&self, app: &mut Application) -> Result<()>; + /// Will be called whenever a view page is on top of the stack and can be used to update its internal + /// state depending on the message passed. + fn update( + &mut self, + app: &mut Application, + message: Message, + ) -> Result<()>; + + /// Will be called whenever a view page is on top of the page stack and needs to be rendered. fn view(&mut self, app: &mut Application, frame: &mut Frame); - fn activate(&self, app: &mut Application) -> Result<()>; + /// Can be used to retrieve a view page's internal state in a unified form. + fn state(&self) -> PageState; } /// /// Home /// -pub struct Home { +pub struct HomeView { active_component: Cid, + patches: (Vec<(PatchId, Patch)>, usize), } -impl Default for Home { - fn default() -> Self { - Home { +impl HomeView { + pub fn new(patches: Vec<(PatchId, Patch)>) -> Self { + HomeView { active_component: Cid::Home(HomeCid::Dashboard), + patches: (patches, 0), } } } -impl ViewPage for Home { +impl ViewPage for HomeView { fn mount( &self, app: &mut Application, context: &Context, theme: &Theme, ) -> Result<()> { + let (patches, _) = &self.patches; let navigation = widget::home::navigation(theme).to_boxed(); let dashboard = widget::home::dashboard(theme, &context.id, &context.project).to_boxed(); let issue_browser = widget::home::issues(theme).to_boxed(); - let patch_browser = - widget::home::patches(theme, &context.patches, &context.profile).to_boxed(); + let patch_browser = widget::home::patches(theme, patches, &context.profile).to_boxed(); app.remount( Cid::Home(HomeCid::Navigation), @@ -65,18 +86,36 @@ impl ViewPage for Home { app.remount(Cid::Home(HomeCid::IssueBrowser), issue_browser, vec![])?; app.remount(Cid::Home(HomeCid::PatchBrowser), patch_browser, vec![])?; + app.active(&self.active_component)?; + Ok(()) } - fn update(&mut self, message: Message) { - if let Message::NavigationChanged(index) = message { - self.active_component = match index { - 0 => Cid::Home(HomeCid::Dashboard), - 1 => Cid::Home(HomeCid::IssueBrowser), - 2 => Cid::Home(HomeCid::PatchBrowser), - _ => Cid::Home(HomeCid::Dashboard), - }; + fn unmount(&self, app: &mut Application) -> Result<()> { + app.umount(&Cid::Home(HomeCid::Navigation))?; + app.umount(&Cid::Home(HomeCid::Dashboard))?; + app.umount(&Cid::Home(HomeCid::IssueBrowser))?; + app.umount(&Cid::Home(HomeCid::PatchBrowser))?; + Ok(()) + } + + fn update( + &mut self, + app: &mut Application, + message: Message, + ) -> Result<()> { + match message { + Message::NavigationChanged(index) => { + self.active_component = Cid::Home(HomeCid::from(index as usize)); + } + Message::Home(HomeMessage::PatchChanged(index)) => { + self.patches.1 = index; + } + _ => {} } + app.active(&self.active_component)?; + + Ok(()) } fn view(&mut self, app: &mut Application, frame: &mut Frame) { @@ -87,9 +126,17 @@ impl ViewPage for Home { app.view(&self.active_component, frame, layout[1]); } - fn activate(&self, app: &mut Application) -> Result<()> { - app.active(&self.active_component)?; - Ok(()) + fn state(&self) -> PageState { + let (patches, selected) = &self.patches; + PageState::Map( + [( + "patches".to_string(), + PageStateValue::Patches(patches.clone(), *selected), + )] + .iter() + .cloned() + .collect(), + ) } } @@ -98,12 +145,14 @@ impl ViewPage for Home { /// pub struct PatchView { active_component: Cid, + patch: (PatchId, Patch), } -impl Default for PatchView { - fn default() -> Self { +impl PatchView { + pub fn new(patch: (PatchId, Patch)) -> Self { PatchView { active_component: Cid::Patch(PatchCid::Activity), + patch, } } } @@ -115,31 +164,42 @@ impl ViewPage for PatchView { context: &Context, theme: &Theme, ) -> Result<()> { - if let Some((id, patch)) = context.patches.get(context.selected_patch) { - let navigation = widget::patch::navigation(theme).to_boxed(); - let activity = - widget::patch::activity(theme, (*id, patch), &context.profile).to_boxed(); - let files = widget::patch::files(theme, (*id, patch), &context.profile).to_boxed(); + let (id, patch) = &self.patch; + let navigation = widget::patch::navigation(theme).to_boxed(); + let activity = widget::patch::activity(theme, (*id, patch), &context.profile).to_boxed(); + let files = widget::patch::files(theme, (*id, patch), &context.profile).to_boxed(); + + app.remount( + Cid::Patch(PatchCid::Navigation), + navigation, + subscription::navigation(), + )?; + app.remount(Cid::Patch(PatchCid::Activity), activity, vec![])?; + app.remount(Cid::Patch(PatchCid::Files), files, vec![])?; + + app.active(&self.active_component)?; - app.remount( - Cid::Patch(PatchCid::Navigation), - navigation, - subscription::navigation(), - )?; - app.remount(Cid::Patch(PatchCid::Activity), activity, vec![])?; - app.remount(Cid::Patch(PatchCid::Files), files, vec![])?; - } Ok(()) } - fn update(&mut self, message: Message) { + fn unmount(&self, app: &mut Application) -> Result<()> { + app.umount(&Cid::Patch(PatchCid::Navigation))?; + app.umount(&Cid::Patch(PatchCid::Activity))?; + app.umount(&Cid::Patch(PatchCid::Files))?; + Ok(()) + } + + fn update( + &mut self, + app: &mut Application, + message: Message, + ) -> Result<()> { if let Message::NavigationChanged(index) = message { - self.active_component = match index { - 0 => Cid::Patch(PatchCid::Activity), - 1 => Cid::Patch(PatchCid::Files), - _ => Cid::Patch(PatchCid::Activity), - }; + self.active_component = Cid::Patch(PatchCid::from(index as usize)); } + app.active(&self.active_component)?; + + Ok(()) } fn view(&mut self, app: &mut Application, frame: &mut Frame) { @@ -150,8 +210,104 @@ impl ViewPage for PatchView { app.view(&self.active_component, frame, layout[1]); } - fn activate(&self, app: &mut Application) -> Result<()> { - app.active(&self.active_component)?; - Ok(()) + fn state(&self) -> PageState { + PageState::None + } +} + +/// Represents a state value that can be retrieved from a view page. +#[derive(Clone)] +pub enum PageStateValue { + /// List of patches and its selected patch + Patches(Vec<(PatchId, Patch)>, usize), +} + +impl PageStateValue { + pub fn unwrap_patches(self) -> Option<(Vec<(PatchId, Patch)>, usize)> { + match self { + PageStateValue::Patches(patches, selection) => Some((patches, selection)), + } + } +} + +/// View pages provide a way to retrieve their state in a unified manner +/// in case that state needs to be passed to other pages. +#[derive(Clone)] +pub enum PageState { + None, + Map(HashMap), +} + +impl PageState { + pub fn unwrap_map(self) -> Option> { + match self { + PageState::Map(map) => Some(map), + _ => None, + } + } +} + +/// View pages need to preserve their state (e.g. selected navigation tab, contents +/// and the selected row of a table). Therefor they should not be (re-)created +/// each time they are displayed. +/// Instead the application can push a new page onto the page stack if it needs to +/// be displayed. Its components are then created using the internal state. If a +/// new page needs to be displayed, it will also be pushed onto the stack. Leaving +/// that page again will pop it from the stack. The application can then return to +/// the previously displayed page in the state it was left. +#[derive(Default)] +pub struct PageStack { + pages: Vec>, +} + +impl PageStack { + pub fn push( + &mut self, + page: Box, + app: &mut Application, + context: &Context, + theme: &Theme, + ) -> Result<()> { + page.mount(app, context, theme)?; + self.pages.push(page); + + Ok(()) + } + + pub fn pop(&mut self, app: &mut Application) -> Result<()> { + self.peek_mut()?.unmount(app)?; + self.pages.pop(); + + Ok(()) + } + + pub fn peek_mut(&mut self) -> Result<&mut Box> { + match self.pages.last_mut() { + Some(page) => Ok(page), + None => Err(anyhow::anyhow!( + "Could not peek active page. Page stack is empty." + )), + } + } +} + +impl From for HomeCid { + fn from(index: usize) -> Self { + match index { + 0 => HomeCid::Dashboard, + 1 => HomeCid::IssueBrowser, + 2 => HomeCid::PatchBrowser, + _ => HomeCid::Dashboard, + } + } +} + +impl From for PatchCid { + fn from(index: usize) -> Self { + match index { + 0 => PatchCid::Activity, + 1 => PatchCid::Files, + _ => PatchCid::Activity, + } } } diff --git a/radicle-tui/src/lib.rs b/radicle-tui/src/lib.rs index cf419cba..4ffced5f 100644 --- a/radicle-tui/src/lib.rs +++ b/radicle-tui/src/lib.rs @@ -80,9 +80,9 @@ impl Window { while !tui.quit() { if update { - self.terminal.raw_mut().draw(|frame| { - tui.view(&mut app, frame); - })?; + self.terminal + .raw_mut() + .draw(|frame| tui.view(&mut app, frame))?; } update = tui.update(&mut app)?; }