Radish alpha
r
rad:z39mP9rQAaGmERfUMPULfPUi473tY
Radicle terminal user interface
Radicle
Git
issue: Remove legacy tui-realm commands
Merged did:key:z6MkgFq6...nBGz opened 2 years ago
18 files changed +1048 -3620 8438ada1 74b576ca
modified bin/commands/issue.rs
@@ -1,11 +1,7 @@
#[path = "issue/common.rs"]
mod common;
-
#[cfg(feature = "flux")]
-
#[path = "issue/flux.rs"]
-
mod flux;
-
#[cfg(feature = "realm")]
-
#[path = "issue/realm.rs"]
-
mod realm;
+
#[path = "issue/select.rs"]
+
mod select;

use std::ffi::OsString;

@@ -139,37 +135,6 @@ impl Args for Options {
    }
}

-
#[cfg(feature = "realm")]
-
pub fn run(options: Options, _ctx: impl terminal::Context) -> anyhow::Result<()> {
-
    use tui::common::context;
-
    use tui::realm::Window;
-

-
    pub const FPS: u64 = 60;
-
    let (_, id) = radicle::rad::cwd()
-
        .map_err(|_| anyhow!("this command must be run in the context of a project"))?;
-

-
    match options.op {
-
        Operation::Select { ref opts } => {
-
            let profile = terminal::profile()?;
-
            let context = context::Context::new(profile, id)?.with_issues();
-

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

-
            let mut app = realm::select::App::new(context, opts.mode.clone(), opts.filter.clone());
-
            let output = Window::default().run(&mut app, 1000 / FPS)?;
-

-
            let output = output
-
                .map(|o| serde_json::to_string(&o).unwrap_or_default())
-
                .unwrap_or_default();
-

-
            eprint!("{output}");
-
        }
-
    }
-

-
    Ok(())
-
}
-

-
#[cfg(feature = "flux")]
#[tokio::main]
pub async fn run(options: Options, _ctx: impl terminal::Context) -> anyhow::Result<()> {
    use radicle::storage::ReadStorage;
@@ -185,13 +150,13 @@ pub async fn run(options: Options, _ctx: impl terminal::Context) -> anyhow::Resu

            log::enable(&profile, "issue", "select")?;

-
            let context = flux::select::Context {
+
            let context = select::Context {
                profile,
                repository,
                mode: opts.mode,
                filter: opts.filter.clone(),
            };
-
            let output = flux::select::App::new(context).run().await?;
+
            let output = select::App::new(context).run().await?;

            let output = output
                .map(|o| serde_json::to_string(&o).unwrap_or_default())
deleted bin/commands/issue/flux.rs
@@ -1,2 +0,0 @@
-
#[path = "flux/select.rs"]
-
pub mod select;
deleted bin/commands/issue/flux/select.rs
@@ -1,160 +0,0 @@
-
#[path = "select/ui.rs"]
-
mod ui;
-

-
use anyhow::Result;
-

-
use radicle::issue::IssueId;
-
use radicle::storage::git::Repository;
-
use radicle::Profile;
-

-
use radicle_tui as tui;
-

-
use tui::common::cob::issue::{self, Filter};
-
use tui::flux::store::{self, StateValue};
-
use tui::flux::task::{self, Interrupted};
-
use tui::flux::ui::items::IssueItem;
-
use tui::flux::ui::Frontend;
-
use tui::Exit;
-

-
use ui::ListPage;
-

-
use super::super::common::Mode;
-

-
type Selection = tui::Selection<IssueId>;
-

-
pub struct Context {
-
    pub profile: Profile,
-
    pub repository: Repository,
-
    pub mode: Mode,
-
    pub filter: Filter,
-
}
-

-
pub struct App {
-
    context: Context,
-
}
-

-
#[derive(Clone, Debug)]
-
pub struct UIState {
-
    page_size: usize,
-
    show_search: bool,
-
    show_help: bool,
-
}
-

-
impl Default for UIState {
-
    fn default() -> Self {
-
        Self {
-
            page_size: 1,
-
            show_search: false,
-
            show_help: false,
-
        }
-
    }
-
}
-

-
#[derive(Clone, Debug)]
-
pub struct State {
-
    issues: Vec<IssueItem>,
-
    mode: Mode,
-
    search: StateValue<String>,
-
    ui: UIState,
-
}
-

-
impl TryFrom<&Context> for State {
-
    type Error = anyhow::Error;
-

-
    fn try_from(context: &Context) -> Result<Self, Self::Error> {
-
        let issues = issue::all(&context.profile, &context.repository)?;
-

-
        // Convert into UI items
-
        let mut items = vec![];
-
        for issue in issues {
-
            if let Ok(item) = IssueItem::new(&context.profile, issue.clone()) {
-
                items.push(item);
-
            }
-
        }
-

-
        Ok(Self {
-
            issues: items,
-
            mode: context.mode.clone(),
-
            search: StateValue::new(context.filter.to_string()),
-
            ui: UIState::default(),
-
        })
-
    }
-
}
-

-
pub enum Action {
-
    Exit { selection: Option<Selection> },
-
    PageSize(usize),
-
    OpenSearch,
-
    UpdateSearch { value: String },
-
    ApplySearch,
-
    CloseSearch,
-
    OpenHelp,
-
    CloseHelp,
-
}
-

-
impl store::State<Action, Selection> for State {
-
    fn tick(&self) {}
-

-
    fn handle_action(&mut self, action: Action) -> Option<Exit<Selection>> {
-
        match action {
-
            Action::Exit { selection } => Some(Exit { value: selection }),
-
            Action::PageSize(size) => {
-
                self.ui.page_size = size;
-
                None
-
            }
-
            Action::OpenSearch => {
-
                self.ui.show_search = true;
-
                None
-
            }
-
            Action::UpdateSearch { value } => {
-
                self.search.write(value);
-
                None
-
            }
-
            Action::ApplySearch => {
-
                self.search.apply();
-
                self.ui.show_search = false;
-
                None
-
            }
-
            Action::CloseSearch => {
-
                self.search.reset();
-
                self.ui.show_search = false;
-
                None
-
            }
-
            Action::OpenHelp => {
-
                self.ui.show_help = true;
-
                None
-
            }
-
            Action::CloseHelp => {
-
                self.ui.show_help = false;
-
                None
-
            }
-
        }
-
    }
-
}
-

-
impl App {
-
    pub fn new(context: Context) -> Self {
-
        Self { context }
-
    }
-

-
    pub async fn run(&self) -> Result<Option<Selection>> {
-
        let (terminator, mut interrupt_rx) = task::create_termination();
-
        let (store, state_rx) = store::Store::<Action, State, Selection>::new();
-
        let (frontend, action_rx) = Frontend::<Action>::new();
-
        let state = State::try_from(&self.context)?;
-

-
        tokio::try_join!(
-
            store.main_loop(state, terminator, action_rx, interrupt_rx.resubscribe()),
-
            frontend.main_loop::<State, ListPage, Selection>(state_rx, interrupt_rx.resubscribe()),
-
        )?;
-

-
        if let Ok(reason) = interrupt_rx.recv().await {
-
            match reason {
-
                Interrupted::User { payload } => Ok(payload),
-
                Interrupted::OsSignal => anyhow::bail!("exited because of an os sig int"),
-
            }
-
        } else {
-
            anyhow::bail!("exited because of an unexpected error");
-
        }
-
    }
-
}
deleted bin/commands/issue/flux/select/ui.rs
@@ -1,884 +0,0 @@
-
use std::collections::HashMap;
-
use std::str::FromStr;
-
use std::vec;
-

-
use radicle::issue::{self, CloseReason};
-
use tokio::sync::mpsc::UnboundedSender;
-

-
use termion::event::Key;
-

-
use ratatui::backend::Backend;
-
use ratatui::layout::{Constraint, Layout, Rect};
-
use ratatui::style::Stylize;
-
use ratatui::text::{Line, Span, Text};
-

-
use radicle_tui as tui;
-

-
use tui::flux::ui::items::{IssueItem, IssueItemFilter};
-
use tui::flux::ui::span;
-
use tui::flux::ui::widget::container::{Footer, FooterProps, Header, HeaderProps};
-
use tui::flux::ui::widget::input::{TextField, TextFieldProps};
-
use tui::flux::ui::widget::text::{Paragraph, ParagraphProps};
-
use tui::flux::ui::widget::{
-
    Render, Shortcut, Shortcuts, ShortcutsProps, Table, TableProps, Widget,
-
};
-
use tui::Selection;
-

-
use crate::tui_issue::common::IssueOperation;
-
use crate::tui_issue::common::Mode;
-

-
use super::{Action, State};
-

-
pub struct ListPageProps {
-
    mode: Mode,
-
    show_search: bool,
-
    show_help: bool,
-
}
-

-
impl From<&State> for ListPageProps {
-
    fn from(state: &State) -> Self {
-
        Self {
-
            mode: state.mode.clone(),
-
            show_search: state.ui.show_search,
-
            show_help: state.ui.show_help,
-
        }
-
    }
-
}
-

-
pub struct ListPage<'a> {
-
    /// Action sender
-
    pub action_tx: UnboundedSender<Action>,
-
    /// State mapped props
-
    props: ListPageProps,
-
    /// Notification widget
-
    issues: Issues,
-
    /// Search widget
-
    search: Search,
-
    /// Help widget
-
    help: Help<'a>,
-
    /// Shortcut widget
-
    shortcuts: Shortcuts<Action>,
-
}
-

-
impl<'a> Widget<State, Action> for ListPage<'a> {
-
    fn new(state: &State, action_tx: UnboundedSender<Action>) -> Self
-
    where
-
        Self: Sized,
-
    {
-
        Self {
-
            action_tx: action_tx.clone(),
-
            props: ListPageProps::from(state),
-
            issues: Issues::new(state, action_tx.clone()),
-
            search: Search::new(state, action_tx.clone()),
-
            help: Help::new(state, action_tx.clone()),
-
            shortcuts: Shortcuts::new(state, action_tx.clone()),
-
        }
-
        .move_with_state(state)
-
    }
-

-
    fn move_with_state(self, state: &State) -> Self
-
    where
-
        Self: Sized,
-
    {
-
        ListPage {
-
            issues: self.issues.move_with_state(state),
-
            shortcuts: self.shortcuts.move_with_state(state),
-
            help: self.help.move_with_state(state),
-
            props: ListPageProps::from(state),
-
            ..self
-
        }
-
    }
-

-
    fn name(&self) -> &str {
-
        "list-page"
-
    }
-

-
    fn handle_key_event(&mut self, key: termion::event::Key) {
-
        if self.props.show_search {
-
            <Search as Widget<State, Action>>::handle_key_event(&mut self.search, key)
-
        } else if self.props.show_help {
-
            <Help as Widget<State, Action>>::handle_key_event(&mut self.help, key)
-
        } else {
-
            match key {
-
                Key::Esc | Key::Ctrl('c') => {
-
                    let _ = self.action_tx.send(Action::Exit { selection: None });
-
                }
-
                Key::Char('/') => {
-
                    let _ = self.action_tx.send(Action::OpenSearch);
-
                }
-
                Key::Char('?') => {
-
                    let _ = self.action_tx.send(Action::OpenHelp);
-
                }
-
                _ => {
-
                    <Issues as Widget<State, Action>>::handle_key_event(&mut self.issues, key);
-
                }
-
            }
-
        }
-
    }
-
}
-

-
impl<'a> Render<()> for ListPage<'a> {
-
    fn render<B: Backend>(&self, frame: &mut ratatui::Frame, _area: Rect, _props: ()) {
-
        let area = frame.size();
-
        let layout = tui::flux::ui::layout::default_page(area, 0u16, 1u16);
-

-
        let shortcuts = if self.props.show_search {
-
            vec![
-
                Shortcut::new("esc", "cancel"),
-
                Shortcut::new("enter", "apply"),
-
            ]
-
        } else if self.props.show_help {
-
            vec![Shortcut::new("?", "close")]
-
        } else {
-
            match self.props.mode {
-
                Mode::Id => vec![
-
                    Shortcut::new("enter", "select"),
-
                    Shortcut::new("/", "search"),
-
                ],
-
                Mode::Operation => vec![
-
                    Shortcut::new("enter", "show"),
-
                    Shortcut::new("e", "edit"),
-
                    Shortcut::new("/", "search"),
-
                    Shortcut::new("?", "help"),
-
                ],
-
            }
-
        };
-

-
        if self.props.show_search {
-
            let component_layout = Layout::vertical([Constraint::Min(1), Constraint::Length(2)])
-
                .split(layout.component);
-

-
            self.issues.render::<B>(frame, component_layout[0], ());
-
            self.search
-
                .render::<B>(frame, component_layout[1], SearchProps {});
-
        } else if self.props.show_help {
-
            self.help.render::<B>(frame, layout.component, ());
-
        } else {
-
            self.issues.render::<B>(frame, layout.component, ());
-
        }
-

-
        self.shortcuts.render::<B>(
-
            frame,
-
            layout.shortcuts,
-
            ShortcutsProps {
-
                shortcuts,
-
                divider: '∙',
-
            },
-
        );
-
    }
-
}
-

-
struct IssuesProps {
-
    mode: Mode,
-
    issues: Vec<IssueItem>,
-
    search: String,
-
    stats: HashMap<String, usize>,
-
    widths: [Constraint; 8],
-
    cutoff: usize,
-
    cutoff_after: usize,
-
    focus: bool,
-
    page_size: usize,
-
    show_search: bool,
-
}
-

-
impl From<&State> for IssuesProps {
-
    fn from(state: &State) -> Self {
-
        use radicle::issue::State;
-

-
        let mut open = 0;
-
        let mut other = 0;
-
        let mut solved = 0;
-

-
        // Filter by search string
-
        let filter = IssueItemFilter::from_str(&state.search.read()).unwrap_or_default();
-
        let mut issues = state
-
            .issues
-
            .clone()
-
            .into_iter()
-
            .filter(|issue| filter.matches(issue))
-
            .collect::<Vec<_>>();
-

-
        // Apply sorting
-
        issues.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
-

-
        for issue in &state.issues {
-
            match issue.state {
-
                State::Open => open += 1,
-
                State::Closed {
-
                    reason: CloseReason::Other,
-
                } => other += 1,
-
                State::Closed {
-
                    reason: CloseReason::Solved,
-
                } => solved += 1,
-
            }
-
        }
-

-
        let closed = solved + other;
-

-
        let stats = HashMap::from([
-
            ("Open".to_string(), open),
-
            ("Other".to_string(), other),
-
            ("Solved".to_string(), solved),
-
            ("Closed".to_string(), closed),
-
        ]);
-

-
        Self {
-
            mode: state.mode.clone(),
-
            issues,
-
            search: state.search.read(),
-
            widths: [
-
                Constraint::Length(3),
-
                Constraint::Length(8),
-
                Constraint::Fill(5),
-
                Constraint::Length(16),
-
                Constraint::Length(16),
-
                Constraint::Fill(1),
-
                Constraint::Fill(1),
-
                Constraint::Length(16),
-
            ],
-
            cutoff: 200,
-
            cutoff_after: 5,
-
            focus: false,
-
            stats,
-
            page_size: state.ui.page_size,
-
            show_search: state.ui.show_search,
-
        }
-
    }
-
}
-

-
struct Issues {
-
    /// Action sender
-
    action_tx: UnboundedSender<Action>,
-
    /// State mapped props
-
    props: IssuesProps,
-
    /// Header
-
    header: Header<Action>,
-
    /// Notification table
-
    table: Table<Action>,
-
    /// Footer
-
    footer: Footer<Action>,
-
}
-

-
impl Widget<State, Action> for Issues {
-
    fn new(state: &State, action_tx: UnboundedSender<Action>) -> Self {
-
        Self {
-
            action_tx: action_tx.clone(),
-
            props: IssuesProps::from(state),
-
            header: Header::new(state, action_tx.clone()),
-
            table: Table::new(state, action_tx.clone()),
-
            footer: Footer::new(state, action_tx),
-
        }
-
    }
-

-
    fn move_with_state(self, state: &State) -> Self
-
    where
-
        Self: Sized,
-
    {
-
        let props = IssuesProps::from(state);
-
        let mut table = self.table.move_with_state(state);
-

-
        if let Some(selected) = table.selected() {
-
            if selected > props.issues.len() {
-
                table.begin();
-
            }
-
        }
-

-
        Self {
-
            props,
-
            table,
-
            header: self.header.move_with_state(state),
-
            footer: self.footer.move_with_state(state),
-
            ..self
-
        }
-
    }
-

-
    fn name(&self) -> &str {
-
        "issues"
-
    }
-

-
    fn handle_key_event(&mut self, key: Key) {
-
        match key {
-
            Key::Up | Key::Char('k') => {
-
                self.table.prev();
-
            }
-
            Key::Down | Key::Char('j') => {
-
                self.table.next(self.props.issues.len());
-
            }
-
            Key::PageUp => {
-
                self.table.prev_page(self.props.page_size);
-
            }
-
            Key::PageDown => {
-
                self.table
-
                    .next_page(self.props.issues.len(), self.props.page_size);
-
            }
-
            Key::Home => {
-
                self.table.begin();
-
            }
-
            Key::End => {
-
                self.table.end(self.props.issues.len());
-
            }
-
            Key::Char('\n') => {
-
                let operation = match self.props.mode {
-
                    Mode::Operation => Some(IssueOperation::Show.to_string()),
-
                    Mode::Id => None,
-
                };
-

-
                self.table
-
                    .selected()
-
                    .and_then(|selected| self.props.issues.get(selected))
-
                    .and_then(|issue| {
-
                        self.action_tx
-
                            .send(Action::Exit {
-
                                selection: Some(Selection {
-
                                    operation,
-
                                    ids: vec![issue.id],
-
                                    args: vec![],
-
                                }),
-
                            })
-
                            .ok()
-
                    });
-
            }
-
            Key::Char('e') => {
-
                self.table
-
                    .selected()
-
                    .and_then(|selected| self.props.issues.get(selected))
-
                    .and_then(|issue| {
-
                        self.action_tx
-
                            .send(Action::Exit {
-
                                selection: Some(Selection {
-
                                    operation: Some(IssueOperation::Edit.to_string()),
-
                                    ids: vec![issue.id],
-
                                    args: vec![],
-
                                }),
-
                            })
-
                            .ok()
-
                    });
-
            }
-
            _ => {}
-
        }
-
    }
-
}
-

-
impl Issues {
-
    fn render_header<B: Backend>(&self, frame: &mut ratatui::Frame, area: Rect) {
-
        self.header.render::<B>(
-
            frame,
-
            area,
-
            HeaderProps {
-
                cells: [
-
                    String::from(" ● ").into(),
-
                    String::from("ID").into(),
-
                    String::from("Title").into(),
-
                    String::from("Author").into(),
-
                    String::from("").into(),
-
                    String::from("Labels").into(),
-
                    String::from("Assignees ").into(),
-
                    String::from("Opened").into(),
-
                ],
-
                widths: self.props.widths,
-
                focus: self.props.focus,
-
                cutoff: self.props.cutoff,
-
                cutoff_after: self.props.cutoff_after,
-
            },
-
        );
-
    }
-

-
    fn render_list<B: Backend>(&self, frame: &mut ratatui::Frame, area: Rect) {
-
        self.table.render::<B>(
-
            frame,
-
            area,
-
            TableProps {
-
                items: self.props.issues.to_vec(),
-
                has_footer: !self.props.show_search,
-
                has_header: true,
-
                widths: self.props.widths,
-
                focus: self.props.focus,
-
                cutoff: self.props.cutoff,
-
                cutoff_after: self.props.cutoff_after,
-
            },
-
        );
-
    }
-

-
    fn render_footer<B: Backend>(&self, frame: &mut ratatui::Frame, area: Rect) {
-
        let search = Line::from(
-
            [
-
                span::default(" Search ".to_string())
-
                    .cyan()
-
                    .dim()
-
                    .reversed(),
-
                span::default(" ".into()),
-
                span::default(self.props.search.to_string()).gray().dim(),
-
            ]
-
            .to_vec(),
-
        );
-

-
        let open = Line::from(
-
            [
-
                span::positive(self.props.stats.get("Open").unwrap_or(&0).to_string()).dim(),
-
                span::default(" Open".to_string()).dim(),
-
            ]
-
            .to_vec(),
-
        );
-
        let solved = Line::from(
-
            [
-
                span::default(self.props.stats.get("Solved").unwrap_or(&0).to_string())
-
                    .magenta()
-
                    .dim(),
-
                span::default(" Solved".to_string()).dim(),
-
            ]
-
            .to_vec(),
-
        );
-
        let closed = Line::from(
-
            [
-
                span::default(self.props.stats.get("Closed").unwrap_or(&0).to_string())
-
                    .magenta()
-
                    .dim(),
-
                span::default(" Closed".to_string()).dim(),
-
            ]
-
            .to_vec(),
-
        );
-
        let sum = Line::from(
-
            [
-
                span::default("Σ ".to_string()).dim(),
-
                span::default(self.props.issues.len().to_string()).dim(),
-
            ]
-
            .to_vec(),
-
        );
-

-
        let progress = self
-
            .table
-
            .progress_percentage(self.props.issues.len(), self.props.page_size);
-
        let progress = span::default(format!("{}%", progress)).dim();
-

-
        match IssueItemFilter::from_str(&self.props.search)
-
            .unwrap_or_default()
-
            .state()
-
        {
-
            Some(state) => {
-
                let block = match state {
-
                    issue::State::Open => open,
-
                    issue::State::Closed {
-
                        reason: issue::CloseReason::Other,
-
                    } => closed,
-
                    issue::State::Closed {
-
                        reason: issue::CloseReason::Solved,
-
                    } => solved,
-
                };
-

-
                self.footer.render::<B>(
-
                    frame,
-
                    area,
-
                    FooterProps {
-
                        cells: [search.into(), block.clone().into(), progress.clone().into()],
-
                        widths: [
-
                            Constraint::Fill(1),
-
                            Constraint::Min(block.width() as u16),
-
                            Constraint::Min(4),
-
                        ],
-
                        focus: self.props.focus,
-
                        cutoff: self.props.cutoff,
-
                        cutoff_after: self.props.cutoff_after,
-
                    },
-
                );
-
            }
-
            None => {
-
                self.footer.render::<B>(
-
                    frame,
-
                    area,
-
                    FooterProps {
-
                        cells: [
-
                            search.into(),
-
                            open.clone().into(),
-
                            closed.clone().into(),
-
                            sum.clone().into(),
-
                            progress.clone().into(),
-
                        ],
-
                        widths: [
-
                            Constraint::Fill(1),
-
                            Constraint::Min(open.width() as u16),
-
                            Constraint::Min(closed.width() as u16),
-
                            Constraint::Min(sum.width() as u16),
-
                            Constraint::Min(4),
-
                        ],
-
                        focus: self.props.focus,
-
                        cutoff: self.props.cutoff,
-
                        cutoff_after: self.props.cutoff_after,
-
                    },
-
                );
-
            }
-
        }
-
    }
-
}
-

-
impl Render<()> for Issues {
-
    fn render<B: Backend>(&self, frame: &mut ratatui::Frame, area: Rect, _props: ()) {
-
        let page_size = if self.props.show_search {
-
            let layout = Layout::vertical([Constraint::Length(3), Constraint::Min(1)]).split(area);
-

-
            self.render_header::<B>(frame, layout[0]);
-
            self.render_list::<B>(frame, layout[1]);
-

-
            layout[1].height as usize
-
        } else {
-
            let layout = Layout::vertical([
-
                Constraint::Length(3),
-
                Constraint::Min(1),
-
                Constraint::Length(3),
-
            ])
-
            .split(area);
-

-
            self.render_header::<B>(frame, layout[0]);
-
            self.render_list::<B>(frame, layout[1]);
-
            self.render_footer::<B>(frame, layout[2]);
-

-
            layout[1].height as usize
-
        };
-

-
        if page_size != self.props.page_size {
-
            let _ = self.action_tx.send(Action::PageSize(page_size));
-
        }
-
    }
-
}
-

-
pub struct SearchProps {}
-

-
pub struct Search {
-
    pub action_tx: UnboundedSender<Action>,
-
    pub input: TextField,
-
}
-

-
impl Widget<State, Action> for Search {
-
    fn new(state: &State, action_tx: UnboundedSender<Action>) -> Self
-
    where
-
        Self: Sized,
-
    {
-
        let mut input = TextField::new(state, action_tx.clone());
-
        input.set_text(&state.search.read().to_string());
-

-
        Self { action_tx, input }.move_with_state(state)
-
    }
-

-
    fn move_with_state(self, state: &State) -> Self
-
    where
-
        Self: Sized,
-
    {
-
        let mut input = <TextField as Widget<State, Action>>::move_with_state(self.input, state);
-
        input.set_text(&state.search.read().to_string());
-

-
        Self { input, ..self }
-
    }
-

-
    fn name(&self) -> &str {
-
        "filter-popup"
-
    }
-

-
    fn handle_key_event(&mut self, key: termion::event::Key) {
-
        match key {
-
            Key::Esc => {
-
                let _ = self.action_tx.send(Action::CloseSearch);
-
            }
-
            Key::Char('\n') => {
-
                let _ = self.action_tx.send(Action::ApplySearch);
-
            }
-
            _ => {
-
                <TextField as Widget<State, Action>>::handle_key_event(&mut self.input, key);
-
                let _ = self.action_tx.send(Action::UpdateSearch {
-
                    value: self.input.text().to_string(),
-
                });
-
            }
-
        }
-
    }
-
}
-

-
impl Render<SearchProps> for Search {
-
    fn render<B: Backend>(&self, frame: &mut ratatui::Frame, area: Rect, _props: SearchProps) {
-
        let layout = Layout::horizontal(Constraint::from_mins([0]))
-
            .horizontal_margin(1)
-
            .split(area);
-

-
        self.input.render::<B>(
-
            frame,
-
            layout[0],
-
            TextFieldProps {
-
                titles: ("Search".into(), "Search".into()),
-
                show_cursor: true,
-
                inline_label: true,
-
            },
-
        );
-
    }
-
}
-

-
pub struct HelpProps<'a> {
-
    content: Text<'a>,
-
    focus: bool,
-
    page_size: usize,
-
}
-

