Radish alpha
r
Radicle terminal user interface
Radicle
Git (anonymous pull)
Log in to clone via SSH
patch-select: Unify select and list operation
Erik Kundt committed 2 years ago
commit 30dfa8acb2a1270cbd3fc16a474f14daea11664c
parent 2fa65d002511e8c9c3515df220e85387e9237e3f
12 files changed +357 -592
modified bin/commands/issue/suite/page.rs
@@ -110,7 +110,7 @@ impl ListPage {

impl ViewPage<Cid, Message> for ListPage {
    fn mount(
-
        &self,
+
        &mut self,
        app: &mut Application<Cid, Message, NoUserEvent>,
        context: &Context,
        theme: &Theme,
@@ -338,7 +338,7 @@ impl IssuePage {

impl ViewPage<Cid, Message> for IssuePage {
    fn mount(
-
        &self,
+
        &mut self,
        app: &mut Application<Cid, Message, NoUserEvent>,
        context: &Context,
        theme: &Theme,
modified bin/commands/patch.rs
@@ -1,7 +1,5 @@
#[path = "patch/common.rs"]
mod common;
-
#[path = "patch/list.rs"]
-
mod list;
#[path = "patch/select.rs"]
mod select;
#[path = "patch/suite.rs"]
@@ -24,12 +22,19 @@ pub const HELP: Help = Help {
    usage: r#"
Usage

-
    rad-tui patch select
-
    rad-tui patch list
+
    rad-tui patch [<option>...]
+
    rad-tui patch select [--operation | --id] [<option>...]

-
General options
+
Select options

-
    --help               Print help
+
    --operation         Select patch id and operation (default)
+
    --id                Select patch id only
+
    
+

+

+
Other options
+

+
    --help              Print help
"#,
};

@@ -38,22 +43,32 @@ pub struct Options {
}

pub enum Operation {
-
    List,
-
    Select,
+
    Select { opts: SelectOptions },
}

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

+
#[derive(Debug, Default, PartialEq, Eq)]
+
pub struct SelectOptions {
+
    subject: select::Subject,
+
}
+

+
impl SelectOptions {
+
    pub fn new(subject: select::Subject) -> Self {
+
        Self { subject }
+
    }
+
}
+

impl Args for Options {
    fn from_args(args: Vec<OsString>) -> anyhow::Result<(Self, Vec<OsString>)> {
        use lexopt::prelude::*;

        let mut parser = lexopt::Parser::from_args(args);
        let mut op: Option<OperationName> = None;
+
        let mut select_opts: Option<SelectOptions> = None;

        #[allow(clippy::never_loop)]
        while let Some(arg) = parser.next()? {
@@ -61,8 +76,22 @@ impl Args for Options {
                Long("help") | Short('h') => {
                    return Err(Error::Help.into());
                }
+

+
                // Select options.
+
                Long("operation") | Short('o') if op == Some(OperationName::Select) => {
+
                    if select_opts.is_some() {
+
                        anyhow::bail!("select option already given")
+
                    }
+
                    select_opts = Some(SelectOptions::new(select::Subject::Operation));
+
                }
+
                Long("id") | Short('i') if op == Some(OperationName::Select) => {
+
                    if select_opts.is_some() {
+
                        anyhow::bail!("select option already given")
+
                    }
+
                    select_opts = Some(SelectOptions::new(select::Subject::Id));
+
                }
+

                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),
                },
@@ -71,8 +100,9 @@ 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,
+
            OperationName::Select => Operation::Select {
+
                opts: select_opts.unwrap_or_default(),
+
            },
        };
        Ok((Options { op }, vec![]))
    }
@@ -83,29 +113,19 @@ 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 mut app = list::App::new(context);
-
            let command = Window::default().run(&mut app, 1000 / FPS)?;
-
            let output = command
-
                .map(|command| serde_json::to_string(&command).unwrap_or_default())
-
                .unwrap_or_default();
-

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

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

-
            let patch_id = Window::default()
-
                .run(&mut select::App::new(context), 1000 / FPS)?
+
            let mut app = select::App::new(context, opts.subject);
+
            let output = Window::default().run(&mut app, 1000 / FPS)?;
+

+
            let json = output
+
                .map(|output| serde_json::to_string(&output).unwrap_or_default())
                .unwrap_or_default();

-
            eprint!("{patch_id}");
+
            eprint!("{json}");
        }
    }

deleted bin/commands/patch/list.rs
@@ -1,181 +0,0 @@
-
#[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 serde::{Serialize, Serializer};
-

-
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(Clone, Debug, Eq, PartialEq)]
-
pub struct PatchId(radicle::cob::patch::PatchId);
-

-
impl Serialize for PatchId {
-
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
-
    where
-
        S: Serializer,
-
    {
-
        serializer.serialize_str(&format!("{}", *self.0))
-
    }
-
}
-

-
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
-
pub enum PatchCommand {
-
    Show { id: PatchId },
-
    Edit { id: PatchId },
-
    Checkout { id: PatchId },
-
}
-

-
#[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<PatchCommand>),
-
    Batch(Vec<Message>),
-
}
-

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

-
/// 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, PatchCommand> 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<PatchCommand>> {
-
        if self.quit {
-
            return Some(Exit {
-
                value: self.result.clone(),
-
            });
-
        }
-
        None
-
    }
-
}
deleted bin/commands/patch/list/event.rs
@@ -1,131 +0,0 @@
-
use tuirealm::command::{Cmd, CmdResult, Direction as MoveDirection};
-
use tuirealm::event::{Event, Key, KeyEvent};
-
use tuirealm::{MockComponent, NoUserEvent, State, StateValue};
-

