Radish alpha
r
Radicle terminal user interface
Radicle
Git (anonymous pull)
Log in to clone via SSH
tui: Restructure component modules
Erik Kundt committed 3 years ago
commit 007aa1578ede24e1cf11006560287e17fa3d2b44
parent 222b26dd109608e839d627edd038c5912561ae9a
8 files changed +629 -585
modified radicle-tui/src/app.rs
@@ -13,9 +13,10 @@ use tuirealm::{
};

use radicle_tui::ui;
-
use radicle_tui::ui::components::{
-
    GlobalListener, LabeledContainer, PropertyList, ShortcutBar, Workspaces,
-
};
+
use radicle_tui::ui::components::container::{GlobalListener, LabeledContainer};
+
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::theme;
use radicle_tui::ui::widget::Widget;

@@ -100,7 +101,7 @@ impl Tui<ComponentId, Message> for App {

        app.mount(
            ComponentId::Shortcuts,
-
            ui::shortcut_bar(
+
            ui::shortcuts(
                &theme,
                vec![
                    ui::shortcut(&theme, "tab", "section"),
@@ -217,7 +218,7 @@ impl Component<Message, NoUserEvent> for Widget<PropertyList> {
    }
}

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

use tuirealm::props::Attribute;
use tuirealm::{MockComponent, StateValue};

-
use components::{
-
    ContainerHeader, GlobalListener, Label, LabeledContainer, Property, PropertyList, Shortcut,
-
    ShortcutBar, Tabs, Workspaces, WorkspacesHeader,
-
};
+
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;

pub fn global_listener() -> Widget<GlobalListener> {
@@ -33,9 +34,7 @@ pub fn labeled_container(
    let title = label(&format!(" {title} "))
        .foreground(theme.colors.default_fg)
        .background(theme.colors.labeled_container_bg);
-
    let spacer = label("");
-
    let header = Widget::new(ContainerHeader::new(title, spacer));
-
    let container = LabeledContainer::new(header, component);
+
    let container = LabeledContainer::new(title, component);

    Widget::new(container).background(theme.colors.labeled_container_bg)
}
@@ -56,10 +55,10 @@ pub fn shortcut(theme: &theme::Theme, short: &str, long: &str) -> Widget<Shortcu
    Widget::new(shortcut).height(1).width(width)
}

-
pub fn shortcut_bar(theme: &theme::Theme, shortcuts: Vec<Widget<Shortcut>>) -> Widget<ShortcutBar> {
+
pub fn shortcuts(theme: &theme::Theme, shortcuts: Vec<Widget<Shortcut>>) -> Widget<Shortcuts> {
    let divider = label(&format!(" {} ", theme.icons.shortcutbar_divider))
        .foreground(theme.colors.shortcutbar_divider_fg);
-
    let shortcut_bar = ShortcutBar::new(shortcuts, divider);
+
    let shortcut_bar = Shortcuts::new(shortcuts, divider);

    Widget::new(shortcut_bar).height(1)
}
@@ -106,8 +105,7 @@ pub fn workspaces(
    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);
+
    let workspaces = Workspaces::new(tabs, info, children);

    Widget::new(workspaces)
}
modified radicle-tui/src/ui/components.rs
@@ -1,568 +1,5 @@
-
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::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
-
/// the application). This component can be used in conjunction with SubEventClause
-
/// to handle those events.
-
#[derive(Default)]
-
pub struct GlobalListener {}
-

-
impl WidgetComponent for GlobalListener {
-
    fn view(&mut self, _properties: &Props, _frame: &mut Frame, _area: Rect) {}
-

-
    fn state(&self) -> State {
-
        State::None
-
    }
-

-
    fn perform(&mut self, _cmd: Cmd) -> CmdResult {
-
        CmdResult::None
-
    }
-
}
-

-
/// Some user events need to be handled globally (e.g. user presses key `q` to quit
-
/// the application). This component can be used in conjunction with SubEventClause
-
/// to handle those events.
-
#[derive(Default, MockComponent)]
-
pub struct GlobalPhantom {
-
    component: Phantom,
-
}
-

-
/// A label that can be styled using a foreground color and text modifiers.
-
/// Its height is fixed, its width depends on the length of the text it displays.
-
#[derive(Clone)]
-
pub struct Label {
-
    content: StateValue,
-
}
-

-
impl Label {
-
    pub fn new(content: StateValue) -> Self {
-
        Self { content }
-
    }
-
}
-

-
impl WidgetComponent for Label {
-
    fn view(&mut self, properties: &Props, frame: &mut Frame, area: Rect) {
-
        use tui_realm_stdlib::Label;
-

-
        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 background = properties
-
            .get_or(Attribute::Background, AttrValue::Color(Color::Reset))
-
            .unwrap_color();
-

-
        if display {
-
            let mut label = match properties.get(Attribute::TextProps) {
-
                Some(modifiers) => Label::default()
-
                    .foreground(foreground)
-
                    .background(background)
-
                    .modifiers(modifiers.unwrap_text_modifiers())
-
                    .text(self.content.clone().unwrap_string()),
-
                None => Label::default()
-
                    .foreground(foreground)
-
                    .background(background)
-
                    .text(self.content.clone().unwrap_string()),
-
            };
-

-
            label.view(frame, area);
-
        }
-
    }
-

-
    fn state(&self) -> State {
-
        State::One(self.content.clone())
-
    }
-

-
    fn perform(&mut self, _cmd: Cmd) -> CmdResult {
-
        CmdResult::None
-
    }
-
}
-

-
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 {
-
    content: Widget<Label>,
-
    spacer: Widget<Label>,
-
}
-

-
impl ContainerHeader {
-
    pub fn new(content: Widget<Label>, spacer: Widget<Label>) -> Self {
-
        Self { content, spacer }
-
    }
-
}
-

-
impl WidgetComponent for ContainerHeader {
-
    fn view(&mut self, properties: &Props, frame: &mut Frame, area: Rect) {
-
        let display = properties
-
            .get_or(Attribute::Display, AttrValue::Flag(true))
-
            .unwrap_flag();
-

-
        if display {
-
            let labels: Vec<Box<dyn MockComponent>> = vec![
-
                self.content.clone().to_boxed(),
-
                self.spacer.clone().to_boxed(),
-
            ];
-

-
            let layout = layout::h_stack(labels, area);
-
            for (mut shortcut, area) in layout {
-
                shortcut.view(frame, area);
-
            }
-
        }
-
    }
-

-
    fn state(&self) -> State {
-
        State::None
-
    }
-

-
    fn perform(&mut self, _cmd: Cmd) -> CmdResult {
-
        CmdResult::None
-
    }
-
}
-

-
pub struct LabeledContainer {
-
    header: Widget<ContainerHeader>,
-
    component: Box<dyn MockComponent>,
-
}
-

-
impl LabeledContainer {
-
    pub fn new(header: Widget<ContainerHeader>, component: Box<dyn MockComponent>) -> Self {
-
        Self { header, component }
-
    }
-
}
-

-
impl WidgetComponent for LabeledContainer {
-
    fn view(&mut self, properties: &Props, frame: &mut Frame, area: Rect) {
-
        let display = properties
-
            .get_or(Attribute::Display, AttrValue::Flag(true))
-
            .unwrap_flag();
-
        let background = properties
-
            .get_or(Attribute::Background, AttrValue::Color(Color::Reset))
-
            .unwrap_color();
-
        let header_height = self
-
            .header
-
            .query(Attribute::Height)
-
            .unwrap_or(AttrValue::Size(1))
-
            .unwrap_size();
-

-
        if display {
-
            let layout = Layout::default()
-
                .direction(Direction::Vertical)
-
                .constraints([Constraint::Length(header_height), Constraint::Length(0)].as_ref())
-
                .split(area);
-

-
            self.header.view(frame, layout[0]);
-

-
            // Make some space on the left
-
            let inner_layout = Layout::default()
-
                .direction(Direction::Horizontal)
-
                .constraints(vec![Constraint::Length(1), Constraint::Min(0)].as_ref())
-
                .split(layout[1]);
-
            // reverse draw order: child needs to be drawn first?
-
            self.component.view(frame, inner_layout[1]);
-

-
            let block = Block::default().style(Style::default().bg(background));
-
            frame.render_widget(block, layout[1]);
-
        }
-
    }
-

-
    fn state(&self) -> State {
-
        State::None
-
    }
-

-
    fn perform(&mut self, cmd: Cmd) -> CmdResult {
-
        self.component.perform(cmd)
-
    }
-
}
-

-
/// A shortcut that consists of a label displaying the "hotkey", a label that displays
-
/// the action and a spacer between them.
-
#[derive(Clone)]
-
pub struct Shortcut {
-
    short: Widget<Label>,
-
    divider: Widget<Label>,
-
    long: Widget<Label>,
-
}
-

-
impl Shortcut {
-
    pub fn new(short: Widget<Label>, divider: Widget<Label>, long: Widget<Label>) -> Self {
-
        Self {
-
            short,
-
            divider,
-
            long,
-
        }
-
    }
-
}
-

-
impl WidgetComponent for Shortcut {
-
    fn view(&mut self, properties: &Props, frame: &mut Frame, area: Rect) {
-
        let display = properties
-
            .get_or(Attribute::Display, AttrValue::Flag(true))
-
            .unwrap_flag();
-

-
        if display {
-
            let labels: Vec<Box<dyn MockComponent>> = vec![
-
                self.short.clone().to_boxed(),
-
                self.divider.clone().to_boxed(),
-
                self.long.clone().to_boxed(),
-
            ];
-

-
            let layout = layout::h_stack(labels, area);
-
            for (mut shortcut, area) in layout {
-
                shortcut.view(frame, area);
-
            }
-
        }
-
    }
-

-
    fn state(&self) -> State {
-
        State::None
-
    }
-

-
    fn perform(&mut self, _cmd: Cmd) -> CmdResult {
-
        CmdResult::None
-
    }
-
}
-

-
/// A shortcut bar that displays multiple shortcuts and separates them with a
-
/// divider.
-
pub struct ShortcutBar {
-
    shortcuts: Vec<Widget<Shortcut>>,
-
    divider: Widget<Label>,
-
}
-

-
impl ShortcutBar {
-
    pub fn new(shortcuts: Vec<Widget<Shortcut>>, divider: Widget<Label>) -> Self {
-
        Self { shortcuts, divider }
-
    }
-
}
-

-
impl WidgetComponent for ShortcutBar {
-
    fn view(&mut self, properties: &Props, frame: &mut Frame, area: Rect) {
-
        let display = properties
-
            .get_or(Attribute::Display, AttrValue::Flag(true))
-
            .unwrap_flag();
-

-
        if display {
-
            let mut widgets: Vec<Box<dyn MockComponent>> = vec![];
-
            let mut shortcuts = self.shortcuts.iter_mut().peekable();
-

-
            while let Some(shortcut) = shortcuts.next() {
-
                if shortcuts.peek().is_some() {
-
                    widgets.push(shortcut.clone().to_boxed());
-
                    widgets.push(self.divider.clone().to_boxed())
-
                } else {
-
                    widgets.push(shortcut.clone().to_boxed());
-
                }
-
            }
-

-
            let layout = layout::h_stack(widgets, area);
-
            for (mut widget, area) in layout {
-
                widget.view(frame, area);
-
            }
-
        }
-
    }
-

-
    fn state(&self) -> State {
-
        State::None
-
    }
-

-
    fn perform(&mut self, _cmd: Cmd) -> CmdResult {
-
        CmdResult::None
-
    }
-
}
-

-
/// A component that displays a labeled property.
-
#[derive(Clone)]
-
pub struct Property {
-
    label: Widget<Label>,
-
    divider: Widget<Label>,
-
    property: Widget<Label>,
-
}
-

-
impl Property {
-
    pub fn new(label: Widget<Label>, divider: Widget<Label>, property: Widget<Label>) -> Self {
-
        Self {
-
            label,
-
            divider,
-
            property,
-
        }
-
    }
-
}
-

-
impl WidgetComponent for Property {
-
    fn view(&mut self, properties: &Props, frame: &mut Frame, area: Rect) {
-
        let display = properties
-
            .get_or(Attribute::Display, AttrValue::Flag(true))
-
            .unwrap_flag();
-

-
        if display {
-
            let labels: Vec<Box<dyn MockComponent>> = vec![
-
                self.label.clone().to_boxed(),
-
                self.divider.clone().to_boxed(),
-
                self.property.clone().to_boxed(),
-
            ];
-

-
            let layout = layout::h_stack(labels, area);
-
            for (mut label, area) in layout {
-
                label.view(frame, area);
-
            }
-
        }
-
    }
-

-
    fn state(&self) -> State {
-
        State::None
-
    }
-

-
    fn perform(&mut self, _cmd: Cmd) -> CmdResult {
-
        CmdResult::None
-
    }
-
}
-

-
/// A component that can display lists of labeled properties
-
#[derive(Default)]
-
#[allow(clippy::vec_box)]
-
pub struct PropertyList {
-
    properties: Vec<Widget<Property>>,
-
}
-

-
impl PropertyList {
-
    pub fn new(properties: Vec<Widget<Property>>) -> Self {
-
        Self { properties }
-
    }
-
}
-

-
impl WidgetComponent for PropertyList {
-
    fn view(&mut self, properties: &Props, frame: &mut Frame, area: Rect) {
-
        let display = properties
-
            .get_or(Attribute::Display, AttrValue::Flag(true))
-
            .unwrap_flag();
-

-
        if display {
-
            let properties = self
-
                .properties
-
                .iter()
-
                .map(|property| property.clone().to_boxed() as Box<dyn MockComponent>)
-
                .collect();
-

-
            let layout = layout::v_stack(properties, area);
-
            for (mut property, area) in layout {
-
                property.view(frame, area);
-
            }
-
        }
-
    }
-

-
    fn state(&self) -> State {
-
        State::None
-
    }
-

-
    fn perform(&mut self, _cmd: Cmd) -> CmdResult {
-
        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(),
-
        )
-
    }
-
}
+
pub mod container;
+
pub mod context;
+
pub mod label;
+
pub mod list;
+
pub mod workspace;
added radicle-tui/src/ui/components/container.rs
@@ -0,0 +1,209 @@
+
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::text::{Span, Spans};
+
use tuirealm::tui::widgets::Block;
+
use tuirealm::{Frame, MockComponent, State, StateValue};
+

+
use crate::ui::components::label::Label;
+
use crate::ui::layout;
+
use crate::ui::state::TabState;
+
use crate::ui::widget::{Widget, WidgetComponent};
+

+
/// Some user events need to be handled globally (e.g. user presses key `q` to quit
+
/// the application). This component can be used in conjunction with SubEventClause
+
/// to handle those events.
+
#[derive(Default)]
+
pub struct GlobalListener {}
+

+
impl WidgetComponent for GlobalListener {
+
    fn view(&mut self, _properties: &Props, _frame: &mut Frame, _area: Rect) {}
+

+
    fn state(&self) -> State {
+
        State::None
+
    }
+

+
    fn perform(&mut self, _cmd: Cmd) -> CmdResult {
+
        CmdResult::None
+
    }
+
}
+

+
/// Some user events need to be handled globally (e.g. user presses key `q` to quit
+
/// the application). This component can be used in conjunction with SubEventClause
+
/// to handle those events.
+
#[derive(Default, MockComponent)]
+
pub struct GlobalPhantom {
+
    component: Phantom,
+
}
+

+
/// 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 = tuirealm::tui::widgets::Tabs::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,
+
        }
+
    }
+
}
+

