Radish alpha
r
Radicle terminal user interface
Radicle
Git (anonymous pull)
Log in to clone via SSH
tui: Implement workspaces container
Erik Kundt committed 3 years ago
commit b8e2335f71405bb231591ece0737ebabf28be6b2
parent 1e0d561af97930a1634a8ba4f0693c8754155f88
6 files changed +311 -17
modified radicle-tui/src/app.rs
@@ -2,14 +2,20 @@ use std::time::Duration;

use anyhow::Result;

+
use tui_realm_stdlib::Phantom;
use tuirealm::application::PollStrategy;
+
use tuirealm::command::{Cmd, Direction as MoveDirection};
use tuirealm::event::{Event, Key, KeyEvent, KeyModifiers};
use tuirealm::props::{AttrValue, Attribute};
use tuirealm::tui::layout::{Constraint, Direction, Layout};
-
use tuirealm::{Application, Component, Frame, NoUserEvent, Sub, SubClause, SubEventClause};
+
use tuirealm::{
+
    Application, Component, Frame, MockComponent, NoUserEvent, Sub, SubClause, SubEventClause,
+
};

use radicle_tui::ui;
-
use radicle_tui::ui::components::{GlobalListener, LabeledContainer, PropertyList, ShortcutBar};
+
use radicle_tui::ui::components::{
+
    GlobalListener, LabeledContainer, PropertyList, ShortcutBar, Workspaces,
+
};
use radicle_tui::ui::theme;
use radicle_tui::ui::widget::Widget;

