tui: Implement larger list on issue page

This commit is contained in:
Erik Kundt 2023-05-23 18:27:03 +02:00
parent 8e7d53aa9a
commit dd16ed780e
10 changed files with 343 additions and 83 deletions

View File

@ -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)?;
}

View File

@ -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 {

View File

@ -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(())
}
}

View File

@ -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),

View File

@ -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],
}
}

View File

@ -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
}
}

View File

@ -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,

View File

@ -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)
}

View File

@ -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,7 @@ 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 {
@ -236,11 +210,87 @@ 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,
},
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 {
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,
},

View File

@ -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);
use super::*;
let not_implemented = common::label("not implemented").foreground(theme.colors.default_fg);
let activity = Activity::new(not_implemented, context, shortcuts);
pub struct LargeList {
container: Widget<LabeledContainer>,
}
Widget::new(activity)
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();