+
/// A labeled container header.
+
#[derive(Clone)]
+
struct Header {
+
    content: Widget<Label>,
+
}
+

+
impl Header {
+
    pub fn new(content: Widget<Label>) -> Self {
+
        Self { content }
+
    }
+
}
+

+
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 spacer = Widget::new(Label::new(StateValue::String(String::default()))).to_boxed();
+

+
        if display {
+
            let labels: Vec<Box<dyn MockComponent>> = vec![self.content.clone().to_boxed(), spacer];
+

+
            let layout = layout::h_stack(labels, area);
+
            for (mut shortcut, area) in layout {
+
                shortcut.view(frame, area);
+
            }
+
        }
+
    }
+

+
    fn state(&self) -> State {
+
        State::None
+
    }
+

+
    fn perform(&mut self, _cmd: Cmd) -> CmdResult {
+
        CmdResult::None
+
    }
+
}
+

+
pub struct LabeledContainer {
+
    header: Widget<Header>,
+
    component: Box<dyn MockComponent>,
+
}
+

+
impl LabeledContainer {
+
    pub fn new(content: Widget<Label>, component: Box<dyn MockComponent>) -> Self {
+
        Self {
+
            header: Widget::new(Header::new(content)),
+
            component,
+
        }
+
    }
+
}
+