@@ -34,7 +40,7 @@ pub enum Message {
#[derive(Debug, Eq, PartialEq, Clone, Hash)]
pub enum ComponentId {
    Workspaces,
-
    ShortcutBar,
+
    Shortcuts,
    GlobalListener,
}

@@ -54,27 +60,46 @@ 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(
+
            &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(),
+
        )
+
        .to_boxed();
+

        app.mount(
            ComponentId::Workspaces,
-
            ui::labeled_container(
+
            ui::workspaces(
                &theme,
-
                "about",
-
                ui::property_list(
+
                self.project.name(),
+
                ui::tabs(
                    &theme,
                    vec![
-
                        ui::property(&theme, "id", &self.id.to_string()),
-
                        ui::property(&theme, "name", self.project.name()),
-
                        ui::property(&theme, "description", self.project.description()),
+
                        ui::label("dashboard"),
+
                        ui::label("issues"),
+
                        ui::label("patches"),
                    ],
-
                )
-
                .to_boxed(),
+
                ),
+
                vec![
+
                    dashboard,
+
                    Box::<Phantom>::default(),
+
                    Box::<Phantom>::default(),
+
                ],
            )
            .to_boxed(),
            vec![],
        )?;

        app.mount(
-
            ComponentId::ShortcutBar,
+
            ComponentId::Shortcuts,
            ui::shortcut_bar(
                &theme,
                vec![
@@ -100,7 +125,7 @@ impl Tui<ComponentId, Message> for App {
        )?;

        // We need to give focus to a component then
-
        app.active(&ComponentId::ShortcutBar)?;
+
        app.active(&ComponentId::Workspaces)?;

        Ok(())
    }
@@ -113,7 +138,7 @@ impl Tui<ComponentId, Message> for App {
        let area = frame.size();
        let margin_h = 1u16;
        let shortcuts_h = app
-
            .query(&ComponentId::ShortcutBar, Attribute::Height)
+
            .query(&ComponentId::Shortcuts, Attribute::Height)
            .ok()
            .flatten()
            .unwrap_or(AttrValue::Size(0))
@@ -135,7 +160,7 @@ impl Tui<ComponentId, Message> for App {
            .split(area);

        app.view(&ComponentId::Workspaces, frame, layout[0]);
-
        app.view(&ComponentId::ShortcutBar, frame, layout[1]);
+
        app.view(&ComponentId::Shortcuts, frame, layout[1]);
    }

    fn update(&mut self, app: &mut Application<ComponentId, Message, NoUserEvent>, interval: u64) {
@@ -168,6 +193,18 @@ impl Component<Message, NoUserEvent> for Widget<GlobalListener> {
    }
}

+
impl Component<Message, NoUserEvent> for Widget<Workspaces> {
+
    fn on(&mut self, event: Event<NoUserEvent>) -> Option<Message> {
+
        match event {
+
            Event::Keyboard(KeyEvent { code: Key::Tab, .. }) => {
+
                self.perform(Cmd::Move(MoveDirection::Right));
+
                None
+
            }
+
            _ => None,
+
        }
+
    }
+
}
+

impl Component<Message, NoUserEvent> for Widget<LabeledContainer> {
    fn on(&mut self, _event: Event<NoUserEvent>) -> Option<Message> {
        None
modified radicle-tui/src/ui.rs
@@ -1,5 +1,6 @@
pub mod components;
pub mod layout;
+
mod state;
pub mod theme;
pub mod widget;

@@ -8,7 +9,7 @@ use tuirealm::{MockComponent, StateValue};

use components::{
    ContainerHeader, GlobalListener, Label, LabeledContainer, Property, PropertyList, Shortcut,
-
    ShortcutBar,
+
    ShortcutBar, Tabs, Workspaces, WorkspacesHeader,
};
use widget::Widget;

@@ -87,3 +88,26 @@ pub fn property_list(

    Widget::new(property_list)
}
+

+
pub fn tabs(theme: &theme::Theme, tabs: Vec<Widget<Label>>) -> Widget<Tabs> {
+
    let divider = label(&theme.icons.tab_divider.to_string());
+
    let tabs = Tabs::new(tabs, divider);
+

+
    Widget::new(tabs)
+
        .height(1)
+
        .foreground(theme.colors.tabs_fg)
+
        .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 header = Widget::new(WorkspacesHeader::new(tabs, info));
+
    let workspaces = Workspaces::new(header, children);
+

+
    Widget::new(workspaces)
+
}
modified radicle-tui/src/ui/components.rs
@@ -3,10 +3,12 @@ use tui_realm_stdlib::Phantom;
use tuirealm::command::{Cmd, CmdResult};
use tuirealm::props::{AttrValue, Attribute, Color, Props, Style};
use tuirealm::tui::layout::{Constraint, Direction, Layout, Rect};
-
use tuirealm::tui::widgets::Block;
+
use tuirealm::tui::text::{Span, Spans};
+
use tuirealm::tui::widgets::{Block, Tabs as TuiTabs};
use tuirealm::{Frame, MockComponent, State, StateValue};

use super::layout;
+
use super::state::TabState;
use super::widget::{Widget, WidgetComponent};

/// Some user events need to be handled globally (e.g. user presses key `q` to quit
@@ -88,6 +90,12 @@ impl WidgetComponent for Label {
    }
}

+
impl From<&Widget<Label>> for Span<'_> {
+
    fn from(label: &Widget<Label>) -> Self {
+
        Span::styled(label.content.clone().unwrap_string(), Style::default())
+
    }
+
}
+

/// A labeled container header.
#[derive(Clone)]
pub struct ContainerHeader {
@@ -368,3 +376,193 @@ impl WidgetComponent for PropertyList {
        CmdResult::None
    }
}
+

+
/// A tab header that displays all labels horizontally aligned and separated
+
/// by a divider. Highlights the label defined by the current tab index.
+
#[derive(Clone)]
+
pub struct Tabs {
+
    tabs: Vec<Widget<Label>>,
+
    divider: Widget<Label>,
+
    state: TabState,
+
}
+

+
impl Tabs {
+
    pub fn new(tabs: Vec<Widget<Label>>, divider: Widget<Label>) -> Self {
+
        Self {
+
            tabs,
+
            divider,
+
            state: TabState {
+
                selected: 0,
+
                len: 3,
+
            },
+
        }
+
    }
+
}
+

+
impl WidgetComponent for Tabs {
+
    fn view(&mut self, properties: &Props, frame: &mut Frame, area: Rect) {
+
        let selected = self.state().unwrap_one().unwrap_u16();
+
        let display = properties
+
            .get_or(Attribute::Display, AttrValue::Flag(true))
+
            .unwrap_flag();
+
        let foreground = properties
+
            .get_or(Attribute::Foreground, AttrValue::Color(Color::Reset))
+
            .unwrap_color();
+
        let highlight = properties
+
            .get_or(Attribute::HighlightedColor, AttrValue::Color(Color::Reset))
+
            .unwrap_color();
+

+
        if display {
+
            let spans = self
+
                .tabs
+
                .iter()
+
                .map(|tab| Spans::from(vec![Span::from(tab)]))
+
                .collect::<Vec<_>>();
+

+
            let tabs = TuiTabs::new(spans)
+
                .style(Style::default().fg(foreground))
+
                .highlight_style(Style::default().fg(highlight))
+
                .divider(Span::from(&self.divider))
+
                .select(selected as usize);
+

+
            frame.render_widget(tabs, area);
+
        }
+
    }
+

+
    fn state(&self) -> State {
+
        State::One(StateValue::U16(self.state.selected))
+
    }
+

+
    fn perform(&mut self, cmd: Cmd) -> CmdResult {
+
        use tuirealm::command::Direction;
+

+
        match cmd {
+
            Cmd::Move(Direction::Right) => {
+
                let prev = self.state.selected;
+
                self.state.incr_tab_index(true);
+
                if prev != self.state.selected {
+
                    CmdResult::Changed(self.state())
+
                } else {
+
                    CmdResult::None
+
                }
+
            }
+
            _ => CmdResult::None,
+
        }
+
    }
+
}
+

+
/// Workspace header that displays all labels horizontally aligned and separated
+
/// by a divider. Highlights the label defined by the current tab index.
+
#[derive(Clone)]
+
pub struct WorkspacesHeader {
+
    tabs: Widget<Tabs>,
+
    info: Widget<Label>,
+
}
+

+
impl WorkspacesHeader {
+
    pub fn new(tabs: Widget<Tabs>, info: Widget<Label>) -> Self {
+
        Self { tabs, info }
+
    }
+
}
+

+
impl WidgetComponent for WorkspacesHeader {
+
    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<WorkspacesHeader>,
+
    children: Vec<Box<dyn MockComponent>>,
+
}
+

+
impl Workspaces {
+
    pub fn new(header: Widget<WorkspacesHeader>, children: Vec<Box<dyn MockComponent>>) -> Self {
+
        Self { header, children }
+
    }
+
}
+

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

+
            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 {
+
        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(),
+
        )
+
    }
+
}
added radicle-tui/src/ui/state.rs
@@ -0,0 +1,17 @@
+
/// State that holds the index of a selected tab item and the count of all tab items.
+
/// The index can be increased and will start at 0, if length was reached.
+
#[derive(Clone, Default)]
+
pub struct TabState {
+
    pub selected: u16,
+
    pub len: u16,
+
}
+

+
impl TabState {
+
    pub fn incr_tab_index(&mut self, rewind: bool) {
+
        if self.selected + 1 < self.len {
+
            self.selected += 1;
+
        } else if rewind {
+
            self.selected = 0;
+
        }
+
    }
+
}
modified radicle-tui/src/ui/theme.rs
@@ -3,6 +3,9 @@ use tuirealm::props::Color;
#[derive(Debug)]
pub struct Colors {
    pub default_fg: Color,
+
    pub tabs_fg: Color,
+
    pub tabs_highlighted_fg: Color,
+
    pub workspaces_info_fg: Color,
    pub labeled_container_bg: Color,
    pub property_name_fg: Color,
    pub property_divider_fg: Color,
@@ -15,6 +18,7 @@ pub struct Colors {
pub struct Icons {
    pub property_divider: char,
    pub shortcutbar_divider: char,
+
    pub tab_divider: char,
    pub whitespace: char,
}

@@ -44,6 +48,9 @@ pub fn default_dark() -> Theme {
        name: String::from("Radicle Dark"),
        colors: Colors {
            default_fg: Color::Rgb(200, 200, 200),
+
            tabs_fg: Color::Rgb(100, 100, 100),
+
            tabs_highlighted_fg: Color::Rgb(38, 162, 105),
+
            workspaces_info_fg: Color::Rgb(220, 140, 40),
            labeled_container_bg: Color::Rgb(20, 20, 20),
            property_name_fg: Color::Rgb(85, 85, 255),
            property_divider_fg: Color::Rgb(10, 206, 209),
@@ -54,6 +61,7 @@ pub fn default_dark() -> Theme {
        icons: Icons {
            property_divider: '∙',
            shortcutbar_divider: '∙',
+
            tab_divider: '|',
            whitespace: ' ',
        },
    }
modified radicle-tui/src/ui/widget.rs
@@ -1,3 +1,5 @@
+
use std::ops::Deref;
+

use tuirealm::command::{Cmd, CmdResult};
use tuirealm::props::{AttrValue, Attribute, Color, Props};
use tuirealm::tui::layout::Rect;
@@ -19,6 +21,14 @@ pub struct Widget<T: WidgetComponent> {
    properties: Props,
}

+
impl<T: WidgetComponent> Deref for Widget<T> {
+
    type Target = T;
+

+
    fn deref(&self) -> &Self::Target {
+
        &self.component
+
    }
+
}
+

impl<T: WidgetComponent> Widget<T> {
    pub fn new(component: T) -> Self {
        Widget {