Radish alpha
h
Radicle Heartwood Protocol & Stack
Radicle
Git (anonymous pull)
Log in to clone via SSH
tui: Add detail widget to issue page
Erik Kundt committed 2 years ago
commit df5cf4023b3f108ae1ebc842f0784360e69505b5
parent 3b88e13e911de2080572011ab319b6da432f7b40
9 files changed +279 -28
modified radicle-tui/src/app.rs
@@ -39,6 +39,7 @@ pub enum PatchCid {
#[derive(Debug, Eq, PartialEq, Clone, Hash)]
pub enum IssueCid {
    List,
+
    Details,
    Shortcuts,
}

@@ -58,6 +59,7 @@ pub enum HomeMessage {}
#[derive(Debug, Eq, PartialEq)]
pub enum IssueMessage {
    Show(IssueId),
+
    Changed(IssueId),
    Leave,
}

@@ -186,7 +188,9 @@ impl Tui<Cid, Message> for App {
                        }
                        Message::Quit => self.quit = true,
                        _ => {
-
                            self.pages.peek_mut()?.update(app, message)?;
+
                            self.pages
+
                                .peek_mut()?
+
                                .update(app, &self.context, &theme, message)?;
                        }
                    }
                }
modified radicle-tui/src/app/event.rs
@@ -52,20 +52,38 @@ impl tuirealm::Component<Message, NoUserEvent> for Widget<issue::LargeList> {
                Some(Message::Issue(IssueMessage::Leave))
            }
            Event::Keyboard(KeyEvent { code: Key::Up, .. }) => {
-
                self.perform(Cmd::Move(MoveDirection::Up));
-
                Some(Message::Tick)
+
                let result = self.perform(Cmd::Move(MoveDirection::Up));
+
                match result {
+
                    CmdResult::Changed(State::One(StateValue::Usize(selected))) => {
+
                        let item = self.items().get(selected)?;
+
                        Some(Message::Issue(IssueMessage::Changed(item.id().to_owned())))
+
                    }
+
                    _ => None,
+
                }
            }
            Event::Keyboard(KeyEvent {
                code: Key::Down, ..
            }) => {
-
                self.perform(Cmd::Move(MoveDirection::Down));
-
                Some(Message::Tick)
+
                let result = self.perform(Cmd::Move(MoveDirection::Down));
+
                match result {
+
                    CmdResult::Changed(State::One(StateValue::Usize(selected))) => {
+
                        let item = self.items().get(selected)?;
+
                        Some(Message::Issue(IssueMessage::Changed(item.id().to_owned())))
+
                    }
+
                    _ => None,
+
                }
            }
            _ => None,
        }
    }
}

+
impl tuirealm::Component<Message, NoUserEvent> for Widget<issue::Details> {
+
    fn on(&mut self, _event: Event<NoUserEvent>) -> Option<Message> {
+
        None
+
    }
+
}
+