+
impl WidgetComponent for LabeledContainer {
+
    fn view(&mut self, properties: &Props, frame: &mut Frame, area: Rect) {
+
        let display = properties
+
            .get_or(Attribute::Display, AttrValue::Flag(true))
+
            .unwrap_flag();
+
        let background = properties
+
            .get_or(Attribute::Background, AttrValue::Color(Color::Reset))
+
            .unwrap_color();
+
        let header_height = self
+
            .header
+
            .query(Attribute::Height)
+
            .unwrap_or(AttrValue::Size(1))
+
            .unwrap_size();
+

+
        if display {
+
            let layout = Layout::default()
+
                .direction(Direction::Vertical)
+
                .constraints([Constraint::Length(header_height), Constraint::Length(0)].as_ref())
+
                .split(area);
+

+
            self.header.view(frame, layout[0]);
+

+
            // Make some space on the left
+
            let inner_layout = Layout::default()
+
                .direction(Direction::Horizontal)
+
                .constraints(vec![Constraint::Length(1), Constraint::Min(0)].as_ref())
+
                .split(layout[1]);
+
            // reverse draw order: child needs to be drawn first?
+
            self.component.view(frame, inner_layout[1]);
+

+
            let block = Block::default().style(Style::default().bg(background));
+
            frame.render_widget(block, layout[1]);
+
        }
+
    }
+

+
    fn state(&self) -> State {
+
        State::None
+
    }
+

+
    fn perform(&mut self, cmd: Cmd) -> CmdResult {
+
        self.component.perform(cmd)
+
    }
+
}
added radicle-tui/src/ui/components/context.rs
@@ -0,0 +1,104 @@
+
use tuirealm::command::{Cmd, CmdResult};
+
use tuirealm::props::{AttrValue, Attribute, Props};
+
use tuirealm::tui::layout::Rect;
+
use tuirealm::{Frame, MockComponent, State};
+