-
impl<'a> From<&State> for HelpProps<'a> {
-
    fn from(state: &State) -> Self {
-
        let content = Text::from(
-
            [
-
                Line::from(Span::raw("Generic keybindings").cyan()),
-
                Line::raw(""),
-
                Line::from(
-
                    [
-
                        Span::raw(format!("{key:>10}", key = "↑,k")).gray(),
-
                        Span::raw(" "),
-
                        Span::raw("move cursor one line up").gray().dim(),
-
                    ]
-
                    .to_vec(),
-
                ),
-
                Line::from(
-
                    [
-
                        Span::raw(format!("{key:>10}", key = "↓,j")).gray(),
-
                        Span::raw(" "),
-
                        Span::raw("move cursor one line down").gray().dim(),
-
                    ]
-
                    .to_vec(),
-
                ),
-
                Line::from(
-
                    [
-
                        Span::raw(format!("{key:>10}", key = "PageUp")).gray(),
-
                        Span::raw(" "),
-
                        Span::raw("move cursor one page up").gray().dim(),
-
                    ]
-
                    .to_vec(),
-
                ),
-
                Line::from(
-
                    [
-
                        Span::raw(format!("{key:>10}", key = "PageDown")).gray(),
-
                        Span::raw(" "),
-
                        Span::raw("move cursor one page down").gray().dim(),
-
                    ]
-
                    .to_vec(),
-
                ),
-
                Line::from(
-
                    [
-
                        Span::raw(format!("{key:>10}", key = "Home")).gray(),
-
                        Span::raw(" "),
-
                        Span::raw("move cursor to the first line").gray().dim(),
-
                    ]
-
                    .to_vec(),
-
                ),
-
                Line::from(
-
                    [
-
                        Span::raw(format!("{key:>10}", key = "End")).gray(),
-
                        Span::raw(" "),
-
                        Span::raw("move cursor to the last line").gray().dim(),
-
                    ]
-
                    .to_vec(),
-
                ),
-
                Line::raw(""),
-
                Line::from(Span::raw("Specific keybindings").cyan()),
-
                Line::raw(""),
-
                Line::from(
-
                    [
-
                        Span::raw(format!("{key:>10}", key = "enter")).gray(),
-
                        Span::raw(" "),
-
                        Span::raw("Select issue (if --mode id)").gray().dim(),
-
                    ]
-
                    .to_vec(),
-
                ),
-
                Line::from(
-
                    [
-
                        Span::raw(format!("{key:>10}", key = "enter")).gray(),
-
                        Span::raw(" "),
-
                        Span::raw("Show issue").gray().dim(),
-
                    ]
-
                    .to_vec(),
-
                ),
-
                Line::from(
-
                    [
-
                        Span::raw(format!("{key:>10}", key = "e")).gray(),
-
                        Span::raw(" "),
-
                        Span::raw("Edit patch").gray().dim(),
-
                    ]
-
                    .to_vec(),
-
                ),
-
                Line::from(
-
                    [
-
                        Span::raw(format!("{key:>10}", key = "/")).gray(),
-
                        Span::raw(" "),
-
                        Span::raw("Search").gray().dim(),
-
                    ]
-
                    .to_vec(),
-
                ),
-
                Line::from(
-
                    [
-
                        Span::raw(format!("{key:>10}", key = "?")).gray(),
-
                        Span::raw(" "),
-
                        Span::raw("Show help").gray().dim(),
-
                    ]
-
                    .to_vec(),
-
                ),
-
                Line::from(
-
                    [
-
                        Span::raw(format!("{key:>10}", key = "Esc")).gray(),
-
                        Span::raw(" "),
-
                        Span::raw("Quit / cancel").gray().dim(),
-
                    ]
-
                    .to_vec(),
-
                ),
-
                Line::raw(""),
-
                Line::from(Span::raw("Searching").cyan()),
-
                Line::raw(""),
-
                Line::from(
-
                    [
-
                        Span::raw(format!("{key:>10}", key = "Pattern")).gray(),
-
                        Span::raw(" "),
-
                        Span::raw("is:<state> | is:authored | is:assigned | authors:[<did>, ...] | assignees:[<did>, ...] | <search>")
-
                            .gray()
-
                            .dim(),
-
                    ]
-
                    .to_vec(),
-
                ),
-
                Line::from(
-
                    [
-
                        Span::raw(format!("{key:>10}", key = "Example")).gray(),
-
                        Span::raw(" "),
-
                        Span::raw("is:solved is:authored alias").gray().dim(),
-
                    ]
-
                    .to_vec(),
-
                ),
-
            ]
-
            .to_vec(),
-
        );
-

-
        Self {
-
            content,
-
            focus: false,
-
            page_size: state.ui.page_size,
-
        }
-
    }
-
}
-

-
pub struct Help<'a> {
-
    /// Send messages
-
    pub action_tx: UnboundedSender<Action>,
-
    /// This widget's render properties
-
    pub props: HelpProps<'a>,
-
    /// Container header
-
    header: Header<Action>,
-
    /// Content widget
-
    content: Paragraph<Action>,
-
    /// Container footer
-
    footer: Footer<Action>,
-
}
-

