Radish alpha
r
Radicle terminal user interface
Radicle
Git (anonymous pull)
Log in to clone via SSH
tui: Consolidate widgets and components
Erik Kundt committed 2 years ago
commit 062a4a4d4855218edaadbb1aa51e8bffc203af5c
parent 7e15b273a0f8ef51c306bbd9f47b202df5089254
18 files changed +1020 -1037
modified radicle-tui/src/app/event.rs
@@ -4,12 +4,14 @@ use tuirealm::command::{Cmd, CmdResult, Direction as MoveDirection};
use tuirealm::event::{Event, Key, KeyEvent};
use tuirealm::{MockComponent, NoUserEvent, State, StateValue};

-
use radicle_tui::ui::components::common::container::{GlobalListener, LabeledContainer, Tabs};
-
use radicle_tui::ui::components::common::context::{ContextBar, Shortcuts};
-
use radicle_tui::ui::components::common::list::PropertyList;
-
use radicle_tui::ui::components::common::Browser;
-
use radicle_tui::ui::components::home::{Dashboard, IssueBrowser};
-
use radicle_tui::ui::components::patch;
+
use radicle_tui::ui::widget::common::container::{GlobalListener, LabeledContainer, Tabs};
+
use radicle_tui::ui::widget::common::context::{ContextBar, Shortcuts};
+

+
use radicle_tui::ui::widget::common::list::PropertyList;
+

+
use radicle_tui::ui::widget::common::Browser;
+
use radicle_tui::ui::widget::home::{Dashboard, IssueBrowser};
+
use radicle_tui::ui::widget::patch;

use radicle_tui::ui::widget::Widget;

modified radicle-tui/src/ui.rs
@@ -1,5 +1,4 @@
pub mod cob;
-
pub mod components;
pub mod ext;
pub mod layout;
pub mod state;
modified radicle-tui/src/ui/cob/patch.rs
@@ -7,8 +7,8 @@ use radicle::cob::patch::{Patch, PatchId};

use tuirealm::props::{Color, TextSpan};

-
use crate::ui::components::common::list::List;
use crate::ui::theme::Theme;
+
use crate::ui::widget::common::list::List;

pub fn format_status(_patch: &Patch) -> String {
    String::from(" ⏺ ")
deleted radicle-tui/src/ui/components.rs
@@ -1,3 +0,0 @@
-
pub mod common;
-
pub mod home;
-
pub mod patch;
deleted radicle-tui/src/ui/components/common.rs
@@ -1,55 +0,0 @@
-
pub mod container;
-
pub mod context;
-
pub mod label;
-
pub mod list;
-

-
use std::marker::PhantomData;
-

-
use tuirealm::command::{Cmd, CmdResult};
-
use tuirealm::props::Props;
-
use tuirealm::tui::layout::Rect;
-
use tuirealm::{AttrValue, Attribute, Frame, MockComponent, State};
-

-
use crate::ui::layout;
-
use crate::ui::widget::{Widget, WidgetComponent};
-

-
use context::Shortcuts;
-
use list::{List, Table};
-

-
pub struct Browser<T> {
-
    list: Widget<Table>,
-
    shortcuts: Widget<Shortcuts>,
-
    phantom: PhantomData<T>,
-
}
-

-
impl<T: List> Browser<T> {
-
    pub fn new(list: Widget<Table>, shortcuts: Widget<Shortcuts>) -> Self {
-
        Self {
-
            list,
-
            shortcuts,
-
            phantom: PhantomData,
-
        }
-
    }
-
}
-

-
impl<T: List> WidgetComponent for Browser<T> {
-
    fn view(&mut self, _properties: &Props, frame: &mut Frame, area: Rect) {
-
        let shortcuts_h = self
-
            .shortcuts
-
            .query(Attribute::Height)
-
            .unwrap_or(AttrValue::Size(0))
-
            .unwrap_size();
-
        let layout = layout::root_component(area, shortcuts_h);
-

-
        self.list.view(frame, layout[0]);
-
        self.shortcuts.view(frame, layout[1]);
-
    }
-

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

-
    fn perform(&mut self, _properties: &Props, cmd: Cmd) -> CmdResult {
-
        self.list.perform(cmd)
-
    }
-
}
deleted radicle-tui/src/ui/components/common/container.rs
@@ -1,258 +0,0 @@
-
use tuirealm::command::{Cmd, CmdResult};
-
use tuirealm::props::{
-
    AttrValue, Attribute, BorderSides, BorderType, Color, PropPayload, PropValue, Props, Style,
-
    TextModifiers,
-
};
-
use tuirealm::tui::layout::{Constraint, Direction, Layout, Rect};
-
use tuirealm::tui::widgets::{Block, Cell, Row};
-
use tuirealm::{Frame, MockComponent, State, StateValue};
-

-
use crate::ui::components::common::label::Label;
-
use crate::ui::ext::HeaderBlock;
-
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, _properties: &Props, _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>>,
-
    line: Widget<Label>,
-
    state: TabState,
-
}
-

-
impl Tabs {
-
    pub fn new(tabs: Vec<Widget<Label>>, line: Widget<Label>) -> Self {
-
        let count = &tabs.len();
-
        Self {
-
            tabs,
-
            line,
-
            state: TabState {
-
                selected: 0,
-
                len: *count as u16,
-
            },
-
        }
-
    }
-
}
-

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

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

-
            // Render tabs on first row, highlighting the selected tab.
-
            let mut tabs = vec![];
-
            for (index, tab) in self.tabs.iter().enumerate() {
-
                let mut tab = tab.clone().to_boxed();
-
                if index == selected as usize {
-
                    tab.attr(
-
                        Attribute::TextProps,
-
                        AttrValue::TextModifiers(TextModifiers::REVERSED),
-
                    );
-
                }
-
                tabs.push(tab.clone().to_boxed() as Box<dyn MockComponent>);
-
            }
-
            tabs.push(Widget::new(Label::default()).to_boxed());
-