impl tuirealm::Component<Message, NoUserEvent> for Widget<PatchBrowser> {
    fn on(&mut self, event: Event<NoUserEvent>) -> Option<Message> {
        match event {
modified radicle-tui/src/app/page.rs
@@ -3,6 +3,7 @@ use anyhow::Result;
use radicle::cob::issue::{Issue, IssueId};
use radicle::cob::patch::{Patch, PatchId};

+
use radicle_tui::cob;
use tuirealm::{Frame, NoUserEvent, Sub, SubClause};

use radicle_tui::ui::context::Context;
@@ -10,7 +11,7 @@ use radicle_tui::ui::layout;
use radicle_tui::ui::theme::Theme;
use radicle_tui::ui::widget;

-
use super::{subscription, Application, Cid, HomeCid, IssueCid, Message, PatchCid};
+
use super::{subscription, Application, Cid, HomeCid, IssueCid, IssueMessage, Message, PatchCid};

/// `tuirealm`'s event and prop system is designed to work with flat component hierarchies.
/// Building deep nested component hierarchies would need a lot more additional effort to
@@ -36,6 +37,8 @@ pub trait ViewPage {
    fn update(
        &mut self,
        app: &mut Application<Cid, Message, NoUserEvent>,
+
        context: &Context,
+
        theme: &Theme,
        message: Message,
    ) -> Result<()>;

@@ -101,6 +104,8 @@ impl ViewPage for HomeView {
    fn update(
        &mut self,
        app: &mut Application<Cid, Message, NoUserEvent>,
+
        _context: &Context,
+
        _theme: &Theme,
        message: Message,
    ) -> Result<()> {
        if let Message::NavigationChanged(index) = message {
@@ -162,8 +167,9 @@ impl ViewPage for IssuePage {
        context: &Context,
        theme: &Theme,
    ) -> Result<()> {
-
        let (id, issue) = self.issue.clone();
-
        let list = widget::issue::list(context, theme, (id, issue)).to_boxed();
+
        let (id, issue) = &self.issue;
+
        let list = widget::issue::list(context, theme, (*id, issue.clone())).to_boxed();
+
        let details = widget::issue::details(context, theme, (*id, issue.clone())).to_boxed();
        let shortcuts = widget::common::shortcuts(
            theme,
            vec![
@@ -174,6 +180,7 @@ impl ViewPage for IssuePage {
        .to_boxed();

        app.remount(Cid::Issue(IssueCid::List), list, vec![])?;
+
        app.remount(Cid::Issue(IssueCid::Details), details, vec![])?;
        app.remount(Cid::Issue(IssueCid::Shortcuts), shortcuts, vec![])?;

        app.active(&self.active_component)?;
@@ -183,6 +190,7 @@ impl ViewPage for IssuePage {

    fn unmount(&self, app: &mut Application<Cid, Message, NoUserEvent>) -> Result<()> {
        app.umount(&Cid::Issue(IssueCid::List))?;
+
        app.umount(&Cid::Issue(IssueCid::Details))?;
        app.umount(&Cid::Issue(IssueCid::Shortcuts))?;
        Ok(())
    }
@@ -190,8 +198,17 @@ impl ViewPage for IssuePage {
    fn update(
        &mut self,
        app: &mut Application<Cid, Message, NoUserEvent>,
-
        _message: Message,
+
        context: &Context,
+
        theme: &Theme,
+
        message: Message,
    ) -> Result<()> {
+
        if let Message::Issue(IssueMessage::Changed(id)) = message {
+
            let repo = context.repository();
+
            if let Some(issue) = cob::issue::find(repo, &id)? {
+
                let details = widget::issue::details(context, theme, (id, issue)).to_boxed();
+
                app.remount(Cid::Issue(IssueCid::Details), details, vec![])?;
+
            }
+
        }
        app.active(&self.active_component)?;

        Ok(())
@@ -203,6 +220,7 @@ impl ViewPage for IssuePage {
        let layout = layout::issue_preview(area, shortcuts_h);

        app.view(&Cid::Issue(IssueCid::List), frame, layout.left);
+
        app.view(&Cid::Issue(IssueCid::Details), frame, layout.details);
        app.view(&Cid::Issue(IssueCid::Shortcuts), frame, layout.shortcuts);
    }

@@ -263,6 +281,8 @@ impl ViewPage for PatchView {
    fn update(
        &mut self,
        app: &mut Application<Cid, Message, NoUserEvent>,
+
        _context: &Context,
+
        _theme: &Theme,
        message: Message,
    ) -> Result<()> {
        if let Message::NavigationChanged(index) = message {
modified radicle-tui/src/ui/cob.rs
@@ -33,6 +33,16 @@ pub struct AuthorItem {
    is_you: bool,
}

+
impl AuthorItem {
+
    pub fn did(&self) -> Did {
+
        self.did
+
    }
+

+
    pub fn is_you(&self) -> bool {
+
        self.is_you
+
    }
+
}
+

/// A patch item that can be used in tables, list or trees.
///
/// Breaks up dependencies to [`Profile`] and [`Repository`] that
modified radicle-tui/src/ui/layout.rs
@@ -4,7 +4,8 @@ use tuirealm::MockComponent;

pub struct IssuePreview {
    pub left: Rect,
-
    pub right: Rect,
+
    pub details: Rect,
+
    pub discussion: Rect,
    pub shortcuts: Rect,
}

@@ -158,9 +159,15 @@ pub fn issue_preview(area: Rect, shortcuts_h: u16) -> IssuePreview {
        .constraints([Constraint::Percentage(50), Constraint::Percentage(50)].as_ref())
        .split(root[0]);

+
    let right = Layout::default()
+
        .direction(Direction::Vertical)
+
        .constraints([Constraint::Length(6), Constraint::Min(0)].as_ref())
+
        .split(split[1]);
+

    IssuePreview {
        left: split[0],
-
        right: split[1],
+
        details: right[0],
+
        discussion: right[1],
        shortcuts: root[1],
    }
}
modified radicle-tui/src/ui/widget/common.rs
@@ -11,7 +11,8 @@ use context::{Shortcut, Shortcuts};
use label::Label;
use list::{Property, PropertyList};

-
use self::list::ColumnWidth;
+
use self::container::Container;
+
use self::list::{ColumnWidth, PropertyTable};

use super::Widget;

@@ -43,6 +44,11 @@ pub fn container_header(theme: &Theme, label: Widget<Label>) -> Widget<Header<1>
    Widget::new(header)
}

+
pub fn container(_theme: &Theme, component: Box<dyn MockComponent>) -> Widget<Container> {
+
    let container = Container::new(component);
+
    Widget::new(container)
+
}
+

pub fn labeled_container(
    theme: &Theme,
    title: &str,
@@ -92,7 +98,7 @@ pub fn property(theme: &Theme, name: &str, value: &str) -> Widget<Property> {
    let value_w = value.query(Attribute::Width).unwrap().unwrap_size();
    let width = name_w.saturating_add(divider_w).saturating_add(value_w);

-
    let property = Property::new(name, divider, value);
+
    let property = Property::new(name, value).with_divider(divider);

    Widget::new(property).height(1).width(width)
}
@@ -103,6 +109,12 @@ pub fn property_list(_theme: &Theme, properties: Vec<Widget<Property>>) -> Widge
    Widget::new(property_list)
}

+
pub fn property_table(_theme: &Theme, properties: Vec<Widget<Property>>) -> Widget<PropertyTable> {
+
    let table = PropertyTable::new(properties);
+

+
    Widget::new(table)
+
}
+

pub fn tabs(theme: &Theme, tabs: Vec<Widget<Label>>) -> Widget<Tabs> {
    let line =
        label(&theme.icons.tab_overline.to_string()).foreground(theme.colors.tabs_highlighted_fg);
modified radicle-tui/src/ui/widget/common/container.rs
@@ -193,6 +193,50 @@ impl<const W: usize> WidgetComponent for Header<W> {
    }
}

+
pub struct Container {
+
    component: Box<dyn MockComponent>,
+
}
+

+
impl Container {
+
    pub fn new(component: Box<dyn MockComponent>) -> Self {
+
        Self { component }
+
    }
+
}
+

+
impl WidgetComponent for Container {
+
    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 {
+
            // Make some space on the left
+
            let layout = Layout::default()
+
                .direction(Direction::Horizontal)
+
                .horizontal_margin(1)
+
                .vertical_margin(1)
+
                .constraints(vec![Constraint::Length(1), Constraint::Min(0)].as_ref())
+
                .split(area);
+
            // reverse draw order: child needs to be drawn first?
+
            self.component.view(frame, layout[1]);
+

+
            let block = Block::default()
+
                .borders(BorderSides::ALL)
+
                .border_style(Style::default().fg(Color::Rgb(48, 48, 48)))
+
                .border_type(BorderType::Rounded);
+
            frame.render_widget(block, area);
+
        }
+
    }
+

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

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

pub struct LabeledContainer {
    header: Widget<Header<1>>,
    component: Box<dyn MockComponent>,
modified radicle-tui/src/ui/widget/common/list.rs
@@ -11,6 +11,7 @@ use crate::ui::widget::{utils, Widget, WidgetComponent};

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

/// A generic item that can be displayed in a table with [`W`] columns.
pub trait TableItem<const W: usize> {
@@ -40,19 +41,33 @@ pub enum ColumnWidth {
/// A component that displays a labeled property.
#[derive(Clone)]
pub struct Property {
-
    label: Widget<Label>,
+
    name: Widget<Label>,
    divider: Widget<Label>,
-
    property: Widget<Label>,
+
    value: Widget<Label>,
}

impl Property {
-
    pub fn new(label: Widget<Label>, divider: Widget<Label>, property: Widget<Label>) -> Self {
+
    pub fn new(name: Widget<Label>, value: Widget<Label>) -> Self {
+
        let divider = label("");
        Self {
-
            label,
+
            name,
            divider,
-
            property,
+
            value,
        }
    }
+

+
    pub fn with_divider(mut self, divider: Widget<Label>) -> Self {
+
        self.divider = divider;
+
        self
+
    }
+

+
    pub fn name(&self) -> &Widget<Label> {
+
        &self.name
+
    }
+

+
    pub fn value(&self) -> &Widget<Label> {
+
        &self.value
+
    }
}

impl WidgetComponent for Property {
@@ -63,9 +78,9 @@ impl WidgetComponent for Property {

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

            let layout = layout::h_stack(labels, area);
@@ -125,6 +140,45 @@ impl WidgetComponent for PropertyList {
    }
}

+
pub struct PropertyTable {
+
    properties: Vec<Widget<Property>>,
+
}
+

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

+
impl WidgetComponent for PropertyTable {
+
    fn view(&mut self, properties: &Props, frame: &mut Frame, area: Rect) {
+
        use tuirealm::tui::widgets::Table;
+

+
        let display = properties
+
            .get_or(Attribute::Display, AttrValue::Flag(true))
+
            .unwrap_flag();
+

+
        if display {
+
            let rows = self
+
                .properties
+
                .iter()
+
                .map(|p| Row::new([Cell::from(p.name()), Cell::from(p.value())]));
+

+
            let table = Table::new(rows)
+
                .widths([Constraint::Percentage(20), Constraint::Percentage(80)].as_ref());
+
            frame.render_widget(table, area);
+
        }
+
    }
+

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

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

/// A table component that can display a list of [`TableItem`]s hold by a [`TableModel`].
pub struct Table<V, const W: usize>
where
modified radicle-tui/src/ui/widget/issue.rs
@@ -5,11 +5,13 @@ use radicle::cob::issue::IssueId;
use radicle::Profile;
use tuirealm::props::Color;

+
use super::common::container::Container;
use super::common::container::LabeledContainer;
use super::common::list::List;
+
use super::common::list::Property;
use super::Widget;

-
use crate::cob;
+
use crate::ui::cob;
use crate::ui::cob::IssueItem;
use crate::ui::context::Context;
use crate::ui::theme::Theme;
@@ -18,13 +20,14 @@ use crate::ui::widget::common::context::ContextBar;
use super::*;

pub struct LargeList {
-
    container: Widget<LabeledContainer>,
+
    items: Vec<IssueItem>,
+
    list: Widget<LabeledContainer>,
}

impl LargeList {
    pub fn new(context: &Context, theme: &Theme, selected: Option<(IssueId, Issue)>) -> Self {
        let repo = context.repository();
-
        let issues = cob::issue::all(repo).unwrap_or_default();
+
        let issues = crate::cob::issue::all(repo).unwrap_or_default();
        let mut items = issues
            .iter()
            .map(|(id, issue)| IssueItem::from((context.profile(), repo, *id, issue.clone())))
@@ -41,13 +44,20 @@ impl LargeList {

        let container = common::labeled_container(theme, "Issues", list.to_boxed());

-
        Self { container }
+
        Self {
+
            items,
+
            list: container,
+
        }
+
    }
+

+
    pub fn items(&self) -> &Vec<IssueItem> {
+
        &self.items
    }
}

impl WidgetComponent for LargeList {
    fn view(&mut self, _properties: &Props, frame: &mut Frame, area: Rect) {
-
        self.container.view(frame, area);
+
        self.list.view(frame, area);
    }

    fn state(&self) -> State {
@@ -55,7 +65,76 @@ impl WidgetComponent for LargeList {
    }

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

+
pub struct Details {
+
    container: Widget<Container>,
+
}
+

+
impl Details {
+
    pub fn new(context: &Context, theme: &Theme, issue: (IssueId, Issue)) -> Self {
+
        let repo = context.repository();
+

+
        let (id, issue) = issue;
+
        let item = IssueItem::from((context.profile(), repo, id, issue));
+

+
        let title = Property::new(
+
            common::label("Title").foreground(theme.colors.property_name_fg),
+
            common::label(item.title()).foreground(theme.colors.browser_list_title),
+
        );
+

+
        let tags = Property::new(
+
            common::label("Tags").foreground(theme.colors.property_name_fg),
+
            common::label(&cob::format_tags(item.tags()))
+
                .foreground(theme.colors.browser_list_tags),
+
        );
+

+
        let assignees = Property::new(
+
            common::label("Assignees").foreground(theme.colors.property_name_fg),
+
            common::label(&cob::format_assignees(
+
                &item
+
                    .assignees()
+
                    .iter()
+
                    .map(|item| (item.did(), item.is_you()))
+
                    .collect::<Vec<_>>(),
+
            ))
+
            .foreground(theme.colors.browser_list_author),
+
        );
+

+
        let state = Property::new(
+
            common::label("Status").foreground(theme.colors.property_name_fg),
+
            common::label(&item.state().to_string()).foreground(theme.colors.browser_list_title),
+
        );
+

+
        // let table = common::property_table(theme, vec![title, tags, assignees, state]);
+
        let table = common::property_table(
+
            theme,
+
            vec![
+
                Widget::new(title),
+
                Widget::new(tags),
+
                Widget::new(assignees),
+
                Widget::new(state),
+
            ],
+
        );
+
        let container = common::container(theme, table.to_boxed());
+

+
        Self { container }
+
    }
+
}
+

+
impl WidgetComponent for Details {
+
    fn view(&mut self, _properties: &Props, frame: &mut Frame, area: Rect) {
+
        self.container.view(frame, area);
+
    }
+

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

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

@@ -65,9 +144,12 @@ pub fn list(context: &Context, theme: &Theme, issue: (IssueId, Issue)) -> Widget
    Widget::new(list)
}

-
pub fn context(theme: &Theme, issue: (IssueId, &Issue), profile: &Profile) -> Widget<ContextBar> {
-
    use crate::ui::cob;
+
pub fn details(context: &Context, theme: &Theme, issue: (IssueId, Issue)) -> Widget<Details> {
+
    let details = Details::new(context, theme, issue);
+
    Widget::new(details)
+
}

+
pub fn context(theme: &Theme, issue: (IssueId, &Issue), profile: &Profile) -> Widget<ContextBar> {
    let (id, issue) = issue;
    let is_you = *issue.author().id() == profile.did();