+
use crate::ui::components::label::Label;
+
use crate::ui::layout;
+
use crate::ui::widget::{Widget, WidgetComponent};
+

+
/// A shortcut that consists of a label displaying the "hotkey", a label that displays
+
/// the action and a spacer between them.
+
#[derive(Clone)]
+
pub struct Shortcut {
+
    short: Widget<Label>,
+
    divider: Widget<Label>,
+
    long: Widget<Label>,
+
}
+

+
impl Shortcut {
+
    pub fn new(short: Widget<Label>, divider: Widget<Label>, long: Widget<Label>) -> Self {
+
        Self {
+
            short,
+
            divider,
+
            long,
+
        }
+
    }
+
}
+

+
impl WidgetComponent for Shortcut {
+
    fn view(&mut self, properties: &Props, frame: &mut Frame, area: Rect) {
+
        let display = properties
+
            .get_or(Attribute::Display, AttrValue::Flag(true))
+
            .unwrap_flag();
+

+
        if display {
+
            let labels: Vec<Box<dyn MockComponent>> = vec![
+
                self.short.clone().to_boxed(),
+
                self.divider.clone().to_boxed(),
+
                self.long.clone().to_boxed(),
+
            ];
+

+
            let layout = layout::h_stack(labels, area);
+
            for (mut shortcut, area) in layout {
+
                shortcut.view(frame, area);
+
            }
+
        }
+
    }
+

+
    fn state(&self) -> State {
+
        State::None
+
    }
+

+
    fn perform(&mut self, _cmd: Cmd) -> CmdResult {
+
        CmdResult::None
+
    }
+
}
+