-
            let tab_layout = layout::h_stack(tabs, layout[1]);
-
            for (mut tab, area) in tab_layout {
-
                tab.view(frame, area);
-
            }
-

-
            // Repeat and render line on second row.
-
            let overlines = vec![self.line.clone(); area.width as usize];
-
            let overlines = overlines
-
                .iter()
-
                .map(|l| l.clone().to_boxed() as Box<dyn MockComponent>)
-
                .collect();
-
            let line_layout = layout::h_stack(overlines, layout[2]);
-
            for (mut line, area) in line_layout {
-
                line.view(frame, area);
-
            }
-
        }
-
    }
-

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

-
    fn perform(&mut self, _properties: &Props, 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(Default)]
-
pub struct Header;
-

-
impl Header {
-
    fn content<'a>(spans: Vec<PropValue>) -> Row<'a> {
-
        Row::new(
-
            spans
-
                .iter()
-
                .map(|span| Cell::from(span.clone().unwrap_text_span().content))
-
                .collect::<Vec<_>>(),
-
        )
-
    }
-

-
    fn widths(widths: Vec<PropValue>) -> Vec<Constraint> {
-
        widths
-
            .iter()
-
            .map(|prop| Constraint::Percentage(prop.clone().unwrap_u16()))
-
            .collect()
-
    }
-
}
-

-
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 content = properties
-
            .get_or(
-
                Attribute::Content,
-
                AttrValue::Payload(PropPayload::Vec(vec![])),
-
            )
-
            .unwrap_payload()
-
            .unwrap_vec();
-
        let widths = properties
-
            .get_or(
-
                Attribute::Custom("widths"),
-
                AttrValue::Payload(PropPayload::Vec(vec![])),
-
            )
-
            .unwrap_payload()
-
            .unwrap_vec();
-

-
        if display {
-
            let block = HeaderBlock::default()
-
                .borders(BorderSides::all())
-
                .border_style(Style::default().fg(Color::Rgb(48, 48, 48)))
-
                .border_type(BorderType::Rounded);
-
            frame.render_widget(block, area);
-

-
            let layout = Layout::default()
-
                .direction(Direction::Vertical)
-
                .constraints(vec![Constraint::Min(1)])
-
                .vertical_margin(1)
-
                .horizontal_margin(1)
-
                .split(area);
-

-
            let header = Self::content(content);
-
            let widths = Self::widths(widths);
-

-
            let table = tuirealm::tui::widgets::Table::new(vec![])
-
                .column_spacing(3u16)
-
                .header(header)
-
                .widths(&widths);
-
            frame.render_widget(table, layout[0]);
-
        }
-
    }
-

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

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

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

-
impl LabeledContainer {
-
    pub fn new(header: Widget<Header>, 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 header_height = self
-
            .header
-
            .query(Attribute::Height)
-
            .unwrap_or(AttrValue::Size(3))
-
            .unwrap_size();
-

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

-
            // Make some space on the left
-
            let inner_layout = Layout::default()
-
                .direction(Direction::Horizontal)
-
                .horizontal_margin(1)
-
                .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()
-
                .borders(BorderSides::BOTTOM | BorderSides::LEFT | BorderSides::RIGHT)
-
                .border_style(Style::default().fg(Color::Rgb(48, 48, 48)))
-
                .border_type(BorderType::Rounded);
-
            frame.render_widget(block, layout[1]);
-

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

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

-
    fn perform(&mut self, _properties: &Props, cmd: Cmd) -> CmdResult {
-
        self.component.perform(cmd)
-
    }
-
}
deleted radicle-tui/src/ui/components/common/context.rs
@@ -1,174 +0,0 @@
-
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::common::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, _properties: &Props, _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, _properties: &Props, _cmd: Cmd) -> CmdResult {
-
        CmdResult::None
-
    }
-
}
-

-
pub struct ContextBar {
-
    context: Widget<Label>,
-
    id: Widget<Label>,
-
    author: Widget<Label>,
-
    title: Widget<Label>,
-
    comments: Widget<Label>,
-
}
-

-
impl ContextBar {
-
    pub fn new(
-
        context: Widget<Label>,
-
        id: Widget<Label>,
-
        author: Widget<Label>,
-
        title: Widget<Label>,
-
        comments: Widget<Label>,
-
    ) -> Self {
-
        Self {
-
            context,
-
            id,
-
            author,
-
            title,
-
            comments,
-
        }
-
    }
-
}
-

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

-
        let context_w = self.context.query(Attribute::Width).unwrap().unwrap_size();
-
        let id_w = self.id.query(Attribute::Width).unwrap().unwrap_size();
-
        let author_w = self.author.query(Attribute::Width).unwrap().unwrap_size();
-
        let count_w = self.comments.query(Attribute::Width).unwrap().unwrap_size();
-

-
        if display {
-
            let layout = layout::h_stack(
-
                vec![
-
                    self.context.clone().to_boxed(),
-
                    self.id.clone().to_boxed(),
-
                    self.title
-
                        .clone()
-
                        .width(
-
                            area.width
-
                                .saturating_sub(context_w + id_w + author_w + count_w),
-
                        )
-
                        .to_boxed(),
-
                    self.author.clone().to_boxed(),
-
                    self.comments.clone().to_boxed(),
-
                ],
-
                area,
-
            );
-

-
            for (mut component, area) in layout {
-
                component.view(frame, area);
-
            }
-
        }
-
    }
-

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

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

-
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, Default)]
-
pub struct Label;
-

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

-
        let content = properties
-
            .get_or(Attribute::Content, AttrValue::String(String::default()))
-
            .unwrap_string();
-
        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(content),
-
                None => Label::default()
-
                    .foreground(foreground)
-
                    .background(background)
-
                    .text(content),
-
            };
-

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

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

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

-
impl From<&Widget<Label>> for Span<'_> {
-
    fn from(label: &Widget<Label>) -> Self {
-
        let content = label
-
            .query(Attribute::Content)
-
            .unwrap_or(AttrValue::String(String::default()))
-
            .unwrap_string();
-

-
        Span::styled(content, Style::default())
-
    }