-
impl<'a> Widget<State, Action> for Help<'a> {
-
    fn new(state: &State, action_tx: UnboundedSender<Action>) -> Self
-
    where
-
        Self: Sized,
-
    {
-
        Self {
-
            action_tx: action_tx.clone(),
-
            props: HelpProps::from(state),
-
            header: Header::new(state, action_tx.clone()),
-
            content: Paragraph::new(state, action_tx.clone()),
-
            footer: Footer::new(state, action_tx),
-
        }
-
        .move_with_state(state)
-
    }
-

-
    fn move_with_state(self, state: &State) -> Self
-
    where
-
        Self: Sized,
-
    {
-
        Self {
-
            props: HelpProps::from(state),
-
            header: self.header.move_with_state(state),
-
            content: self.content.move_with_state(state),
-
            footer: self.footer.move_with_state(state),
-
            ..self
-
        }
-
    }
-

-
    fn name(&self) -> &str {
-
        "help"
-
    }
-

-
    fn handle_key_event(&mut self, key: termion::event::Key) {
-
        let len = self.props.content.lines.len() + 1;
-
        let page_size = self.props.page_size;
-
        match key {
-
            Key::Esc => {
-
                let _ = self.action_tx.send(Action::Exit { selection: None });
-
            }
-
            Key::Char('?') => {
-
                let _ = self.action_tx.send(Action::CloseHelp);
-
            }
-
            Key::Up | Key::Char('k') => {
-
                self.content.prev(len, page_size);
-
            }
-
            Key::Down | Key::Char('j') => {
-
                self.content.next(len, page_size);
-
            }
-
            Key::PageUp => {
-
                self.content.prev_page(len, page_size);
-
            }
-
            Key::PageDown => {
-
                self.content.next_page(len, page_size);
-
            }
-
            Key::Home => {
-
                self.content.begin(len, page_size);
-
            }
-
            Key::End => {
-
                self.content.end(len, page_size);
-
            }
-
            _ => {}
-
        }
-
    }
-
}
-

-
impl<'a> Render<()> for Help<'a> {
-
    fn render<B: Backend>(&self, frame: &mut ratatui::Frame, area: Rect, _props: ()) {
-
        let [header_area, content_area, footer_area] = Layout::vertical([
-
            Constraint::Length(3),
-
            Constraint::Min(1),
-
            Constraint::Length(3),
-
        ])
-
        .areas(area);
-

-
        self.header.render::<B>(
-
            frame,
-
            header_area,
-
            HeaderProps {
-
                cells: [String::from(" Help ").into()],
-
                widths: [Constraint::Fill(1)],
-
                focus: self.props.focus,
-
                cutoff: usize::MIN,
-
                cutoff_after: usize::MAX,
-
            },
-
        );
-

-
        self.content.render::<B>(
-
            frame,
-
            content_area,
-
            ParagraphProps {
-
                content: self.props.content.clone(),
-
                focus: self.props.focus,
-
                has_footer: true,
-
                has_header: true,
-
            },
-
        );
-

-
        let progress = span::default(format!("{}%", self.content.progress())).dim();
-

-
        self.footer.render::<B>(
-
            frame,
-
            footer_area,
-
            FooterProps {
-
                cells: [String::new().into(), progress.clone().into()],
-
                widths: [Constraint::Fill(1), Constraint::Min(4)],
-
                focus: self.props.focus,
-
                cutoff: usize::MAX,
-
                cutoff_after: usize::MAX,
-
            },
-
        );
-

-
        let page_size = content_area.height as usize;
-
        if page_size != self.props.page_size {
-
            let _ = self.action_tx.send(Action::PageSize(page_size));
-
        }
-
    }
-
}
deleted bin/commands/issue/realm.rs
@@ -1,6 +0,0 @@
-
#[path = "realm/common.rs"]
-
pub mod common;
-
#[path = "realm/select.rs"]
-
pub mod select;
-
#[path = "realm/suite.rs"]
-
pub mod suite;
deleted bin/commands/issue/realm/common.rs
@@ -1,2 +0,0 @@
-
#[path = "common/ui.rs"]
-
pub mod ui;
deleted bin/commands/issue/realm/common/ui.rs
@@ -1,162 +0,0 @@
-
use radicle::issue::{Issue, IssueId};
-

-
use tuirealm::command::{Cmd, CmdResult};
-
use tuirealm::tui::layout::Rect;
-
use tuirealm::{AttrValue, Attribute, Frame, MockComponent, Props, State};
-

-
use radicle_tui as tui;
-

-
use tui::common::cob::issue::Filter;
-
use tui::common::context::Context;
-
use tui::realm::ui::cob::IssueItem;
-
use tui::realm::ui::theme::{style, Theme};
-
use tui::realm::ui::widget::context::{ContextBar, Progress};
-
use tui::realm::ui::widget::label::{self};
-
use tui::realm::ui::widget::list::{ColumnWidth, Table};
-
use tui::realm::ui::widget::{Widget, WidgetComponent};
-

-
pub struct IssueBrowser {
-
    items: Vec<IssueItem>,
-
    table: Widget<Table<IssueItem, 7>>,
-
}
-

-
impl IssueBrowser {
-
    pub fn new(
-
        theme: &Theme,
-
        context: &Context,
-
        filter: Filter,
-
        selected: Option<(IssueId, Issue)>,
-
    ) -> Self {
-
        let header = [
-
            label::header(" ● "),
-
            label::header("ID"),
-
            label::header("Title"),
-
            label::header("Author"),
-
            label::header("Tags"),
-
            label::header("Assignees"),
-
            label::header("Opened"),
-
        ];
-

-
        let widths = [
-
            ColumnWidth::Fixed(3),
-
            ColumnWidth::Fixed(7),
-
            ColumnWidth::Grow,
-
            ColumnWidth::Fixed(21),
-
            ColumnWidth::Fixed(25),
-
            ColumnWidth::Fixed(21),
-
            ColumnWidth::Fixed(18),
-
        ];
-

-
        let repo = context.repository();
-
        let issues = context
-
            .issues()
-
            .as_ref()
-
            .unwrap()
-
            .iter()
-
            .filter(|(_, issue)| filter.matches(context.profile(), issue));
-

-
        let mut items = vec![];
-
        for (id, issue) in issues {
-
            if let Ok(item) = IssueItem::try_from((context.profile(), repo, *id, issue.clone())) {
-
                items.push(item);
-
            }
-
        }
-

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

-
        let selected = match selected {
-
            Some((id, issue)) => Some(IssueItem::from((context.profile(), repo, id, issue))),
-
            _ => items.first().cloned(),
-
        };
-

-
        let table = Widget::new(Table::new(&items, selected, header, widths, theme.clone()));
-

-
        Self { items, table }
-
    }
-

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

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

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

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

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

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

-
    let mut open = 0;
-
    let mut closed = 0;
-

-
    let issues = context
-
        .issues()
-
        .as_ref()
-
        .unwrap()
-
        .iter()
-
        .filter(|(_, issue)| filter.matches(context.profile(), issue));
-

-
    for (_, issue) in issues {
-
        match issue.state() {
-
            State::Open => open += 1,
-
            State::Closed { reason: _ } => closed += 1,
-
        }
-
    }
-

-
    let context = label::reversable("/").style(style::magenta_reversed());
-
    let filter = label::default(&filter.to_string()).style(style::magenta_dim());
-

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

-
    let closed_n = label::default(&format!("{closed}")).style(style::cyan());
-
    let closed = label::default(" Closed ");
-

-
    let progress = label::reversable(&progress.to_string()).style(style::magenta_reversed());
-

-
    let spacer = label::default("");
-
    let divider = label::default(" | ");
-

-
    let context_bar = ContextBar::new(
-
        label::group(&[context]),
-
        label::group(&[filter]),
-
        label::group(&[spacer.clone()]),
-
        label::group(&[
-
            spacer.clone(),
-
            spacer.clone(),
-
            spacer.clone(),
-
            spacer.clone(),
-
            spacer.clone(),
-
            spacer,
-
            open_n,
-
            open,
-
            divider,
-
            closed_n,
-
            closed,
-
        ]),
-
        label::group(&[progress]),
-
    );
-

-
    Widget::new(context_bar).height(1)
-
}
deleted bin/commands/issue/realm/select.rs
@@ -1,173 +0,0 @@
-
#[path = "select/event.rs"]
-
mod event;
-
#[path = "select/page.rs"]
-
mod page;
-
#[path = "select/ui.rs"]
-
mod ui;
-

-
use std::hash::Hash;
-

-
use anyhow::Result;
-
use radicle::issue::IssueId;
-

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

-
use radicle_tui as tui;
-

-
use tui::common::cob::issue::Filter;
-
use tui::common::context::Context;
-

-
use tui::realm::ui::subscription;
-
use tui::realm::ui::theme::Theme;
-
use tui::realm::{PageStack, Tui};
-
use tui::Exit;
-

-
use page::ListView;
-

-
use super::super::common::Mode;
-

-
type Selection = tui::Selection<IssueId>;
-

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

-
pub struct App {
-
    context: Context,
-
    pages: PageStack<Cid, Message>,
-
    theme: Theme,
-
    quit: bool,
-
    mode: Mode,
-
    filter: Filter,
-
    output: Option<Selection>,
-
}
-

-
/// 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, mode: Mode, filter: Filter) -> Self {
-
        Self {
-
            context,
-
            pages: PageStack::default(),
-
            theme: Theme::default(),
-
            quit: false,
-
            mode,
-
            filter,
-
            output: None,
-
        }
-
    }
-

-
    fn view_list(
-
        &mut self,
-
        app: &mut Application<Cid, Message, NoUserEvent>,
-
        theme: &Theme,
-
    ) -> Result<()> {
-
        let home = Box::new(ListView::new(self.mode.clone(), self.filter.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(output) => {
-
                self.quit = true;
-
                self.output = output;
-
                Ok(None)
-
            }
-
            _ => self
-
                .pages
-
                .peek_mut()?
-
                .update(app, &self.context, &theme, message),
-
        }
-
    }
-
}
-

-
impl Tui<Cid, Message, Selection> 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::realm::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<Selection>> {
-
        if self.quit {
-
            return Some(Exit {
-
                value: self.output.clone(),
-
            });
-
        }
-
        None
-
    }
-
}
deleted bin/commands/issue/realm/select/event.rs
@@ -1,172 +0,0 @@
-
use radicle::issue::IssueId;
-

-
use tuirealm::command::{Cmd, CmdResult, Direction as MoveDirection};
-
use tuirealm::event::{Event, Key, KeyEvent};
-
use tuirealm::{MockComponent, NoUserEvent};
-

-
use radicle_tui as tui;
-

-
use tui::realm::ui::state::ItemState;
-
use tui::realm::ui::widget::container::{AppHeader, GlobalListener, LabeledContainer};
-
use tui::realm::ui::widget::context::{ContextBar, Shortcuts};
-
use tui::realm::ui::widget::list::PropertyList;
-
use tui::realm::ui::widget::Widget;
-

-
use crate::tui_issue::common::IssueOperation;
-

-
use super::ui::{IdSelect, OperationSelect};
-
use super::Message;
-

-
type Selection = tui::Selection<IssueId>;
-

-
/// 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<IdSelect> {
-
    fn on(&mut self, event: Event<NoUserEvent>) -> Option<Message> {
-
        let mut submit = || -> Option<radicle::cob::patch::PatchId> {
-
            match self.perform(Cmd::Submit) {
-
                CmdResult::Submit(state) => {
-
                    let selected = ItemState::try_from(state).ok()?.selected()?;
-
                    let item = self.items().get(selected)?;
-
                    Some(item.id().to_owned())
-
                }
-
                _ => None,
-
            }
-
        };
-

-
        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, ..
-
            }) => submit().map(|id| {
-
                let selection = Selection {
-
                    operation: None,
-
                    ids: vec![id],
-
                    args: vec![],
-
                };
-
                Message::Quit(Some(selection))
-
            }),
-
            _ => None,
-
        }
-
    }
-
}
-

-
impl tuirealm::Component<Message, NoUserEvent> for Widget<OperationSelect> {
-
    fn on(&mut self, event: Event<NoUserEvent>) -> Option<Message> {
-
        let mut submit = || -> Option<radicle::cob::patch::PatchId> {
-
            match self.perform(Cmd::Submit) {
-
                CmdResult::Submit(state) => {
-
                    let selected = ItemState::try_from(state).ok()?.selected()?;
-
                    let item = self.items().get(selected)?;
-
                    Some(item.id().to_owned())
-
                }
-
                _ => None,
-
            }
-
        };
-

-
        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, ..
-
            }) => submit().map(|id| {
-
                let selection = Selection {
-
                    operation: Some(IssueOperation::Show.to_string()),
-
                    ids: vec![id],
-
                    args: vec![],
-
                };
-
                Message::Quit(Some(selection))
-
            }),
-
            Event::Keyboard(KeyEvent {
-
                code: Key::Char('e'),
-
                ..
-
            }) => submit().map(|id| {
-
                let selection = Selection {
-
                    operation: Some(IssueOperation::Edit.to_string()),
-
                    ids: vec![id],
-
                    args: vec![],
-
                };
-
                Message::Quit(Some(selection))
-
            }),
-
            _ => 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/issue/realm/select/page.rs
@@ -1,177 +0,0 @@
-
use std::collections::HashMap;
-

-
use anyhow::Result;
-

-
use tuirealm::{AttrValue, Attribute, Frame, NoUserEvent, Sub, SubClause};
-

-
use radicle_tui as tui;
-

-
use tui::common::cob::issue::Filter;
-
use tui::common::context::Context;
-
use tui::realm::ui::state::ItemState;
-
use tui::realm::ui::theme::Theme;
-
use tui::realm::ui::widget::context::{Progress, Shortcuts};
-
use tui::realm::ui::widget::Widget;
-
use tui::realm::ui::{layout, subscription};
-
use tui::realm::ViewPage;
-

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

-
///
-
/// Home
-
///
-
pub struct ListView {
-
    active_component: ListCid,
-
    subject: Mode,
-
    filter: Filter,
-
    shortcuts: HashMap<ListCid, Widget<Shortcuts>>,
-
}
-