-
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, PatchCommand};
-

-
/// 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)
-
            }
-
            Event::Keyboard(KeyEvent {
-
                code: Key::Enter, ..
-
            }) => {
-
                let result = self.perform(Cmd::Submit);
-
                match result {
-
                    CmdResult::Submit(State::One(StateValue::Usize(selected))) => {
-
                        let item = self.items().get(selected)?;
-
                        Some(Message::Quit(Some(PatchCommand::Show {
-
                            id: super::PatchId(item.id().to_owned()),
-
                        })))
-
                    }
-
                    _ => None,
-
                }
-
            }
-
            Event::Keyboard(KeyEvent {
-
                code: Key::Char('e'),
-
                ..
-
            }) => {
-
                let result = self.perform(Cmd::Submit);
-
                match result {
-
                    CmdResult::Submit(State::One(StateValue::Usize(selected))) => {
-
                        let item = self.items().get(selected)?;
-
                        Some(Message::Quit(Some(PatchCommand::Edit {
-
                            id: super::PatchId(item.id().to_owned()),
-
                        })))
-
                    }
-
                    _ => None,
-
                }
-
            }
-
            Event::Keyboard(KeyEvent {
-
                code: Key::Char('c'),
-
                ..
-
            }) => {
-
                let result = self.perform(Cmd::Submit);
-
                match result {
-
                    CmdResult::Submit(State::One(StateValue::Usize(selected))) => {
-
                        let item = self.items().get(selected)?;
-
                        Some(Message::Quit(Some(PatchCommand::Checkout {
-
                            id: super::PatchId(item.id().to_owned()),
-
                        })))
-
                    }
-
                    _ => None,
-
                }
-
            }
-
            _ => 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
-
    }
-
}
deleted bin/commands/patch/list/page.rs
@@ -1,170 +0,0 @@
-
use std::collections::HashMap;
-

-
use anyhow::Result;
-

-
use tuirealm::{AttrValue, Attribute, 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::super::common;
-
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, "enter", "show"),
-
                    tui::ui::shortcut(theme, "c", "checkout"),
-
                    tui::ui::shortcut(theme, "e", "edit"),