-
}
-

-
impl From<&Widget<Label>> for Text<'_> {
-
    fn from(label: &Widget<Label>) -> Self {
-
        let content = label
-
            .query(Attribute::Content)
-
            .unwrap_or(AttrValue::String(String::default()))
-
            .unwrap_string();
-
        let foreground = label
-
            .query(Attribute::Foreground)
-
            .unwrap_or(AttrValue::Color(Color::Reset))
-
            .unwrap_color();
-

-
        Text::styled(content, Style::default().fg(foreground))
-
    }
-
}
deleted radicle-tui/src/ui/components/common/list.rs
@@ -1,238 +0,0 @@
-
use radicle::Profile;
-
use tuirealm::command::{Cmd, CmdResult};
-
use tuirealm::props::{
-
    AttrValue, Attribute, BorderSides, BorderType, Color, PropPayload, PropValue, Props, Style,
-
    TextSpan,
-
};
-
use tuirealm::tui::layout::{Constraint, Direction, Layout, Rect};
-
use tuirealm::tui::widgets::{Block, Cell, Row, TableState};
-
use tuirealm::{Frame, MockComponent, State, StateValue};
-

-
use crate::ui::components::common::label::Label;
-
use crate::ui::layout;
-
use crate::ui::theme::Theme;
-
use crate::ui::widget::{Widget, WidgetComponent};
-

-
use super::container::Header;
-

-
pub trait List {
-
    fn row(&self, theme: &Theme, profile: &Profile) -> Vec<TextSpan>;
-
}
-

-
/// 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, _properties: &Props, _cmd: Cmd) -> CmdResult {
-
        CmdResult::None
-
    }
-
}
-

-
/// A component that can display lists of labeled properties
-
#[derive(Default)]
-
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, _properties: &Props, _cmd: Cmd) -> CmdResult {
-
        CmdResult::None
-
    }
-
}
-

-
pub struct Table {
-
    header: Widget<Header>,
-
    state: TableState,
-
}
-

-
impl Table {
-
    pub fn new(header: Widget<Header>) -> Self {
-
        let mut state = TableState::default();
-
        state.select(Some(0));
-
        Self { header, state }
-
    }
-

-
    fn select_previous(&mut self) {
-
        let index = match self.state.selected() {
-
            Some(selected) if selected == 0 => 0,
-
            Some(selected) => selected.saturating_sub(1),
-
            None => 0,
-
        };
-
        self.state.select(Some(index));
-
    }
-

-
    fn select_next(&mut self, len: usize) {
-
        let index = match self.state.selected() {
-
            Some(selected) if selected >= len.saturating_sub(1) => len.saturating_sub(1),
-
            Some(selected) => selected.saturating_add(1),
-
            None => 0,
-
        };
-
        self.state.select(Some(index));
-
    }
-

-
    fn rows<'a>(spans: Vec<Vec<TextSpan>>) -> Vec<Row<'a>> {
-
        spans
-
            .iter()
-
            .map(|spans| {
-
                let cells = spans.iter().map(|span| {
-
                    let style = Style::default().fg(span.fg);
-
                    Cell::from(span.content.clone()).style(style)
-
                });
-
                Row::new(cells).height(1)
-
            })
-
            .collect::<Vec<Row>>()
-
    }
-

-
    fn widths(widths: Vec<PropValue>) -> Vec<Constraint> {
-
        widths
-
            .iter()
-
            .map(|prop| Constraint::Percentage(prop.clone().unwrap_u16()))
-
            .collect()
-
    }
-
}
-

-
impl WidgetComponent for Table {
-
    fn view(&mut self, properties: &Props, frame: &mut Frame, area: Rect) {
-
        let content = properties
-
            .get_or(Attribute::Content, AttrValue::Table(vec![]))
-
            .unwrap_table();
-
        let highlight = properties
-
            .get_or(Attribute::HighlightedColor, AttrValue::Color(Color::Reset))
-
            .unwrap_color();
-
        let widths = properties
-
            .get_or(
-
                Attribute::Custom("widths"),
-
                AttrValue::Payload(PropPayload::Vec(vec![])),
-
            )
-
            .unwrap_payload()
-
            .unwrap_vec();
-

-
        let layout = Layout::default()
-
            .direction(Direction::Vertical)
-
            .constraints(vec![Constraint::Length(3), Constraint::Min(1)])
-
            .split(area);
-

-
        let rows = Self::rows(content);
-
        let widths = Self::widths(widths);
-

-
        let rows = tuirealm::tui::widgets::Table::new(rows)
-
            .block(
-
                Block::default()
-
                    .borders(BorderSides::BOTTOM | BorderSides::LEFT | BorderSides::RIGHT)
-
                    .border_style(Style::default().fg(Color::Rgb(48, 48, 48)))
-
                    .border_type(BorderType::Rounded),
-
            )
-
            .highlight_style(Style::default().bg(highlight))
-
            .column_spacing(3u16)
-
            .widths(&widths);
-

-
        self.header.view(frame, layout[0]);
-
        frame.render_stateful_widget(rows, layout[1], &mut self.state);
-
    }
-

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

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

-
        let content = properties
-
            .get_or(Attribute::Content, AttrValue::Table(vec![]))
-
            .unwrap_table();
-

-
        match cmd {
-
            Cmd::Move(Direction::Up) => {
-
                self.select_previous();
-
                if let Some(selected) = self.state.selected() {
-
                    CmdResult::Changed(State::One(StateValue::Usize(selected)))
-
                } else {
-
                    CmdResult::None
-
                }
-
            }
-
            Cmd::Move(Direction::Down) => {
-
                self.select_next(content.len());
-
                if let Some(selected) = self.state.selected() {
-
                    CmdResult::Changed(State::One(StateValue::Usize(selected)))
-
                } else {
-
                    CmdResult::None
-
                }
-
            }
-
            Cmd::Submit => {
-
                if let Some(selected) = self.state.selected() {
-
                    CmdResult::Submit(State::One(StateValue::Usize(selected)))
-
                } else {
-
                    CmdResult::None
-
                }
-
            }
-
            _ => CmdResult::None,
-
        }
-
    }
