From 8325eb69d7cc8d33eac0cf04a94c4d03073c204b Mon Sep 17 00:00:00 2001 From: Erik Kundt Date: Fri, 24 Mar 2023 13:45:04 +0100 Subject: [PATCH] tui: Implement patches workspace Signed-off-by: Erik Kundt --- radicle-tui/src/app.rs | 167 +++++++++++++-------- radicle-tui/src/lib.rs | 1 + radicle-tui/src/main.rs | 2 +- radicle-tui/src/ui.rs | 83 ++++++++-- radicle-tui/src/ui/components/workspace.rs | 129 +++------------- 5 files changed, 199 insertions(+), 183 deletions(-) diff --git a/radicle-tui/src/app.rs b/radicle-tui/src/app.rs index cdde6d9c..92ae58aa 100644 --- a/radicle-tui/src/app.rs +++ b/radicle-tui/src/app.rs @@ -9,27 +9,25 @@ use tuirealm::event::{Event, Key, KeyEvent, KeyModifiers}; use tuirealm::props::{AttrValue, Attribute}; use tuirealm::tui::layout::{Constraint, Direction, Layout}; use tuirealm::{ - Application, Component, Frame, MockComponent, NoUserEvent, Sub, SubClause, SubEventClause, + Application, Component, Frame, MockComponent, NoUserEvent, StateValue, Sub, SubClause, + SubEventClause, }; +use radicle_tui::cob::patch::{self}; + use radicle_tui::ui; -use radicle_tui::ui::components::container::{GlobalListener, LabeledContainer}; +use radicle_tui::ui::components::container::{GlobalListener, LabeledContainer, Tabs}; use radicle_tui::ui::components::context::Shortcuts; use radicle_tui::ui::components::list::PropertyList; -use radicle_tui::ui::components::workspace::Workspaces; +use radicle_tui::ui::components::workspace::Browser; use radicle_tui::ui::theme; use radicle_tui::ui::widget::Widget; use radicle_tui::Tui; +use radicle::cob::patch::{Patch, PatchId}; use radicle::identity::{Id, Project}; - -#[allow(dead_code)] -pub struct App { - id: Id, - project: Project, - quit: bool, -} +use radicle::profile::Profile; /// Messages handled by this application. #[derive(Debug, Eq, PartialEq)] @@ -40,18 +38,33 @@ pub enum Message { /// All components known to this application. #[derive(Debug, Eq, PartialEq, Clone, Hash)] pub enum ComponentId { - Workspaces, + Navigation, + Dashboard, + IssueBrowser, + PatchBrowser, Shortcuts, GlobalListener, } +#[allow(dead_code)] +pub struct App { + profile: Profile, + id: Id, + project: Project, + patches: Vec<(PatchId, Patch)>, + quit: bool, +} + /// Creates a new application using a tui-realm-application, mounts all /// components and sets focus to a default one. impl App { - pub fn new(id: Id, project: Project) -> Self { + pub fn new(profile: Profile, id: Id, project: Project) -> Self { + let patches = patch::load_all(&profile, id); Self { id, + profile, project, + patches, quit: false, } } @@ -61,56 +74,47 @@ impl Tui for App { fn init(&mut self, app: &mut Application) -> Result<()> { let theme = theme::default_dark(); - let dashboard = ui::labeled_container( + let navigation = ui::navigation(&theme).to_boxed(); + + let dashboard = ui::dashboard(&theme, &self.id, &self.project).to_boxed(); + let issue_browser = Box::::default(); + let patch_browser = ui::patch_browser(&theme, &self.patches, &self.profile).to_boxed(); + + let shortcuts = ui::shortcuts( &theme, - "about", - ui::property_list( - &theme, - vec![ - ui::property(&theme, "id", &self.id.to_string()), - ui::property(&theme, "name", self.project.name()), - ui::property(&theme, "description", self.project.description()), - ], - ) - .to_boxed(), + vec![ + ui::shortcut(&theme, "tab", "section"), + ui::shortcut(&theme, "q", "quit"), + ], ) .to_boxed(); + app.mount(ComponentId::Navigation, navigation, vec![])?; + + app.mount(ComponentId::Dashboard, dashboard, vec![])?; + app.mount(ComponentId::IssueBrowser, issue_browser, vec![])?; app.mount( - ComponentId::Workspaces, - ui::workspaces( - &theme, - self.project.name(), - ui::tabs( - &theme, - vec![ - ui::label("dashboard"), - ui::label("issues"), - ui::label("patches"), - ], + ComponentId::PatchBrowser, + patch_browser, + vec![ + Sub::new( + SubEventClause::Keyboard(KeyEvent { + code: Key::Up, + modifiers: KeyModifiers::NONE, + }), + SubClause::Always, ), - vec![ - dashboard, - Box::::default(), - Box::::default(), - ], - ) - .to_boxed(), - vec![], + Sub::new( + SubEventClause::Keyboard(KeyEvent { + code: Key::Down, + modifiers: KeyModifiers::NONE, + }), + SubClause::Always, + ), + ], )?; - app.mount( - ComponentId::Shortcuts, - ui::shortcuts( - &theme, - vec![ - ui::shortcut(&theme, "tab", "section"), - ui::shortcut(&theme, "q", "quit"), - ], - ) - .to_boxed(), - vec![], - )?; + app.mount(ComponentId::Shortcuts, shortcuts, vec![])?; // Add global key listener and subscribe to key events app.mount( @@ -126,7 +130,7 @@ impl Tui for App { )?; // We need to give focus to a component then - app.active(&ComponentId::Workspaces)?; + app.active(&ComponentId::Navigation)?; Ok(()) } @@ -138,21 +142,25 @@ impl Tui for App { ) { let area = frame.size(); let margin_h = 1u16; + let navigation_h = 2u16; let shortcuts_h = app .query(&ComponentId::Shortcuts, Attribute::Height) .ok() .flatten() .unwrap_or(AttrValue::Size(0)) .unwrap_size(); - let workspaces_h = area - .height - .saturating_sub(shortcuts_h.saturating_add(margin_h)); + let workspaces_h = area.height.saturating_sub( + shortcuts_h + .saturating_add(navigation_h) + .saturating_add(margin_h), + ); let layout = Layout::default() .direction(Direction::Vertical) .horizontal_margin(margin_h) .constraints( [ + Constraint::Length(navigation_h), Constraint::Length(workspaces_h), Constraint::Length(shortcuts_h), ] @@ -160,8 +168,21 @@ impl Tui for App { ) .split(area); - app.view(&ComponentId::Workspaces, frame, layout[0]); - app.view(&ComponentId::Shortcuts, frame, layout[1]); + let workspace = app + .state(&ComponentId::Navigation) + .unwrap_or(tuirealm::State::One(StateValue::U16(0))) + .unwrap_one(); + + let component = match workspace { + StateValue::U16(0) => ComponentId::Dashboard, + StateValue::U16(1) => ComponentId::IssueBrowser, + StateValue::U16(2) => ComponentId::PatchBrowser, + _ => ComponentId::Dashboard, + }; + + app.view(&ComponentId::Navigation, frame, layout[0]); + app.view(&component, frame, layout[1]); + app.view(&ComponentId::Shortcuts, frame, layout[2]); } fn update(&mut self, app: &mut Application, interval: u64) { @@ -194,7 +215,7 @@ impl Component for Widget { } } -impl Component for Widget { +impl Component for Widget { fn on(&mut self, event: Event) -> Option { match event { Event::Keyboard(KeyEvent { code: Key::Tab, .. }) => { @@ -206,6 +227,24 @@ impl Component for Widget { } } +impl Component for Widget> { + fn on(&mut self, event: Event) -> Option { + match event { + Event::Keyboard(KeyEvent { code: Key::Up, .. }) => { + self.perform(Cmd::Move(MoveDirection::Up)); + None + } + Event::Keyboard(KeyEvent { + code: Key::Down, .. + }) => { + self.perform(Cmd::Move(MoveDirection::Down)); + None + } + _ => None, + } + } +} + impl Component for Widget { fn on(&mut self, _event: Event) -> Option { None @@ -223,3 +262,9 @@ impl Component for Widget { None } } + +impl Component for Phantom { + fn on(&mut self, _event: Event) -> Option { + None + } +} diff --git a/radicle-tui/src/lib.rs b/radicle-tui/src/lib.rs index e6dfd6d1..c5cb69b3 100644 --- a/radicle-tui/src/lib.rs +++ b/radicle-tui/src/lib.rs @@ -7,6 +7,7 @@ use tuirealm::terminal::TerminalBridge; use tuirealm::Frame; use tuirealm::{Application, EventListenerCfg, NoUserEvent}; +pub mod cob; pub mod ui; /// Trait that must be implemented by client applications in order to be run diff --git a/radicle-tui/src/main.rs b/radicle-tui/src/main.rs index 0caa1e39..6689cb7f 100644 --- a/radicle-tui/src/main.rs +++ b/radicle-tui/src/main.rs @@ -71,7 +71,7 @@ fn execute() -> anyhow::Result<()> { let project = payload.project()?; let mut window = Window::default(); - window.run(&mut app::App::new(id, project), 1000 / FPS)?; + window.run(&mut app::App::new(profile, id, project), 1000 / FPS)?; Ok(()) } diff --git a/radicle-tui/src/ui.rs b/radicle-tui/src/ui.rs index 23d5bc58..b2d17c68 100644 --- a/radicle-tui/src/ui.rs +++ b/radicle-tui/src/ui.rs @@ -1,18 +1,26 @@ +pub mod cob; pub mod components; pub mod layout; pub mod state; pub mod theme; pub mod widget; +use radicle::prelude::{Id, Project}; +use radicle::Profile; +use tuirealm::props::{AttrValue, Attribute, PropPayload, PropValue, TextSpan}; +use tuirealm::MockComponent; + +use radicle::cob::patch::{Patch, PatchId}; + use components::container::{GlobalListener, LabeledContainer, Tabs}; use components::context::{Shortcut, Shortcuts}; use components::label::Label; use components::list::{Property, PropertyList}; -use components::workspace::Workspaces; + use widget::Widget; -use tuirealm::props::{AttrValue, Attribute}; -use tuirealm::MockComponent; +use self::components::list::{List, Table}; +use self::components::workspace::Browser; pub fn global_listener() -> Widget { Widget::new(GlobalListener::default()) @@ -100,14 +108,65 @@ pub fn tabs(theme: &theme::Theme, tabs: Vec>) -> Widget { .highlight(theme.colors.tabs_highlighted_fg) } -pub fn workspaces( - theme: &theme::Theme, - info: &str, - tabs: Widget, - children: Vec>, -) -> Widget { - let info = label(info).foreground(theme.colors.workspaces_info_fg); - let workspaces = Workspaces::new(tabs, info, children); +pub fn table(theme: &theme::Theme, items: &[impl List], profile: &Profile) -> Widget { + let table = Table::default(); + let items = items.iter().map(|item| item.row(theme, profile)).collect(); - Widget::new(workspaces) + Widget::new(table) + .content(AttrValue::Table(items)) + .background(theme.colors.labeled_container_bg) + .highlight(theme.colors.item_list_highlighted_bg) +} + +pub fn patch_browser( + theme: &theme::Theme, + items: &[(PatchId, Patch)], + profile: &Profile, +) -> Widget> { + let widths = AttrValue::Payload(PropPayload::Vec(vec![ + PropValue::U16(2), + PropValue::U16(43), + PropValue::U16(15), + PropValue::U16(15), + PropValue::U16(5), + PropValue::U16(20), + ])); + let header = AttrValue::Payload(PropPayload::Vec(vec![ + PropValue::TextSpan(TextSpan::from("")), + PropValue::TextSpan(TextSpan::from("title")), + PropValue::TextSpan(TextSpan::from("author")), + PropValue::TextSpan(TextSpan::from("tags")), + PropValue::TextSpan(TextSpan::from("comments")), + PropValue::TextSpan(TextSpan::from("date")), + ])); + + let table = table(theme, items, profile) + .custom("widths", widths) + .custom("header", header); + let browser: Browser<(PatchId, Patch)> = Browser::new(table); + + Widget::new(browser) +} + +pub fn navigation(theme: &theme::Theme) -> Widget { + tabs( + theme, + vec![label("dashboard"), label("issues"), label("patches")], + ) +} + +pub fn dashboard(theme: &theme::Theme, id: &Id, project: &Project) -> Widget { + labeled_container( + theme, + "about", + property_list( + theme, + vec![ + property(theme, "id", &id.to_string()), + property(theme, "name", project.name()), + property(theme, "description", project.description()), + ], + ) + .to_boxed(), + ) } diff --git a/radicle-tui/src/ui/components/workspace.rs b/radicle-tui/src/ui/components/workspace.rs index c3656fd6..4ccbbc1a 100644 --- a/radicle-tui/src/ui/components/workspace.rs +++ b/radicle-tui/src/ui/components/workspace.rs @@ -1,132 +1,43 @@ +use std::marker::PhantomData; + use tuirealm::command::{Cmd, CmdResult}; -use tuirealm::props::{AttrValue, Attribute, Props}; +use tuirealm::props::Props; use tuirealm::tui::layout::{Constraint, Direction, Layout, Rect}; use tuirealm::{Frame, MockComponent, State}; -use crate::ui::components::label::Label; use crate::ui::widget::{Widget, WidgetComponent}; -use super::container::Tabs; +use super::list::{List, Table}; -/// Workspace header that displays all labels horizontally aligned and separated -/// by a divider. Highlights the label defined by the current tab index. -#[derive(Clone)] -struct Header { - tabs: Widget, - info: Widget
, + phantom: PhantomData, } -impl Header { - pub fn new(tabs: Widget, info: Widget
) -> Self { Self { - header: Widget::new(Header::new(tabs, info)), - children, + list, + phantom: PhantomData, } } } -impl WidgetComponent for Workspaces { - fn view(&mut self, properties: &Props, frame: &mut Frame, area: Rect) { - let display = properties - .get_or(Attribute::Display, AttrValue::Flag(true)) - .unwrap_flag(); - let header_height = self - .header - .query(Attribute::Height) - .unwrap_or(AttrValue::Size(1)) - .unwrap_size(); - let selected = self.header.tabs.state().unwrap_one().unwrap_u16(); +impl WidgetComponent for Browser { + fn view(&mut self, _properties: &Props, frame: &mut Frame, area: Rect) { + let layout = Layout::default() + .direction(Direction::Vertical) + .constraints(vec![Constraint::Min(1)].as_ref()) + .split(area); - if display { - let layout = Layout::default() - .direction(Direction::Vertical) - .constraints( - [ - Constraint::Length(header_height), - Constraint::Length(1), - Constraint::Length(0), - ] - .as_ref(), - ) - .split(area); - - self.header.view(frame, layout[0]); - - if let Some(child) = self.children.get_mut(selected as usize) { - child.view(frame, layout[2]); - } - } + self.list.view(frame, layout[0]); } fn state(&self) -> State { State::None } - fn perform(&mut self, cmd: Cmd) -> CmdResult { - CmdResult::Batch( - [ - self.children - .iter_mut() - .map(|child| child.perform(cmd)) - .collect(), - vec![self.header.perform(cmd)], - ] - .concat(), - ) + fn perform(&mut self, _properties: &Props, cmd: Cmd) -> CmdResult { + self.list.perform(cmd) } }