Radish alpha
r
Radicle terminal user interface
Radicle
Git (anonymous pull)
Log in to clone via SSH
tui: Implement larger list on issue page
Erik Kundt committed 2 years ago
commit 23fe24ac7dc2de250ebdb32d70b7fa9b64f200c2
parent 716738e84cf134e104dbf2121d196ea599da144a
10 files changed +345 -85
modified radicle-tui/src/app.rs
@@ -39,6 +39,7 @@ pub enum PatchCid {
#[derive(Debug, Eq, PartialEq, Clone, Hash)]
pub enum IssueCid {
    List,
+
    Shortcuts,
}

/// All component ids known to this application.
@@ -174,6 +175,9 @@ impl Tui<Cid, Message> for App {
                        Message::Issue(IssueMessage::Show(id)) => {
                            self.view_issue(app, id, &theme)?;
                        }
+
                        Message::Issue(IssueMessage::Leave) => {
+
                            self.pages.pop(app)?;
+
                        }
                        Message::Patch(PatchMessage::Show(id)) => {
                            self.view_patch(app, id, &theme)?;
                        }
modified radicle-tui/src/app/event.rs
@@ -6,7 +6,7 @@ use radicle_tui::ui::widget::common::container::{GlobalListener, LabeledContaine
use radicle_tui::ui::widget::common::context::{ContextBar, Shortcuts};
use radicle_tui::ui::widget::common::list::PropertyList;
use radicle_tui::ui::widget::home::{Dashboard, IssueBrowser, PatchBrowser};
-
use radicle_tui::ui::widget::patch;
+
use radicle_tui::ui::widget::{issue, patch};

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

@@ -45,6 +45,27 @@ impl tuirealm::Component<Message, NoUserEvent> for Widget<Tabs> {
    }
}

+
impl tuirealm::Component<Message, NoUserEvent> for Widget<issue::LargeList> {
+
    fn on(&mut self, event: Event<NoUserEvent>) -> Option<Message> {
+
        match event {
+
            Event::Keyboard(KeyEvent { code: Key::Esc, .. }) => {
+
                Some(Message::Issue(IssueMessage::Leave))
+
            }
+
            Event::Keyboard(KeyEvent { code: Key::Up, .. }) => {
+
                self.perform(Cmd::Move(MoveDirection::Up));
+
                Some(Message::Tick)
+
            }
+
            Event::Keyboard(KeyEvent {
+
                code: Key::Down, ..
+
            }) => {
+
                self.perform(Cmd::Move(MoveDirection::Down));
+
                Some(Message::Tick)
+
            }
+
            _ => 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
@@ -163,9 +163,19 @@ impl ViewPage for IssuePage {
        theme: &Theme,
    ) -> Result<()> {
        let (id, issue) = &self.issue;
-
        let list = widget::issue::list(theme, (*id, issue), context.profile()).to_boxed();
+
        let list = widget::issue::list(context, theme, (*id, issue)).to_boxed();
+
        let shortcuts = widget::common::shortcuts(
+
            theme,
+
            vec![
+
                widget::common::shortcut(theme, "esc", "back"),
+
                widget::common::shortcut(theme, "q", "quit"),
+
            ],
+
        )
+
        .to_boxed();

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

        app.active(&self.active_component)?;

        Ok(())
@@ -173,6 +183,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::Shortcuts))?;
        Ok(())
    }

@@ -188,27 +199,18 @@ impl ViewPage for IssuePage {

    fn view(&mut self, app: &mut Application<Cid, Message, NoUserEvent>, frame: &mut Frame) {
        let area = frame.size();
-
        let layout = layout::default_page(area);
+
        let shortcuts_h = 1u16;
+
        let layout = layout::issue_preview(area, shortcuts_h);

-
        app.view(&Cid::Patch(PatchCid::Navigation), frame, layout[0]);
-
        app.view(&self.active_component, frame, layout[1]);
+
        app.view(&Cid::Issue(IssueCid::List), frame, layout.left);
+
        app.view(&Cid::Issue(IssueCid::Shortcuts), frame, layout.shortcuts);
    }

-
    fn subscribe(&self, app: &mut Application<Cid, Message, NoUserEvent>) -> Result<()> {
-
        app.subscribe(
-
            &Cid::Home(HomeCid::Navigation),
-
            Sub::new(subscription::navigation_clause(), SubClause::Always),
-
        )?;
-

+
    fn subscribe(&self, _app: &mut Application<Cid, Message, NoUserEvent>) -> Result<()> {
        Ok(())
    }

-
    fn unsubscribe(&self, app: &mut Application<Cid, Message, NoUserEvent>) -> Result<()> {
-
        app.unsubscribe(
-
            &Cid::Home(HomeCid::Navigation),
-
            subscription::navigation_clause(),
-
        )?;
-

+
    fn unsubscribe(&self, _app: &mut Application<Cid, Message, NoUserEvent>) -> Result<()> {
        Ok(())
    }
}
modified radicle-tui/src/ui/cob.rs
@@ -13,11 +13,14 @@ use radicle::cob::patch::{Patch, PatchId, State as PatchState};
use radicle::cob::{Tag, Timestamp};

use tuirealm::props::{Color, Style};
+
use tuirealm::tui::text::{Span, Spans};
use tuirealm::tui::widgets::Cell;

use crate::ui::theme::Theme;
use crate::ui::widget::common::list::TableItem;

+
use super::widget::common::list::ListItem;
+

/// An author item that can be used in tables, list or trees.
///
/// Breaks up dependencies to [`Profile`] and [`Repository`] that
@@ -197,13 +200,11 @@ impl IssueItem {
    }
}

-
impl TryFrom<(&Profile, &Repository, IssueId, Issue)> for IssueItem {
-
    type Error = anyhow::Error;
-

-
    fn try_from(value: (&Profile, &Repository, IssueId, Issue)) -> Result<Self, Self::Error> {
+
impl From<(&Profile, &Repository, IssueId, Issue)> for IssueItem {
+
    fn from(value: (&Profile, &Repository, IssueId, Issue)) -> Self {
        let (profile, _, id, issue) = value;

-
        Ok(IssueItem {
+
        IssueItem {
            id,
            state: *issue.state(),
            title: issue.title().into(),
@@ -220,7 +221,7 @@ impl TryFrom<(&Profile, &Repository, IssueId, Issue)> for IssueItem {
                })
                .collect::<Vec<_>>(),
            timestamp: issue.timestamp(),
-
        })
+
        }
    }
}

@@ -256,6 +257,37 @@ impl TableItem<7> for IssueItem {
    }
}

+
impl ListItem for IssueItem {
+
    fn row(&self, theme: &Theme) -> tuirealm::tui::widgets::ListItem {
+
        let (state, state_color) = format_issue_state(&self.state);
+
        let lines = vec![
+
            Spans::from(vec![
+
                Span::styled(state, Style::default().fg(state_color)),
+
                Span::styled(
+
                    self.title.clone(),
+
                    Style::default().fg(theme.colors.browser_list_title),
+
                ),
+
            ]),
+
            Spans::from(vec![
+
                Span::raw(String::from("   ")),
+
                Span::styled(
+
                    format_author(&self.author.did, self.author.is_you),
+
                    Style::default().fg(theme.colors.browser_list_author),
+
                ),
+
                Span::styled(
+
                    format!(" {} ", theme.icons.property_divider),
+
                    Style::default().fg(theme.colors.property_divider_fg),
+
                ),
+
                Span::styled(
+
                    format::timestamp(&self.timestamp).to_string(),
+
                    Style::default().fg(theme.colors.browser_list_timestamp),
+
                ),
+
            ]),
+
        ];
+
        tuirealm::tui::widgets::ListItem::new(lines)
+
    }
+
}
+

pub fn format_patch_state(state: &PatchState) -> (String, Color) {
    match state {
        PatchState::Open { conflicts: _ } => (" ● ".into(), Color::Green),
modified radicle-tui/src/ui/layout.rs
@@ -2,6 +2,12 @@ use tuirealm::props::{AttrValue, Attribute};
use tuirealm::tui::layout::{Constraint, Direction, Layout, Rect};
use tuirealm::MockComponent;

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

pub fn v_stack(
    widgets: Vec<Box<dyn MockComponent>>,
    area: Rect,
@@ -58,6 +64,17 @@ pub fn default_page(area: Rect) -> Vec<Rect> {
        .split(area)
}

+
pub fn headerless_page(area: Rect) -> Vec<Rect> {
+
    let margin_h = 1u16;
+
    let content_h = area.height.saturating_sub(margin_h);
+

+
    Layout::default()
+
        .direction(Direction::Vertical)
+
        .horizontal_margin(margin_h)
+
        .constraints([Constraint::Length(content_h)].as_ref())
+
        .split(area)
+
}
+

pub fn root_component(area: Rect, shortcuts_h: u16) -> Vec<Rect> {
    let content_h = area.height.saturating_sub(shortcuts_h);

@@ -120,3 +137,30 @@ pub fn centered_label(label_w: u16, area: Rect) -> Rect {
        )
        .split(layout[1])[1]
}
+

+
pub fn issue_preview(area: Rect, shortcuts_h: u16) -> IssuePreview {
+
    let content_h = area.height.saturating_sub(shortcuts_h);
+

+
    let root = Layout::default()
+
        .direction(Direction::Vertical)
+
        .horizontal_margin(1)
+
        .constraints(
+
            [
+
                Constraint::Length(content_h),
+
                Constraint::Length(shortcuts_h),
+
            ]
+
            .as_ref(),
+
        )
+
        .split(area);
+

+
    let split = Layout::default()
+
        .direction(Direction::Horizontal)
+
        .constraints([Constraint::Percentage(50), Constraint::Percentage(50)].as_ref())
+
        .split(root[0]);
+

+
    IssuePreview {
+
        left: split[0],
+
        right: split[1],
+
        shortcuts: root[1],
+
    }
+
}
modified radicle-tui/src/ui/state.rs
@@ -1,3 +1,5 @@
+
use tuirealm::tui::widgets::{ListState, TableState};
+

/// State that holds the index of a selected tab item and the count of all tab items.
/// The index can be increased and will start at 0, if length was reached.
#[derive(Clone, Default)]
@@ -15,3 +17,72 @@ impl TabState {
        }
    }
}
+

+
#[derive(Clone)]
+
pub struct ItemState {
+
    selected: Option<usize>,
+
    len: usize,
+
}
+

+
impl ItemState {
+
    pub fn new(len: usize) -> Self {
+
        Self {
+
            selected: Some(0),
+
            len,
+
        }
+
    }
+

+
    pub fn selected(&self) -> Option<usize> {
+
        self.selected
+
    }
+

+
    pub fn select_previous(&mut self) -> Option<usize> {
+
        let old_index = self.selected();
+
        let new_index = match old_index {
+
            Some(selected) if selected == 0 => Some(0),
+
            Some(selected) => Some(selected.saturating_sub(1)),
+
            None => Some(0),
+
        };
+

+
        if old_index != new_index {
+
            self.selected = new_index;
+
            self.selected()
+
        } else {
+
            None
+
        }
+
    }
+

+
    pub fn select_next(&mut self) -> Option<usize> {
+
        let old_index = self.selected();
+
        let new_index = match old_index {
+
            Some(selected) if selected >= self.len.saturating_sub(1) => {
+
                Some(self.len.saturating_sub(1))
+
            }
+
            Some(selected) => Some(selected.saturating_add(1)),
+
            None => Some(0),
+
        };
+

+
        if old_index != new_index {
+
            self.selected = new_index;
+
            self.selected()
+
        } else {
+
            None
+
        }
+
    }
+
}
+

+
impl From<&ItemState> for TableState {
+
    fn from(value: &ItemState) -> Self {
+
        let mut state = TableState::default();
+
        state.select(value.selected);
+
        state
+
    }
+
}
+

+
impl From<&ItemState> for ListState {
+
    fn from(value: &ItemState) -> Self {
+
        let mut state = ListState::default();
+
        state.select(value.selected);
+
        state
+
    }
+
}
modified radicle-tui/src/ui/theme.rs
@@ -21,6 +21,7 @@ pub struct Colors {
    pub shortcutbar_divider_fg: Color,
    pub browser_list_id: Color,
    pub browser_list_title: Color,
+
    pub browser_list_description: Color,
    pub browser_list_author: Color,
    pub browser_list_tags: Color,
    pub browser_list_comments: Color,
@@ -82,12 +83,13 @@ pub fn default_dark() -> Theme {
            labeled_container_bg: COLOR_DEFAULT_FAINT,
            item_list_highlighted_bg: COLOR_DEFAULT_DARKER,
            property_name_fg: Color::Cyan,
-
            property_divider_fg: COLOR_DEFAULT_FG,
+
            property_divider_fg: COLOR_DEFAULT_DARK,
            shortcut_short_fg: COLOR_DEFAULT_DARK,
            shortcut_long_fg: COLOR_DEFAULT_DARKER,
            shortcutbar_divider_fg: COLOR_DEFAULT_DARKER,
            browser_list_id: Color::Cyan,
            browser_list_title: COLOR_DEFAULT_FG,
+
            browser_list_description: COLOR_DEFAULT_DARK,
            browser_list_author: Color::Gray,
            browser_list_tags: Color::LightBlue,
            browser_list_comments: COLOR_DEFAULT_DARK_FG,
modified radicle-tui/src/ui/widget/common.rs
@@ -38,7 +38,7 @@ pub fn reversable_label(content: &str) -> Widget<Label> {
}

pub fn container_header(theme: &Theme, label: Widget<Label>) -> Widget<Header<1>> {
-
    let header = Header::new([label], [ColumnWidth::Fixed(100)], theme.clone());
+
    let header = Header::new([label], [ColumnWidth::Grow], theme.clone());

    Widget::new(header)
}
modified radicle-tui/src/ui/widget/common/list.rs
@@ -1,10 +1,11 @@
use tuirealm::command::{Cmd, CmdResult};
use tuirealm::props::{AttrValue, Attribute, BorderSides, BorderType, Color, Props, Style};
use tuirealm::tui::layout::{Constraint, Direction, Layout, Rect};
-
use tuirealm::tui::widgets::{Block, Cell, Row, TableState};
+
use tuirealm::tui::widgets::{Block, Cell, ListState, Row, TableState};
use tuirealm::{Frame, MockComponent, State, StateValue};

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

@@ -17,12 +18,18 @@ pub trait TableItem<const W: usize> {
    fn row(&self, theme: &Theme) -> [Cell; W];
}

+
/// A generic item that can be displayed in a list.
+
pub trait ListItem {
+
    /// Should return fields as list item.
+
    fn row(&self, theme: &Theme) -> tuirealm::tui::widgets::ListItem;
+
}
+

/// Grow behavior of a table column.
///
/// [`tui::widgets::Table`] does only support percental column widths.
/// A [`ColumnWidth`] is used to specify the grow behaviour of a table column
/// and a percental column width is calculated based on that.
-
#[derive(Clone, Copy, Eq, PartialEq)]
+
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ColumnWidth {
    /// A fixed-size column.
    Fixed(u16),
@@ -129,7 +136,9 @@ where
    header: [Widget<Label>; W],
    /// Grow behavior of table columns.
    widths: [ColumnWidth; W],
-
    state: TableState,
+
    /// State that keeps track of the selection.
+
    state: ItemState,
+
    /// The current theme.
    theme: Theme,
}

@@ -143,49 +152,14 @@ where
        widths: [ColumnWidth; W],
        theme: Theme,
    ) -> Self {
-
        let mut state = TableState::default();
-
        state.select(Some(0));
-

        Self {
            items: items.to_vec(),
            header,
            widths,
-
            state,
+
            state: ItemState::new(items.len()),
            theme,
        }
    }
-

-
    fn select_previous(&mut self) -> Option<usize> {
-
        let old_index = self.state.selected();
-
        let new_index = match old_index {
-
            Some(selected) if selected == 0 => Some(0),
-
            Some(selected) => Some(selected.saturating_sub(1)),
-
            None => Some(0),
-
        };
-

-
        if old_index != new_index {
-
            self.state.select(new_index);
-
            self.state.selected()
-
        } else {
-
            None
-
        }
-
    }
-

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

-
        if old_index != new_index {
-
            self.state.select(new_index);
-
            self.state.selected()
-
        } else {
-
            None
-
        }
-
    }
}

impl<V, const W: usize> WidgetComponent for Table<V, W>
@@ -226,7 +200,83 @@ where
            self.theme.clone(),
        ));
        header.view(frame, layout[0]);
-
        frame.render_stateful_widget(table, layout[1], &mut self.state);
+
        frame.render_stateful_widget(table, layout[1], &mut TableState::from(&self.state));
+
    }
+

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

+
    fn perform(&mut self, _properties: &Props, cmd: Cmd) -> CmdResult {
+
        use tuirealm::command::Direction;
+
        match cmd {
+
            Cmd::Move(Direction::Up) => match self.state.select_previous() {
+
                Some(selected) => CmdResult::Changed(State::One(StateValue::Usize(selected))),
+
                None => CmdResult::None,
+
            },
+
            Cmd::Move(Direction::Down) => match self.state.select_next() {
+
                Some(selected) => CmdResult::Changed(State::One(StateValue::Usize(selected))),
+
                None => CmdResult::None,
+
            },
+
            Cmd::Submit => match self.state.selected() {
+
                Some(selected) => CmdResult::Submit(State::One(StateValue::Usize(selected))),
+
                None => CmdResult::None,
+
            },
+
            _ => CmdResult::None,
+
        }
+
    }
+
}
+