-
impl ListView {
-
    pub fn new(subject: Mode, filter: Filter) -> Self {
-
        Self {
-
            active_component: ListCid::PatchBrowser,
-
            subject,
-
            filter,
-
            shortcuts: HashMap::default(),
-
        }
-
    }
-

-
    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 ItemState::try_from(state) {
-
            Ok(state) => Progress::Step(
-
                state
-
                    .selected()
-
                    .map(|s| s.saturating_add(1))
-
                    .unwrap_or_default(),
-
                state.len(),
-
            ),
-
            Err(_) => Progress::None,
-
        };
-

-
        let context = common::ui::browse_context(context, theme, self.filter.clone(), 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(
-
        &mut self,
-
        app: &mut Application<Cid, Message, NoUserEvent>,
-
        context: &Context,
-
        theme: &Theme,
-
    ) -> Result<()> {
-
        let navigation = ui::list_navigation(theme);
-
        let header = tui::realm::ui::app_header(context, theme, Some(navigation)).to_boxed();
-

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

-
        match self.subject {
-
            Mode::Id => {
-
                let patch_browser =
-
                    ui::id_select(theme, context, self.filter.clone(), None).to_boxed();
-
                self.shortcuts = patch_browser.as_ref().shortcuts();
-

-
                app.remount(Cid::List(ListCid::PatchBrowser), patch_browser, vec![])?;
-
            }
-
            Mode::Operation => {
-
                let patch_browser =
-
                    ui::operation_select(theme, context, self.filter.clone(), 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())?;
-
        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/issue/realm/select/ui.rs
@@ -1,155 +0,0 @@
-
use std::collections::HashMap;
-

-
use radicle::issue::{Issue, IssueId};
-

-
use tuirealm::command::{Cmd, CmdResult};
-
use tuirealm::tui::layout::Rect;
-
use tuirealm::{AttrValue, Attribute, Frame, MockComponent, Props, State};
-

-
use radicle_tui as tui;
-

-
use tui::common::cob::issue::Filter;
-
use tui::common::context::Context;
-
use tui::realm::ui::cob::IssueItem;
-
use tui::realm::ui::theme::{style, Theme};
-
use tui::realm::ui::widget::container::Tabs;
-
use tui::realm::ui::widget::context::Shortcuts;
-
use tui::realm::ui::widget::label::{self};
-
use tui::realm::ui::widget::{Widget, WidgetComponent};
-

-
use super::super::common;
-
use super::ListCid;
-

-
pub struct IdSelect {
-
    theme: Theme,
-
    browser: Widget<common::ui::IssueBrowser>,
-
}
-

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

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

-
    pub fn shortcuts(&self) -> HashMap<ListCid, Widget<Shortcuts>> {
-
        [(
-
            ListCid::PatchBrowser,
-
            tui::realm::ui::shortcuts(
-
                &self.theme,
-
                vec![
-
                    tui::realm::ui::shortcut(&self.theme, "enter", "select"),
-
                    tui::realm::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::IssueBrowser>,
-
}
-

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

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

-
    pub fn shortcuts(&self) -> HashMap<ListCid, Widget<Shortcuts>> {
-
        [(
-
            ListCid::PatchBrowser,
-
            tui::realm::ui::shortcuts(
-
                &self.theme,
-
                vec![
-
                    tui::realm::ui::shortcut(&self.theme, "enter", "show"),
-
                    tui::realm::ui::shortcut(&self.theme, "e", "edit"),
-
                    tui::realm::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::realm::ui::tabs(
-
        theme,
-
        vec![label::reversable("Patches").style(style::cyan())],
-
    )
-
}
-

-
pub fn id_select(
-
    theme: &Theme,
-
    context: &Context,
-
    filter: Filter,
-
    selected: Option<(IssueId, Issue)>,
-
) -> Widget<IdSelect> {
-
    let browser = Widget::new(common::ui::IssueBrowser::new(
-
        theme, context, filter, selected,
-
    ));
-

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

-
pub fn operation_select(
-
    theme: &Theme,
-
    context: &Context,
-
    filter: Filter,
-
    selected: Option<(IssueId, Issue)>,
-
) -> Widget<OperationSelect> {
-
    let browser = Widget::new(common::ui::IssueBrowser::new(
-
        theme, context, filter, selected,
-
    ));
-

-
    Widget::new(OperationSelect::new(theme.clone(), browser))
-
}
deleted bin/commands/issue/realm/suite.rs
@@ -1,357 +0,0 @@
-
#[path = "suite/event.rs"]
-
mod event;
-
#[path = "suite/page.rs"]
-
mod page;
-
#[path = "suite/ui.rs"]
-
mod ui;
-

-
use anyhow::Result;
-

-
use radicle::cob::issue::IssueId;
-

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

-
use radicle_tui as tui;
-

-
use tui::common::cob;
-
use tui::common::context::Context;
-
use tui::realm::ui::subscription;
-
use tui::realm::ui::theme::Theme;
-
use tui::realm::{PageStack, Tui};
-
use tui::Exit;
-

-
use page::{IssuePage, ListPage};
-

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

-
#[derive(Debug, Eq, PartialEq, Clone, Hash)]
-
pub enum IssueCid {
-
    Header,
-
    List,
-
    Details,
-
    Context,
-
    Form,
-
    Shortcuts,
-
}
-

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

-
#[derive(Clone, Debug, Eq, PartialEq)]
-
pub enum IssueCobMessage {
-
    Create {
-
        title: String,
-
        tags: String,
-
        assignees: String,
-
        description: String,
-
    },
-
}
-

-
#[derive(Clone, Debug, Eq, PartialEq)]
-
pub enum IssueMessage {
-
    Show(Option<IssueId>),
-
    Changed(IssueId),
-
    Focus(IssueCid),
-
    Created(IssueId),
-
    Cob(IssueCobMessage),
-
    Reload(Option<IssueId>),
-
    OpenForm,
-
    HideForm,
-
    Leave(Option<IssueId>),
-
}
-

-
#[derive(Clone, Debug, Eq, PartialEq)]
-
pub enum PopupMessage {
-
    Info(String),
-
    Warning(String),
-
    Error(String),
-
    Hide,
-
}
-

-
#[derive(Clone, Debug, Default, Eq, PartialEq)]
-
pub enum Message {
-
    Issue(IssueMessage),
-
    NavigationChanged(u16),
-
    FormSubmitted(String),
-
    Popup(PopupMessage),
-
    #[default]
-
    Tick,
-
    Quit,
-
    Batch(Vec<Message>),
-
}
-

-
#[allow(dead_code)]
-
pub struct App {
-
    context: Context,
-
    pages: PageStack<Cid, Message>,
-
    theme: Theme,
-
    quit: bool,
-
}
-

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

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

-
        Ok(())
-
    }
-

-
    fn view_issue(
-
        &mut self,
-
        app: &mut Application<Cid, Message, NoUserEvent>,
-
        id: Option<IssueId>,
-
        theme: &Theme,
-
    ) -> Result<()> {
-
        let repo = self.context.repository();
-
        match id {
-
            Some(id) => {
-
                let profile = self.context.profile();
-
                if let Some(issue) = cob::issue::find(profile, repo, &id)? {
-
                    let view = Box::new(IssuePage::new(&self.context, theme, Some((id, issue))));
-
                    self.pages.push(view, app, &self.context, theme)?;
-

-
                    Ok(())
-
                } else {
-
                    Err(anyhow::anyhow!(
-
                        "Could not mount 'page::IssueView'. Issue not found."
-
                    ))
-
                }
-
            }
-
            None => {
-
                let view = Box::new(IssuePage::new(&self.context, theme, None));
-
                self.pages.push(view, 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::Issue(IssueMessage::Cob(IssueCobMessage::Create {
-
                title,
-
                tags,
-
                assignees,
-
                description,
-
            })) => match self.create_issue(title, description, tags, assignees) {
-
                Ok(id) => {
-
                    self.context.reload_issues();
-

-
                    Ok(Some(Message::Batch(vec![
-
                        Message::Issue(IssueMessage::HideForm),
-
                        Message::Issue(IssueMessage::Created(id)),
-
                    ])))
-
                }
-
                Err(err) => {
-
                    let error = format!("{:?}", err);
-
                    self.show_error_popup(app, &theme, &error)?;
-

-
                    Ok(None)
-
                }
-
            },
-
            Message::Issue(IssueMessage::Show(id)) => {
-
                self.view_issue(app, id, &theme)?;
-
                Ok(None)
-
            }
-
            Message::Issue(IssueMessage::Leave(id)) => {
-
                self.pages.pop(app)?;
-
                Ok(Some(Message::Issue(IssueMessage::Reload(id))))
-
            }
-
            Message::Popup(PopupMessage::Info(info)) => {
-
                self.show_info_popup(app, &theme, &info)?;
-
                Ok(None)
-
            }
-
            Message::Popup(PopupMessage::Warning(warning)) => {
-
                self.show_warning_popup(app, &theme, &warning)?;
-
                Ok(None)
-
            }
-
            Message::Popup(PopupMessage::Error(error)) => {
-
                self.show_error_popup(app, &theme, &error)?;
-
                Ok(None)
-
            }
-
            Message::Popup(PopupMessage::Hide) => {
-
                self.hide_popup(app)?;
-
                Ok(None)
-
            }
-
            Message::Quit => {
-
                self.quit = true;
-
                Ok(None)
-
            }
-
            _ => self
-
                .pages
-
                .peek_mut()?
-
                .update(app, &self.context, &theme, message),
-
        }
-
    }
-

-
    fn show_info_popup(
-
        &mut self,
-
        app: &mut Application<Cid, Message, NoUserEvent>,
-
        theme: &Theme,
-
        message: &str,
-
    ) -> Result<()> {
-
        let popup = tui::realm::ui::info(theme, message);
-
        app.remount(Cid::Popup, popup.to_boxed(), vec![])?;
-
        app.active(&Cid::Popup)?;
-

-
        Ok(())
-
    }
-

-
    fn show_warning_popup(
-
        &mut self,
-
        app: &mut Application<Cid, Message, NoUserEvent>,
-
        theme: &Theme,
-
        message: &str,
-
    ) -> Result<()> {
-
        let popup = tui::realm::ui::warning(theme, message);
-
        app.remount(Cid::Popup, popup.to_boxed(), vec![])?;
-
        app.active(&Cid::Popup)?;
-

-
        Ok(())
-
    }
-

-
    fn show_error_popup(
-
        &mut self,
-
        app: &mut Application<Cid, Message, NoUserEvent>,
-
        theme: &Theme,
-
        message: &str,
-
    ) -> Result<()> {
-
        let popup = tui::realm::ui::error(theme, message);
-
        app.remount(Cid::Popup, popup.to_boxed(), vec![])?;
-
        app.active(&Cid::Popup)?;
-

-
        Ok(())
-
    }
-

-
    fn hide_popup(&mut self, app: &mut Application<Cid, Message, NoUserEvent>) -> Result<()> {
-
        app.blur()?;
-
        app.umount(&Cid::Popup)?;
-

-
        Ok(())
-
    }
-

-
    fn create_issue(
-
        &mut self,
-
        title: String,
-
        description: String,
-
        labels: String,
-
        assignees: String,
-
    ) -> Result<IssueId> {
-
        let profile = self.context.profile();
-
        let repository = self.context.repository();
-
        let signer = self.context.signer().as_ref().unwrap();
-

-
        let labels = cob::parse_labels(labels)?;
-
        let assignees = cob::parse_assignees(assignees)?;
-

-
        cob::issue::create(
-
            profile,
-
            repository,
-
            signer,
-
            title,
-
            description,
-
            labels.as_slice(),
-
            assignees.as_slice(),
-
        )
-
    }
-
}
-

-
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::realm::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);
-
        }
-

-
        if app.mounted(&Cid::Popup) {
-
            app.view(&Cid::Popup, frame, frame.size());
-
        }
-
    }
-

-
    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: None });
-
        }
-
        None
-
    }
-
}
deleted bin/commands/issue/realm/suite/event.rs
@@ -1,322 +0,0 @@
-
use radicle::cob::issue::IssueId;
-

-
use tuirealm::command::{Cmd, CmdResult, Direction as MoveDirection, Position};
-
use tuirealm::event::{Event, Key, KeyEvent, KeyModifiers};
-
use tuirealm::{MockComponent, NoUserEvent, State, StateValue};
-

-
use radicle_tui as tui;
-

-
use tui::realm::ui::state::ItemState;
-
use tui::realm::ui::widget::container::{AppHeader, GlobalListener, LabeledContainer, Popup};
-
use tui::realm::ui::widget::context::{ContextBar, Shortcuts};
-
use tui::realm::ui::widget::form::Form;
-
use tui::realm::ui::widget::list::PropertyList;
-
use tui::realm::ui::widget::Widget;
-

-
use super::ui;
-
use super::{IssueCid, IssueMessage, Message, PopupMessage};
-

-
/// 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,
-
        }
-
    }
-
}
-

-
impl tuirealm::Component<Message, NoUserEvent> for Widget<AppHeader> {
-
    fn on(&mut self, event: Event<NoUserEvent>) -> Option<Message> {
-
        match event {
-
            Event::Keyboard(KeyEvent { code: Key::Tab, .. }) => {
-
                match self.perform(Cmd::Move(MoveDirection::Right)) {
-
                    CmdResult::Changed(State::One(StateValue::U16(index))) => {
-
                        Some(Message::NavigationChanged(index))
-
                    }
-
                    _ => None,
-
                }
-
            }
-
            _ => None,
-
        }
-
    }
-
}
-

-
impl tuirealm::Component<Message, NoUserEvent> for Widget<ui::LargeList> {
-
    fn on(&mut self, event: Event<NoUserEvent>) -> Option<Message> {
-
        match event {
-
            Event::Keyboard(KeyEvent { code: Key::Esc, .. }) => {
-
                let selected = ItemState::try_from(self.state()).ok()?.selected()?;
-
                let item = self.items().get(selected)?;
-

-
                Some(Message::Issue(IssueMessage::Leave(Some(
-
                    item.id().to_owned(),
-
                ))))
-
            }
-
            Event::Keyboard(KeyEvent { code: Key::Up, .. })
-
            | Event::Keyboard(KeyEvent {
-
                code: Key::Char('k'),
-
                ..
-
            }) => match self.perform(Cmd::Move(MoveDirection::Up)) {
-
                CmdResult::Changed(state) => {
-
                    let selected = ItemState::try_from(state).ok()?.selected()?;
-
                    let item = self.items().get(selected)?;
-

-
                    Some(Message::Issue(IssueMessage::Changed(item.id().to_owned())))
-
                }
-
                _ => None,
-
            },
-
            Event::Keyboard(KeyEvent {
-
                code: Key::Down, ..
-
            })
-
            | Event::Keyboard(KeyEvent {
-
                code: Key::Char('j'),
-
                ..
-
            }) => match self.perform(Cmd::Move(MoveDirection::Down)) {
-
                CmdResult::Changed(state) => {
-
                    let selected = ItemState::try_from(state).ok()?.selected()?;
-
                    let item = self.items().get(selected)?;
-
                    Some(Message::Issue(IssueMessage::Changed(item.id().to_owned())))
-
                }
-
                _ => None,
-
            },
-
            Event::Keyboard(KeyEvent {
-
                code: Key::Enter, ..
-
            }) => Some(Message::Issue(IssueMessage::Focus(IssueCid::Details))),
-
            Event::Keyboard(KeyEvent {
-
                code: Key::Char('o'),
-
                ..
-
            }) => Some(Message::Issue(IssueMessage::OpenForm)),
-
            _ => None,
-
        }
-
    }
-
}
-

-
impl tuirealm::Component<Message, NoUserEvent> for Widget<ui::IssueDetails> {
-
    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::Scroll(MoveDirection::Up));
-
                Some(Message::Tick)
-
            }
-
            Event::Keyboard(KeyEvent {
-
                code: Key::Down, ..
-
            })
-
            | Event::Keyboard(KeyEvent {
-
                code: Key::Char('j'),
-
                ..
-
            }) => {
-
                self.perform(Cmd::Scroll(MoveDirection::Down));
-
                Some(Message::Tick)
-
            }
-
            Event::Keyboard(KeyEvent { code: Key::Esc, .. }) => {
-
                Some(Message::Issue(IssueMessage::Focus(IssueCid::List)))
-
            }
-
            _ => None,
-
        }
-
    }
-
}
-