+
/// A shortcut bar that displays multiple shortcuts and separates them with a
+
/// divider.
+
pub struct Shortcuts {
+
    shortcuts: Vec<Widget<Shortcut>>,
+
    divider: Widget<Label>,
+
}
+

+
impl Shortcuts {
+
    pub fn new(shortcuts: Vec<Widget<Shortcut>>, divider: Widget<Label>) -> Self {
+
        Self { shortcuts, divider }
+
    }
+
}
+

+
impl WidgetComponent for Shortcuts {
+
    fn view(&mut self, properties: &Props, frame: &mut Frame, area: Rect) {
+
        let display = properties
+
            .get_or(Attribute::Display, AttrValue::Flag(true))
+
            .unwrap_flag();
+

+
        if display {
+
            let mut widgets: Vec<Box<dyn MockComponent>> = vec![];
+
            let mut shortcuts = self.shortcuts.iter_mut().peekable();
+

+
            while let Some(shortcut) = shortcuts.next() {
+
                if shortcuts.peek().is_some() {
+
                    widgets.push(shortcut.clone().to_boxed());
+
                    widgets.push(self.divider.clone().to_boxed())
+
                } else {
+
                    widgets.push(shortcut.clone().to_boxed());
+
                }
+
            }
+

+
            let layout = layout::h_stack(widgets, area);
+
            for (mut widget, area) in layout {
+
                widget.view(frame, area);
+
            }
+
        }
+
    }
+

+
    fn state(&self) -> State {
+
        State::None
+
    }
+

+
    fn perform(&mut self, _cmd: Cmd) -> CmdResult {
+
        CmdResult::None
+
    }
+
}
added radicle-tui/src/ui/components/label.rs
@@ -0,0 +1,66 @@
+
use tuirealm::command::{Cmd, CmdResult};
+
use tuirealm::props::{AttrValue, Attribute, Color, Props, Style};
+
use tuirealm::tui::layout::Rect;
+
use tuirealm::tui::text::Span;
+
use tuirealm::{Frame, MockComponent, State, StateValue};
+