-
}
deleted radicle-tui/src/ui/components/home.rs
@@ -1,83 +0,0 @@
-
use tuirealm::command::{Cmd, CmdResult};
-
use tuirealm::props::Props;
-
use tuirealm::tui::layout::Rect;
-
use tuirealm::{AttrValue, Attribute, Frame, MockComponent, State};
-

-
use crate::ui::layout;
-
use crate::ui::widget::{Widget, WidgetComponent};
-

-
use super::common::container::LabeledContainer;
-
use super::common::context::Shortcuts;
-
use super::common::label::Label;
-

-
pub struct Dashboard {
-
    about: Widget<LabeledContainer>,
-
    shortcuts: Widget<Shortcuts>,
-
}
-

-
impl Dashboard {
-
    pub fn new(about: Widget<LabeledContainer>, shortcuts: Widget<Shortcuts>) -> Self {
-
        Self { about, shortcuts }
-
    }
-
}
-

-
impl WidgetComponent for Dashboard {
-
    fn view(&mut self, _properties: &Props, frame: &mut Frame, area: Rect) {
-
        let shortcuts_h = self
-
            .shortcuts
-
            .query(Attribute::Height)
-
            .unwrap_or(AttrValue::Size(0))
-
            .unwrap_size();
-
        let layout = layout::root_component(area, shortcuts_h);
-

-
        self.about.view(frame, layout[0]);
-
        self.shortcuts.view(frame, layout[1]);
-
    }
-

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

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

-
pub struct IssueBrowser {
-
    label: Widget<Label>,
-
    shortcuts: Widget<Shortcuts>,
-
}
-

-
impl IssueBrowser {
-
    pub fn new(label: Widget<Label>, shortcuts: Widget<Shortcuts>) -> Self {
-
        Self { label, shortcuts }
-
    }
-
}
-

-
impl WidgetComponent for IssueBrowser {
-
    fn view(&mut self, _properties: &Props, frame: &mut Frame, area: Rect) {
-
        let label_w = self
-
            .label
-
            .query(Attribute::Width)
-
            .unwrap_or(AttrValue::Size(1))
-
            .unwrap_size();
-
        let shortcuts_h = self
-
            .shortcuts
-
            .query(Attribute::Height)
-
            .unwrap_or(AttrValue::Size(0))
-
            .unwrap_size();
-
        let layout = layout::root_component(area, shortcuts_h);
-

-
        self.label
-
            .view(frame, layout::centered_label(label_w, layout[0]));
-
        self.shortcuts.view(frame, layout[1])
-
    }
-

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

-
    fn perform(&mut self, _properties: &Props, _cmd: Cmd) -> CmdResult {
-
        CmdResult::None
-
    }
-
}
deleted radicle-tui/src/ui/components/patch.rs
@@ -1,118 +0,0 @@
-
use tuirealm::command::{Cmd, CmdResult};
-
use tuirealm::props::Props;
-
use tuirealm::tui::layout::Rect;
-
use tuirealm::{AttrValue, Attribute, Frame, MockComponent, State};
-

-
use crate::ui::layout;
-
use crate::ui::widget::{Widget, WidgetComponent};
-

-
use super::common::context::{ContextBar, Shortcuts};
-
use super::common::label::Label;
-

-
pub struct Activity {
-
    label: Widget<Label>,
-
    context: Widget<ContextBar>,
-
    shortcuts: Widget<Shortcuts>,
-
}
-

-
impl Activity {
-
    pub fn new(
-
        label: Widget<Label>,
-
        context: Widget<ContextBar>,
-
        shortcuts: Widget<Shortcuts>,
-
    ) -> Self {
-
        Self {
-
            label,
-
            context,
-
            shortcuts,
-
        }
-
    }
-
}
-

-
impl WidgetComponent for Activity {
-
    fn view(&mut self, _properties: &Props, frame: &mut Frame, area: Rect) {
-
        let label_w = self
-
            .label
-
            .query(Attribute::Width)
-
            .unwrap_or(AttrValue::Size(1))
-
            .unwrap_size();
-
        let context_h = self
-
            .context
-
            .query(Attribute::Height)
-
            .unwrap_or(AttrValue::Size(0))
-
            .unwrap_size();
-
        let shortcuts_h = self
-
            .shortcuts
-
            .query(Attribute::Height)
-
            .unwrap_or(AttrValue::Size(0))
-
            .unwrap_size();
-
        let layout = layout::root_component_with_context(area, context_h, shortcuts_h);
-

-
        self.label
-
            .view(frame, layout::centered_label(label_w, layout[0]));
-
        self.context.view(frame, layout[1]);
-
        self.shortcuts.view(frame, layout[2]);
-
    }
-

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

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

-
pub struct Files {
-
    label: Widget<Label>,
-
    context: Widget<ContextBar>,
-
    shortcuts: Widget<Shortcuts>,
-
}
-

-
impl Files {
-
    pub fn new(
-
        label: Widget<Label>,
-
        context: Widget<ContextBar>,
-
        shortcuts: Widget<Shortcuts>,
-
    ) -> Self {
-
        Self {
-
            label,
-
            context,
-
            shortcuts,
-
        }
-
    }
-
}
-

-
impl WidgetComponent for Files {
-
    fn view(&mut self, _properties: &Props, frame: &mut Frame, area: Rect) {
-
        let label_w = self
-
            .label
-
            .query(Attribute::Width)
-
            .unwrap_or(AttrValue::Size(1))
-
            .unwrap_size();
-
        let context_h = self
-
            .context
-
            .query(Attribute::Height)
-
            .unwrap_or(AttrValue::Size(0))
-
            .unwrap_size();
-
        let shortcuts_h = self
-
            .shortcuts
-
            .query(Attribute::Height)
-
            .unwrap_or(AttrValue::Size(0))
-
            .unwrap_size();
-
        let layout = layout::root_component_with_context(area, context_h, shortcuts_h);
-

-
        self.label
-
            .view(frame, layout::centered_label(label_w, layout[0]));
-
        self.context.view(frame, layout[1]);
-
        self.shortcuts.view(frame, layout[2]);
-
    }
-

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

-
    fn perform(&mut self, _properties: &Props, _cmd: Cmd) -> CmdResult {
-
        CmdResult::None
-
    }
-
}
modified radicle-tui/src/ui/widget/common.rs
@@ -1,17 +1,64 @@
+
pub mod container;
+
pub mod context;
+
pub mod label;
+
pub mod list;
+

+
use std::marker::PhantomData;
+

use radicle::Profile;
+

+
use tuirealm::command::{Cmd, CmdResult};
use tuirealm::props::{AttrValue, Attribute, PropPayload, PropValue, TextSpan};
-
use tuirealm::MockComponent;
+
use tuirealm::tui::layout::Rect;
+
use tuirealm::{Frame, MockComponent, Props, State};

-
use crate::ui;
-
use crate::ui::components::common::container::Header;
+
use container::{GlobalListener, Header, LabeledContainer, Tabs};
+
use context::{Shortcut, Shortcuts};
+
use label::Label;
+
use list::{List, Property, PropertyList, Table};

-
use ui::components::common::container::{GlobalListener, LabeledContainer, Tabs};
-
use ui::components::common::context::{Shortcut, Shortcuts};
-
use ui::components::common::label::Label;
-
use ui::components::common::list::{List, Property, PropertyList, Table};
-
use ui::theme::Theme;
+
use super::{Widget, WidgetComponent};

-
use super::Widget;
+
use crate::ui::layout;
+
use crate::ui::theme::Theme;
+

+
pub struct Browser<T> {
+
    list: Widget<Table>,
+
    shortcuts: Widget<Shortcuts>,
+
    phantom: PhantomData<T>,
+
}
+

+
impl<T: List> Browser<T> {
+
    pub fn new(list: Widget<Table>, shortcuts: Widget<Shortcuts>) -> Self {
+
        Self {
+
            list,
+
            shortcuts,
+
            phantom: PhantomData,
+
        }
+
    }
+
}
+

+
impl<T: List> WidgetComponent for Browser<T> {
+
    fn view(&mut self, _properties: &Props, frame: &mut Frame, area: Rect) {
+
        let shortcuts_h = self
+
            .shortcuts
+
            .query(Attribute::Height)
+
            .unwrap_or(AttrValue::Size(0))
+
            .unwrap_size();
+
        let layout = layout::root_component(area, shortcuts_h);
+

+
        self.list.view(frame, layout[0]);
+
        self.shortcuts.view(frame, layout[1]);
+
    }
+

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

+
    fn perform(&mut self, _properties: &Props, cmd: Cmd) -> CmdResult {
+
        self.list.perform(cmd)
+
    }
+
}

pub fn global_listener() -> Widget<GlobalListener> {
    Widget::new(GlobalListener::default())
added radicle-tui/src/ui/widget/common/container.rs
@@ -0,0 +1,259 @@
+
use tuirealm::command::{Cmd, CmdResult};
+
use tuirealm::props::{
+
    AttrValue, Attribute, BorderSides, BorderType, Color, PropPayload, PropValue, Props, Style,
+
    TextModifiers,
+
};
+
use tuirealm::tui::layout::{Constraint, Direction, Layout, Rect};
+
use tuirealm::tui::widgets::{Block, Cell, Row};
+
use tuirealm::{Frame, MockComponent, State, StateValue};
+

+
use crate::ui::ext::HeaderBlock;
+
use crate::ui::layout;
+
use crate::ui::state::TabState;
+
use crate::ui::widget::{Widget, WidgetComponent};
+

+
use super::label::Label;
+

+
/// 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, _properties: &Props, _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>>,
+
    line: Widget<Label>,
+
    state: TabState,
+
}
+

+
impl Tabs {
+
    pub fn new(tabs: Vec<Widget<Label>>, line: Widget<Label>) -> Self {
+
        let count = &tabs.len();
+
        Self {
+
            tabs,
+
            line,
+
            state: TabState {
+
                selected: 0,
+
                len: *count as u16,
+
            },
+
        }
+
    }
+
}
+

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

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

+
            // Render tabs on first row, highlighting the selected tab.
+
            let mut tabs = vec![];
+
            for (index, tab) in self.tabs.iter().enumerate() {
+
                let mut tab = tab.clone().to_boxed();
+
                if index == selected as usize {
+
                    tab.attr(
+
                        Attribute::TextProps,
+
                        AttrValue::TextModifiers(TextModifiers::REVERSED),
+
                    );
+
                }
+
                tabs.push(tab.clone().to_boxed() as Box<dyn MockComponent>);
+
            }
+
            tabs.push(Widget::new(Label::default()).to_boxed());
+

+
            let tab_layout = layout::h_stack(tabs, layout[1]);
+
            for (mut tab, area) in tab_layout {
+
                tab.view(frame, area);
+
            }
+

+
            // Repeat and render line on second row.
+
            let overlines = vec![self.line.clone(); area.width as usize];
+
            let overlines = overlines
+
                .iter()
+
                .map(|l| l.clone().to_boxed() as Box<dyn MockComponent>)
+
                .collect();
+
            let line_layout = layout::h_stack(overlines, layout[2]);
+
            for (mut line, area) in line_layout {
+
                line.view(frame, area);
+
            }
+
        }
+
    }