-
impl tuirealm::Component<Message, NoUserEvent> for Widget<Form> {
-
    fn on(&mut self, event: Event<NoUserEvent>) -> Option<Message> {
-
        match event {
-
            Event::Keyboard(KeyEvent {
-
                code: Key::Left, ..
-
            }) => {
-
                self.perform(Cmd::Move(MoveDirection::Left));
-
                Some(Message::Tick)
-
            }
-
            Event::Keyboard(KeyEvent {
-
                code: Key::Right, ..
-
            }) => {
-
                self.perform(Cmd::Move(MoveDirection::Right));
-
                Some(Message::Tick)
-
            }
-
            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)
-
            }
-
            Event::Keyboard(KeyEvent {
-
                code: Key::Home, ..
-
            }) => {
-
                self.perform(Cmd::GoTo(Position::Begin));
-
                Some(Message::Tick)
-
            }
-
            Event::Keyboard(KeyEvent { code: Key::End, .. }) => {
-
                self.perform(Cmd::GoTo(Position::End));
-
                Some(Message::Tick)
-
            }
-
            Event::Keyboard(KeyEvent {
-
                code: Key::Delete, ..
-
            }) => {
-
                self.perform(Cmd::Cancel);
-
                Some(Message::Tick)
-
            }
-
            Event::Keyboard(KeyEvent {
-
                code: Key::Backspace,
-
                ..
-
            }) => {
-
                self.perform(Cmd::Delete);
-
                Some(Message::Tick)
-
            }
-
            Event::Keyboard(KeyEvent {
-
                code: Key::Enter, ..
-
            }) => {
-
                self.perform(Cmd::Custom(Form::CMD_NEWLINE));
-
                Some(Message::Tick)
-
            }
-
            Event::Keyboard(KeyEvent {
-
                code: Key::Char('s'),
-
                modifiers: KeyModifiers::CONTROL,
-
            }) => {
-
                self.perform(Cmd::Submit);
-
                self.query(tuirealm::Attribute::Custom(Form::PROP_ID))
-
                    .map(|cid| Message::FormSubmitted(cid.unwrap_string()))
-
            }
-
            Event::Keyboard(KeyEvent { code: Key::Esc, .. }) => {
-
                Some(Message::Issue(IssueMessage::HideForm))
-
            }
-
            Event::Keyboard(KeyEvent {
-
                code: Key::BackTab, ..
-
            }) => {
-
                self.perform(Cmd::Custom(Form::CMD_FOCUS_PREVIOUS));
-
                Some(Message::Tick)
-
            }
-
            Event::Keyboard(KeyEvent { code: Key::Tab, .. }) => {
-
                self.perform(Cmd::Custom(Form::CMD_FOCUS_NEXT));
-
                Some(Message::Tick)
-
            }
-
            Event::Keyboard(KeyEvent {
-
                code: Key::Char(ch),
-
                modifiers: KeyModifiers::SHIFT,
-
            }) => {
-
                self.perform(Cmd::Type(ch.to_ascii_uppercase()));
-
                Some(Message::Tick)
-
            }
-
            Event::Keyboard(KeyEvent {
-
                code: Key::Char('v'),
-
                modifiers: KeyModifiers::CONTROL,
-
            }) => {
-
                self.perform(Cmd::Custom(Form::CMD_PASTE));
-
                Some(Message::Tick)
-
            }
-
            Event::Keyboard(KeyEvent {
-
                code: Key::Char(ch),
-
                ..
-
            }) => {
-
                self.perform(Cmd::Type(ch));
-
                Some(Message::Tick)
-
            }
-
            _ => None,
-
        }
-
    }
-
}
-

-
impl tuirealm::Component<Message, NoUserEvent> for Widget<ui::IssueBrowser> {
-
    fn on(&mut self, event: Event<NoUserEvent>) -> Option<Message> {
-
        let mut submit = || -> Option<IssueId> {
-
            match self.perform(Cmd::Submit) {
-
                CmdResult::Submit(state) => {
-
                    let selected = ItemState::try_from(state).ok()?.selected()?;
-
                    let item = self.items().get(selected)?;
-
                    Some(item.id().to_owned())
-
                }
-
                _ => None,
-
            }
-
        };
-

-
        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::Char('o'),
-
                ..
-
            }) => {
-
                let id = submit();
-
                Some(Message::Batch(vec![
-
                    Message::Issue(IssueMessage::Show(id)),
-
                    Message::Issue(IssueMessage::OpenForm),
-
                ]))
-
            }
-
            Event::Keyboard(KeyEvent {
-
                code: Key::Enter, ..
-
            }) => {
-
                let id = submit();
-
                if id.is_some() {
-
                    Some(Message::Issue(IssueMessage::Show(id)))
-
                } else {
-
                    None
-
                }
-
            }
-
            _ => None,
-
        }
-
    }
-
}
-

