tui: Implement patches workspace

Signed-off-by: Erik Kundt <erik@zirkular.io>
This commit is contained in:
Erik Kundt 2023-03-24 13:45:04 +01:00
parent 80c1e45a99
commit 8325eb69d7
5 changed files with 199 additions and 183 deletions

View File

@ -9,27 +9,25 @@ use tuirealm::event::{Event, Key, KeyEvent, KeyModifiers};
use tuirealm::props::{AttrValue, Attribute}; use tuirealm::props::{AttrValue, Attribute};
use tuirealm::tui::layout::{Constraint, Direction, Layout}; use tuirealm::tui::layout::{Constraint, Direction, Layout};
use tuirealm::{ 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;
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::context::Shortcuts;
use radicle_tui::ui::components::list::PropertyList; 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::theme;
use radicle_tui::ui::widget::Widget; use radicle_tui::ui::widget::Widget;
use radicle_tui::Tui; use radicle_tui::Tui;
use radicle::cob::patch::{Patch, PatchId};
use radicle::identity::{Id, Project}; use radicle::identity::{Id, Project};
use radicle::profile::Profile;
#[allow(dead_code)]
pub struct App {
id: Id,
project: Project,
quit: bool,
}
/// Messages handled by this application. /// Messages handled by this application.
#[derive(Debug, Eq, PartialEq)] #[derive(Debug, Eq, PartialEq)]
@ -40,18 +38,33 @@ pub enum Message {
/// All components known to this application. /// All components known to this application.
#[derive(Debug, Eq, PartialEq, Clone, Hash)] #[derive(Debug, Eq, PartialEq, Clone, Hash)]
pub enum ComponentId { pub enum ComponentId {
Workspaces, Navigation,
Dashboard,
IssueBrowser,
PatchBrowser,
Shortcuts, Shortcuts,
GlobalListener, 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 /// Creates a new application using a tui-realm-application, mounts all
/// components and sets focus to a default one. /// components and sets focus to a default one.
impl App { 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 { Self {
id, id,
profile,
project, project,
patches,
quit: false, quit: false,
} }
} }
@ -61,56 +74,47 @@ impl Tui<ComponentId, Message> for App {
fn init(&mut self, app: &mut Application<ComponentId, Message, NoUserEvent>) -> Result<()> { fn init(&mut self, app: &mut Application<ComponentId, Message, NoUserEvent>) -> Result<()> {
let theme = theme::default_dark(); 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::<Phantom>::default();
let patch_browser = ui::patch_browser(&theme, &self.patches, &self.profile).to_boxed();
let shortcuts = ui::shortcuts(
&theme, &theme,
"about", vec![
ui::property_list( ui::shortcut(&theme, "tab", "section"),
&theme, ui::shortcut(&theme, "q", "quit"),
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(),
) )
.to_boxed(); .to_boxed();
app.mount(ComponentId::Navigation, navigation, vec![])?;
app.mount(ComponentId::Dashboard, dashboard, vec![])?;
app.mount(ComponentId::IssueBrowser, issue_browser, vec![])?;
app.mount( app.mount(
ComponentId::Workspaces, ComponentId::PatchBrowser,
ui::workspaces( patch_browser,
&theme, vec![
self.project.name(), Sub::new(
ui::tabs( SubEventClause::Keyboard(KeyEvent {
&theme, code: Key::Up,
vec![ modifiers: KeyModifiers::NONE,
ui::label("dashboard"), }),
ui::label("issues"), SubClause::Always,
ui::label("patches"),
],
), ),
vec![ Sub::new(
dashboard, SubEventClause::Keyboard(KeyEvent {
Box::<Phantom>::default(), code: Key::Down,
Box::<Phantom>::default(), modifiers: KeyModifiers::NONE,
], }),
) SubClause::Always,
.to_boxed(), ),
vec![], ],
)?; )?;
app.mount( app.mount(ComponentId::Shortcuts, shortcuts, vec![])?;
ComponentId::Shortcuts,
ui::shortcuts(
&theme,
vec![
ui::shortcut(&theme, "tab", "section"),
ui::shortcut(&theme, "q", "quit"),
],
)
.to_boxed(),
vec![],
)?;
// Add global key listener and subscribe to key events // Add global key listener and subscribe to key events
app.mount( app.mount(
@ -126,7 +130,7 @@ impl Tui<ComponentId, Message> for App {
)?; )?;
// We need to give focus to a component then // We need to give focus to a component then
app.active(&ComponentId::Workspaces)?; app.active(&ComponentId::Navigation)?;
Ok(()) Ok(())
} }
@ -138,21 +142,25 @@ impl Tui<ComponentId, Message> for App {
) { ) {
let area = frame.size(); let area = frame.size();
let margin_h = 1u16; let margin_h = 1u16;
let navigation_h = 2u16;
let shortcuts_h = app let shortcuts_h = app
.query(&ComponentId::Shortcuts, Attribute::Height) .query(&ComponentId::Shortcuts, Attribute::Height)
.ok() .ok()
.flatten() .flatten()
.unwrap_or(AttrValue::Size(0)) .unwrap_or(AttrValue::Size(0))
.unwrap_size(); .unwrap_size();
let workspaces_h = area let workspaces_h = area.height.saturating_sub(
.height shortcuts_h
.saturating_sub(shortcuts_h.saturating_add(margin_h)); .saturating_add(navigation_h)
.saturating_add(margin_h),
);
let layout = Layout::default() let layout = Layout::default()
.direction(Direction::Vertical) .direction(Direction::Vertical)
.horizontal_margin(margin_h) .horizontal_margin(margin_h)
.constraints( .constraints(
[ [
Constraint::Length(navigation_h),
Constraint::Length(workspaces_h), Constraint::Length(workspaces_h),
Constraint::Length(shortcuts_h), Constraint::Length(shortcuts_h),
] ]
@ -160,8 +168,21 @@ impl Tui<ComponentId, Message> for App {
) )
.split(area); .split(area);
app.view(&ComponentId::Workspaces, frame, layout[0]); let workspace = app
app.view(&ComponentId::Shortcuts, frame, layout[1]); .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<ComponentId, Message, NoUserEvent>, interval: u64) { fn update(&mut self, app: &mut Application<ComponentId, Message, NoUserEvent>, interval: u64) {
@ -194,7 +215,7 @@ impl Component<Message, NoUserEvent> for Widget<GlobalListener> {
} }
} }
impl Component<Message, NoUserEvent> for Widget<Workspaces> { impl Component<Message, NoUserEvent> for Widget<Tabs> {
fn on(&mut self, event: Event<NoUserEvent>) -> Option<Message> { fn on(&mut self, event: Event<NoUserEvent>) -> Option<Message> {
match event { match event {
Event::Keyboard(KeyEvent { code: Key::Tab, .. }) => { Event::Keyboard(KeyEvent { code: Key::Tab, .. }) => {
@ -206,6 +227,24 @@ impl Component<Message, NoUserEvent> for Widget<Workspaces> {
} }
} }
impl Component<Message, NoUserEvent> for Widget<Browser<(PatchId, Patch)>> {
fn on(&mut self, event: Event<NoUserEvent>) -> Option<Message> {
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<Message, NoUserEvent> for Widget<LabeledContainer> { impl Component<Message, NoUserEvent> for Widget<LabeledContainer> {
fn on(&mut self, _event: Event<NoUserEvent>) -> Option<Message> { fn on(&mut self, _event: Event<NoUserEvent>) -> Option<Message> {
None None
@ -223,3 +262,9 @@ impl Component<Message, NoUserEvent> for Widget<Shortcuts> {
None None
} }
} }
impl Component<Message, NoUserEvent> for Phantom {
fn on(&mut self, _event: Event<NoUserEvent>) -> Option<Message> {
None
}
}

View File

@ -7,6 +7,7 @@ use tuirealm::terminal::TerminalBridge;
use tuirealm::Frame; use tuirealm::Frame;
use tuirealm::{Application, EventListenerCfg, NoUserEvent}; use tuirealm::{Application, EventListenerCfg, NoUserEvent};
pub mod cob;
pub mod ui; pub mod ui;
/// Trait that must be implemented by client applications in order to be run /// Trait that must be implemented by client applications in order to be run

View File

@ -71,7 +71,7 @@ fn execute() -> anyhow::Result<()> {
let project = payload.project()?; let project = payload.project()?;
let mut window = Window::default(); 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(()) Ok(())
} }

View File

@ -1,18 +1,26 @@
pub mod cob;
pub mod components; pub mod components;
pub mod layout; pub mod layout;
pub mod state; pub mod state;
pub mod theme; pub mod theme;
pub mod widget; 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::container::{GlobalListener, LabeledContainer, Tabs};
use components::context::{Shortcut, Shortcuts}; use components::context::{Shortcut, Shortcuts};
use components::label::Label; use components::label::Label;
use components::list::{Property, PropertyList}; use components::list::{Property, PropertyList};
use components::workspace::Workspaces;
use widget::Widget; use widget::Widget;
use tuirealm::props::{AttrValue, Attribute}; use self::components::list::{List, Table};
use tuirealm::MockComponent; use self::components::workspace::Browser;
pub fn global_listener() -> Widget<GlobalListener> { pub fn global_listener() -> Widget<GlobalListener> {
Widget::new(GlobalListener::default()) Widget::new(GlobalListener::default())
@ -100,14 +108,65 @@ pub fn tabs(theme: &theme::Theme, tabs: Vec<Widget<Label>>) -> Widget<Tabs> {
.highlight(theme.colors.tabs_highlighted_fg) .highlight(theme.colors.tabs_highlighted_fg)
} }
pub fn workspaces( pub fn table(theme: &theme::Theme, items: &[impl List], profile: &Profile) -> Widget<Table> {
theme: &theme::Theme, let table = Table::default();
info: &str, let items = items.iter().map(|item| item.row(theme, profile)).collect();
tabs: Widget<Tabs>,
children: Vec<Box<dyn MockComponent>>,
) -> Widget<Workspaces> {
let info = label(info).foreground(theme.colors.workspaces_info_fg);
let workspaces = Workspaces::new(tabs, info, children);
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<Browser<(PatchId, Patch)>> {
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> {
tabs(
theme,
vec![label("dashboard"), label("issues"), label("patches")],
)
}
pub fn dashboard(theme: &theme::Theme, id: &Id, project: &Project) -> Widget<LabeledContainer> {
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(),
)
} }

View File

@ -1,132 +1,43 @@
use std::marker::PhantomData;
use tuirealm::command::{Cmd, CmdResult}; 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::tui::layout::{Constraint, Direction, Layout, Rect};
use tuirealm::{Frame, MockComponent, State}; use tuirealm::{Frame, MockComponent, State};
use crate::ui::components::label::Label;
use crate::ui::widget::{Widget, WidgetComponent}; 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 pub struct Browser<T> {
/// by a divider. Highlights the label defined by the current tab index. list: Widget<Table>,
#[derive(Clone)] phantom: PhantomData<T>,
struct Header {
tabs: Widget<Tabs>,
info: Widget<Label>,
} }
impl Header { impl<T: List> Browser<T> {
pub fn new(tabs: Widget<Tabs>, info: Widget<Label>) -> Self { pub fn new(list: Widget<Table>) -> Self {
Self { tabs, info }
}
}
impl WidgetComponent for Header {
fn view(&mut self, properties: &Props, frame: &mut Frame, area: Rect) {
let display = properties
.get_or(Attribute::Display, AttrValue::Flag(true))
.unwrap_flag();
let info_width = self
.info
.query(Attribute::Width)
.unwrap_or(AttrValue::Size(1))
.unwrap_size();
let tabs_width = area.width.saturating_sub(info_width);
if display {
let layout = Layout::default()
.direction(Direction::Horizontal)
.constraints(
[
Constraint::Length(tabs_width),
Constraint::Length(info_width),
]
.as_ref(),
)
.split(area);
self.tabs.view(frame, layout[0]);
self.info.view(frame, layout[1]);
}
}
fn state(&self) -> State {
State::None
}
fn perform(&mut self, cmd: Cmd) -> CmdResult {
self.tabs.perform(cmd)
}
}
/// A container with a tab header. Displays the component selected by the index
/// held in the header state.
pub struct Workspaces {
header: Widget<Header>,
children: Vec<Box<dyn MockComponent>>,
}
impl Workspaces {
pub fn new(
tabs: Widget<Tabs>,
info: Widget<Label>,
children: Vec<Box<dyn MockComponent>>,
) -> Self {
Self { Self {
header: Widget::new(Header::new(tabs, info)), list,
children, phantom: PhantomData,
} }
} }
} }
impl WidgetComponent for Workspaces { impl<T: List> WidgetComponent for Browser<T> {
fn view(&mut self, properties: &Props, frame: &mut Frame, area: Rect) { fn view(&mut self, _properties: &Props, frame: &mut Frame, area: Rect) {
let display = properties let layout = Layout::default()
.get_or(Attribute::Display, AttrValue::Flag(true)) .direction(Direction::Vertical)
.unwrap_flag(); .constraints(vec![Constraint::Min(1)].as_ref())
let header_height = self .split(area);
.header
.query(Attribute::Height)
.unwrap_or(AttrValue::Size(1))
.unwrap_size();
let selected = self.header.tabs.state().unwrap_one().unwrap_u16();
if display { self.list.view(frame, layout[0]);
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]);
}
}
} }
fn state(&self) -> State { fn state(&self) -> State {
State::None State::None
} }
fn perform(&mut self, cmd: Cmd) -> CmdResult { fn perform(&mut self, _properties: &Props, cmd: Cmd) -> CmdResult {
CmdResult::Batch( self.list.perform(cmd)
[
self.children
.iter_mut()
.map(|child| child.perform(cmd))
.collect(),
vec![self.header.perform(cmd)],
]
.concat(),
)
} }
} }