-
                    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 = common::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 context_h = app
-
            .query(&Cid::List(ListCid::Context), Attribute::Height)
-
            .unwrap_or_default()
-
            .unwrap_or(AttrValue::Size(0))
-
            .unwrap_size();
-
        let shortcuts_h = 1u16;
-

-
        let layout = layout::default_page(area, context_h, 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(())
-
    }
-
}
deleted bin/commands/patch/list/ui.rs
@@ -1,27 +0,0 @@
-
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::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))
-
}
modified bin/commands/patch/select.rs
@@ -8,8 +8,7 @@ mod ui;
use std::hash::Hash;

use anyhow::Result;
-

-
use radicle::cob::patch::PatchId;
+
use serde::{Serialize, Serializer};

use tuirealm::application::PollStrategy;
use tuirealm::event::Key;
@@ -24,6 +23,54 @@ use tui::{Exit, PageStack, Tui};

use page::ListView;

+
/// Wrapper around radicle's `PatchId` that serializes
+
/// to a human-readable string.
+
#[derive(Clone, Debug, Eq, PartialEq)]
+
pub struct PatchId(radicle::cob::patch::PatchId);
+

+
impl From<radicle::cob::patch::PatchId> for PatchId {
+
    fn from(value: radicle::cob::patch::PatchId) -> Self {
+
        PatchId(value)
+
    }
+
}
+

+
impl Serialize for PatchId {
+
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+
    where
+
        S: Serializer,
+
    {
+
        serializer.serialize_str(&format!("{}", *self.0))
+
    }
+
}
+

+
/// The application's subject. It tells the application
+
/// which widgets to render and which output to produce.
+
///
+
/// Depends on CLI arguments given by the user.
+
#[derive(Clone, Default, Debug, Eq, PartialEq)]
+
pub enum Subject {
+
    #[default]
+
    Operation,
+
    Id,
+
}
+

+
/// The selected patch operation returned by the operation
+
/// selection widget.
+
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
+
pub enum PatchOperation {
+
    Show,
+
    Edit,
+
    Checkout,
+
}
+

+
/// The application's output that depends on the application's
+
/// subject.
+
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
+
pub struct Output {
+
    operation: Option<PatchOperation>,
+
    id: PatchId,
+
}
+