+

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

+
    fn perform(&mut self, _properties: &Props, 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(Default)]
+
pub struct Header;
+

+
impl Header {
+
    fn content<'a>(spans: Vec<PropValue>) -> Row<'a> {
+
        Row::new(
+
            spans
+
                .iter()
+
                .map(|span| Cell::from(span.clone().unwrap_text_span().content))
+
                .collect::<Vec<_>>(),
+
        )
+
    }
+

+
    fn widths(widths: Vec<PropValue>) -> Vec<Constraint> {
+
        widths
+
            .iter()
+
            .map(|prop| Constraint::Percentage(prop.clone().unwrap_u16()))
+
            .collect()
+
    }
+
}
+

+
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 content = properties
+
            .get_or(
+
                Attribute::Content,
+
                AttrValue::Payload(PropPayload::Vec(vec![])),
+
            )
+
            .unwrap_payload()
+
            .unwrap_vec();
+
        let widths = properties
+
            .get_or(
+
                Attribute::Custom("widths"),
+
                AttrValue::Payload(PropPayload::Vec(vec![])),
+
            )
+
            .unwrap_payload()
+
            .unwrap_vec();
+

+
        if display {
+
            let block = HeaderBlock::default()
+
                .borders(BorderSides::all())
+
                .border_style(Style::default().fg(Color::Rgb(48, 48, 48)))
+
                .border_type(BorderType::Rounded);
+
            frame.render_widget(block, area);
+

+
            let layout = Layout::default()
+
                .direction(Direction::Vertical)
+
                .constraints(vec![Constraint::Min(1)])
+
                .vertical_margin(1)
+
                .horizontal_margin(1)
+
                .split(area);
+

+
            let header = Self::content(content);
+
            let widths = Self::widths(widths);
+

+
            let table = tuirealm::tui::widgets::Table::new(vec![])
+
                .column_spacing(3u16)
+
                .header(header)
+
                .widths(&widths);
+
            frame.render_widget(table, layout[0]);
+
        }