-
impl tuirealm::Component<Message, NoUserEvent> for Widget<Popup> {
-
    fn on(&mut self, event: Event<NoUserEvent>) -> Option<Message> {
-
        match event {
-
            Event::Keyboard(KeyEvent { code: Key::Esc, .. }) => {
-
                Some(Message::Popup(PopupMessage::Hide))
-
            }
-
            _ => 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/issue/realm/suite/page.rs
@@ -1,573 +0,0 @@
-
use std::collections::HashMap;
-

-
use anyhow::Result;
-

-
use radicle::cob::issue::{Issue, IssueId};
-

-
use tuirealm::event::Key;
-
use tuirealm::{AttrValue, Attribute, Frame, NoUserEvent, State, StateValue, Sub, SubClause};
-

-
use radicle_tui as tui;
-

-
use tui::common::cob;
-
use tui::common::context::Context;
-
use tui::realm::ui::layout;
-
use tui::realm::ui::state::ItemState;
-
use tui::realm::ui::theme::Theme;
-
use tui::realm::ui::widget::context::{Progress, Shortcuts};
-
use tui::realm::ui::widget::Widget;
-
use tui::realm::ViewPage;
-

-
use super::{
-
    Application, Cid, IssueCid, IssueCobMessage, IssueMessage, ListCid, Message, PopupMessage,
-
};
-

-
use super::subscription;
-
use super::ui;
-

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

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

-
    fn activate(
-
        &mut self,
-
        app: &mut Application<Cid, Message, NoUserEvent>,
-
        cid: ListCid,
-
    ) -> Result<()> {
-
        self.active_component = cid;
-
        let cid = Cid::List(self.active_component.clone());
-
        app.active(&cid)?;
-
        app.attr(&cid, Attribute::Focus, AttrValue::Flag(true))?;
-

-
        Ok(())
-
    }
-

-
    fn build_shortcuts(theme: &Theme) -> HashMap<ListCid, Widget<Shortcuts>> {
-
        [(
-
            ListCid::IssueBrowser,
-
            tui::realm::ui::shortcuts(
-
                theme,
-
                vec![
-
                    tui::realm::ui::shortcut(theme, "tab", "section"),
-
                    tui::realm::ui::shortcut(theme, "↑/↓", "navigate"),
-
                    tui::realm::ui::shortcut(theme, "enter", "show"),
-
                    tui::realm::ui::shortcut(theme, "o", "open"),
-
                    tui::realm::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::IssueBrowser))?;
-
        let progress = match ItemState::try_from(state) {
-
            Ok(state) => Progress::Step(
-
                state
-
                    .selected()
-
                    .map(|s| s.saturating_add(1))
-
                    .unwrap_or_default(),
-
                state.len(),
-
            ),
-
            Err(_) => 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 ListPage {
-
    fn mount(
-
        &mut self,
-
        app: &mut Application<Cid, Message, NoUserEvent>,
-
        context: &Context,
-
        theme: &Theme,
-
    ) -> Result<()> {
-
        let navigation = ui::list_navigation(theme);
-
        let header = tui::realm::ui::app_header(context, theme, Some(navigation)).to_boxed();
-
        let issue_browser = ui::issues(context, theme, None).to_boxed();
-

-
        app.remount(Cid::List(ListCid::Header), header, vec![])?;
-
        app.remount(Cid::List(ListCid::IssueBrowser), issue_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::IssueBrowser))?;
-
        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>> {
-
        if let Message::Issue(IssueMessage::Reload(id)) = message {
-
            let selected = match id {
-
                Some(id) => cob::issue::find(context.profile(), context.repository(), &id)?
-
                    .map(|issue| (id, issue)),
-
                _ => None,
-
            };
-

-
            let issue_browser = ui::issues(context, theme, selected).to_boxed();
-
            app.remount(Cid::List(ListCid::IssueBrowser), issue_browser, vec![])?;
-

-
            self.activate(app, ListCid::IssueBrowser)?;
-
        }
-

-
        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::full_page(area, context_h, shortcuts_h);
-

-
        app.view(&Cid::List(ListCid::Header), frame, layout.navigation);
-
        app.view(
-
            &Cid::List(self.active_component.clone()),
-
            frame,
-
            layout.component,
-
        );
-

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

-
///
-
/// Issue detail page
-
///
-
pub struct IssuePage {
-
    issue: Option<(IssueId, Issue)>,
-
    active_component: IssueCid,
-
    shortcuts: HashMap<IssueCid, Widget<Shortcuts>>,
-
}
-

-
impl IssuePage {
-
    pub fn new(_context: &Context, theme: &Theme, issue: Option<(IssueId, Issue)>) -> Self {
-
        let shortcuts = Self::build_shortcuts(theme);
-
        let active_component = IssueCid::List;
-

-
        Self {
-
            issue,
-
            active_component,
-
            shortcuts,
-
        }
-
    }
-

-
    fn activate(
-
        &mut self,
-
        app: &mut Application<Cid, Message, NoUserEvent>,
-
        cid: IssueCid,
-
    ) -> Result<()> {
-
        self.active_component = cid;
-
        let cid = Cid::Issue(self.active_component.clone());
-
        app.active(&cid)?;
-
        app.attr(&cid, Attribute::Focus, AttrValue::Flag(true))?;
-

-
        Ok(())
-
    }
-

-
    fn build_shortcuts(theme: &Theme) -> HashMap<IssueCid, Widget<Shortcuts>> {
-
        [
-
            (
-
                IssueCid::List,
-
                tui::realm::ui::shortcuts(
-
                    theme,
-
                    vec![
-
                        tui::realm::ui::shortcut(theme, "esc", "back"),
-
                        tui::realm::ui::shortcut(theme, "↑/↓", "navigate"),
-
                        tui::realm::ui::shortcut(theme, "enter", "show"),
-
                        tui::realm::ui::shortcut(theme, "o", "open"),
-
                        tui::realm::ui::shortcut(theme, "q", "quit"),
-
                    ],
-
                ),
-
            ),
-
            (
-
                IssueCid::Details,
-
                tui::realm::ui::shortcuts(
-
                    theme,
-
                    vec![
-
                        tui::realm::ui::shortcut(theme, "esc", "back"),
-
                        tui::realm::ui::shortcut(theme, "↑/↓", "scroll"),
-
                        tui::realm::ui::shortcut(theme, "q", "quit"),
-
                    ],
-
                ),
-
            ),
-
            (
-
                IssueCid::Form,
-
                tui::realm::ui::shortcuts(
-
                    theme,
-
                    vec![
-
                        tui::realm::ui::shortcut(theme, "esc", "back"),
-
                        tui::realm::ui::shortcut(theme, "shift + tab / tab", "navigate"),
-
                        tui::realm::ui::shortcut(theme, "ctrl + s", "submit"),
-
                    ],
-
                ),
-
            ),
-
        ]
-
        .iter()
-
        .cloned()
-
        .collect()
-
    }
-

-
    fn update_context(
-
        &self,
-
        app: &mut Application<Cid, Message, NoUserEvent>,
-
        context: &Context,
-
        theme: &Theme,
-
        cid: IssueCid,
-
    ) -> Result<()> {
-
        let context = match cid {
-
            IssueCid::List => {
-
                let state = app.state(&Cid::Issue(IssueCid::List))?;
-
                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);
-
                Some(context)
-
            }
-
            IssueCid::Details => {
-
                let state = app.state(&Cid::Issue(IssueCid::Details))?;
-
                let progress = match state {
-
                    State::One(StateValue::Usize(scroll)) => Progress::Percentage(scroll),
-
                    _ => Progress::None,
-
                };
-
                let context = ui::description_context(context, theme, progress);
-
                Some(context)
-
            }
-
            IssueCid::Form => {
-
                let context = ui::form_context(context, theme, Progress::None);
-
                Some(context)
-
            }
-
            _ => None,
-
        };
-

-
        if let Some(context) = context {
-
            app.remount(Cid::Issue(IssueCid::Context), context.to_boxed(), vec![])?;
-
        }
-

-
        Ok(())
-
    }
-

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

-
impl ViewPage<Cid, Message> for IssuePage {
-
    fn mount(
-
        &mut self,
-
        app: &mut Application<Cid, Message, NoUserEvent>,
-
        context: &Context,
-
        theme: &Theme,
-
    ) -> Result<()> {
-
        let navigation = ui::list_navigation(theme);
-
        let header = tui::realm::ui::app_header(context, theme, Some(navigation)).to_boxed();
-
        let list = ui::list(context, theme, self.issue.clone()).to_boxed();
-

-
        app.remount(Cid::Issue(IssueCid::Header), header, vec![])?;
-
        app.remount(Cid::Issue(IssueCid::List), list, vec![])?;
-

-
        if let Some((id, issue)) = &self.issue {
-
            let comments = issue.comments().collect::<Vec<_>>();
-
            let details = ui::details(
-
                context,
-
                theme,
-
                (*id, issue.clone()),
-
                comments.first().copied(),
-
            )
-
            .to_boxed();
-
            app.remount(Cid::Issue(IssueCid::Details), details, vec![])?;
-
        }
-

-
        app.active(&Cid::Issue(self.active_component.clone()))?;
-

-
        self.update_shortcuts(app, self.active_component.clone())?;
-
        self.update_context(app, context, theme, self.active_component.clone())?;
-

-
        Ok(())
-
    }
-

-
    fn unmount(&self, app: &mut Application<Cid, Message, NoUserEvent>) -> Result<()> {
-
        app.umount(&Cid::Issue(IssueCid::Header))?;
-
        app.umount(&Cid::Issue(IssueCid::List))?;
-
        app.umount(&Cid::Issue(IssueCid::Context))?;
-
        app.umount(&Cid::Issue(IssueCid::Shortcuts))?;
-

-
        if app.mounted(&Cid::Issue(IssueCid::Details)) {
-
            app.umount(&Cid::Issue(IssueCid::Details))?;
-
        }
-

-
        if app.mounted(&Cid::Issue(IssueCid::Form)) {
-
            app.umount(&Cid::Issue(IssueCid::Form))?;
-
        }
-

-
        Ok(())
-
    }
-

-
    fn update(
-
        &mut self,
-
        app: &mut Application<Cid, Message, NoUserEvent>,
-
        context: &Context,
-
        theme: &Theme,
-
        message: Message,
-
    ) -> Result<Option<Message>> {
-
        match message {
-
            Message::Issue(IssueMessage::Created(id)) => {
-
                let profile = context.profile();
-
                let repo = context.repository();
-

-
                if let Some(issue) = cob::issue::find(profile, repo, &id)? {
-
                    self.issue = Some((id, issue.clone()));
-
                    let list = ui::list(context, theme, self.issue.clone()).to_boxed();
-
                    let comments = issue.comments().collect::<Vec<_>>();
-

-
                    let details = ui::details(
-
                        context,
-
                        theme,
-
                        (id, issue.clone()),
-
                        comments.first().copied(),
-
                    )
-
                    .to_boxed();
-

-
                    app.remount(Cid::Issue(IssueCid::List), list, vec![])?;
-
                    app.remount(Cid::Issue(IssueCid::Details), details, vec![])?;
-
                }
-
            }
-
            Message::Issue(IssueMessage::Changed(id)) => {
-
                let profile = context.profile();
-
                let repo = context.repository();
-

-
                if let Some(issue) = cob::issue::find(profile, repo, &id)? {
-
                    self.issue = Some((id, issue.clone()));
-
                    let comments = issue.comments().collect::<Vec<_>>();
-
                    let details = ui::details(
-
                        context,
-
                        theme,
-
                        (id, issue.clone()),
-
                        comments.first().copied(),
-
                    )
-
                    .to_boxed();
-
                    app.remount(Cid::Issue(IssueCid::Details), details, vec![])?;
-
                }
-
            }
-
            Message::Issue(IssueMessage::Focus(cid)) => {
-
                self.activate(app, cid)?;
-
                self.update_shortcuts(app, self.active_component.clone())?;
-
            }
-
            Message::Issue(IssueMessage::OpenForm) => {
-
                let new_form = ui::new_form(context, theme).to_boxed();
-
                let list = ui::list(context, theme, None).to_boxed();
-

-
                app.remount(Cid::Issue(IssueCid::List), list, vec![])?;
-
                app.remount(Cid::Issue(IssueCid::Form), new_form, vec![])?;
-
                app.active(&Cid::Issue(IssueCid::Form))?;
-

-
                app.unsubscribe(
-
                    &Cid::GlobalListener,
-
                    subscription::quit_clause(Key::Char('q')),
-
                )?;
-

-
                return Ok(Some(Message::Issue(IssueMessage::Focus(IssueCid::Form))));
-
            }
-
            Message::Issue(IssueMessage::HideForm) => {
-
                app.umount(&Cid::Issue(IssueCid::Form))?;
-

-
                let list = ui::list(context, theme, self.issue.clone()).to_boxed();
-
                app.remount(Cid::Issue(IssueCid::List), list, vec![])?;
-

-
                app.subscribe(
-
                    &Cid::GlobalListener,
-
                    Sub::new(subscription::quit_clause(Key::Char('q')), SubClause::Always),
-
                )?;
-

-
                if self.issue.is_none() {
-
                    return Ok(Some(Message::Issue(IssueMessage::Leave(None))));
-
                }
-
                return Ok(Some(Message::Issue(IssueMessage::Focus(IssueCid::List))));
-
            }
-
            Message::FormSubmitted(id) => {
-
                if id == ui::FORM_ID_EDIT {
-
                    let state = app.state(&Cid::Issue(IssueCid::Form))?;
-
                    if let State::Linked(mut states) = state {
-
                        let mut missing_values = vec![];
-

-
                        let title = match states.front() {
-
                            Some(State::One(StateValue::String(title))) if !title.is_empty() => {
-
                                Some(title.clone())
-
                            }
-
                            _ => None,
-
                        };
-
                        states.pop_front();
-

-
                        let tags = match states.front() {
-
                            Some(State::One(StateValue::String(tags))) => Some(tags.clone()),
-
                            _ => Some(String::from("[]")),
-
                        };
-
                        states.pop_front();
-

-
                        let assignees = match states.front() {
-
                            Some(State::One(StateValue::String(assignees))) => {
-
                                Some(assignees.clone())
-
                            }
-
                            _ => Some(String::from("[]")),
-
                        };
-
                        states.pop_front();
-

-
                        let description = match states.front() {
-
                            Some(State::One(StateValue::String(description)))
-
                                if !description.is_empty() =>
-
                            {
-
                                Some(description.clone())
-
                            }
-
                            _ => None,
-
                        };
-
                        states.pop_front();
-

-
                        if title.is_none() {
-
                            missing_values.push("title");
-
                        }
-
                        if description.is_none() {
-
                            missing_values.push("description");
-
                        }
-

-
                        // show error popup if missing.
-
                        if !missing_values.is_empty() {
-
                            let error = format!("Missing fields: {:?}", missing_values);
-
                            return Ok(Some(Message::Popup(PopupMessage::Error(error))));
-
                        } else {
-
                            return Ok(Some(Message::Issue(IssueMessage::Cob(
-
                                IssueCobMessage::Create {
-
                                    title: title.unwrap(),
-
                                    tags: tags.unwrap(),
-
                                    assignees: assignees.unwrap(),
-
                                    description: description.unwrap(),
-
                                },
-
                            ))));
-
                        }
-
                    }
-
                }
-
            }
-
            _ => {}
-
        }
-

-
        self.update_context(app, context, theme, self.active_component.clone())?;
-

-
        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::issue_page(area, shortcuts_h);
-

-
        app.view(&Cid::Issue(IssueCid::Header), frame, layout.header);
-
        app.view(&Cid::Issue(IssueCid::List), frame, layout.left);
-

-
        if app.mounted(&Cid::Issue(IssueCid::Form)) {
-
            app.view(&Cid::Issue(IssueCid::Form), frame, layout.right);
-
        } else if app.mounted(&Cid::Issue(IssueCid::Details)) {
-
            app.view(&Cid::Issue(IssueCid::Details), frame, layout.right);
-
        }
-

-
        app.view(&Cid::Issue(IssueCid::Context), frame, layout.context);
-
        app.view(&Cid::Issue(IssueCid::Shortcuts), frame, layout.shortcuts);
-
    }
-

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

-
    fn unsubscribe(&self, _app: &mut Application<Cid, Message, NoUserEvent>) -> Result<()> {
-
        Ok(())
-
    }
-
}
deleted bin/commands/issue/realm/suite/ui.rs
@@ -1,435 +0,0 @@
-
use radicle::node::AliasStore;
-

-
use radicle::cob::thread::Comment;
-
use radicle::cob::thread::CommentId;
-

-
use radicle::cob::issue::Issue;
-
use radicle::cob::issue::IssueId;
-

-
use tuirealm::command::{Cmd, CmdResult};
-
use tuirealm::tui::layout::{Constraint, Direction, Layout, Rect};
-
use tuirealm::{AttrValue, Attribute, Frame, MockComponent, Props, State};
-

-
use radicle_tui as tui;
-

-
use tui::common::context::Context;
-
use tui::realm::ui::cob;
-
use tui::realm::ui::cob::IssueItem;
-
use tui::realm::ui::theme::style;
-
use tui::realm::ui::theme::Theme;
-
use tui::realm::ui::widget::container::{Container, Tabs};
-
use tui::realm::ui::widget::context::{ContextBar, Progress};
-
use tui::realm::ui::widget::form::{Form, TextArea, TextField};
-
use tui::realm::ui::widget::label::{self, Textarea};
-
use tui::realm::ui::widget::list::{ColumnWidth, List, Property, Table};
-
use tui::realm::ui::widget::{Widget, WidgetComponent};
-

-
pub const FORM_ID_EDIT: &str = "edit-form";
-

-
pub struct IssueBrowser {
-
    items: Vec<IssueItem>,
-
    table: Widget<Table<IssueItem, 7>>,
-
}
-

-
impl IssueBrowser {
-
    pub fn new(context: &Context, theme: &Theme, selected: Option<(IssueId, Issue)>) -> Self {
-
        let header = [
-
            label::header(" ● "),
-
            label::header("ID"),
-
            label::header("Title"),
-
            label::header("Author"),
-
            label::header("Tags"),
-
            label::header("Assignees"),
-
            label::header("Opened"),
-
        ];
-

-
        let widths = [
-
            ColumnWidth::Fixed(3),
-
            ColumnWidth::Fixed(7),
-
            ColumnWidth::Grow,
-
            ColumnWidth::Fixed(21),
-
            ColumnWidth::Fixed(25),
-
            ColumnWidth::Fixed(21),
-
            ColumnWidth::Fixed(18),
-
        ];
-

-
        let repo = context.repository();
-
        let mut items = vec![];
-

-
        let issues = context.issues().as_ref().unwrap();
-
        for (id, issue) in issues {
-
            if let Ok(item) = IssueItem::try_from((context.profile(), repo, *id, issue.clone())) {
-
                items.push(item);
-
            }
-
        }
-

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

-
        let selected = match selected {
-
            Some((id, issue)) => Some(IssueItem::from((context.profile(), repo, id, issue))),
-
            _ => items.first().cloned(),
-
        };
-

-
        let table = Widget::new(Table::new(&items, selected, header, widths, theme.clone()));
-

-
        Self { items, table }
-
    }
-

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

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

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

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

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

-
pub struct LargeList {
-
    items: Vec<IssueItem>,
-
    list: Widget<Container>,
-
}
-

-
impl LargeList {
-
    pub fn new(context: &Context, theme: &Theme, selected: Option<(IssueId, Issue)>) -> Self {
-
        let repo = context.repository();
-

-
        let issues = context.issues().as_ref().unwrap();
-
        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| b.state().cmp(a.state()));
-

-
        let selected =
-
            selected.map(|(id, issue)| IssueItem::from((context.profile(), repo, id, issue)));
-

-
        let list = Widget::new(List::new(&items, selected, theme.clone()));
-

-
        let container = tui::realm::ui::container(theme, list.to_boxed());
-

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

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

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

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

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

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

-
pub struct IssueHeader {
-
    container: Widget<Container>,
-
}
-

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

-
        let (id, issue) = issue;
-
        let author = issue.author();
-
        let author = author.id();
-
        let alias = context.profile().aliases().alias(author);
-
        let by_you = *author == context.profile().did();
-
        let item = IssueItem::from((context.profile(), repo, id, issue.clone()));
-

-
        let title = Property::new(label::property("Title"), label::default(item.title()));
-

-
        let author = match alias {
-
            Some(_) => label::alias(&cob::format_author(issue.author().id(), &alias, by_you)),
-
            None => label::did(&cob::format_author(issue.author().id(), &alias, by_you)),
-
        };
-
        let author = Property::new(label::property("Author"), author);
-

-
        let issue_id = Property::new(
-
            label::property("Issue"),
-
            label::default(&id.to_string()).style(style::gray()),
-
        );
-

-
        let labels = Property::new(
-
            label::property("Labels"),
-
            label::labels(&cob::format_labels(item.labels())),
-
        );
-

-
        let assignees = Property::new(
-
            label::property("Assignees"),
-
            label::did(&cob::format_assignees(
-
                &item
-
                    .assignees()
-
                    .iter()
-
                    .map(|item| (item.did(), item.alias(), item.is_you()))
-
                    .collect::<Vec<_>>(),
-
            )),
-
        );
-

-
        let state = Property::new(
-
            label::property("Status"),
-
            label::default(&item.state().to_string()),
-
        );
-

-
        let table = tui::realm::ui::property_table(
-
            theme,
-
            vec![
-
                Widget::new(title),
-
                Widget::new(issue_id),
-
                Widget::new(author),
-
                Widget::new(labels),
-
                Widget::new(assignees),
-
                Widget::new(state),
-
            ],
-
        );
-
        let container = tui::realm::ui::container(theme, table.to_boxed());
-

-
        Self { container }
-
    }
-
}
-

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

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

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

-
pub struct IssueDetails {
-
    header: Widget<IssueHeader>,
-
    description: Widget<CommentBody>,
-
}
-

-
impl IssueDetails {
-
    pub fn new(
-
        context: &Context,
-
        theme: &Theme,
-
        issue: (IssueId, Issue),
-
        description: Option<(&CommentId, &Comment)>,
-
    ) -> Self {
-
        Self {
-
            header: header(context, theme, issue),
-
            description: self::description(context, theme, description),
-
        }
-
    }
-
}
-

-
impl WidgetComponent for IssueDetails {
-
    fn view(&mut self, properties: &Props, frame: &mut Frame, area: Rect) {
-
        let focus = properties
-
            .get_or(Attribute::Focus, AttrValue::Flag(false))
-
            .unwrap_flag();
-
        let layout = Layout::default()
-
            .direction(Direction::Vertical)
-
            .constraints([Constraint::Length(8), Constraint::Min(1)])
-
            .split(area);
-

-
        self.header.view(frame, layout[0]);
-

-
        self.description
-
            .attr(Attribute::Focus, AttrValue::Flag(focus));
-
        self.description.view(frame, layout[1]);
-
    }
-

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

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

-
pub struct CommentBody {
-
    textarea: Widget<Container>,
-
}
-

-
impl CommentBody {
-
    pub fn new(_context: &Context, theme: &Theme, comment: Option<(&CommentId, &Comment)>) -> Self {
-
        let content = match comment {
-
            Some((_, comment)) => comment.body().to_string(),
-
            None => String::new(),
-
        };
-
        let textarea = Widget::new(Textarea::default())
-
            .content(AttrValue::String(content))
-
            .style(style::reset());
-

-
        let textarea = tui::realm::ui::container(theme, textarea.to_boxed());
-

-
        Self { textarea }
-
    }
-
}
-

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

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

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

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

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

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

-
    Widget::new(list)
-
}
-

-
pub fn header(context: &Context, theme: &Theme, issue: (IssueId, Issue)) -> Widget<IssueHeader> {
-
    let header = IssueHeader::new(context, theme, issue);
-
    Widget::new(header)
-
}
-

-
pub fn description(
-
    context: &Context,
-
    theme: &Theme,
-
    comment: Option<(&CommentId, &Comment)>,
-
) -> Widget<CommentBody> {
-
    let body = CommentBody::new(context, theme, comment);
-
    Widget::new(body)
-
}
-

-
pub fn new_form(_context: &Context, theme: &Theme) -> Widget<Form> {
-
    use tuirealm::props::Layout;
-

-
    let title = Widget::new(TextField::new(theme.clone(), "Title")).to_boxed();
-
    let tags = Widget::new(TextField::new(theme.clone(), "Labels (bug, ...)")).to_boxed();
-
    let assignees = Widget::new(TextField::new(
-
        theme.clone(),
-
        "Assignees (z6MkvAdxCp1oLVVTsqYvev9YrhSN3gBQNUSM45hhy4pgkexk, ...)",
-
    ))
-
    .to_boxed();
-
    let description = Widget::new(TextArea::new(theme.clone(), "Description")).to_boxed();
-
    let inputs: Vec<Box<dyn MockComponent>> = vec![title, tags, assignees, description];
-

-
    let layout = Layout::default().constraints(
-
        [
-
            Constraint::Length(3),
-
            Constraint::Length(3),
-
            Constraint::Length(3),
-
            Constraint::Min(3),
-
        ]
-
        .as_ref(),
-
    );
-

-
    Widget::new(Form::new(theme.clone(), inputs))
-
        .custom(Form::PROP_ID, AttrValue::String(String::from(FORM_ID_EDIT)))
-
        .layout(layout)
-
}
-

-
pub fn details(
-
    context: &Context,
-
    theme: &Theme,
-
    issue: (IssueId, Issue),
-
    comment: Option<(&CommentId, &Comment)>,
-
) -> Widget<IssueDetails> {
-
    let discussion = IssueDetails::new(context, theme, issue, comment);
-
    Widget::new(discussion)
-
}
-

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

-
    let issues = context.issues().as_ref().unwrap();
-
    let open = issues
-
        .iter()
-
        .filter(|issue| *issue.1.state() == State::Open)
-
        .collect::<Vec<_>>()
-
        .len();
-
    let closed = issues
-
        .iter()
-
        .filter(|issue| *issue.1.state() != State::Open)
-
        .collect::<Vec<_>>()
-
        .len();
-

-
    tui::realm::ui::widget::context::bar(
-
        theme,
-
        "Browse",
-
        "",
-
        "",
-
        &format!("{open} open | {closed} closed"),
-
        &progress.to_string(),
-
    )
-
}
-

-
pub fn description_context(
-
    _context: &Context,
-
    theme: &Theme,
-
    progress: Progress,
-
) -> Widget<ContextBar> {
-
    tui::realm::ui::widget::context::bar(theme, "Show", "", "", "", &progress.to_string())
-
}
-

-
pub fn form_context(_context: &Context, theme: &Theme, progress: Progress) -> Widget<ContextBar> {
-
    tui::realm::ui::widget::context::bar(theme, "Open", "", "", "", &progress.to_string())
-
        .custom(ContextBar::PROP_EDIT_MODE, AttrValue::Flag(true))
-
}
-

-
pub fn issues(
-
    context: &Context,
-
    theme: &Theme,
-
    selected: Option<(IssueId, Issue)>,
-
) -> Widget<IssueBrowser> {
-
    Widget::new(IssueBrowser::new(context, theme, selected))
-
}
added bin/commands/issue/select.rs
@@ -0,0 +1,160 @@
+
#[path = "select/ui.rs"]
+
mod ui;
+

+
use anyhow::Result;
+

+
use radicle::issue::IssueId;
+
use radicle::storage::git::Repository;
+
use radicle::Profile;
+

+
use radicle_tui as tui;
+

+
use tui::common::cob::issue::{self, Filter};
+
use tui::flux::store::{self, StateValue};
+
use tui::flux::task::{self, Interrupted};
+
use tui::flux::ui::items::IssueItem;
+
use tui::flux::ui::Frontend;
+
use tui::Exit;
+

+
use ui::ListPage;
+

+
use super::common::Mode;
+

+
type Selection = tui::Selection<IssueId>;
+

+
pub struct Context {
+
    pub profile: Profile,
+
    pub repository: Repository,
+
    pub mode: Mode,
+
    pub filter: Filter,
+
}
+

+
pub struct App {
+
    context: Context,
+
}
+

+
#[derive(Clone, Debug)]
+
pub struct UIState {
+
    page_size: usize,
+
    show_search: bool,
+
    show_help: bool,
+
}
+

+
impl Default for UIState {
+
    fn default() -> Self {
+
        Self {
+
            page_size: 1,
+
            show_search: false,
+
            show_help: false,
+
        }
+
    }
+
}
+

+
#[derive(Clone, Debug)]
+
pub struct State {
+
    issues: Vec<IssueItem>,
+
    mode: Mode,
+
    search: StateValue<String>,
+
    ui: UIState,
+
}
+

+
impl TryFrom<&Context> for State {
+
    type Error = anyhow::Error;
+

+
    fn try_from(context: &Context) -> Result<Self, Self::Error> {
+
        let issues = issue::all(&context.profile, &context.repository)?;
+

+
        // Convert into UI items
+
        let mut items = vec![];
+
        for issue in issues {
+
            if let Ok(item) = IssueItem::new(&context.profile, issue.clone()) {
+
                items.push(item);
+
            }
+
        }
+

+
        Ok(Self {
+
            issues: items,
+
            mode: context.mode.clone(),
+
            search: StateValue::new(context.filter.to_string()),
+
            ui: UIState::default(),
+
        })
+
    }
+
}
+

+
pub enum Action {
+
    Exit { selection: Option<Selection> },
+
    PageSize(usize),
+
    OpenSearch,
+
    UpdateSearch { value: String },
+
    ApplySearch,
+
    CloseSearch,
+
    OpenHelp,
+
    CloseHelp,
+
}
+

+
impl store::State<Action, Selection> for State {
+
    fn tick(&self) {}
+

+
    fn handle_action(&mut self, action: Action) -> Option<Exit<Selection>> {
+
        match action {
+
            Action::Exit { selection } => Some(Exit { value: selection }),
+
            Action::PageSize(size) => {
+
                self.ui.page_size = size;
+
                None
+
            }
+
            Action::OpenSearch => {
+
                self.ui.show_search = true;
+
                None
+
            }
+
            Action::UpdateSearch { value } => {
+
                self.search.write(value);
+
                None
+
            }
+
            Action::ApplySearch => {
+
                self.search.apply();
+
                self.ui.show_search = false;
+
                None
+
            }
+
            Action::CloseSearch => {
+
                self.search.reset();
+
                self.ui.show_search = false;
+
                None
+
            }
+
            Action::OpenHelp => {
+
                self.ui.show_help = true;
+
                None
+
            }
+
            Action::CloseHelp => {
+
                self.ui.show_help = false;
+
                None
+
            }
+
        }
+
    }
+
}
+

+
impl App {
+
    pub fn new(context: Context) -> Self {
+
        Self { context }
+
    }
+

+
    pub async fn run(&self) -> Result<Option<Selection>> {
+
        let (terminator, mut interrupt_rx) = task::create_termination();
+
        let (store, state_rx) = store::Store::<Action, State, Selection>::new();
+
        let (frontend, action_rx) = Frontend::<Action>::new();
+
        let state = State::try_from(&self.context)?;
+

+
        tokio::try_join!(
+
            store.main_loop(state, terminator, action_rx, interrupt_rx.resubscribe()),
+
            frontend.main_loop::<State, ListPage, Selection>(state_rx, interrupt_rx.resubscribe()),
+
        )?;
+

+
        if let Ok(reason) = interrupt_rx.recv().await {
+
            match reason {
+
                Interrupted::User { payload } => Ok(payload),
+
                Interrupted::OsSignal => anyhow::bail!("exited because of an os sig int"),
+
            }
+
        } else {
+
            anyhow::bail!("exited because of an unexpected error");
+
        }
+
    }
+
}
added bin/commands/issue/select/ui.rs
@@ -0,0 +1,884 @@
+
use std::collections::HashMap;
+
use std::str::FromStr;
+
use std::vec;
+

+
use radicle::issue::{self, CloseReason};
+
use tokio::sync::mpsc::UnboundedSender;
+

+
use termion::event::Key;
+

+
use ratatui::backend::Backend;
+
use ratatui::layout::{Constraint, Layout, Rect};
+
use ratatui::style::Stylize;
+
use ratatui::text::{Line, Span, Text};
+

+
use radicle_tui as tui;
+

+
use tui::flux::ui::items::{IssueItem, IssueItemFilter};
+
use tui::flux::ui::span;
+
use tui::flux::ui::widget::container::{Footer, FooterProps, Header, HeaderProps};
+
use tui::flux::ui::widget::input::{TextField, TextFieldProps};
+
use tui::flux::ui::widget::text::{Paragraph, ParagraphProps};
+
use tui::flux::ui::widget::{
+
    Render, Shortcut, Shortcuts, ShortcutsProps, Table, TableProps, Widget,
+
};
+
use tui::Selection;
+

+
use crate::tui_issue::common::IssueOperation;
+
use crate::tui_issue::common::Mode;
+

+
use super::{Action, State};
+

+
pub struct ListPageProps {
+
    mode: Mode,
+
    show_search: bool,
+
    show_help: bool,
+
}
+

+
impl From<&State> for ListPageProps {
+
    fn from(state: &State) -> Self {
+
        Self {
+
            mode: state.mode.clone(),
+
            show_search: state.ui.show_search,
+
            show_help: state.ui.show_help,
+
        }
+
    }
+
}
+

+
pub struct ListPage<'a> {
+
    /// Action sender
+
    pub action_tx: UnboundedSender<Action>,
+
    /// State mapped props
+
    props: ListPageProps,
+
    /// Notification widget
+
    issues: Issues,
+
    /// Search widget
+
    search: Search,
+
    /// Help widget
+
    help: Help<'a>,
+
    /// Shortcut widget
+
    shortcuts: Shortcuts<Action>,
+
}
+

+
impl<'a> Widget<State, Action> for ListPage<'a> {
+
    fn new(state: &State, action_tx: UnboundedSender<Action>) -> Self
+
    where
+
        Self: Sized,
+
    {
+
        Self {
+
            action_tx: action_tx.clone(),
+
            props: ListPageProps::from(state),
+
            issues: Issues::new(state, action_tx.clone()),
+
            search: Search::new(state, action_tx.clone()),
+
            help: Help::new(state, action_tx.clone()),
+
            shortcuts: Shortcuts::new(state, action_tx.clone()),
+
        }
+
        .move_with_state(state)
+
    }
+

+
    fn move_with_state(self, state: &State) -> Self
+
    where
+
        Self: Sized,
+
    {
+
        ListPage {
+
            issues: self.issues.move_with_state(state),
+
            shortcuts: self.shortcuts.move_with_state(state),
+
            help: self.help.move_with_state(state),
+
            props: ListPageProps::from(state),
+
            ..self
+
        }
+
    }
+

+
    fn name(&self) -> &str {
+
        "list-page"
+
    }
+

+
    fn handle_key_event(&mut self, key: termion::event::Key) {
+
        if self.props.show_search {
+
            <Search as Widget<State, Action>>::handle_key_event(&mut self.search, key)
+
        } else if self.props.show_help {
+
            <Help as Widget<State, Action>>::handle_key_event(&mut self.help, key)
+
        } else {
+
            match key {
+
                Key::Esc | Key::Ctrl('c') => {
+
                    let _ = self.action_tx.send(Action::Exit { selection: None });
+
                }
+
                Key::Char('/') => {
+
                    let _ = self.action_tx.send(Action::OpenSearch);
+
                }
+
                Key::Char('?') => {
+
                    let _ = self.action_tx.send(Action::OpenHelp);
+
                }
+
                _ => {
+
                    <Issues as Widget<State, Action>>::handle_key_event(&mut self.issues, key);
+
                }
+
            }
+
        }
+
    }
+
}
+

+
impl<'a> Render<()> for ListPage<'a> {
+
    fn render<B: Backend>(&self, frame: &mut ratatui::Frame, _area: Rect, _props: ()) {
+
        let area = frame.size();
+
        let layout = tui::flux::ui::layout::default_page(area, 0u16, 1u16);
+

+
        let shortcuts = if self.props.show_search {
+
            vec![
+
                Shortcut::new("esc", "cancel"),
+
                Shortcut::new("enter", "apply"),
+
            ]
+
        } else if self.props.show_help {
+
            vec![Shortcut::new("?", "close")]
+
        } else {
+
            match self.props.mode {
+
                Mode::Id => vec![
+
                    Shortcut::new("enter", "select"),
+
                    Shortcut::new("/", "search"),
+
                ],
+
                Mode::Operation => vec![
+
                    Shortcut::new("enter", "show"),
+
                    Shortcut::new("e", "edit"),
+
                    Shortcut::new("/", "search"),
+
                    Shortcut::new("?", "help"),
+
                ],
+
            }
+
        };
+

+
        if self.props.show_search {
+
            let component_layout = Layout::vertical([Constraint::Min(1), Constraint::Length(2)])
+
                .split(layout.component);
+

+
            self.issues.render::<B>(frame, component_layout[0], ());
+
            self.search
+
                .render::<B>(frame, component_layout[1], SearchProps {});
+
        } else if self.props.show_help {
+
            self.help.render::<B>(frame, layout.component, ());
+
        } else {
+
            self.issues.render::<B>(frame, layout.component, ());
+
        }
+

+
        self.shortcuts.render::<B>(
+
            frame,
+
            layout.shortcuts,
+
            ShortcutsProps {
+
                shortcuts,
+
                divider: '∙',
+
            },
+
        );
+
    }
+
}
+

+
struct IssuesProps {
+
    mode: Mode,
+
    issues: Vec<IssueItem>,
+
    search: String,
+
    stats: HashMap<String, usize>,
+
    widths: [Constraint; 8],
+
    cutoff: usize,
+
    cutoff_after: usize,
+
    focus: bool,
+
    page_size: usize,
+
    show_search: bool,
+
}
+

+
impl From<&State> for IssuesProps {
+
    fn from(state: &State) -> Self {
+
        use radicle::issue::State;
+

+
        let mut open = 0;
+
        let mut other = 0;
+
        let mut solved = 0;
+

+
        // Filter by search string
+
        let filter = IssueItemFilter::from_str(&state.search.read()).unwrap_or_default();
+
        let mut issues = state
+
            .issues
+
            .clone()
+
            .into_iter()
+
            .filter(|issue| filter.matches(issue))
+
            .collect::<Vec<_>>();
+

+
        // Apply sorting
+
        issues.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
+

+
        for issue in &state.issues {
+
            match issue.state {
+
                State::Open => open += 1,
+
                State::Closed {
+
                    reason: CloseReason::Other,
+
                } => other += 1,
+
                State::Closed {
+
                    reason: CloseReason::Solved,
+
                } => solved += 1,
+
            }
+
        }
+

+
        let closed = solved + other;
+

+
        let stats = HashMap::from([
+
            ("Open".to_string(), open),
+
            ("Other".to_string(), other),
+
            ("Solved".to_string(), solved),
+
            ("Closed".to_string(), closed),
+
        ]);
+

+
        Self {
+
            mode: state.mode.clone(),
+
            issues,
+
            search: state.search.read(),
+
            widths: [
+
                Constraint::Length(3),
+
                Constraint::Length(8),
+
                Constraint::Fill(5),
+
                Constraint::Length(16),
+
                Constraint::Length(16),
+
                Constraint::Fill(1),
+
                Constraint::Fill(1),
+
                Constraint::Length(16),
+
            ],
+
            cutoff: 200,
+
            cutoff_after: 5,
+
            focus: false,
+
            stats,
+
            page_size: state.ui.page_size,
+
            show_search: state.ui.show_search,
+
        }
+
    }
+
}
+

+
struct Issues {
+
    /// Action sender
+
    action_tx: UnboundedSender<Action>,
+
    /// State mapped props
+
    props: IssuesProps,
+
    /// Header
+
    header: Header<Action>,
+
    /// Notification table
+
    table: Table<Action>,
+
    /// Footer
+
    footer: Footer<Action>,
+
}
+

+
impl Widget<State, Action> for Issues {
+
    fn new(state: &State, action_tx: UnboundedSender<Action>) -> Self {
+
        Self {
+
            action_tx: action_tx.clone(),
+
            props: IssuesProps::from(state),
+
            header: Header::new(state, action_tx.clone()),
+
            table: Table::new(state, action_tx.clone()),
+
            footer: Footer::new(state, action_tx),
+
        }
+
    }
+

+
    fn move_with_state(self, state: &State) -> Self
+
    where
+
        Self: Sized,
+
    {
+
        let props = IssuesProps::from(state);
+
        let mut table = self.table.move_with_state(state);
+

+
        if let Some(selected) = table.selected() {
+
            if selected > props.issues.len() {
+
                table.begin();
+
            }
+
        }
+

+
        Self {
+
            props,
+
            table,
+
            header: self.header.move_with_state(state),
+
            footer: self.footer.move_with_state(state),
+
            ..self
+
        }
+
    }
+

+
    fn name(&self) -> &str {
+
        "issues"
+
    }
+

+
    fn handle_key_event(&mut self, key: Key) {
+
        match key {
+
            Key::Up | Key::Char('k') => {
+
                self.table.prev();
+
            }
+
            Key::Down | Key::Char('j') => {
+
                self.table.next(self.props.issues.len());
+
            }
+
            Key::PageUp => {
+
                self.table.prev_page(self.props.page_size);
+
            }
+
            Key::PageDown => {
+
                self.table
+
                    .next_page(self.props.issues.len(), self.props.page_size);
+
            }
+
            Key::Home => {
+
                self.table.begin();
+
            }
+
            Key::End => {
+
                self.table.end(self.props.issues.len());
+
            }
+
            Key::Char('\n') => {
+
                let operation = match self.props.mode {
+
                    Mode::Operation => Some(IssueOperation::Show.to_string()),
+
                    Mode::Id => None,
+
                };
+

+
                self.table
+
                    .selected()
+
                    .and_then(|selected| self.props.issues.get(selected))
+
                    .and_then(|issue| {
+
                        self.action_tx
+
                            .send(Action::Exit {
+
                                selection: Some(Selection {
+
                                    operation,
+
                                    ids: vec![issue.id],
+
                                    args: vec![],
+
                                }),
+
                            })
+
                            .ok()
+
                    });
+
            }
+
            Key::Char('e') => {
+
                self.table
+
                    .selected()
+
                    .and_then(|selected| self.props.issues.get(selected))
+
                    .and_then(|issue| {
+
                        self.action_tx
+
                            .send(Action::Exit {
+
                                selection: Some(Selection {
+
                                    operation: Some(IssueOperation::Edit.to_string()),
+
                                    ids: vec![issue.id],
+
                                    args: vec![],
+
                                }),
+
                            })
+
                            .ok()
+
                    });
+
            }
+
            _ => {}
+
        }
+
    }
+
}
+

+
impl Issues {
+
    fn render_header<B: Backend>(&self, frame: &mut ratatui::Frame, area: Rect) {
+
        self.header.render::<B>(
+
            frame,
+
            area,
+
            HeaderProps {
+
                cells: [
+
                    String::from(" ● ").into(),
+
                    String::from("ID").into(),
+
                    String::from("Title").into(),
+
                    String::from("Author").into(),
+
                    String::from("").into(),
+
                    String::from("Labels").into(),
+
                    String::from("Assignees ").into(),
+
                    String::from("Opened").into(),
+
                ],
+
                widths: self.props.widths,
+
                focus: self.props.focus,
+
                cutoff: self.props.cutoff,
+
                cutoff_after: self.props.cutoff_after,
+
            },
+
        );
+
    }
+

+
    fn render_list<B: Backend>(&self, frame: &mut ratatui::Frame, area: Rect) {
+
        self.table.render::<B>(
+
            frame,
+
            area,
+
            TableProps {
+
                items: self.props.issues.to_vec(),
+
                has_footer: !self.props.show_search,
+
                has_header: true,
+
                widths: self.props.widths,
+
                focus: self.props.focus,
+
                cutoff: self.props.cutoff,
+
                cutoff_after: self.props.cutoff_after,
+
            },
+
        );
+
    }
+

+
    fn render_footer<B: Backend>(&self, frame: &mut ratatui::Frame, area: Rect) {
+
        let search = Line::from(
+
            [
+
                span::default(" Search ".to_string())
+
                    .cyan()
+
                    .dim()
+
                    .reversed(),
+
                span::default(" ".into()),
+
                span::default(self.props.search.to_string()).gray().dim(),
+
            ]
+
            .to_vec(),
+
        );
+

+
        let open = Line::from(
+
            [
+
                span::positive(self.props.stats.get("Open").unwrap_or(&0).to_string()).dim(),
+
                span::default(" Open".to_string()).dim(),
+
            ]
+
            .to_vec(),
+
        );
+
        let solved = Line::from(
+
            [
+
                span::default(self.props.stats.get("Solved").unwrap_or(&0).to_string())
+
                    .magenta()
+
                    .dim(),
+
                span::default(" Solved".to_string()).dim(),
+
            ]
+
            .to_vec(),
+
        );
+
        let closed = Line::from(
+
            [
+
                span::default(self.props.stats.get("Closed").unwrap_or(&0).to_string())
+
                    .magenta()
+
                    .dim(),
+
                span::default(" Closed".to_string()).dim(),
+
            ]
+
            .to_vec(),
+
        );
+
        let sum = Line::from(
+
            [
+
                span::default("Σ ".to_string()).dim(),
+
                span::default(self.props.issues.len().to_string()).dim(),
+
            ]
+
            .to_vec(),
+
        );
+

+
        let progress = self
+
            .table
+
            .progress_percentage(self.props.issues.len(), self.props.page_size);
+
        let progress = span::default(format!("{}%", progress)).dim();
+

+
        match IssueItemFilter::from_str(&self.props.search)
+
            .unwrap_or_default()
+
            .state()
+
        {
+
            Some(state) => {
+
                let block = match state {
+
                    issue::State::Open => open,
+
                    issue::State::Closed {
+
                        reason: issue::CloseReason::Other,
+
                    } => closed,
+
                    issue::State::Closed {
+
                        reason: issue::CloseReason::Solved,
+
                    } => solved,
+
                };
+

+
                self.footer.render::<B>(
+
                    frame,
+
                    area,
+
                    FooterProps {
+
                        cells: [search.into(), block.clone().into(), progress.clone().into()],
+
                        widths: [
+
                            Constraint::Fill(1),
+
                            Constraint::Min(block.width() as u16),
+
                            Constraint::Min(4),
+
                        ],
+
                        focus: self.props.focus,
+
                        cutoff: self.props.cutoff,
+
                        cutoff_after: self.props.cutoff_after,
+
                    },
+
                );
+
            }
+
            None => {
+
                self.footer.render::<B>(
+
                    frame,
+
                    area,
+
                    FooterProps {
+
                        cells: [
+
                            search.into(),
+
                            open.clone().into(),
+
                            closed.clone().into(),
+
                            sum.clone().into(),
+
                            progress.clone().into(),
+
                        ],
+
                        widths: [
+
                            Constraint::Fill(1),
+
                            Constraint::Min(open.width() as u16),
+
                            Constraint::Min(closed.width() as u16),
+
                            Constraint::Min(sum.width() as u16),
+
                            Constraint::Min(4),
+
                        ],
+
                        focus: self.props.focus,
+
                        cutoff: self.props.cutoff,
+
                        cutoff_after: self.props.cutoff_after,
+
                    },
+
                );
+
            }
+
        }
+
    }
+
}
+

+
impl Render<()> for Issues {
+
    fn render<B: Backend>(&self, frame: &mut ratatui::Frame, area: Rect, _props: ()) {
+
        let page_size = if self.props.show_search {
+
            let layout = Layout::vertical([Constraint::Length(3), Constraint::Min(1)]).split(area);
+

+
            self.render_header::<B>(frame, layout[0]);
+
            self.render_list::<B>(frame, layout[1]);
+

+
            layout[1].height as usize
+
        } else {
+
            let layout = Layout::vertical([
+
                Constraint::Length(3),
+
                Constraint::Min(1),
+
                Constraint::Length(3),
+
            ])
+
            .split(area);
+

+
            self.render_header::<B>(frame, layout[0]);
+
            self.render_list::<B>(frame, layout[1]);
+
            self.render_footer::<B>(frame, layout[2]);
+

+
            layout[1].height as usize
+
        };
+

+
        if page_size != self.props.page_size {
+
            let _ = self.action_tx.send(Action::PageSize(page_size));
+
        }
+
    }
+
}
+

+
pub struct SearchProps {}
+

+
pub struct Search {
+
    pub action_tx: UnboundedSender<Action>,
+
    pub input: TextField,
+
}
+

+
impl Widget<State, Action> for Search {
+
    fn new(state: &State, action_tx: UnboundedSender<Action>) -> Self
+
    where
+
        Self: Sized,
+
    {
+
        let mut input = TextField::new(state, action_tx.clone());
+
        input.set_text(&state.search.read().to_string());
+

+
        Self { action_tx, input }.move_with_state(state)
+
    }
+

+
    fn move_with_state(self, state: &State) -> Self
+
    where
+
        Self: Sized,
+
    {
+
        let mut input = <TextField as Widget<State, Action>>::move_with_state(self.input, state);
+
        input.set_text(&state.search.read().to_string());
+

+
        Self { input, ..self }
+
    }
+

+
    fn name(&self) -> &str {
+
        "filter-popup"
+
    }
+

+
    fn handle_key_event(&mut self, key: termion::event::Key) {
+
        match key {
+
            Key::Esc => {
+
                let _ = self.action_tx.send(Action::CloseSearch);
+
            }
+
            Key::Char('\n') => {
+
                let _ = self.action_tx.send(Action::ApplySearch);
+
            }
+
            _ => {
+
                <TextField as Widget<State, Action>>::handle_key_event(&mut self.input, key);
+
                let _ = self.action_tx.send(Action::UpdateSearch {
+
                    value: self.input.text().to_string(),
+
                });
+
            }
+
        }
+
    }
+
}
+

+
impl Render<SearchProps> for Search {
+
    fn render<B: Backend>(&self, frame: &mut ratatui::Frame, area: Rect, _props: SearchProps) {
+
        let layout = Layout::horizontal(Constraint::from_mins([0]))
+
            .horizontal_margin(1)
+
            .split(area);
+

+
        self.input.render::<B>(
+
            frame,
+
            layout[0],
+
            TextFieldProps {
+
                titles: ("Search".into(), "Search".into()),
+
                show_cursor: true,
+
                inline_label: true,
+
            },
+
        );
+
    }
+
}
+

+
pub struct HelpProps<'a> {
+
    content: Text<'a>,
+
    focus: bool,
+
    page_size: usize,
+
}
+

+
impl<'a> From<&State> for HelpProps<'a> {
+
    fn from(state: &State) -> Self {
+
        let content = Text::from(
+
            [
+
                Line::from(Span::raw("Generic keybindings").cyan()),
+
                Line::raw(""),
+
                Line::from(
+
                    [
+
                        Span::raw(format!("{key:>10}", key = "↑,k")).gray(),
+
                        Span::raw(" "),
+
                        Span::raw("move cursor one line up").gray().dim(),
+
                    ]
+
                    .to_vec(),
+
                ),
+
                Line::from(
+
                    [
+
                        Span::raw(format!("{key:>10}", key = "↓,j")).gray(),
+
                        Span::raw(" "),
+
                        Span::raw("move cursor one line down").gray().dim(),
+
                    ]
+
                    .to_vec(),
+
                ),
+
                Line::from(
+
                    [
+
                        Span::raw(format!("{key:>10}", key = "PageUp")).gray(),
+
                        Span::raw(" "),
+
                        Span::raw("move cursor one page up").gray().dim(),
+
                    ]
+
                    .to_vec(),
+
                ),
+
                Line::from(
+
                    [
+
                        Span::raw(format!("{key:>10}", key = "PageDown")).gray(),
+
                        Span::raw(" "),
+
                        Span::raw("move cursor one page down").gray().dim(),
+
                    ]
+
                    .to_vec(),
+
                ),
+
                Line::from(
+
                    [
+
                        Span::raw(format!("{key:>10}", key = "Home")).gray(),
+
                        Span::raw(" "),
+
                        Span::raw("move cursor to the first line").gray().dim(),
+
                    ]
+
                    .to_vec(),
+
                ),
+
                Line::from(
+
                    [
+
                        Span::raw(format!("{key:>10}", key = "End")).gray(),
+
                        Span::raw(" "),
+
                        Span::raw("move cursor to the last line").gray().dim(),
+
                    ]
+
                    .to_vec(),
+
                ),
+
                Line::raw(""),
+
                Line::from(Span::raw("Specific keybindings").cyan()),
+
                Line::raw(""),
+
                Line::from(
+
                    [
+
                        Span::raw(format!("{key:>10}", key = "enter")).gray(),
+
                        Span::raw(" "),
+
                        Span::raw("Select issue (if --mode id)").gray().dim(),
+
                    ]
+
                    .to_vec(),
+
                ),
+
                Line::from(
+
                    [
+
                        Span::raw(format!("{key:>10}", key = "enter")).gray(),
+
                        Span::raw(" "),
+
                        Span::raw("Show issue").gray().dim(),
+
                    ]
+
                    .to_vec(),
+
                ),
+
                Line::from(
+
                    [
+
                        Span::raw(format!("{key:>10}", key = "e")).gray(),
+
                        Span::raw(" "),
+
                        Span::raw("Edit patch").gray().dim(),
+
                    ]
+
                    .to_vec(),
+
                ),
+
                Line::from(
+
                    [
+
                        Span::raw(format!("{key:>10}", key = "/")).gray(),
+
                        Span::raw(" "),
+
                        Span::raw("Search").gray().dim(),
+
                    ]
+
                    .to_vec(),
+
                ),
+
                Line::from(
+
                    [
+
                        Span::raw(format!("{key:>10}", key = "?")).gray(),
+
                        Span::raw(" "),
+
                        Span::raw("Show help").gray().dim(),
+
                    ]
+
                    .to_vec(),
+
                ),
+
                Line::from(
+
                    [
+
                        Span::raw(format!("{key:>10}", key = "Esc")).gray(),
+
                        Span::raw(" "),
+
                        Span::raw("Quit / cancel").gray().dim(),
+
                    ]
+
                    .to_vec(),
+
                ),
+
                Line::raw(""),
+
                Line::from(Span::raw("Searching").cyan()),
+
                Line::raw(""),
+
                Line::from(
+
                    [
+
                        Span::raw(format!("{key:>10}", key = "Pattern")).gray(),
+
                        Span::raw(" "),
+
                        Span::raw("is:<state> | is:authored | is:assigned | authors:[<did>, ...] | assignees:[<did>, ...] | <search>")
+
                            .gray()
+
                            .dim(),
+
                    ]
+
                    .to_vec(),
+
                ),
+
                Line::from(
+
                    [
+
                        Span::raw(format!("{key:>10}", key = "Example")).gray(),
+
                        Span::raw(" "),
+
                        Span::raw("is:solved is:authored alias").gray().dim(),
+
                    ]
+
                    .to_vec(),
+
                ),
+
            ]
+
            .to_vec(),
+
        );
+

+
        Self {
+
            content,
+
            focus: false,
+
            page_size: state.ui.page_size,
+
        }
+
    }
+
}
+

+
pub struct Help<'a> {
+
    /// Send messages
+
    pub action_tx: UnboundedSender<Action>,
+
    /// This widget's render properties
+
    pub props: HelpProps<'a>,
+
    /// Container header
+
    header: Header<Action>,
+
    /// Content widget
+
    content: Paragraph<Action>,
+
    /// Container footer
+
    footer: Footer<Action>,
+
}
+

+
impl<'a> Widget<State, Action> for Help<'a> {
+
    fn new(state: &State, action_tx: UnboundedSender<Action>) -> Self
+
    where
+
        Self: Sized,
+
    {
+
        Self {
+
            action_tx: action_tx.clone(),
+
            props: HelpProps::from(state),
+
            header: Header::new(state, action_tx.clone()),
+
            content: Paragraph::new(state, action_tx.clone()),
+
            footer: Footer::new(state, action_tx),
+
        }
+
        .move_with_state(state)
+
    }
+

+
    fn move_with_state(self, state: &State) -> Self
+
    where
+
        Self: Sized,
+
    {
+
        Self {
+
            props: HelpProps::from(state),
+
            header: self.header.move_with_state(state),
+
            content: self.content.move_with_state(state),
+
            footer: self.footer.move_with_state(state),
+
            ..self
+
        }
+
    }
+

+
    fn name(&self) -> &str {
+
        "help"
+
    }
+

+
    fn handle_key_event(&mut self, key: termion::event::Key) {
+
        let len = self.props.content.lines.len() + 1;
+
        let page_size = self.props.page_size;
+
        match key {
+
            Key::Esc => {
+
                let _ = self.action_tx.send(Action::Exit { selection: None });
+
            }
+
            Key::Char('?') => {
+
                let _ = self.action_tx.send(Action::CloseHelp);
+
            }
+
            Key::Up | Key::Char('k') => {
+
                self.content.prev(len, page_size);
+
            }
+
            Key::Down | Key::Char('j') => {
+
                self.content.next(len, page_size);
+
            }
+
            Key::PageUp => {
+
                self.content.prev_page(len, page_size);
+
            }
+
            Key::PageDown => {
+
                self.content.next_page(len, page_size);
+
            }
+
            Key::Home => {
+
                self.content.begin(len, page_size);
+
            }
+
            Key::End => {
+
                self.content.end(len, page_size);
+
            }
+
            _ => {}
+
        }
+
    }
+
}
+

+
impl<'a> Render<()> for Help<'a> {
+
    fn render<B: Backend>(&self, frame: &mut ratatui::Frame, area: Rect, _props: ()) {
+
        let [header_area, content_area, footer_area] = Layout::vertical([
+
            Constraint::Length(3),
+
            Constraint::Min(1),
+
            Constraint::Length(3),
+
        ])
+
        .areas(area);
+

+
        self.header.render::<B>(
+
            frame,
+
            header_area,
+
            HeaderProps {
+
                cells: [String::from(" Help ").into()],
+
                widths: [Constraint::Fill(1)],
+
                focus: self.props.focus,
+
                cutoff: usize::MIN,
+
                cutoff_after: usize::MAX,
+
            },
+
        );
+

+
        self.content.render::<B>(
+
            frame,
+
            content_area,
+
            ParagraphProps {
+
                content: self.props.content.clone(),
+
                focus: self.props.focus,
+
                has_footer: true,
+
                has_header: true,
+
            },
+
        );
+

+
        let progress = span::default(format!("{}%", self.content.progress())).dim();
+

+
        self.footer.render::<B>(
+
            frame,
+
            footer_area,
+
            FooterProps {
+
                cells: [String::new().into(), progress.clone().into()],
+
                widths: [Constraint::Fill(1), Constraint::Min(4)],
+
                focus: self.props.focus,
+
                cutoff: usize::MAX,
+
                cutoff_after: usize::MAX,
+
            },
+
        );
+

+
        let page_size = content_area.height as usize;
+
        if page_size != self.props.page_size {
+
            let _ = self.action_tx.send(Action::PageSize(page_size));
+
        }
+
    }
+
}
modified bin/commands/patch.rs
@@ -144,7 +144,6 @@ impl Args for Options {
    }
}

-
#[cfg(feature = "flux")]
#[tokio::main]
pub async fn run(options: Options, _ctx: impl terminal::Context) -> anyhow::Result<()> {
    use radicle::storage::ReadStorage;