Radish alpha
r
Radicle terminal user interface
Radicle
Git (anonymous pull)
Log in to clone via SSH
patch-list: Add basic application /wo operations
Erik Kundt committed 2 years ago
commit 9253736a528e54b71ef4539f9baececb4ef94912
parent eb3ac81ad260f464f5deb759f74085421c82e9fe
7 files changed +528 -3
modified bin/commands/patch.rs
@@ -1,5 +1,7 @@
#[path = "patch/common.rs"]
mod common;
+
#[path = "patch/list.rs"]
+
mod list;
#[path = "patch/select.rs"]
mod select;
#[path = "patch/suite.rs"]
@@ -23,6 +25,7 @@ pub const HELP: Help = Help {
Usage

    rad-tui patch select
+
    rad-tui patch list

General options

@@ -35,11 +38,13 @@ pub struct Options {
}

pub enum Operation {
+
    List,
    Select,
}

#[derive(PartialEq, Eq)]
pub enum OperationName {
+
    List,
    Select,
}

@@ -57,6 +62,7 @@ impl Args for Options {
                    return Err(Error::Help.into());
                }
                Value(val) if op.is_none() => match val.to_string_lossy().as_ref() {
+
                    "list" => op = Some(OperationName::List),
                    "select" => op = Some(OperationName::Select),
                    unknown => anyhow::bail!("unknown operation '{}'", unknown),
                },
@@ -65,6 +71,7 @@ impl Args for Options {
        }

        let op = match op.ok_or_else(|| anyhow!("an operation must be provided"))? {
+
            OperationName::List => Operation::List,
            OperationName::Select => Operation::Select,
        };
        Ok((Options { op }, vec![]))
@@ -76,6 +83,17 @@ pub fn run(options: Options, _ctx: impl terminal::Context) -> anyhow::Result<()>
        .map_err(|_| anyhow!("this command must be run in the context of a project"))?;

    match options.op {
+
        Operation::List => {
+
            let context = context::Context::new(id)?.with_patches();
+

+
            log::enable(context.profile(), "patch", "list")?;
+

+
            let patch_id = Window::default()
+
                .run(&mut list::App::new(context), 1000 / FPS)?
+
                .unwrap_or_default();
+

+
            eprint!("{patch_id}");
+
        }
        Operation::Select => {
            let context = context::Context::new(id)?.with_patches();

added bin/commands/patch/list.rs
@@ -0,0 +1,163 @@
+
#[path = "list/event.rs"]
+
mod event;
+
#[path = "list/page.rs"]
+
mod page;
+
#[path = "list/ui.rs"]
+
mod ui;
+

+
use std::hash::Hash;
+

+
use anyhow::Result;
+

+
use radicle::cob::patch::PatchId;
+

+
use tuirealm::application::PollStrategy;
+
use tuirealm::event::Key;
+
use tuirealm::{Application, Frame, NoUserEvent, Sub, SubClause};
+

+
use radicle_tui as tui;
+

+
use tui::context::Context;
+
use tui::ui::subscription;
+
use tui::ui::theme::Theme;
+
use tui::{Exit, PageStack, Tui};
+

+
use page::ListView;
+

+
#[derive(Debug, Eq, PartialEq, Clone, Hash)]
+
pub enum ListCid {
+
    Header,
+
    PatchBrowser,
+
    Context,
+
    Shortcuts,
+
}
+

+
/// All component ids known to this application.
+
#[derive(Debug, Default, Eq, PartialEq, Clone, Hash)]
+
pub enum Cid {
+
    List(ListCid),
+
    #[default]
+
    GlobalListener,
+
}
+

+
#[derive(Clone, Default, Debug, Eq, PartialEq)]
+
pub enum Message {
+
    #[default]
+
    Tick,
+
    Quit(Option<PatchId>),
+
    Batch(Vec<Message>),
+
}
+

+
pub struct App {
+
    context: Context,
+
    pages: PageStack<Cid, Message>,
+
    theme: Theme,
+
    quit: bool,
+
    result: Option<PatchId>,
+
}
+

+
/// Creates a new application using a tui-realm-application, mounts all
+
/// components and sets focus to a default one.
+
impl App {
+
    pub fn new(context: Context) -> Self {
+
        Self {
+
            context,
+
            pages: PageStack::default(),
+
            theme: Theme::default(),
+
            quit: false,
+
            result: None,
+
        }
+
    }
+

+
    fn view_list(
+
        &mut self,
+
        app: &mut Application<Cid, Message, NoUserEvent>,
+
        theme: &Theme,
+
    ) -> Result<()> {
+
        let home = Box::new(ListView::new(theme.clone()));
+
        self.pages.push(home, app, &self.context, theme)?;
+

+
        Ok(())
+
    }
+

+
    fn process(
+
        &mut self,
+
        app: &mut Application<Cid, Message, NoUserEvent>,
+
        message: Message,
+
    ) -> Result<Option<Message>> {
+
        let theme = Theme::default();
+
        match message {
+
            Message::Batch(messages) => {
+
                let mut results = vec![];
+
                for message in messages {
+
                    if let Some(result) = self.process(app, message)? {
+
                        results.push(result);
+
                    }
+
                }
+
                match results.len() {
+
                    0 => Ok(None),
+
                    1 => Ok(Some(results[0].to_owned())),
+
                    _ => Ok(Some(Message::Batch(results))),
+
                }
+
            }
+
            Message::Quit(id) => {
+
                self.quit = true;
+
                self.result = id;
+
                Ok(None)
+
            }
+
            _ => self
+
                .pages
+
                .peek_mut()?
+
                .update(app, &self.context, &theme, message),
+
        }
+
    }
+
}
+

+
impl Tui<Cid, Message> for App {
+
    fn init(&mut self, app: &mut Application<Cid, Message, NoUserEvent>) -> Result<()> {
+
        self.view_list(app, &self.theme.clone())?;
+

+
        // Add global key listener and subscribe to key events
+
        let global = tui::ui::global_listener().to_boxed();
+
        app.mount(
+
            Cid::GlobalListener,
+
            global,
+
            vec![Sub::new(
+
                subscription::quit_clause(Key::Char('q')),
+
                SubClause::Always,
+
            )],
+
        )?;
+

+
        Ok(())
+
    }
+

+
    fn view(&mut self, app: &mut Application<Cid, Message, NoUserEvent>, frame: &mut Frame) {
+
        if let Ok(page) = self.pages.peek_mut() {
+
            page.view(app, frame);
+
        }
+
    }
+

+
    fn update(&mut self, app: &mut Application<Cid, Message, NoUserEvent>) -> Result<bool> {
+
        match app.tick(PollStrategy::Once) {
+
            Ok(messages) if !messages.is_empty() => {
+
                for message in messages {
+
                    let mut msg = Some(message);
+
                    while msg.is_some() {
+
                        msg = self.process(app, msg.unwrap())?;
+
                    }
+
                }
+
                Ok(true)
+
            }
+
            _ => Ok(false),
+
        }
+
    }
+

+
    fn exit(&self) -> Option<Exit> {
+
        if self.quit {
+
            return Some(Exit {
+
                value: self.result.map(|id| format!("{id}")),
+
            });
+
        }
+
        None
+
    }
+
}
added bin/commands/patch/list/event.rs
@@ -0,0 +1,87 @@
+
use tuirealm::command::{Cmd, Direction as MoveDirection};
+
use tuirealm::event::{Event, Key, KeyEvent};
+
use tuirealm::{MockComponent, NoUserEvent};
+

+
use radicle_tui as tui;
+

+
use tui::ui::widget::container::{AppHeader, GlobalListener, LabeledContainer};
+
use tui::ui::widget::context::{ContextBar, Shortcuts};
+
use tui::ui::widget::list::PropertyList;
+

+
use tui::ui::widget::Widget;
+

+
use super::super::common;
+
use super::Message;
+

+
/// Since the framework does not know the type of messages that are being
+
/// passed around in the app, the following handlers need to be implemented for
+
/// each component used.
+
///
+
/// TODO: should handle `Event::WindowResize`, which is not emitted by `termion`.
+
impl tuirealm::Component<Message, NoUserEvent> for Widget<GlobalListener> {
+
    fn on(&mut self, event: Event<NoUserEvent>) -> Option<Message> {
+
        match event {
+
            Event::Keyboard(KeyEvent {
+
                code: Key::Char('q'),
+
                ..
+
            }) => Some(Message::Quit(None)),
+
            _ => None,
+
        }
+
    }
+
}
+

+
impl tuirealm::Component<Message, NoUserEvent> for Widget<common::ui::PatchBrowser> {
+
    fn on(&mut self, event: Event<NoUserEvent>) -> Option<Message> {
+
        match event {
+
            Event::Keyboard(KeyEvent { code: Key::Up, .. })
+
            | Event::Keyboard(KeyEvent {
+
                code: Key::Char('k'),
+
                ..
+
            }) => {
+
                self.perform(Cmd::Move(MoveDirection::Up));
+
                Some(Message::Tick)
+
            }
+
            Event::Keyboard(KeyEvent {
+
                code: Key::Down, ..
+
            })
+
            | Event::Keyboard(KeyEvent {
+
                code: Key::Char('j'),
+
                ..
+
            }) => {
+
                self.perform(Cmd::Move(MoveDirection::Down));
+
                Some(Message::Tick)
+
            }
+
            _ => None,
+
        }
+
    }
+
}
+

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

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

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

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

+
impl tuirealm::Component<Message, NoUserEvent> for Widget<Shortcuts> {
+
    fn on(&mut self, _event: Event<NoUserEvent>) -> Option<Message> {
+
        None
+
    }
+
}
added bin/commands/patch/list/page.rs
@@ -0,0 +1,160 @@
+
use std::collections::HashMap;
+

+
use anyhow::Result;
+

+
use tuirealm::{Frame, NoUserEvent, State, StateValue, Sub, SubClause};
+

+
use radicle_tui as tui;
+

+
use tui::context::Context;
+
use tui::ui::theme::Theme;
+
use tui::ui::widget::context::{Progress, Shortcuts};
+
use tui::ui::widget::Widget;
+
use tui::ui::{layout, subscription};
+
use tui::ViewPage;
+

+
use super::{ui, Application, Cid, ListCid, Message};
+

+
///
+
/// Home
+
///
+
pub struct ListView {
+
    active_component: ListCid,
+
    shortcuts: HashMap<ListCid, Widget<Shortcuts>>,
+
}
+

+
impl ListView {
+
    pub fn new(theme: Theme) -> Self {
+
        let shortcuts = Self::build_shortcuts(&theme);
+
        Self {
+
            active_component: ListCid::PatchBrowser,
+
            shortcuts,
+
        }
+
    }
+

+
    fn build_shortcuts(theme: &Theme) -> HashMap<ListCid, Widget<Shortcuts>> {
+
        [(
+
            ListCid::PatchBrowser,
+
            tui::ui::shortcuts(
+
                theme,
+
                vec![
+
                    tui::ui::shortcut(theme, "↑/↓", "navigate"),
+
                    tui::ui::shortcut(theme, "q", "quit"),
+
                ],
+
            ),
+
        )]
+
        .iter()
+
        .cloned()
+
        .collect()
+
    }
+

+
    fn update_context(
+
        &self,
+
        app: &mut Application<Cid, Message, NoUserEvent>,
+
        context: &Context,
+
        theme: &Theme,
+
    ) -> Result<()> {
+
        let state = app.state(&Cid::List(ListCid::PatchBrowser))?;
+
        let progress = match state {
+
            State::Tup2((StateValue::Usize(step), StateValue::Usize(total))) => {
+
                Progress::Step(step.saturating_add(1), total)
+
            }
+
            _ => Progress::None,
+
        };
+
        let context = ui::browse_context(context, theme, progress);
+

+
        app.remount(Cid::List(ListCid::Context), context.to_boxed(), vec![])?;
+

+
        Ok(())
+
    }
+

+
    fn update_shortcuts(
+
        &self,
+
        app: &mut Application<Cid, Message, NoUserEvent>,
+
        cid: ListCid,
+
    ) -> Result<()> {
+
        if let Some(shortcuts) = self.shortcuts.get(&cid) {
+
            app.remount(
+
                Cid::List(ListCid::Shortcuts),
+
                shortcuts.clone().to_boxed(),
+
                vec![],
+
            )?;
+
        }
+
        Ok(())
+
    }
+
}
+

+
impl ViewPage<Cid, Message> for ListView {
+
    fn mount(
+
        &self,
+
        app: &mut Application<Cid, Message, NoUserEvent>,
+
        context: &Context,
+
        theme: &Theme,
+
    ) -> Result<()> {
+
        let navigation = ui::list_navigation(theme);
+
        let header = tui::ui::app_header(context, theme, Some(navigation)).to_boxed();
+
        let patch_browser = ui::patches(context, theme, None).to_boxed();
+

+
        app.remount(Cid::List(ListCid::Header), header, vec![])?;
+
        app.remount(Cid::List(ListCid::PatchBrowser), patch_browser, vec![])?;
+

+
        app.active(&Cid::List(self.active_component.clone()))?;
+
        self.update_shortcuts(app, self.active_component.clone())?;
+
        self.update_context(app, context, theme)?;
+

+
        Ok(())
+
    }
+

+
    fn unmount(&self, app: &mut Application<Cid, Message, NoUserEvent>) -> Result<()> {
+
        app.umount(&Cid::List(ListCid::Header))?;
+
        app.umount(&Cid::List(ListCid::PatchBrowser))?;
+
        app.umount(&Cid::List(ListCid::Context))?;
+
        app.umount(&Cid::List(ListCid::Shortcuts))?;
+
        Ok(())
+
    }
+

+
    fn update(
+
        &mut self,
+
        app: &mut Application<Cid, Message, NoUserEvent>,
+
        context: &Context,
+
        theme: &Theme,
+
        _message: Message,
+
    ) -> Result<Option<Message>> {
+
        self.update_context(app, context, theme)?;
+

+
        Ok(None)
+
    }
+

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

+
        app.view(
+
            &Cid::List(self.active_component.clone()),
+
            frame,
+
            layout.component,
+
        );
+

+
        app.view(&Cid::List(ListCid::Context), frame, layout.context);
+
        app.view(&Cid::List(ListCid::Shortcuts), frame, layout.shortcuts);
+
    }
+

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

+
        Ok(())
+
    }
+

+
    fn unsubscribe(&self, app: &mut Application<Cid, Message, NoUserEvent>) -> Result<()> {
+
        app.unsubscribe(
+
            &Cid::List(ListCid::Header),
+
            subscription::navigation_clause(),
+
        )?;
+

+
        Ok(())
+
    }
+
}
added bin/commands/patch/list/ui.rs
@@ -0,0 +1,91 @@
+
use radicle::cob::patch::{Patch, PatchId};
+

+
use radicle_tui as tui;
+

+
use tui::context::Context;
+
use tui::ui::theme::{style, Theme};
+
use tui::ui::widget::Widget;
+

+
use tui::ui::widget::container::Tabs;
+
use tui::ui::widget::context::{ContextBar, Progress};
+
use tui::ui::widget::label::{self};
+

+
use super::super::common;
+

+
pub fn list_navigation(theme: &Theme) -> Widget<Tabs> {
+
    tui::ui::tabs(
+
        theme,
+
        vec![label::reversable("Patches").style(style::cyan())],
+
    )
+
}
+

+
pub fn patches(
+
    context: &Context,
+
    theme: &Theme,
+
    selected: Option<(PatchId, Patch)>,
+
) -> Widget<common::ui::PatchBrowser> {
+
    Widget::new(common::ui::PatchBrowser::new(context, theme, selected))
+
}
+

+
pub fn browse_context(context: &Context, _theme: &Theme, progress: Progress) -> Widget<ContextBar> {
+
    use radicle::cob::patch::State;
+

+
    let mut draft = 0;
+
    let mut open = 0;
+
    let mut archived = 0;
+
    let mut merged = 0;
+

+
    let patches = context.patches().as_ref().unwrap();
+
    for (_, patch) in patches {
+
        match patch.state() {
+
            State::Draft => draft += 1,
+
            State::Open { conflicts: _ } => open += 1,
+
            State::Archived => archived += 1,
+
            State::Merged {
+
                commit: _,
+
                revision: _,
+
            } => merged += 1,
+
        }
+
    }
+

+
    let context = label::default("");
+
    let divider = label::default(" | ");
+

+
    let draft_n = label::default(&format!("{draft}")).style(style::gray_dim());
+
    let draft = label::default(" Draft");
+

+
    let open_n = label::default(&format!("{open}")).style(style::green());
+
    let open = label::default(" Open");
+

+
    let archived_n = label::default(&format!("{archived}")).style(style::yellow());
+
    let archived = label::default(" Archived");
+

+
    let merged_n = label::default(&format!("{merged}")).style(style::cyan());
+
    let merged = label::default(" Merged ");
+

+
    let progress =
+
        label::default(&format!(" {} ", progress.to_string())).style(style::magenta_reversed());
+
    let spacer = label::default("");
+

+
    let context_bar = ContextBar::new(
+
        label::group(&[context]),
+
        label::group(&[spacer.clone()]),
+
        label::group(&[spacer]),
+
        label::group(&[
+
            draft_n,
+
            draft,
+
            divider.clone(),
+
            open_n,
+
            open,
+
            divider.clone(),
+
            archived_n,
+
            archived,
+
            divider,
+
            merged_n,
+
            merged,
+
        ]),
+
        label::group(&[progress]),
+
    );
+

+
    Widget::new(context_bar).height(1)
+
}
modified bin/commands/patch/select/page.rs
@@ -129,7 +129,8 @@ impl ViewPage<Cid, Message> for ListView {

    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 = 0u16;
+
        let layout = layout::default_page(area, shortcuts_h);

        app.view(
            &Cid::List(self.active_component.clone()),
modified src/ui/layout.rs
@@ -18,6 +18,7 @@ pub struct FullPage {
pub struct DefaultPage {
    pub component: Rect,
    pub context: Rect,
+
    pub shortcuts: Rect,
}

pub struct IssuePage {
@@ -126,10 +127,12 @@ pub fn full_page(area: Rect, shortcuts_h: u16) -> FullPage {
    }
}

-
pub fn default_page(area: Rect) -> DefaultPage {
+
pub fn default_page(area: Rect, shortcuts_h: u16) -> DefaultPage {
    let context_h = 1u16;
    let margin_h = 1u16;
-
    let component_h = area.height.saturating_sub(context_h);
+
    let component_h = area
+
        .height
+
        .saturating_sub(context_h.saturating_add(shortcuts_h));

    let layout = Layout::default()
        .direction(Direction::Vertical)
@@ -138,6 +141,7 @@ pub fn default_page(area: Rect) -> DefaultPage {
            [
                Constraint::Length(component_h),
                Constraint::Length(context_h),
+
                Constraint::Length(shortcuts_h),
            ]
            .as_ref(),
        )
@@ -146,6 +150,7 @@ pub fn default_page(area: Rect) -> DefaultPage {
    DefaultPage {
        component: layout[0],
        context: layout[1],
+
        shortcuts: layout[2],
    }
}