+
/// A list component that can display [`ListItem`]'s.
+
pub struct List<V>
+
where
+
    V: ListItem + Clone,
+
{
+
    /// Items held by this list.
+
    items: Vec<V>,
+
    /// State keeps track of the current selection.
+
    state: ItemState,
+
    /// The current theme.
+
    theme: Theme,
+
}
+

+
impl<V> List<V>
+
where
+
    V: ListItem + Clone,
+
{
+
    pub fn new(items: &[V], theme: Theme) -> Self {
+
        Self {
+
            items: items.to_vec(),
+
            state: ItemState::new(items.len()),
+
            theme,
+
        }
+
    }
+
}
+

+
impl<V> WidgetComponent for List<V>
+
where
+
    V: ListItem + Clone,
+
{
+
    fn view(&mut self, properties: &Props, frame: &mut Frame, area: Rect) {
+
        use tuirealm::tui::widgets::{List, ListItem};
+

+
        let highlight = properties
+
            .get_or(Attribute::HighlightedColor, AttrValue::Color(Color::Reset))
+
            .unwrap_color();
+

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

+
        let rows: Vec<ListItem> = self
+
            .items
+
            .iter()
+
            .map(|item| item.row(&self.theme))
+
            .collect();
+
        let list = List::new(rows).highlight_style(Style::default().bg(highlight));
+

+
        frame.render_stateful_widget(list, layout[0], &mut ListState::from(&self.state));
    }

    fn state(&self) -> State {
@@ -236,11 +286,11 @@ where
    fn perform(&mut self, _properties: &Props, cmd: Cmd) -> CmdResult {
        use tuirealm::command::Direction;
        match cmd {
-
            Cmd::Move(Direction::Up) => match self.select_previous() {
+
            Cmd::Move(Direction::Up) => match self.state.select_previous() {
                Some(selected) => CmdResult::Changed(State::One(StateValue::Usize(selected))),
                None => CmdResult::None,
            },
-
            Cmd::Move(Direction::Down) => match self.select_next(self.items.len()) {
+
            Cmd::Move(Direction::Down) => match self.state.select_next() {
                Some(selected) => CmdResult::Changed(State::One(StateValue::Usize(selected))),
                None => CmdResult::None,
            },
modified radicle-tui/src/ui/widget/issue.rs
@@ -5,32 +5,66 @@ use radicle::cob::issue::IssueId;
use radicle::Profile;
use tuirealm::props::Color;

-
use super::common;
+
use super::common::container::LabeledContainer;
+
use super::common::list::List;
use super::Widget;

-
use crate::ui::cob;
+
use crate::cob;
+
use crate::ui::cob::IssueItem;
+
use crate::ui::context::Context;
use crate::ui::theme::Theme;
use crate::ui::widget::common::context::ContextBar;
-
use crate::ui::widget::patch::Activity;

-
pub fn list(theme: &Theme, issue: (IssueId, &Issue), profile: &Profile) -> Widget<Activity> {
-
    let (id, issue) = issue;
-
    let shortcuts = common::shortcuts(
-
        theme,
-
        vec![
-
            common::shortcut(theme, "esc", "back"),
-
            common::shortcut(theme, "q", "quit"),
-
        ],
-
    );
-
    let context = context(theme, (id, issue), profile);
-

-
    let not_implemented = common::label("not implemented").foreground(theme.colors.default_fg);
-
    let activity = Activity::new(not_implemented, context, shortcuts);
-

-
    Widget::new(activity)
+
use super::*;
+

+
pub struct LargeList {
+
    container: Widget<LabeledContainer>,
+
}
+

+
impl LargeList {
+
    pub fn new(context: &Context, theme: &Theme) -> Self {
+
        let repo = context.repository();
+
        let issues = cob::issue::all(repo).unwrap_or(vec![]);
+

+
        let mut items = issues
+
            .iter()
+
            .map(|(id, issue)| IssueItem::from((context.profile(), repo, *id, issue.clone())))
+
            .collect::<Vec<_>>();
+

+
        items.sort_by(|a, b| b.timestamp().cmp(a.timestamp()));
+
        items.sort_by(|a, b| a.state().cmp(b.state()));
+

+
        let list = Widget::new(List::new(&items, theme.clone()))
+
            .highlight(theme.colors.item_list_highlighted_bg);
+

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

+
        Self { container }
+
    }
+
}
+

+
impl WidgetComponent for LargeList {
+
    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 {
+
        self.container.perform(cmd)
+
    }
+
}
+

+
pub fn list(context: &Context, theme: &Theme, _issue: (IssueId, &Issue)) -> Widget<LargeList> {
+
    let list = LargeList::new(context, theme);
+
    Widget::new(list)
}

pub fn context(theme: &Theme, issue: (IssueId, &Issue), profile: &Profile) -> Widget<ContextBar> {
+
    use crate::ui::cob;
+

    let (id, issue) = issue;
    let is_you = *issue.author().id() == profile.did();