+
    }
+

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

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

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

+
impl LabeledContainer {
+
    pub fn new(header: Widget<Header>, 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 header_height = self
+
            .header
+
            .query(Attribute::Height)
+
            .unwrap_or(AttrValue::Size(3))
+
            .unwrap_size();
+

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

+
            // Make some space on the left
+
            let inner_layout = Layout::default()
+
                .direction(Direction::Horizontal)
+
                .horizontal_margin(1)
+
                .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()
+
                .borders(BorderSides::BOTTOM | BorderSides::LEFT | BorderSides::RIGHT)
+
                .border_style(Style::default().fg(Color::Rgb(48, 48, 48)))
+
                .border_type(BorderType::Rounded);
+
            frame.render_widget(block, layout[1]);
+

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

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

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

+
use super::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, _properties: &Props, _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, _properties: &Props, _cmd: Cmd) -> CmdResult {
+
        CmdResult::None
+
    }
+
}
+

+
pub struct ContextBar {
+
    context: Widget<Label>,
+
    id: Widget<Label>,
+
    author: Widget<Label>,
+
    title: Widget<Label>,
+
    comments: Widget<Label>,
+
}
+

+
impl ContextBar {
+
    pub fn new(
+
        context: Widget<Label>,
+
        id: Widget<Label>,
+
        author: Widget<Label>,
+
        title: Widget<Label>,
+
        comments: Widget<Label>,
+
    ) -> Self {
+
        Self {
+
            context,
+
            id,
+
            author,
+
            title,
+
            comments,
+
        }
+
    }
+
}
+

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

+
        let context_w = self.context.query(Attribute::Width).unwrap().unwrap_size();
+
        let id_w = self.id.query(Attribute::Width).unwrap().unwrap_size();
+
        let author_w = self.author.query(Attribute::Width).unwrap().unwrap_size();
+
        let count_w = self.comments.query(Attribute::Width).unwrap().unwrap_size();
+

+
        if display {
+
            let layout = layout::h_stack(
+
                vec![
+
                    self.context.clone().to_boxed(),
+
                    self.id.clone().to_boxed(),
+
                    self.title
+
                        .clone()
+
                        .width(
+
                            area.width
+
                                .saturating_sub(context_w + id_w + author_w + count_w),
+
                        )
+
                        .to_boxed(),
+
                    self.author.clone().to_boxed(),
+
                    self.comments.clone().to_boxed(),
+
                ],
+
                area,
+
            );
+

+
            for (mut component, area) in layout {
+
                component.view(frame, area);
+
            }
+
        }
+
    }
+

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

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

+
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, Default)]
+
pub struct Label;
+

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

+
        let content = properties
+
            .get_or(Attribute::Content, AttrValue::String(String::default()))
+
            .unwrap_string();
+
        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(content),
+
                None => Label::default()
+
                    .foreground(foreground)
+
                    .background(background)
+
                    .text(content),
+
            };
+

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

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

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

+
impl From<&Widget<Label>> for Span<'_> {
+
    fn from(label: &Widget<Label>) -> Self {
+
        let content = label
+
            .query(Attribute::Content)
+
            .unwrap_or(AttrValue::String(String::default()))
+
            .unwrap_string();
+

+
        Span::styled(content, Style::default())
+
    }
+
}
+

+
impl From<&Widget<Label>> for Text<'_> {
+
    fn from(label: &Widget<Label>) -> Self {
+
        let content = label
+
            .query(Attribute::Content)
+
            .unwrap_or(AttrValue::String(String::default()))
+
            .unwrap_string();
+
        let foreground = label
+
            .query(Attribute::Foreground)
+
            .unwrap_or(AttrValue::Color(Color::Reset))
+
            .unwrap_color();
+

+
        Text::styled(content, Style::default().fg(foreground))
+
    }
+
}
added radicle-tui/src/ui/widget/common/list.rs
@@ -0,0 +1,238 @@
+
use radicle::Profile;
+
use tuirealm::command::{Cmd, CmdResult};
+
use tuirealm::props::{
+
    AttrValue, Attribute, BorderSides, BorderType, Color, PropPayload, PropValue, Props, Style,
+
    TextSpan,
+
};
+
use tuirealm::tui::layout::{Constraint, Direction, Layout, Rect};
+
use tuirealm::tui::widgets::{Block, Cell, Row, TableState};
+
use tuirealm::{Frame, MockComponent, State, StateValue};
+

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

