diff --git a/src/issue/app/page.rs b/src/issue/app/page.rs index 438462e7..afdcad68 100644 --- a/src/issue/app/page.rs +++ b/src/issue/app/page.rs @@ -160,7 +160,7 @@ impl HomeView { } _ => Progress::None, }; - let context = widget::patch::browse_context(context, theme, progress); + let context = widget::issue::browse_context(context, theme, progress); Some(context) } _ => None, diff --git a/src/lib.rs b/src/lib.rs index adf335d0..d5effa09 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -11,6 +11,9 @@ use tuirealm::{Application, EventListenerCfg, NoUserEvent}; pub mod cob; pub mod ui; +use ui::context::Context; +use ui::theme::Theme; + /// Trait that must be implemented by client applications in order to be run /// as tui-application using tui-realm. Implementors act as models to the /// tui-realm application that can be polled for new messages, updated @@ -96,3 +99,109 @@ impl Window { Ok(()) } } + +/// `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 +/// properly pass events and props down these hierarchies. This makes it hard to implement +/// full app views (home, patch details etc) as components. +/// +/// View pages take into account these flat component hierarchies, and provide +/// switchable sets of components. +pub trait ViewPage +where + Id: Eq + PartialEq + Clone + Hash, + Message: Eq, +{ + /// 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, + context: &Context, + theme: &Theme, + ) -> Result<()>; + + /// 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, + context: &Context, + theme: &Theme, + 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); + + /// Will be called whenever this view page is pushed to the stack, or it is on top of the stack again + /// after another view page was popped from the stack. + fn subscribe(&self, app: &mut Application) -> Result<()>; + + /// Will be called whenever this view page is on top of the stack and another view page is pushed + /// to the stack, or if this is popped from the stack. + fn unsubscribe(&self, app: &mut Application) -> Result<()>; +} + +/// 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 +where + Id: Eq + PartialEq + Clone + Hash, + Message: Eq, +{ + pages: Vec>>, +} + +impl PageStack +where + Id: Eq + PartialEq + Clone + Hash, + Message: Eq, +{ + pub fn push( + &mut self, + page: Box>, + app: &mut Application, + context: &Context, + theme: &Theme, + ) -> Result<()> { + if let Some(page) = self.pages.last() { + page.unsubscribe(app)?; + } + + page.mount(app, context, theme)?; + page.subscribe(app)?; + + self.pages.push(page); + + Ok(()) + } + + pub fn pop(&mut self, app: &mut Application) -> Result<()> { + self.peek_mut()?.unsubscribe(app)?; + self.peek_mut()?.unmount(app)?; + self.pages.pop(); + + self.peek_mut()?.subscribe(app)?; + + 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." + )), + } + } +} diff --git a/src/patch/app.rs b/src/patch/app.rs index 481a50d5..77cc4847 100644 --- a/src/patch/app.rs +++ b/src/patch/app.rs @@ -1,10 +1,11 @@ mod event; mod page; -mod subscription; +mod ui; + +use std::hash::Hash; use anyhow::Result; -use radicle::cob::issue::IssueId; use radicle::cob::patch::PatchId; use radicle::identity::{Id, Project}; use radicle::prelude::Signer; @@ -14,20 +15,18 @@ use radicle_tui::ui::widget; use tuirealm::application::PollStrategy; use tuirealm::{Application, Frame, NoUserEvent, Sub, SubClause}; +use radicle_tui::cob; use radicle_tui::ui::context::Context; +use radicle_tui::ui::subscription; use radicle_tui::ui::theme::{self, Theme}; +use radicle_tui::PageStack; use radicle_tui::Tui; -use radicle_tui::{cob, ui}; -use page::{HomeView, PatchView}; - -use self::page::{IssuePage, PageStack}; +use page::{ListView, PatchView}; #[derive(Debug, Eq, PartialEq, Clone, Hash)] -pub enum HomeCid { +pub enum ListCid { Header, - Dashboard, - IssueBrowser, PatchBrowser, Context, Shortcuts, @@ -42,54 +41,16 @@ pub enum PatchCid { Shortcuts, } -#[derive(Debug, Eq, PartialEq, Clone, Hash)] -pub enum IssueCid { - Header, - List, - Details, - Context, - Form, - Shortcuts, -} - /// All component ids known to this application. -#[derive(Debug, Eq, PartialEq, Clone, Hash)] +#[derive(Debug, Default, Eq, PartialEq, Clone, Hash)] pub enum Cid { - Home(HomeCid), - Issue(IssueCid), + List(ListCid), Patch(PatchCid), + #[default] GlobalListener, Popup, } -/// Messages handled by this application. -#[derive(Clone, Debug, Eq, PartialEq)] -pub enum HomeMessage { - RefreshIssues(Option), -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub enum IssueCobMessage { - Create { - title: String, - tags: String, - assignees: String, - description: String, - }, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub enum IssueMessage { - Show(Option), - Changed(IssueId), - Focus(IssueCid), - Created(IssueId), - Cob(IssueCobMessage), - OpenForm, - HideForm, - Leave(Option), -} - #[derive(Clone, Debug, Eq, PartialEq)] pub enum PatchMessage { Show(PatchId), @@ -104,14 +65,13 @@ pub enum PopupMessage { Hide, } -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Default, Debug, Eq, PartialEq)] pub enum Message { - Home(HomeMessage), - Issue(IssueMessage), Patch(PatchMessage), NavigationChanged(u16), FormSubmitted(String), Popup(PopupMessage), + #[default] Tick, Quit, Batch(Vec), @@ -120,7 +80,7 @@ pub enum Message { #[allow(dead_code)] pub struct App { context: Context, - pages: PageStack, + pages: PageStack, theme: Theme, quit: bool, } @@ -137,12 +97,12 @@ impl App { } } - fn view_home( + fn view_list( &mut self, app: &mut Application, theme: &Theme, ) -> Result<()> { - let home = Box::new(HomeView::new(theme.clone())); + let home = Box::new(ListView::new(theme.clone())); self.pages.push(home, app, &self.context, theme)?; Ok(()) @@ -168,35 +128,6 @@ impl App { } } - fn view_issue( - &mut self, - app: &mut Application, - id: Option, - theme: &Theme, - ) -> Result<()> { - let repo = self.context.repository(); - match id { - Some(id) => { - if let Some(issue) = cob::issue::find(repo, &id)? { - let view = Box::new(IssuePage::new(&self.context, theme, Some((id, issue)))); - self.pages.push(view, app, &self.context, theme)?; - - Ok(()) - } else { - Err(anyhow::anyhow!( - "Could not mount 'page::IssueView'. Issue not found." - )) - } - } - None => { - let view = Box::new(IssuePage::new(&self.context, theme, None)); - self.pages.push(view, app, &self.context, theme)?; - - Ok(()) - } - } - } - fn process( &mut self, app: &mut Application, @@ -217,35 +148,6 @@ impl App { _ => Ok(Some(Message::Batch(results))), } } - Message::Issue(IssueMessage::Cob(IssueCobMessage::Create { - title, - tags, - assignees, - description, - })) => match self.create_issue(title, description, tags, assignees) { - Ok(id) => { - self.context.reload(); - - Ok(Some(Message::Batch(vec![ - Message::Issue(IssueMessage::HideForm), - Message::Issue(IssueMessage::Created(id)), - ]))) - } - Err(err) => { - let error = format!("{:?}", err); - self.show_error_popup(app, &theme, &error)?; - - Ok(None) - } - }, - Message::Issue(IssueMessage::Show(id)) => { - self.view_issue(app, id, &theme)?; - Ok(None) - } - Message::Issue(IssueMessage::Leave(id)) => { - self.pages.pop(app)?; - Ok(Some(Message::Home(HomeMessage::RefreshIssues(id)))) - } Message::Patch(PatchMessage::Show(id)) => { self.view_patch(app, id, &theme)?; Ok(None) @@ -326,37 +228,14 @@ impl App { Ok(()) } - - fn create_issue( - &mut self, - title: String, - description: String, - labels: String, - assignees: String, - ) -> Result { - let repository = self.context.repository(); - let signer = self.context.signer(); - - let labels = cob::parse_labels(labels)?; - let assignees = cob::parse_assignees(assignees)?; - - cob::issue::create( - repository, - signer, - title, - description, - labels.as_slice(), - assignees.as_slice(), - ) - } } impl Tui for App { fn init(&mut self, app: &mut Application) -> Result<()> { - self.view_home(app, &self.theme.clone())?; + self.view_list(app, &self.theme.clone())?; // Add global key listener and subscribe to key events - let global = ui::widget::common::global_listener().to_boxed(); + let global = widget::common::global_listener().to_boxed(); app.mount( Cid::GlobalListener, global, diff --git a/src/patch/app/event.rs b/src/patch/app/event.rs index 0fdea58a..1e05bef3 100644 --- a/src/patch/app/event.rs +++ b/src/patch/app/event.rs @@ -1,20 +1,17 @@ -use radicle::cob::issue::IssueId; -use tuirealm::command::{Cmd, CmdResult, Direction as MoveDirection, Position}; -use tuirealm::event::{Event, Key, KeyEvent, KeyModifiers}; +use tuirealm::command::{Cmd, CmdResult, Direction as MoveDirection}; +use tuirealm::event::{Event, Key, KeyEvent}; use tuirealm::{MockComponent, NoUserEvent, State, StateValue}; use radicle_tui::ui::widget::common::container::{ AppHeader, GlobalListener, LabeledContainer, Popup, }; use radicle_tui::ui::widget::common::context::{ContextBar, Shortcuts}; -use radicle_tui::ui::widget::common::form::Form; use radicle_tui::ui::widget::common::list::PropertyList; -use radicle_tui::ui::widget::home::{Dashboard, IssueBrowser, PatchBrowser}; -use radicle_tui::ui::widget::{issue, patch}; +use radicle_tui::ui::widget::home::PatchBrowser; use radicle_tui::ui::widget::Widget; -use super::{IssueCid, IssueMessage, Message, PatchMessage, PopupMessage}; +use super::{ui, Message, PatchMessage, PopupMessage}; /// 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 @@ -49,190 +46,6 @@ impl tuirealm::Component for Widget { } } -impl tuirealm::Component for Widget { - fn on(&mut self, event: Event) -> Option { - match event { - Event::Keyboard(KeyEvent { code: Key::Esc, .. }) => match self.state() { - State::Tup2((StateValue::Usize(selected), StateValue::Usize(_))) => { - let item = self.items().get(selected)?; - Some(Message::Issue(IssueMessage::Leave(Some( - item.id().to_owned(), - )))) - } - _ => None, - }, - Event::Keyboard(KeyEvent { code: Key::Up, .. }) - | Event::Keyboard(KeyEvent { - code: Key::Char('k'), - .. - }) => { - let result = self.perform(Cmd::Move(MoveDirection::Up)); - match result { - CmdResult::Changed(State::One(StateValue::Usize(selected))) => { - let item = self.items().get(selected)?; - Some(Message::Issue(IssueMessage::Changed(item.id().to_owned()))) - } - _ => None, - } - } - Event::Keyboard(KeyEvent { - code: Key::Down, .. - }) - | Event::Keyboard(KeyEvent { - code: Key::Char('j'), - .. - }) => { - let result = self.perform(Cmd::Move(MoveDirection::Down)); - match result { - CmdResult::Changed(State::One(StateValue::Usize(selected))) => { - let item = self.items().get(selected)?; - Some(Message::Issue(IssueMessage::Changed(item.id().to_owned()))) - } - _ => None, - } - } - Event::Keyboard(KeyEvent { - code: Key::Enter, .. - }) => Some(Message::Issue(IssueMessage::Focus(IssueCid::Details))), - Event::Keyboard(KeyEvent { - code: Key::Char('o'), - .. - }) => Some(Message::Issue(IssueMessage::OpenForm)), - _ => None, - } - } -} - -impl tuirealm::Component for Widget { - fn on(&mut self, event: Event) -> Option { - match event { - Event::Keyboard(KeyEvent { code: Key::Up, .. }) - | Event::Keyboard(KeyEvent { - code: Key::Char('k'), - .. - }) => { - self.perform(Cmd::Scroll(MoveDirection::Up)); - Some(Message::Tick) - } - Event::Keyboard(KeyEvent { - code: Key::Down, .. - }) - | Event::Keyboard(KeyEvent { - code: Key::Char('j'), - .. - }) => { - self.perform(Cmd::Scroll(MoveDirection::Down)); - Some(Message::Tick) - } - Event::Keyboard(KeyEvent { code: Key::Esc, .. }) => { - Some(Message::Issue(IssueMessage::Focus(IssueCid::List))) - } - _ => None, - } - } -} - -impl tuirealm::Component for Widget
{ - fn on(&mut self, event: Event) -> Option { - match event { - Event::Keyboard(KeyEvent { - code: Key::Left, .. - }) => { - self.perform(Cmd::Move(MoveDirection::Left)); - Some(Message::Tick) - } - Event::Keyboard(KeyEvent { - code: Key::Right, .. - }) => { - self.perform(Cmd::Move(MoveDirection::Right)); - Some(Message::Tick) - } - Event::Keyboard(KeyEvent { code: Key::Up, .. }) => { - self.perform(Cmd::Move(MoveDirection::Up)); - Some(Message::Tick) - } - Event::Keyboard(KeyEvent { - code: Key::Down, .. - }) => { - self.perform(Cmd::Move(MoveDirection::Down)); - Some(Message::Tick) - } - Event::Keyboard(KeyEvent { - code: Key::Home, .. - }) => { - self.perform(Cmd::GoTo(Position::Begin)); - Some(Message::Tick) - } - Event::Keyboard(KeyEvent { code: Key::End, .. }) => { - self.perform(Cmd::GoTo(Position::End)); - Some(Message::Tick) - } - Event::Keyboard(KeyEvent { - code: Key::Delete, .. - }) => { - self.perform(Cmd::Cancel); - Some(Message::Tick) - } - Event::Keyboard(KeyEvent { - code: Key::Backspace, - .. - }) => { - self.perform(Cmd::Delete); - Some(Message::Tick) - } - Event::Keyboard(KeyEvent { - code: Key::Enter, .. - }) => { - self.perform(Cmd::Custom(Form::CMD_NEWLINE)); - Some(Message::Tick) - } - Event::Keyboard(KeyEvent { - code: Key::Char('s'), - modifiers: KeyModifiers::CONTROL, - }) => { - self.perform(Cmd::Submit); - self.query(tuirealm::Attribute::Custom(Form::PROP_ID)) - .map(|cid| Message::FormSubmitted(cid.unwrap_string())) - } - Event::Keyboard(KeyEvent { code: Key::Esc, .. }) => { - Some(Message::Issue(IssueMessage::HideForm)) - } - Event::Keyboard(KeyEvent { - code: Key::BackTab, .. - }) => { - self.perform(Cmd::Custom(Form::CMD_FOCUS_PREVIOUS)); - Some(Message::Tick) - } - Event::Keyboard(KeyEvent { code: Key::Tab, .. }) => { - self.perform(Cmd::Custom(Form::CMD_FOCUS_NEXT)); - Some(Message::Tick) - } - Event::Keyboard(KeyEvent { - code: Key::Char(ch), - modifiers: KeyModifiers::SHIFT, - }) => { - self.perform(Cmd::Type(ch.to_ascii_uppercase())); - Some(Message::Tick) - } - Event::Keyboard(KeyEvent { - code: Key::Char('v'), - modifiers: KeyModifiers::CONTROL, - }) => { - self.perform(Cmd::Custom(Form::CMD_PASTE)); - Some(Message::Tick) - } - Event::Keyboard(KeyEvent { - code: Key::Char(ch), - .. - }) => { - self.perform(Cmd::Type(ch)); - Some(Message::Tick) - } - _ => None, - } - } -} - impl tuirealm::Component for Widget { fn on(&mut self, event: Event) -> Option { match event { @@ -271,70 +84,7 @@ impl tuirealm::Component for Widget { } } -impl tuirealm::Component for Widget { - fn on(&mut self, event: Event) -> Option { - let mut submit = || -> Option { - let result = self.perform(Cmd::Submit); - match result { - CmdResult::Submit(State::One(StateValue::Usize(selected))) => { - let item = self.items().get(selected)?; - Some(item.id().to_owned()) - } - _ => None, - } - }; - - match event { - Event::Keyboard(KeyEvent { code: Key::Up, .. }) - | Event::Keyboard(KeyEvent { - code: Key::Char('k'), - .. - }) => { - self.perform(Cmd::Move(MoveDirection::Up)); - Some(Message::Tick) - } - Event::Keyboard(KeyEvent { - code: Key::Down, .. - }) - | Event::Keyboard(KeyEvent { - code: Key::Char('j'), - .. - }) => { - self.perform(Cmd::Move(MoveDirection::Down)); - Some(Message::Tick) - } - Event::Keyboard(KeyEvent { - code: Key::Char('o'), - .. - }) => { - let id = submit(); - Some(Message::Batch(vec![ - Message::Issue(IssueMessage::Show(id)), - Message::Issue(IssueMessage::OpenForm), - ])) - } - Event::Keyboard(KeyEvent { - code: Key::Enter, .. - }) => { - let id = submit(); - if id.is_some() { - Some(Message::Issue(IssueMessage::Show(id))) - } else { - None - } - } - _ => None, - } - } -} - -impl tuirealm::Component for Widget { - fn on(&mut self, _event: Event) -> Option { - None - } -} - -impl tuirealm::Component for Widget { +impl tuirealm::Component for Widget { fn on(&mut self, event: Event) -> Option { match event { Event::Keyboard(KeyEvent { code: Key::Esc, .. }) => { @@ -345,7 +95,7 @@ impl tuirealm::Component for Widget { } } -impl tuirealm::Component for Widget { +impl tuirealm::Component for Widget { fn on(&mut self, event: Event) -> Option { match event { Event::Keyboard(KeyEvent { code: Key::Esc, .. }) => { diff --git a/src/patch/app/page.rs b/src/patch/app/page.rs index 438462e7..cc66cb15 100644 --- a/src/patch/app/page.rs +++ b/src/patch/app/page.rs @@ -2,132 +2,50 @@ use std::collections::HashMap; use anyhow::Result; -use radicle::cob::issue::{Issue, IssueId}; use radicle::cob::patch::{Patch, PatchId}; -use radicle_tui::cob; use radicle_tui::ui::widget::common::context::{Progress, Shortcuts}; -use tuirealm::{AttrValue, Attribute, Frame, NoUserEvent, State, StateValue, Sub, SubClause}; +use tuirealm::{Frame, NoUserEvent, State, StateValue, Sub, SubClause}; use radicle_tui::ui::context::Context; use radicle_tui::ui::layout; +use radicle_tui::ui::subscription; use radicle_tui::ui::theme::Theme; use radicle_tui::ui::widget::{self, Widget}; +use radicle_tui::ViewPage; -use super::{ - subscription, Application, Cid, HomeCid, HomeMessage, IssueCid, IssueCobMessage, IssueMessage, - Message, PatchCid, PopupMessage, -}; - -/// `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 -/// properly pass events and props down these hierarchies. This makes it hard to implement -/// full app views (home, patch details etc) as components. -/// -/// 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, - context: &Context, - theme: &Theme, - ) -> Result<()>; - - /// 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, - context: &Context, - theme: &Theme, - 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); - - /// Will be called whenever this view page is pushed to the stack, or it is on top of the stack again - /// after another view page was popped from the stack. - fn subscribe(&self, app: &mut Application) -> Result<()>; - - /// Will be called whenever this view page is on top of the stack and another view page is pushed - /// to the stack, or if this is popped from the stack. - fn unsubscribe(&self, app: &mut Application) -> Result<()>; -} +use super::{ui, Application, Cid, ListCid, Message, PatchCid}; /// /// Home /// -pub struct HomeView { - active_component: HomeCid, - shortcuts: HashMap>, +pub struct ListView { + active_component: ListCid, + shortcuts: HashMap>, } -impl HomeView { +impl ListView { pub fn new(theme: Theme) -> Self { let shortcuts = Self::build_shortcuts(&theme); - HomeView { - active_component: HomeCid::Dashboard, + Self { + active_component: ListCid::PatchBrowser, shortcuts, } } - fn activate( - &mut self, - app: &mut Application, - cid: HomeCid, - ) -> Result<()> { - self.active_component = cid; - let cid = Cid::Home(self.active_component.clone()); - app.active(&cid)?; - app.attr(&cid, Attribute::Focus, AttrValue::Flag(true))?; - - Ok(()) - } - - fn build_shortcuts(theme: &Theme) -> HashMap> { - [ - ( - HomeCid::Dashboard, - widget::common::shortcuts( - theme, - vec![ - widget::common::shortcut(theme, "tab", "section"), - widget::common::shortcut(theme, "q", "quit"), - ], - ), + fn build_shortcuts(theme: &Theme) -> HashMap> { + [( + ListCid::PatchBrowser, + widget::common::shortcuts( + theme, + vec![ + widget::common::shortcut(theme, "tab", "section"), + widget::common::shortcut(theme, "↑/↓", "navigate"), + widget::common::shortcut(theme, "enter", "show"), + widget::common::shortcut(theme, "q", "quit"), + ], ), - ( - HomeCid::IssueBrowser, - widget::common::shortcuts( - theme, - vec![ - widget::common::shortcut(theme, "tab", "section"), - widget::common::shortcut(theme, "↑/↓", "navigate"), - widget::common::shortcut(theme, "enter", "show"), - widget::common::shortcut(theme, "o", "open"), - widget::common::shortcut(theme, "q", "quit"), - ], - ), - ), - ( - HomeCid::PatchBrowser, - widget::common::shortcuts( - theme, - vec![ - widget::common::shortcut(theme, "tab", "section"), - widget::common::shortcut(theme, "↑/↓", "navigate"), - widget::common::shortcut(theme, "enter", "show"), - widget::common::shortcut(theme, "q", "quit"), - ], - ), - ), - ] + )] .iter() .cloned() .collect() @@ -138,37 +56,17 @@ impl HomeView { app: &mut Application, context: &Context, theme: &Theme, - cid: HomeCid, ) -> Result<()> { - let context = match cid { - HomeCid::IssueBrowser => { - let state = app.state(&Cid::Home(HomeCid::IssueBrowser))?; - let progress = match state { - State::Tup2((StateValue::Usize(step), StateValue::Usize(total))) => { - Progress::Step(step.saturating_add(1), total) - } - _ => Progress::None, - }; - let context = widget::issue::browse_context(context, theme, progress); - Some(context) + let state = app.state(&Cid::List(ListCid::PatchBrowser))?; + let progress = match state { + State::Tup2((StateValue::Usize(step), StateValue::Usize(total))) => { + Progress::Step(step.saturating_add(1), total) } - HomeCid::PatchBrowser => { - let state = app.state(&Cid::Home(HomeCid::PatchBrowser))?; - let progress = match state { - State::Tup2((StateValue::Usize(step), StateValue::Usize(total))) => { - Progress::Step(step.saturating_add(1), total) - } - _ => Progress::None, - }; - let context = widget::patch::browse_context(context, theme, progress); - Some(context) - } - _ => None, + _ => Progress::None, }; + let context = ui::browse_context(context, theme, progress); - if let Some(context) = context { - app.remount(Cid::Home(HomeCid::Context), context.to_boxed(), vec![])?; - } + app.remount(Cid::List(ListCid::Context), context.to_boxed(), vec![])?; Ok(()) } @@ -176,11 +74,11 @@ impl HomeView { fn update_shortcuts( &self, app: &mut Application, - cid: HomeCid, + cid: ListCid, ) -> Result<()> { if let Some(shortcuts) = self.shortcuts.get(&cid) { app.remount( - Cid::Home(HomeCid::Shortcuts), + Cid::List(ListCid::Shortcuts), shortcuts.clone().to_boxed(), vec![], )?; @@ -189,41 +87,32 @@ impl HomeView { } } -impl ViewPage for HomeView { +impl ViewPage for ListView { fn mount( &self, app: &mut Application, context: &Context, theme: &Theme, ) -> Result<()> { - let navigation = widget::home::navigation(theme); + let navigation = ui::list_navigation(theme); let header = widget::common::app_header(context, theme, Some(navigation)).to_boxed(); - - let dashboard = widget::home::dashboard(context, theme).to_boxed(); - let issue_browser = widget::home::issues(context, theme, None).to_boxed(); let patch_browser = widget::home::patches(context, theme, None).to_boxed(); - app.remount(Cid::Home(HomeCid::Header), header, vec![])?; + app.remount(Cid::List(ListCid::Header), header, vec![])?; + app.remount(Cid::List(ListCid::PatchBrowser), patch_browser, vec![])?; - app.remount(Cid::Home(HomeCid::Dashboard), dashboard, vec![])?; - app.remount(Cid::Home(HomeCid::IssueBrowser), issue_browser, vec![])?; - app.remount(Cid::Home(HomeCid::PatchBrowser), patch_browser, vec![])?; - - let active_component = Cid::Home(self.active_component.clone()); - app.active(&active_component)?; + app.active(&Cid::List(self.active_component.clone()))?; self.update_shortcuts(app, self.active_component.clone())?; - self.update_context(app, context, theme, self.active_component.clone())?; + self.update_context(app, context, theme)?; Ok(()) } fn unmount(&self, app: &mut Application) -> Result<()> { - app.umount(&Cid::Home(HomeCid::Header))?; - app.umount(&Cid::Home(HomeCid::Dashboard))?; - app.umount(&Cid::Home(HomeCid::IssueBrowser))?; - app.umount(&Cid::Home(HomeCid::PatchBrowser))?; - app.umount(&Cid::Home(HomeCid::Context))?; - app.umount(&Cid::Home(HomeCid::Shortcuts))?; + app.umount(&Cid::List(ListCid::Header))?; + app.umount(&Cid::List(ListCid::PatchBrowser))?; + app.umount(&Cid::List(ListCid::Context))?; + app.umount(&Cid::List(ListCid::Shortcuts))?; Ok(()) } @@ -232,30 +121,9 @@ impl ViewPage for HomeView { app: &mut Application, context: &Context, theme: &Theme, - message: Message, + _message: Message, ) -> Result> { - match message { - Message::NavigationChanged(index) => { - self.activate(app, HomeCid::from(index as usize))?; - self.update_shortcuts(app, self.active_component.clone())?; - } - Message::Home(HomeMessage::RefreshIssues(id)) => { - let selected = match id { - Some(id) => { - cob::issue::find(context.repository(), &id)?.map(|issue| (id, issue)) - } - _ => None, - }; - - let issue_browser = widget::home::issues(context, theme, selected).to_boxed(); - app.remount(Cid::Home(HomeCid::IssueBrowser), issue_browser, vec![])?; - - self.activate(app, HomeCid::IssueBrowser)?; - } - _ => {} - } - - self.update_context(app, context, theme, self.active_component.clone())?; + self.update_context(app, context, theme)?; Ok(None) } @@ -265,23 +133,20 @@ impl ViewPage for HomeView { let shortcuts_h = 1u16; let layout = layout::default_page(area, shortcuts_h); - app.view(&Cid::Home(HomeCid::Header), frame, layout.navigation); + app.view(&Cid::List(ListCid::Header), frame, layout.navigation); app.view( - &Cid::Home(self.active_component.clone()), + &Cid::List(self.active_component.clone()), frame, layout.component, ); - if self.active_component != HomeCid::Dashboard { - app.view(&Cid::Home(HomeCid::Context), frame, layout.context); - } - - app.view(&Cid::Home(HomeCid::Shortcuts), frame, layout.shortcuts); + app.view(&Cid::List(ListCid::Context), frame, layout.context); + app.view(&Cid::List(ListCid::Shortcuts), frame, layout.shortcuts); } fn subscribe(&self, app: &mut Application) -> Result<()> { app.subscribe( - &Cid::Home(HomeCid::Header), + &Cid::List(ListCid::Header), Sub::new(subscription::navigation_clause(), SubClause::Always), )?; @@ -290,7 +155,7 @@ impl ViewPage for HomeView { fn unsubscribe(&self, app: &mut Application) -> Result<()> { app.unsubscribe( - &Cid::Home(HomeCid::Header), + &Cid::List(ListCid::Header), subscription::navigation_clause(), )?; @@ -298,363 +163,6 @@ impl ViewPage for HomeView { } } -/// -/// Issue detail page -/// -pub struct IssuePage { - issue: Option<(IssueId, Issue)>, - active_component: IssueCid, - shortcuts: HashMap>, -} - -impl IssuePage { - pub fn new(_context: &Context, theme: &Theme, issue: Option<(IssueId, Issue)>) -> Self { - let shortcuts = Self::build_shortcuts(theme); - let active_component = IssueCid::List; - - Self { - issue, - active_component, - shortcuts, - } - } - - fn activate( - &mut self, - app: &mut Application, - cid: IssueCid, - ) -> Result<()> { - self.active_component = cid; - let cid = Cid::Issue(self.active_component.clone()); - app.active(&cid)?; - app.attr(&cid, Attribute::Focus, AttrValue::Flag(true))?; - - Ok(()) - } - - fn build_shortcuts(theme: &Theme) -> HashMap> { - [ - ( - IssueCid::List, - widget::common::shortcuts( - theme, - vec![ - widget::common::shortcut(theme, "esc", "back"), - widget::common::shortcut(theme, "↑/↓", "navigate"), - widget::common::shortcut(theme, "enter", "show"), - widget::common::shortcut(theme, "o", "open"), - widget::common::shortcut(theme, "q", "quit"), - ], - ), - ), - ( - IssueCid::Details, - widget::common::shortcuts( - theme, - vec![ - widget::common::shortcut(theme, "esc", "back"), - widget::common::shortcut(theme, "↑/↓", "scroll"), - widget::common::shortcut(theme, "q", "quit"), - ], - ), - ), - ( - IssueCid::Form, - widget::common::shortcuts( - theme, - vec![ - widget::common::shortcut(theme, "esc", "back"), - widget::common::shortcut(theme, "shift + tab / tab", "navigate"), - widget::common::shortcut(theme, "ctrl + s", "submit"), - ], - ), - ), - ] - .iter() - .cloned() - .collect() - } - - fn update_context( - &self, - app: &mut Application, - context: &Context, - theme: &Theme, - cid: IssueCid, - ) -> Result<()> { - let context = match cid { - IssueCid::List => { - let state = app.state(&Cid::Issue(IssueCid::List))?; - let progress = match state { - State::Tup2((StateValue::Usize(step), StateValue::Usize(total))) => { - Progress::Step(step.saturating_add(1), total) - } - _ => Progress::None, - }; - let context = widget::issue::browse_context(context, theme, progress); - Some(context) - } - IssueCid::Details => { - let state = app.state(&Cid::Issue(IssueCid::Details))?; - let progress = match state { - State::One(StateValue::Usize(scroll)) => Progress::Percentage(scroll), - _ => Progress::None, - }; - let context = widget::issue::description_context(context, theme, progress); - Some(context) - } - IssueCid::Form => { - let context = widget::issue::form_context(context, theme, Progress::None); - Some(context) - } - _ => None, - }; - - if let Some(context) = context { - app.remount(Cid::Issue(IssueCid::Context), context.to_boxed(), vec![])?; - } - - Ok(()) - } - - fn update_shortcuts( - &self, - app: &mut Application, - cid: IssueCid, - ) -> Result<()> { - if let Some(shortcuts) = self.shortcuts.get(&cid) { - app.remount( - Cid::Issue(IssueCid::Shortcuts), - shortcuts.clone().to_boxed(), - vec![], - )?; - } - Ok(()) - } -} - -impl ViewPage for IssuePage { - fn mount( - &self, - app: &mut Application, - context: &Context, - theme: &Theme, - ) -> Result<()> { - let header = widget::common::app_header(context, theme, None).to_boxed(); - let list = widget::issue::list(context, theme, self.issue.clone()).to_boxed(); - - app.remount(Cid::Issue(IssueCid::Header), header, vec![])?; - app.remount(Cid::Issue(IssueCid::List), list, vec![])?; - - if let Some((id, issue)) = &self.issue { - let comments = issue.comments().collect::>(); - let details = widget::issue::details( - context, - theme, - (*id, issue.clone()), - comments.first().copied(), - ) - .to_boxed(); - app.remount(Cid::Issue(IssueCid::Details), details, vec![])?; - } - - app.active(&Cid::Issue(self.active_component.clone()))?; - - self.update_shortcuts(app, self.active_component.clone())?; - self.update_context(app, context, theme, self.active_component.clone())?; - - Ok(()) - } - - fn unmount(&self, app: &mut Application) -> Result<()> { - app.umount(&Cid::Issue(IssueCid::Header))?; - app.umount(&Cid::Issue(IssueCid::List))?; - app.umount(&Cid::Issue(IssueCid::Context))?; - app.umount(&Cid::Issue(IssueCid::Shortcuts))?; - - if app.mounted(&Cid::Issue(IssueCid::Details)) { - app.umount(&Cid::Issue(IssueCid::Details))?; - } - - if app.mounted(&Cid::Issue(IssueCid::Form)) { - app.umount(&Cid::Issue(IssueCid::Form))?; - } - - Ok(()) - } - - fn update( - &mut self, - app: &mut Application, - context: &Context, - theme: &Theme, - message: Message, - ) -> Result> { - match message { - Message::Issue(IssueMessage::Created(id)) => { - let repo = context.repository(); - - if let Some(issue) = cob::issue::find(repo, &id)? { - self.issue = Some((id, issue.clone())); - let list = widget::issue::list(context, theme, self.issue.clone()).to_boxed(); - let comments = issue.comments().collect::>(); - - let details = widget::issue::details( - context, - theme, - (id, issue.clone()), - comments.first().copied(), - ) - .to_boxed(); - - app.remount(Cid::Issue(IssueCid::List), list, vec![])?; - app.remount(Cid::Issue(IssueCid::Details), details, vec![])?; - } - } - Message::Issue(IssueMessage::Changed(id)) => { - let repo = context.repository(); - if let Some(issue) = cob::issue::find(repo, &id)? { - self.issue = Some((id, issue.clone())); - let comments = issue.comments().collect::>(); - let details = widget::issue::details( - context, - theme, - (id, issue.clone()), - comments.first().copied(), - ) - .to_boxed(); - app.remount(Cid::Issue(IssueCid::Details), details, vec![])?; - } - } - Message::Issue(IssueMessage::Focus(cid)) => { - self.activate(app, cid)?; - self.update_shortcuts(app, self.active_component.clone())?; - } - Message::Issue(IssueMessage::OpenForm) => { - let new_form = widget::issue::new_form(context, theme).to_boxed(); - let list = widget::issue::list(context, theme, None).to_boxed(); - - app.remount(Cid::Issue(IssueCid::List), list, vec![])?; - app.remount(Cid::Issue(IssueCid::Form), new_form, vec![])?; - app.active(&Cid::Issue(IssueCid::Form))?; - - app.unsubscribe(&Cid::GlobalListener, subscription::global_clause())?; - - return Ok(Some(Message::Issue(IssueMessage::Focus(IssueCid::Form)))); - } - Message::Issue(IssueMessage::HideForm) => { - app.umount(&Cid::Issue(IssueCid::Form))?; - - let list = widget::issue::list(context, theme, self.issue.clone()).to_boxed(); - app.remount(Cid::Issue(IssueCid::List), list, vec![])?; - - app.subscribe( - &Cid::GlobalListener, - Sub::new(subscription::global_clause(), SubClause::Always), - )?; - - if self.issue.is_none() { - return Ok(Some(Message::Issue(IssueMessage::Leave(None)))); - } - return Ok(Some(Message::Issue(IssueMessage::Focus(IssueCid::List)))); - } - Message::FormSubmitted(id) => { - if id == widget::issue::FORM_ID_EDIT { - let state = app.state(&Cid::Issue(IssueCid::Form))?; - if let State::Linked(mut states) = state { - let mut missing_values = vec![]; - - let title = match states.front() { - Some(State::One(StateValue::String(title))) if !title.is_empty() => { - Some(title.clone()) - } - _ => None, - }; - states.pop_front(); - - let tags = match states.front() { - Some(State::One(StateValue::String(tags))) => Some(tags.clone()), - _ => Some(String::from("[]")), - }; - states.pop_front(); - - let assignees = match states.front() { - Some(State::One(StateValue::String(assignees))) => { - Some(assignees.clone()) - } - _ => Some(String::from("[]")), - }; - states.pop_front(); - - let description = match states.front() { - Some(State::One(StateValue::String(description))) - if !description.is_empty() => - { - Some(description.clone()) - } - _ => None, - }; - states.pop_front(); - - if title.is_none() { - missing_values.push("title"); - } - if description.is_none() { - missing_values.push("description"); - } - - // show error popup if missing. - if !missing_values.is_empty() { - let error = format!("Missing fields: {:?}", missing_values); - return Ok(Some(Message::Popup(PopupMessage::Error(error)))); - } else { - return Ok(Some(Message::Issue(IssueMessage::Cob( - IssueCobMessage::Create { - title: title.unwrap(), - tags: tags.unwrap(), - assignees: assignees.unwrap(), - description: description.unwrap(), - }, - )))); - } - } - } - } - _ => {} - } - - self.update_context(app, context, theme, self.active_component.clone())?; - - Ok(None) - } - - fn view(&mut self, app: &mut Application, frame: &mut Frame) { - let area = frame.size(); - let shortcuts_h = 1u16; - let layout = layout::issue_page(area, shortcuts_h); - - app.view(&Cid::Issue(IssueCid::Header), frame, layout.header); - app.view(&Cid::Issue(IssueCid::List), frame, layout.left); - - if app.mounted(&Cid::Issue(IssueCid::Form)) { - app.view(&Cid::Issue(IssueCid::Form), frame, layout.right); - } else if app.mounted(&Cid::Issue(IssueCid::Details)) { - app.view(&Cid::Issue(IssueCid::Details), frame, layout.right); - } - - app.view(&Cid::Issue(IssueCid::Context), frame, layout.context); - app.view(&Cid::Issue(IssueCid::Shortcuts), frame, layout.shortcuts); - } - - fn subscribe(&self, _app: &mut Application) -> Result<()> { - Ok(()) - } - - fn unsubscribe(&self, _app: &mut Application) -> Result<()> { - Ok(()) - } -} - /// /// Patch detail page /// @@ -720,18 +228,18 @@ impl PatchView { } } -impl ViewPage for PatchView { +impl ViewPage for PatchView { fn mount( &self, app: &mut Application, context: &Context, theme: &Theme, ) -> Result<()> { - let navigation = widget::patch::navigation(theme); + let navigation = ui::navigation(theme); let header = widget::common::app_header(context, theme, Some(navigation)).to_boxed(); - let activity = widget::patch::activity(theme).to_boxed(); - let files = widget::patch::files(theme).to_boxed(); - let context = widget::patch::context(context, theme, self.patch.clone()).to_boxed(); + let activity = ui::activity(theme).to_boxed(); + let files = ui::files(theme).to_boxed(); + let context = ui::context(context, theme, self.patch.clone()).to_boxed(); app.remount(Cid::Patch(PatchCid::Header), header, vec![])?; app.remount(Cid::Patch(PatchCid::Activity), activity, vec![])?; @@ -806,70 +314,6 @@ impl ViewPage for PatchView { } } -/// 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<()> { - if let Some(page) = self.pages.last() { - page.unsubscribe(app)?; - } - - page.mount(app, context, theme)?; - page.subscribe(app)?; - - self.pages.push(page); - - Ok(()) - } - - pub fn pop(&mut self, app: &mut Application) -> Result<()> { - self.peek_mut()?.unsubscribe(app)?; - self.peek_mut()?.unmount(app)?; - self.pages.pop(); - - self.peek_mut()?.subscribe(app)?; - - 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 { diff --git a/src/patch/app/ui.rs b/src/patch/app/ui.rs new file mode 100644 index 00000000..6273b221 --- /dev/null +++ b/src/patch/app/ui.rs @@ -0,0 +1,152 @@ +use radicle::cob::patch::{Patch, PatchId}; + +use tuirealm::command::{Cmd, CmdResult}; +use tuirealm::tui::layout::Rect; +use tuirealm::{AttrValue, Attribute, Frame, MockComponent, Props, State}; + +use radicle_tui::ui::context::Context; +use radicle_tui::ui::theme::Theme; +use radicle_tui::ui::widget::common; +use radicle_tui::ui::widget::{Widget, WidgetComponent}; +use radicle_tui::ui::{cob, layout}; + +use common::container::Tabs; +use common::context::{ContextBar, Progress}; +use common::label::Label; + +pub struct Activity { + label: Widget