+
use crate::ui::widget::{Widget, WidgetComponent};
+

+
/// A label that can be styled using a foreground color and text modifiers.
+
/// Its height is fixed, its width depends on the length of the text it displays.
+
#[derive(Clone)]
+
pub struct Label {
+
    content: StateValue,
+
}
+

+
impl Label {
+
    pub fn new(content: StateValue) -> Self {
+
        Self { content }
+
    }
+
}
+

+
impl WidgetComponent for Label {
+
    fn view(&mut self, properties: &Props, frame: &mut Frame, area: Rect) {
+
        use tui_realm_stdlib::Label;
+

+
        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 background = properties
+
            .get_or(Attribute::Background, AttrValue::Color(Color::Reset))
+
            .unwrap_color();
+

+
        if display {
+
            let mut label = match properties.get(Attribute::TextProps) {
+
                Some(modifiers) => Label::default()
+
                    .foreground(foreground)
+
                    .background(background)
+
                    .modifiers(modifiers.unwrap_text_modifiers())
+
                    .text(self.content.clone().unwrap_string()),
+
                None => Label::default()
+
                    .foreground(foreground)
+
                    .background(background)
+
                    .text(self.content.clone().unwrap_string()),
+
            };
+

+
            label.view(frame, area);
+
        }
+
    }
+

+
    fn state(&self) -> State {
+
        State::One(self.content.clone())
+
    }
+

+
    fn perform(&mut self, _cmd: Cmd) -> CmdResult {
+
        CmdResult::None
+
    }
+
}
+

