Radish alpha
r
Radicle terminal user interface
Radicle
Git (anonymous pull)
Log in to clone via SSH
tui: Implement patches workspace
Erik Kundt committed 3 years ago
commit efda39e801531b6b8fcef942face76cc798b866d
parent 3358c264ed568d2d2848f5bfb16dc8774126cee7
5 files changed +199 -183
modified 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<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
+
    }
+
}
modified 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
modified 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(())
}
modified 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<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(
+
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(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,
-
    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);
-

-
    Widget::new(workspaces)
+
    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(),
+
    )
}
modified 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;
-

-
/// 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>,
-
}
+
use super::list::{List, Table};

-
impl Header {
-
    pub fn new(tabs: Widget<Tabs>, info: Widget<Label>) -> Self {
-
        Self { tabs, info }
-
    }
+
pub struct Browser<T> {
+
    list: Widget<Table>,
+
    phantom: PhantomData<T>,
}

-
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();
-

-
        if display {
-
            let layout = Layout::default()
-
                .direction(Direction::Vertical)
-
                .constraints(
-
                    [
-
                        Constraint::Length(header_height),
-
                        Constraint::Length(1),
-
                        Constraint::Length(0),
-
                    ]
-
                    .as_ref(),
-
                )
-
                .split(area);
+
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);

-
            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)
    }
}