#[derive(Debug, Eq, PartialEq, Clone, Hash)]
pub enum ListCid {
    Header,
@@ -44,7 +91,7 @@ pub enum Cid {
pub enum Message {
    #[default]
    Tick,
-
    Quit(Option<PatchId>),
+
    Quit(Option<Output>),
    Batch(Vec<Message>),
}

@@ -53,19 +100,21 @@ pub struct App {
    pages: PageStack<Cid, Message>,
    theme: Theme,
    quit: bool,
-
    result: Option<PatchId>,
+
    subject: Subject,
+
    output: Option<Output>,
}

/// 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 {
+
    pub fn new(context: Context, subject: Subject) -> Self {
        Self {
            context,
            pages: PageStack::default(),
            theme: Theme::default(),
            quit: false,
-
            result: None,
+
            subject,
+
            output: None,
        }
    }

@@ -74,7 +123,7 @@ impl App {
        app: &mut Application<Cid, Message, NoUserEvent>,
        theme: &Theme,
    ) -> Result<()> {
-
        let home = Box::new(ListView::new(theme.clone()));
+
        let home = Box::new(ListView::new(self.subject.clone()));
        self.pages.push(home, app, &self.context, theme)?;

        Ok(())
@@ -100,9 +149,9 @@ impl App {
                    _ => Ok(Some(Message::Batch(results))),
                }
            }
-
            Message::Quit(id) => {
+
            Message::Quit(output) => {
                self.quit = true;
-
                self.result = id;
+
                self.output = output;
                Ok(None)
            }
            _ => self
@@ -113,7 +162,7 @@ impl App {
    }
}

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

@@ -123,7 +172,7 @@ impl Tui<Cid, Message, String> for App {
            Cid::GlobalListener,
            global,
            vec![Sub::new(
-
                subscription::quit_clause(Key::Esc),
+
                subscription::quit_clause(Key::Char('q')),
                SubClause::Always,
            )],
        )?;
@@ -152,10 +201,10 @@ impl Tui<Cid, Message, String> for App {
        }
    }

-
    fn exit(&self) -> Option<Exit<String>> {
+
    fn exit(&self) -> Option<Exit<Output>> {
        if self.quit {
            return Some(Exit {
-
                value: self.result.map(|id| format!("{id}")),
+
                value: self.output.clone(),
            });
        }
        None
modified bin/commands/patch/select/event.rs
@@ -10,8 +10,8 @@ use tui::ui::widget::list::PropertyList;

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

-
use super::super::common;
-
use super::Message;
+
use super::ui::{IdSelect, OperationSelect};
+
use super::{Message, Output, PatchId, PatchOperation};

/// 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
@@ -21,13 +21,58 @@ use super::Message;
impl tuirealm::Component<Message, NoUserEvent> for Widget<GlobalListener> {
    fn on(&mut self, event: Event<NoUserEvent>) -> Option<Message> {
        match event {
-
            Event::Keyboard(KeyEvent { code: Key::Esc, .. }) => Some(Message::Quit(None)),
+
            Event::Keyboard(KeyEvent {
+
                code: Key::Char('q'),
+
                ..
+
            }) => Some(Message::Quit(None)),
+
            _ => None,
+
        }
+
    }
+
}
+

+
impl tuirealm::Component<Message, NoUserEvent> for Widget<IdSelect> {
+
    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)
+
            }
+
            Event::Keyboard(KeyEvent {
+
                code: Key::Enter, ..
+
            }) => {
+
                let result = self.perform(Cmd::Submit);
+
                match result {
+
                    CmdResult::Submit(State::One(StateValue::Usize(selected))) => {
+
                        let item = self.items().get(selected)?;
+
                        let output = Output {
+
                            operation: None,
+
                            id: PatchId::from(item.id().to_owned()),
+
                        };
+
                        Some(Message::Quit(Some(output)))
+
                    }
+
                    _ => None,
+
                }
+
            }
            _ => None,
        }
    }
}