+
impl From<&Widget<Label>> for Span<'_> {
+
    fn from(label: &Widget<Label>) -> Self {
+
        Span::styled(label.content.clone().unwrap_string(), Style::default())
+
    }
+
}
added radicle-tui/src/ui/components/list.rs
@@ -0,0 +1,97 @@
+
use tuirealm::command::{Cmd, CmdResult};
+
use tuirealm::props::{AttrValue, Attribute, Props};
+
use tuirealm::tui::layout::Rect;
+
use tuirealm::{Frame, MockComponent, State};
+

+
use crate::ui::components::label::Label;
+
use crate::ui::layout;
+
use crate::ui::widget::{Widget, WidgetComponent};
+

+
/// A component that displays a labeled property.
+
#[derive(Clone)]
+
pub struct Property {
+
    label: Widget<Label>,
+
    divider: Widget<Label>,
+
    property: Widget<Label>,
+
}
+

+
impl Property {
+
    pub fn new(label: Widget<Label>, divider: Widget<Label>, property: Widget<Label>) -> Self {
+
        Self {
+
            label,
+
            divider,
+
            property,
+
        }
+
    }
+
}
+

+
impl WidgetComponent for Property {
+
    fn view(&mut self, properties: &Props, frame: &mut Frame, area: Rect) {
+
        let display = properties
+
            .get_or(Attribute::Display, AttrValue::Flag(true))
+
            .unwrap_flag();
+

+
        if display {
+
            let labels: Vec<Box<dyn MockComponent>> = vec![
+
                self.label.clone().to_boxed(),
+
                self.divider.clone().to_boxed(),
+
                self.property.clone().to_boxed(),
+
            ];
+

+
            let layout = layout::h_stack(labels, area);
+
            for (mut label, area) in layout {
+
                label.view(frame, area);
+
            }
+
        }
+
    }
+

+
    fn state(&self) -> State {
+
        State::None
+
    }
+

+
    fn perform(&mut self, _cmd: Cmd) -> CmdResult {
+
        CmdResult::None
+
    }
+
}
+

+
/// A component that can display lists of labeled properties
+
#[derive(Default)]
+
#[allow(clippy::vec_box)]
+
pub struct PropertyList {
+
    properties: Vec<Widget<Property>>,
+
}
+

+
impl PropertyList {
+
    pub fn new(properties: Vec<Widget<Property>>) -> Self {
+
        Self { properties }
+
    }
+
}
+

+
impl WidgetComponent for PropertyList {
+
    fn view(&mut self, properties: &Props, frame: &mut Frame, area: Rect) {
+
        let display = properties
+
            .get_or(Attribute::Display, AttrValue::Flag(true))
+
            .unwrap_flag();
+

+
        if display {
+
            let properties = self
+
                .properties
+
                .iter()
+
                .map(|property| property.clone().to_boxed() as Box<dyn MockComponent>)
+
                .collect();
+

+
            let layout = layout::v_stack(properties, area);
+
            for (mut property, area) in layout {
+
                property.view(frame, area);
+
            }
+
        }
+
    }
+

+
    fn state(&self) -> State {
+
        State::None
+
    }
+

+
    fn perform(&mut self, _cmd: Cmd) -> CmdResult {
+
        CmdResult::None
+
    }
+
}
added radicle-tui/src/ui/components/workspace.rs
@@ -0,0 +1,132 @@
+
use tuirealm::command::{Cmd, CmdResult};
+
use tuirealm::props::{AttrValue, Attribute, 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>,
+
}
+

+
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 {
+
        Self {
+
            header: Widget::new(Header::new(tabs, info)),
+
            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(),
+
        )
+
    }
+
}