+
use super::container::Header;
+
use super::label::Label;
+

+
pub trait List {
+
    fn row(&self, theme: &Theme, profile: &Profile) -> Vec<TextSpan>;
+
}
+

+
/// 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, _properties: &Props, _cmd: Cmd) -> CmdResult {
+
        CmdResult::None
+
    }
+
}
+

+
/// A component that can display lists of labeled properties
+
#[derive(Default)]
+
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, _properties: &Props, _cmd: Cmd) -> CmdResult {
+
        CmdResult::None
+
    }
+
}
+

+
pub struct Table {
+
    header: Widget<Header>,
+
    state: TableState,
+
}
+

+
impl Table {
+
    pub fn new(header: Widget<Header>) -> Self {
+
        let mut state = TableState::default();
+
        state.select(Some(0));
+
        Self { header, state }
+
    }
+

+
    fn select_previous(&mut self) {
+
        let index = match self.state.selected() {
+
            Some(selected) if selected == 0 => 0,
+
            Some(selected) => selected.saturating_sub(1),
+
            None => 0,
+
        };
+
        self.state.select(Some(index));
+
    }
+

+
    fn select_next(&mut self, len: usize) {
+
        let index = match self.state.selected() {
+
            Some(selected) if selected >= len.saturating_sub(1) => len.saturating_sub(1),
+
            Some(selected) => selected.saturating_add(1),
+
            None => 0,
+
        };
+
        self.state.select(Some(index));
+
    }
+

+
    fn rows<'a>(spans: Vec<Vec<TextSpan>>) -> Vec<Row<'a>> {
+
        spans
+
            .iter()
+
            .map(|spans| {
+
                let cells = spans.iter().map(|span| {
+
                    let style = Style::default().fg(span.fg);
+
                    Cell::from(span.content.clone()).style(style)
+
                });
+
                Row::new(cells).height(1)
+
            })
+
            .collect::<Vec<Row>>()
+
    }
+

+
    fn widths(widths: Vec<PropValue>) -> Vec<Constraint> {
+
        widths
+
            .iter()
+
            .map(|prop| Constraint::Percentage(prop.clone().unwrap_u16()))
+
            .collect()
+
    }
+
}
+

+
impl WidgetComponent for Table {
+
    fn view(&mut self, properties: &Props, frame: &mut Frame, area: Rect) {
+
        let content = properties
+
            .get_or(Attribute::Content, AttrValue::Table(vec![]))
+
            .unwrap_table();
+
        let highlight = properties
+
            .get_or(Attribute::HighlightedColor, AttrValue::Color(Color::Reset))
+
            .unwrap_color();
+
        let widths = properties
+
            .get_or(
+
                Attribute::Custom("widths"),
+
                AttrValue::Payload(PropPayload::Vec(vec![])),
+
            )
+
            .unwrap_payload()
+
            .unwrap_vec();
+

+
        let layout = Layout::default()
+
            .direction(Direction::Vertical)
+
            .constraints(vec![Constraint::Length(3), Constraint::Min(1)])
+
            .split(area);
+

+
        let rows = Self::rows(content);
+
        let widths = Self::widths(widths);
+

+
        let rows = tuirealm::tui::widgets::Table::new(rows)
+
            .block(
+
                Block::default()
+
                    .borders(BorderSides::BOTTOM | BorderSides::LEFT | BorderSides::RIGHT)
+
                    .border_style(Style::default().fg(Color::Rgb(48, 48, 48)))
+
                    .border_type(BorderType::Rounded),
+
            )
+
            .highlight_style(Style::default().bg(highlight))
+
            .column_spacing(3u16)
+
            .widths(&widths);
+

+
        self.header.view(frame, layout[0]);
+
        frame.render_stateful_widget(rows, layout[1], &mut self.state);
+
    }
+

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

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

+
        let content = properties
+
            .get_or(Attribute::Content, AttrValue::Table(vec![]))
+
            .unwrap_table();
+

+
        match cmd {
+
            Cmd::Move(Direction::Up) => {
+
                self.select_previous();
+
                if let Some(selected) = self.state.selected() {
+
                    CmdResult::Changed(State::One(StateValue::Usize(selected)))
+
                } else {
+
                    CmdResult::None
+
                }
+
            }
+
            Cmd::Move(Direction::Down) => {
+
                self.select_next(content.len());
+
                if let Some(selected) = self.state.selected() {
+
                    CmdResult::Changed(State::One(StateValue::Usize(selected)))
+
                } else {
+
                    CmdResult::None
+
                }
+
            }
+
            Cmd::Submit => {
+
                if let Some(selected) = self.state.selected() {
+
                    CmdResult::Submit(State::One(StateValue::Usize(selected)))
+
                } else {
+
                    CmdResult::None
+
                }
+
            }
+
            _ => CmdResult::None,
+
        }
+
    }
+
}
modified radicle-tui/src/ui/widget/home.rs
@@ -1,13 +1,92 @@
use radicle::cob::patch::{Patch, PatchId};
-
use radicle::identity::{Id, Project};
use radicle::Profile;

-
use crate::ui::components::common::container::Tabs;
-
use crate::ui::components::common::Browser;
-
use crate::ui::components::home::{Dashboard, IssueBrowser};
+
use radicle::prelude::{Id, Project};
+
use tuirealm::command::{Cmd, CmdResult};
+
use tuirealm::tui::layout::Rect;
+
use tuirealm::{AttrValue, Attribute, Frame, MockComponent, Props, State};
+

+
use super::{Widget, WidgetComponent};
+

+
use super::common::container::{LabeledContainer, Tabs};
+
use super::common::context::Shortcuts;
+
use super::common::label::Label;
+
use super::common::{self, Browser};
+

+
use crate::ui::layout;
use crate::ui::theme::Theme;

-
use super::{common, Widget};
+
pub struct Dashboard {
+
    about: Widget<LabeledContainer>,
+
    shortcuts: Widget<Shortcuts>,
+
}
+