-
impl tuirealm::Component<Message, NoUserEvent> for Widget<common::ui::PatchBrowser> {
+
impl tuirealm::Component<Message, NoUserEvent> for Widget<OperationSelect> {
    fn on(&mut self, event: Event<NoUserEvent>) -> Option<Message> {
        match event {
            Event::Keyboard(KeyEvent { code: Key::Up, .. })
@@ -55,7 +100,45 @@ impl tuirealm::Component<Message, NoUserEvent> for Widget<common::ui::PatchBrows
                match result {
                    CmdResult::Submit(State::One(StateValue::Usize(selected))) => {
                        let item = self.items().get(selected)?;
-
                        Some(Message::Quit(Some(item.id().to_owned())))
+
                        let output = Output {
+
                            operation: Some(PatchOperation::Show),
+
                            id: PatchId::from(item.id().to_owned()),
+
                        };
+
                        Some(Message::Quit(Some(output)))
+
                    }
+
                    _ => None,
+
                }
+
            }
+
            Event::Keyboard(KeyEvent {
+
                code: Key::Char('e'),
+
                ..
+
            }) => {
+
                let result = self.perform(Cmd::Submit);
+
                match result {
+
                    CmdResult::Submit(State::One(StateValue::Usize(selected))) => {
+
                        let item = self.items().get(selected)?;
+
                        let output = Output {
+
                            operation: Some(PatchOperation::Edit),
+
                            id: PatchId::from(item.id().to_owned()),
+
                        };
+
                        Some(Message::Quit(Some(output)))
+
                    }
+
                    _ => None,
+
                }
+
            }
+
            Event::Keyboard(KeyEvent {
+
                code: Key::Char('c'),
+
                ..
+
            }) => {
+
                let result = self.perform(Cmd::Submit);
+
                match result {
+
                    CmdResult::Submit(State::One(StateValue::Usize(selected))) => {
+
                        let item = self.items().get(selected)?;
+
                        let output = Output {
+
                            operation: Some(PatchOperation::Checkout),
+
                            id: PatchId::from(item.id().to_owned()),
+
                        };
+
                        Some(Message::Quit(Some(output)))
                    }
                    _ => None,
                }
modified bin/commands/patch/select/page.rs
@@ -14,43 +14,26 @@ use tui::ui::{layout, subscription};
use tui::ViewPage;

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

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

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

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

    fn update_context(
        &self,
        app: &mut Application<Cid, Message, NoUserEvent>,
@@ -83,23 +66,37 @@ impl ListView {
                vec![],
            )?;
        }
+

        Ok(())
    }
}

impl ViewPage<Cid, Message> for ListView {
    fn mount(
-
        &self,
+
        &mut 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![])?;
+

+
        match self.subject {
+
            Subject::Id => {
+
                let patch_browser = ui::id_select(context, theme, None).to_boxed();
+
                self.shortcuts = patch_browser.as_ref().shortcuts();
+

+
                app.remount(Cid::List(ListCid::PatchBrowser), patch_browser, vec![])?;
+
            }
+
            Subject::Operation => {
+
                let patch_browser = ui::operation_select(context, theme, None).to_boxed();
+
                self.shortcuts = patch_browser.as_ref().shortcuts();
+

+
                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())?;
@@ -135,7 +132,7 @@ impl ViewPage<Cid, Message> for ListView {
            .unwrap_or_default()
            .unwrap_or(AttrValue::Size(0))
            .unwrap_size();
-
        let shortcuts_h = 0u16;
+
        let shortcuts_h = 1u16;

        let layout = layout::default_page(area, context_h, shortcuts_h);

@@ -146,6 +143,7 @@ impl ViewPage<Cid, Message> for ListView {
        );

        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<()> {
modified bin/commands/patch/select/ui.rs
@@ -1,15 +1,127 @@
+
use std::collections::HashMap;
+

use radicle::cob::patch::{Patch, PatchId};

use radicle_tui as tui;

use tui::context::Context;
+
use tui::ui::cob::PatchItem;
use tui::ui::theme::{style, Theme};
-
use tui::ui::widget::Widget;
+
use tui::ui::widget::context::Shortcuts;
+
use tui::ui::widget::{Widget, WidgetComponent};

use tui::ui::widget::container::Tabs;
use tui::ui::widget::label::{self};
+
use tuirealm::command::{Cmd, CmdResult};
+
use tuirealm::tui::layout::Rect;
+
use tuirealm::{AttrValue, Attribute, Frame, MockComponent, Props, State};

use super::super::common;
+
use super::ListCid;
+

+
pub struct IdSelect {
+
    theme: Theme,
+
    browser: Widget<common::ui::PatchBrowser>,
+
}
+

+
impl IdSelect {
+
    pub fn new(theme: Theme, browser: Widget<common::ui::PatchBrowser>) -> Self {
+
        Self { theme, browser }
+
    }
+

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

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

+
impl WidgetComponent for IdSelect {
+
    fn view(&mut self, properties: &Props, frame: &mut Frame, area: Rect) {
+
        let focus = properties
+
            .get_or(Attribute::Focus, AttrValue::Flag(false))
+
            .unwrap_flag();
+

+
        self.browser.attr(Attribute::Focus, AttrValue::Flag(focus));
+
        self.browser.view(frame, area);
+
    }
+

+
    fn state(&self) -> State {
+
        self.browser.state()
+
    }
+

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

+
pub struct OperationSelect {
+
    theme: Theme,
+
    browser: Widget<common::ui::PatchBrowser>,
+
}
+

+
impl OperationSelect {
+
    pub fn new(theme: Theme, browser: Widget<common::ui::PatchBrowser>) -> Self {
+
        Self { theme, browser }
+
    }
+

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

+
    pub fn shortcuts(&self) -> HashMap<ListCid, Widget<Shortcuts>> {
+
        [(
+
            ListCid::PatchBrowser,
+
            tui::ui::shortcuts(
+
                &self.theme,
+
                vec![
+
                    tui::ui::shortcut(&self.theme, "↑/↓", "navigate"),
+
                    tui::ui::shortcut(&self.theme, "enter", "show"),
+
                    tui::ui::shortcut(&self.theme, "c", "checkout"),
+
                    tui::ui::shortcut(&self.theme, "e", "edit"),
+
                    tui::ui::shortcut(&self.theme, "q", "quit"),
+
                ],
+
            ),
+
        )]
+
        .iter()
+
        .cloned()
+
        .collect()
+
    }
+
}
+

+
impl WidgetComponent for OperationSelect {
+
    fn view(&mut self, properties: &Props, frame: &mut Frame, area: Rect) {
+
        let focus = properties
+
            .get_or(Attribute::Focus, AttrValue::Flag(false))
+
            .unwrap_flag();
+

+
        self.browser.attr(Attribute::Focus, AttrValue::Flag(focus));
+
        self.browser.view(frame, area);
+
    }
+

+
    fn state(&self) -> State {
+
        self.browser.state()
+
    }
+

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

pub fn list_navigation(theme: &Theme) -> Widget<Tabs> {
    tui::ui::tabs(
@@ -18,10 +130,22 @@ pub fn list_navigation(theme: &Theme) -> Widget<Tabs> {
    )
}

-
pub fn patches(
+
pub fn id_select(
    context: &Context,
    theme: &Theme,
    selected: Option<(PatchId, Patch)>,
-
) -> Widget<common::ui::PatchBrowser> {
-
    Widget::new(common::ui::PatchBrowser::new(context, theme, selected))
+
) -> Widget<IdSelect> {
+
    let browser = Widget::new(common::ui::PatchBrowser::new(context, theme, selected));
+

+
    Widget::new(IdSelect::new(theme.clone(), browser))
+
}
+

+
pub fn operation_select(
+
    context: &Context,
+
    theme: &Theme,
+
    selected: Option<(PatchId, Patch)>,
+
) -> Widget<OperationSelect> {
+
    let browser = Widget::new(common::ui::PatchBrowser::new(context, theme, selected));
+

+
    Widget::new(OperationSelect::new(theme.clone(), browser))
}
modified bin/commands/patch/suite/page.rs
@@ -90,7 +90,7 @@ impl ListView {

impl ViewPage<Cid, Message> for ListView {
    fn mount(
-
        &self,
+
        &mut self,
        app: &mut Application<Cid, Message, NoUserEvent>,
        context: &Context,
        theme: &Theme,
@@ -237,7 +237,7 @@ impl PatchView {

impl ViewPage<Cid, Message> for PatchView {
    fn mount(
-
        &self,
+
        &mut self,
        app: &mut Application<Cid, Message, NoUserEvent>,
        context: &Context,
        theme: &Theme,
modified src/lib.rs
@@ -125,7 +125,7 @@ where
{
    /// Will be called whenever a view page is pushed onto the page stack. Should create and mount all widgets.
    fn mount(
-
        &self,
+
        &mut self,
        app: &mut Application<Id, Message, NoUserEvent>,
        context: &Context,
        theme: &Theme,
@@ -180,7 +180,7 @@ where
{
    pub fn push(
        &mut self,
-
        page: Box<dyn ViewPage<Id, Message>>,
+
        mut page: Box<dyn ViewPage<Id, Message>>,
        app: &mut Application<Id, Message, NoUserEvent>,
        context: &Context,
        theme: &Theme,