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::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<ComponentId, Message> for App {
fn init(&mut self, app: &mut Application<ComponentId, Message, NoUserEvent>) -> 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::<Phantom>::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::<Phantom>::default(),
Box::<Phantom>::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<ComponentId, Message> 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<ComponentId, Message> 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<ComponentId, Message> 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<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> {
match event {
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> {
fn on(&mut self, _event: Event<NoUserEvent>) -> Option<Message> {
None
@ -223,3 +262,9 @@ impl Component<Message, NoUserEvent> for Widget<Shortcuts> {
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::{Application, EventListenerCfg, NoUserEvent};
pub mod cob;
pub mod ui;
/// 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 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(())
}

View File

@ -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<GlobalListener> {
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)
}
pub fn workspaces(
theme: &theme::Theme,
info: &str,
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);
pub fn table(theme: &theme::Theme, items: &[impl List], profile: &Profile) -> Widget<Table> {
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<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::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<Tabs>,
info: Widget<Label>,
pub struct Browser<T> {
list: Widget<Table>,
phantom: PhantomData<T>,
}
impl Header {
pub fn new(tabs: Widget<Tabs>, info: Widget<Label>) -> 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 {
impl<T: List> Browser<T> {
pub fn new(list: Widget<Table>) -> 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<T: List> WidgetComponent for Browser<T> {
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)
}
}