+
impl Dashboard {
+
    pub fn new(about: Widget<LabeledContainer>, shortcuts: Widget<Shortcuts>) -> Self {
+
        Self { about, shortcuts }
+
    }
+
}
+

+
impl WidgetComponent for Dashboard {
+
    fn view(&mut self, _properties: &Props, frame: &mut Frame, area: Rect) {
+
        let shortcuts_h = self
+
            .shortcuts
+
            .query(Attribute::Height)
+
            .unwrap_or(AttrValue::Size(0))
+
            .unwrap_size();
+
        let layout = layout::root_component(area, shortcuts_h);
+

+
        self.about.view(frame, layout[0]);
+
        self.shortcuts.view(frame, layout[1]);
+
    }
+

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

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

+
pub struct IssueBrowser {
+
    label: Widget<Label>,
+
    shortcuts: Widget<Shortcuts>,
+
}
+

+
impl IssueBrowser {
+
    pub fn new(label: Widget<Label>, shortcuts: Widget<Shortcuts>) -> Self {
+
        Self { label, shortcuts }
+
    }
+
}
+

+
impl WidgetComponent for IssueBrowser {
+
    fn view(&mut self, _properties: &Props, frame: &mut Frame, area: Rect) {
+
        let label_w = self
+
            .label
+
            .query(Attribute::Width)
+
            .unwrap_or(AttrValue::Size(1))
+
            .unwrap_size();
+
        let shortcuts_h = self
+
            .shortcuts
+
            .query(Attribute::Height)
+
            .unwrap_or(AttrValue::Size(0))
+
            .unwrap_size();
+
        let layout = layout::root_component(area, shortcuts_h);
+

+
        self.label
+
            .view(frame, layout::centered_label(label_w, layout[0]));
+
        self.shortcuts.view(frame, layout[1])
+
    }
+

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

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

pub fn navigation(theme: &Theme) -> Widget<Tabs> {
    common::tabs(
modified radicle-tui/src/ui/widget/patch.rs
@@ -1,17 +1,130 @@
+
use radicle::cob::patch::{Patch, PatchId};
use radicle::Profile;
+

+
use tuirealm::command::{Cmd, CmdResult};
use tuirealm::props::Color;
+
use tuirealm::tui::layout::Rect;
+
use tuirealm::{AttrValue, Attribute, Frame, MockComponent, Props, State};

-
use radicle::cob::patch::{Patch, PatchId};
+
use super::{Widget, WidgetComponent};

use super::common;
-
use super::Widget;
+
use super::common::container::Tabs;
+
use super::common::context::{ContextBar, Shortcuts};
+
use super::common::label::Label;

use crate::ui::cob::patch;
-
use crate::ui::components::common::container::Tabs;
-
use crate::ui::components::common::context::ContextBar;
-
use crate::ui::components::patch::Activity;
+
use crate::ui::layout;
use crate::ui::theme::Theme;

+
pub struct Activity {
+
    label: Widget<Label>,
+
    context: Widget<ContextBar>,
+
    shortcuts: Widget<Shortcuts>,
+
}
+

+
impl Activity {
+
    pub fn new(
+
        label: Widget<Label>,
+
        context: Widget<ContextBar>,
+
        shortcuts: Widget<Shortcuts>,
+
    ) -> Self {
+
        Self {
+
            label,
+
            context,
+
            shortcuts,
+
        }
+
    }
+
}
+

+
impl WidgetComponent for Activity {
+
    fn view(&mut self, _properties: &Props, frame: &mut Frame, area: Rect) {
+
        let label_w = self
+
            .label
+
            .query(Attribute::Width)
+
            .unwrap_or(AttrValue::Size(1))
+
            .unwrap_size();
+
        let context_h = self
+
            .context
+
            .query(Attribute::Height)
+
            .unwrap_or(AttrValue::Size(0))
+
            .unwrap_size();
+
        let shortcuts_h = self
+
            .shortcuts
+
            .query(Attribute::Height)
+
            .unwrap_or(AttrValue::Size(0))
+
            .unwrap_size();
+
        let layout = layout::root_component_with_context(area, context_h, shortcuts_h);
+

+
        self.label
+
            .view(frame, layout::centered_label(label_w, layout[0]));
+
        self.context.view(frame, layout[1]);
+
        self.shortcuts.view(frame, layout[2]);
+
    }
+

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

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

+
pub struct Files {
+
    label: Widget<Label>,
+
    context: Widget<ContextBar>,
+
    shortcuts: Widget<Shortcuts>,
+
}
+

+
impl Files {
+
    pub fn new(
+
        label: Widget<Label>,
+
        context: Widget<ContextBar>,
+
        shortcuts: Widget<Shortcuts>,
+
    ) -> Self {
+
        Self {
+
            label,
+
            context,
+
            shortcuts,
+
        }
+
    }
+
}
+

+
impl WidgetComponent for Files {
+
    fn view(&mut self, _properties: &Props, frame: &mut Frame, area: Rect) {
+
        let label_w = self
+
            .label
+
            .query(Attribute::Width)
+
            .unwrap_or(AttrValue::Size(1))
+
            .unwrap_size();
+
        let context_h = self
+
            .context
+
            .query(Attribute::Height)
+
            .unwrap_or(AttrValue::Size(0))
+
            .unwrap_size();
+
        let shortcuts_h = self
+
            .shortcuts
+
            .query(Attribute::Height)
+
            .unwrap_or(AttrValue::Size(0))
+
            .unwrap_size();
+
        let layout = layout::root_component_with_context(area, context_h, shortcuts_h);
+

+
        self.label
+
            .view(frame, layout::centered_label(label_w, layout[0]));
+
        self.context.view(frame, layout[1]);
+
        self.shortcuts.view(frame, layout[2]);
+
    }
+

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

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

pub fn navigation(theme: &Theme) -> Widget<Tabs> {
    common::tabs(
        theme,