Radish alpha
r
rad:z39mP9rQAaGmERfUMPULfPUi473tY
Radicle terminal user interface
Radicle
Git
bin/patch: Sunset rmUI app
Erik Kundt committed 4 months ago
commit 0a58d177165ed7c822d252a78aa00dd84772963a
parent 16d3e7b
5 files changed +572 -1253
modified bin/commands/patch.rs
@@ -373,7 +373,7 @@ mod interface {
            filter: opts.filter.clone(),
        };

-
        list::App::new(context, true).run().await
+
        list::Tui::new(context).run().await
    }

    pub async fn review(
modified bin/commands/patch/list.rs
@@ -1,45 +1,62 @@
-
#[path = "list/imui.rs"]
-
mod imui;
-
#[path = "list/rmui.rs"]
-
mod rmui;
-

use std::str::FromStr;

use anyhow::Result;

use termion::event::Key;

-
use ratatui::layout::Constraint;
-
use ratatui::style::Stylize;
-
use ratatui::text::Text;
-
use ratatui::Viewport;
-

use radicle::patch::PatchId;
use radicle::storage::git::Repository;
use radicle::Profile;

+
use ratatui::layout::{Constraint, Layout, Position};
+
use ratatui::style::Stylize;
+
use ratatui::text::Span;
+
use ratatui::{Frame, Viewport};
+

use radicle_tui as tui;

use tui::store;
use tui::task::EmptyProcessors;
-
use tui::ui::rm::widget::container::{Container, Footer, FooterProps, Header, HeaderProps};
-
use tui::ui::rm::widget::text::{TextView, TextViewProps, TextViewState};
-
use tui::ui::rm::widget::window::{
-
    Page, PageProps, Shortcuts, ShortcutsProps, Window, WindowProps,
-
};
-
use tui::ui::rm::widget::{ToWidget, Widget};
+
use tui::ui::im;
+
use tui::ui::im::widget::{PanesState, TableState, TextEditState, TextViewState, Window};
+
use tui::ui::im::Borders;
+
use tui::ui::im::Show;
+
use tui::ui::BufferedValue;
use tui::ui::Column;
-
use tui::ui::{span, BufferedValue};
-
use tui::{BoxedAny, Channel, Exit, PageStack};
+
use tui::{Channel, Exit};
+

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

-
use self::rmui::{Browser, BrowserProps};
use super::common::{Mode, PatchOperation};

use crate::cob::patch;
+
use crate::ui::items::filter::Filter;
use crate::ui::items::{PatchItem, PatchItemFilter};
-
use crate::ui::rm::BrowserState;

-
type Selection = tui::Selection<PatchId>;
+
const HELP: &str = r#"# Generic keybindings
+

+
`↑,k`:      move cursor one line up
+
`↓,j:       move cursor one line down
+
`PageUp`:   move cursor one page up
+
`PageDown`: move cursor one page down
+
`Home`:     move cursor to the first line
+
`End`:      move cursor to the last line
+
`Esc`:      Cancel
+
`q`:        Quit
+

+
# Specific keybindings
+

+
`enter`:    Select patch (if --mode id)
+
`enter`:    Show patch
+
`c`:        Checkout patch
+
`d`:        Show patch diff
+
`/`:        Search
+
`?`:        Show help
+

+
# Searching
+

+
Pattern:    is:<state> | is:authored | authors:[<did>, <did>] | <search>
+
Example:    is:open is:authored improve"#;

pub struct Context {
    pub profile: Profile,
@@ -48,114 +65,124 @@ pub struct Context {
    pub filter: patch::Filter,
}

-
pub struct App {
+
pub struct Tui {
    context: Context,
-
    im: bool,
}

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

    pub async fn run(&self) -> Result<Option<Selection>> {
        let viewport = Viewport::Inline(20);
+
        let channel = Channel::default();
+
        let state = App::try_from(&self.context)?;

-
        if self.im {
-
            let channel = Channel::default();
-
            let state = imui::App::try_from(&self.context)?;
-

-
            tui::im(state, viewport, channel, EmptyProcessors::new()).await
-
        } else {
-
            let channel = Channel::default();
-
            let tx = channel.tx.clone();
-
            let state = State::try_from(&self.context)?;
-
            let window = Window::default()
-
                .page(AppPage::Browse, browser_page(&state, &channel))
-
                .page(AppPage::Help, help_page(&state, &channel))
-
                .to_widget(tx.clone())
-
                .on_update(|state| {
-
                    WindowProps::default()
-
                        .current_page(state.pages.peek().unwrap_or(&AppPage::Browse).clone())
-
                        .to_boxed_any()
-
                        .into()
-
                });
-

-
            tui::rm(state, window, viewport, channel, EmptyProcessors::new()).await
-
        }
+
        tui::im(state, viewport, channel, EmptyProcessors::new()).await
    }
}

-
#[derive(Clone, Debug, Eq, PartialEq, Hash)]
-
pub enum AppPage {
-
    Browse,
+
#[derive(Clone, Debug)]
+
pub enum Message {
+
    Quit,
+
    Exit {
+
        operation: Option<PatchOperation>,
+
    },
+
    ExitFromMode,
+
    PatchesChanged {
+
        state: TableState,
+
    },
+
    MainGroupChanged {
+
        state: PanesState,
+
    },
+
    PageChanged {
+
        page: Page,
+
    },
+
    HelpChanged {
+
        state: TextViewState,
+
    },
+
    ShowSearch,
+
    UpdateSearch {
+
        search: BufferedValue<TextEditState>,
+
    },
+
    HideSearch {
+
        apply: bool,
+
    },
+
}
+

+
#[derive(Clone, Debug)]
+
pub enum Page {
+
    Main,
    Help,
}

#[derive(Clone, Debug)]
-
pub struct HelpState {
-
    text: TextViewState,
+
pub struct Storage {
+
    patches: Vec<PatchItem>,
}

#[derive(Clone, Debug)]
-
pub struct State {
+
pub struct App {
+
    storage: Storage,
    mode: Mode,
-
    pages: PageStack<AppPage>,
-
    browser: BrowserState<PatchItem, PatchItemFilter>,
-
    help: HelpState,
+
    page: Page,
+
    main_group: PanesState,
+
    patches: TableState,
+
    search: BufferedValue<TextEditState>,
+
    show_search: bool,
+
    help: TextViewState,
+
    filter: PatchItemFilter,
}

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

    fn try_from(context: &Context) -> Result<Self, Self::Error> {
        let patches = patch::all(&context.profile, &context.repository)?;
-
        let search = BufferedValue::new(context.filter.to_string());
+
        let search = {
+
            let raw = context.filter.to_string();
+
            raw.trim().to_string()
+
        };
        let filter = PatchItemFilter::from_str(&context.filter.to_string()).unwrap_or_default();

-
        // Convert into UI items
-
        let mut items: Vec<_> = patches
+
        let mut items = patches
            .into_iter()
            .flat_map(|patch| {
                PatchItem::new(&context.profile, &context.repository, patch.clone()).ok()
            })
-
            .collect();
+
            .collect::<Vec<_>>();

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

-
        Ok(Self {
-
            mode: context.mode.clone(),
-
            pages: PageStack::new(vec![AppPage::Browse]),
-
            browser: BrowserState::build(items.clone(), filter, search),
-
            help: HelpState {
-
                text: TextViewState::default().content(help_text()),
+
        Ok(App {
+
            storage: Storage {
+
                patches: items.clone(),
            },
+
            mode: context.mode.clone(),
+
            page: Page::Main,
+
            main_group: PanesState::new(3, Some(0)),
+
            patches: TableState::new(Some(0)),
+
            search: BufferedValue::new(TextEditState {
+
                text: search.clone(),
+
                cursor: search.len(),
+
            }),
+
            show_search: false,
+
            help: TextViewState::new(Position::default()),
+
            filter,
        })
    }
}

-
#[derive(Clone, Debug)]
-
pub enum Message {
-
    Quit,
-
    Exit { operation: Option<PatchOperation> },
-
    ExitFromMode,
-
    SelectPatch { selected: Option<usize> },
-
    OpenSearch,
-
    UpdateSearch { value: String },
-
    ApplySearch,
-
    CloseSearch,
-
    OpenHelp,
-
    LeavePage,
-
    ScrollHelp { state: TextViewState },
-
}
-

-
impl store::Update<Message> for State {
+
impl store::Update<Message> for App {
    type Return = Selection;

-
    fn update(&mut self, message: Message) -> Option<Exit<Selection>> {
+
    fn update(&mut self, message: Message) -> Option<tui::Exit<Selection>> {
+
        log::debug!("[State] Received message: {message:?}");
+

        match message {
            Message::Quit => Some(Exit { value: None }),
-
            Message::Exit { operation } => self.browser.selected_item().map(|issue| Exit {
+
            Message::Exit { operation } => self.selected_patch().map(|issue| Exit {
                value: Some(Selection {
                    operation: operation.map(|op| op.to_string()),
                    ids: vec![issue.id],
@@ -168,7 +195,7 @@ impl store::Update<Message> for State {
                    Mode::Id => None,
                };

-
                self.browser.selected_item().map(|issue| Exit {
+
                self.selected_patch().map(|issue| Exit {
                    value: Some(Selection {
                        operation,
                        ids: vec![issue.id],
@@ -176,204 +203,493 @@ impl store::Update<Message> for State {
                    }),
                })
            }
-
            Message::SelectPatch { selected } => {
-
                self.browser.select_item(selected);
+
            Message::PatchesChanged { state } => {
+
                self.patches = state;
                None
            }
-
            Message::OpenSearch => {
-
                self.browser.show_search();
+
            Message::MainGroupChanged { state } => {
+
                self.main_group = state;
                None
            }
-
            Message::UpdateSearch { value } => {
-
                self.browser.update_search(value);
-
                self.browser.select_first_item();
+
            Message::PageChanged { page } => {
+
                self.page = page;
                None
            }
-
            Message::ApplySearch => {
-
                self.browser.hide_search();
-
                self.browser.apply_search();
+
            Message::ShowSearch => {
+
                self.main_group = PanesState::new(3, None);
+
                self.show_search = true;
                None
            }
-
            Message::CloseSearch => {
-
                self.browser.hide_search();
-
                self.browser.reset_search();
-
                None
-
            }
-
            Message::OpenHelp => {
-
                self.pages.push(AppPage::Help);
+
            Message::HideSearch { apply } => {
+
                self.main_group = PanesState::new(3, Some(0));
+
                self.show_search = false;
+

+
                if apply {
+
                    self.search.apply();
+
                } else {
+
                    self.search.reset();
+
                }
+

+
                self.filter =
+
                    PatchItemFilter::from_str(&self.search.read().text).unwrap_or_default();
+

                None
            }
-
            Message::LeavePage => {
-
                self.pages.pop();
+
            Message::UpdateSearch { search } => {
+
                self.search = search;
+
                self.filter =
+
                    PatchItemFilter::from_str(&self.search.read().text).unwrap_or_default();
+
                self.patches.select_first();
                None
            }
-
            Message::ScrollHelp { state } => {
-
                self.help.text = state;
+
            Message::HelpChanged { state } => {
+
                self.help = state;
                None
            }
        }
    }
}

-
fn browser_page(_state: &State, channel: &Channel<Message>) -> Widget<State, Message> {
-
    let tx = channel.tx.clone();
-

-
    let content = Browser::new(tx.clone())
-
        .to_widget(tx.clone())
-
        .on_update(|state| BrowserProps::from(state).to_boxed_any().into());
-

-
    let shortcuts = Shortcuts::default()
-
        .to_widget(tx.clone())
-
        .on_update(|state: &State| {
-
            let shortcuts = if state.browser.is_search_shown() {
-
                vec![("esc", "cancel"), ("enter", "apply")]
-
            } else {
-
                match state.mode {
-
                    Mode::Id => vec![("enter", "select"), ("/", "search")],
-
                    Mode::Operation => vec![
-
                        ("enter", "show"),
-
                        ("c", "checkout"),
-
                        ("d", "diff"),
-
                        ("r", "review"),
-
                        ("/", "search"),
-
                        ("?", "help"),
-
                    ],
+
impl Show<Message> for App {
+
    fn show(&self, ctx: &im::Context<Message>, frame: &mut Frame) -> Result<()> {
+
        Window::default().show(ctx, |ui| {
+
            match self.page {
+
                Page::Main => {
+
                    let show_search = self.show_search;
+
                    let mut page_focus = if show_search { Some(1) } else { Some(0) };
+
                    let mut group_focus = self.main_group.focus();
+

+
                    ui.panes(
+
                        Layout::vertical([Constraint::Fill(1), Constraint::Length(2)]),
+
                        &mut page_focus,
+
                        |ui| {
+
                            let group = ui.panes(
+
                                im::Layout::Expandable3 { left_only: true },
+
                                &mut group_focus,
+
                                |ui| {
+
                                    self.show_patches(frame, ui);
+

+
                                    ui.text_view(
+
                                        frame,
+
                                        String::new(),
+
                                        &mut Position::default(),
+
                                        Some(Borders::All),
+
                                    );
+
                                    ui.text_view(
+
                                        frame,
+
                                        String::new(),
+
                                        &mut Position::default(),
+
                                        Some(Borders::All),
+
                                    );
+
                                },
+
                            );
+
                            if group.response.changed {
+
                                ui.send_message(Message::MainGroupChanged {
+
                                    state: PanesState::new(3, group_focus),
+
                                });
+
                            }
+

+
                            if show_search {
+
                                self.show_search_text_edit(frame, ui);
+
                            } else {
+
                                ui.layout(Layout::vertical([1, 1]), None, |ui| {
+
                                    ui.bar(
+
                                        frame,
+
                                        match group_focus {
+
                                            Some(0) => browser_context(ui, self),
+
                                            _ => default_context(ui),
+
                                        },
+
                                        Some(Borders::None),
+
                                    );
+

+
                                    ui.shortcuts(
+
                                        frame,
+
                                        &match self.mode {
+
                                            Mode::Id => {
+
                                                [("enter", "select"), ("/", "search")].to_vec()
+
                                            }
+
                                            Mode::Operation => [
+
                                                ("enter", "show"),
+
                                                ("c", "checkout"),
+
                                                ("d", "diff"),
+
                                                ("r", "review"),
+
                                                ("/", "search"),
+
                                                ("?", "help"),
+
                                            ]
+
                                            .to_vec(),
+
                                        },
+
                                        '∙',
+
                                    );
+
                                });
+

+
                                if ui.input_global(|key| key == Key::Char('q')) {
+
                                    ui.send_message(Message::Quit);
+
                                }
+
                                if ui.input_global(|key| key == Key::Char('?')) {
+
                                    ui.send_message(Message::PageChanged { page: Page::Help });
+
                                }
+
                                if ui.input_global(|key| key == Key::Char('\n')) {
+
                                    ui.send_message(Message::ExitFromMode);
+
                                }
+
                                if ui.input_global(|key| key == Key::Char('d')) {
+
                                    ui.send_message(Message::Exit {
+
                                        operation: Some(PatchOperation::Diff),
+
                                    });
+
                                }
+
                                if ui.input_global(|key| key == Key::Char('r')) {
+
                                    ui.send_message(Message::Exit {
+
                                        operation: Some(PatchOperation::Review),
+
                                    });
+
                                }
+
                                if ui.input_global(|key| key == Key::Char('c')) {
+
                                    ui.send_message(Message::Exit {
+
                                        operation: Some(PatchOperation::Checkout),
+
                                    });
+
                                }
+
                            }
+
                        },
+
                    );
                }
-
            };
-

-
            ShortcutsProps::default()
-
                .shortcuts(&shortcuts)
-
                .to_boxed_any()
-
                .into()
-
        });

-
    Page::default()
-
        .content(content)
-
        .shortcuts(shortcuts)
-
        .to_widget(tx.clone())
-
        .on_event(|key, _, props| {
-
            let default = PageProps::default();
-
            let props = props
-
                .and_then(|props| props.inner_ref::<PageProps>())
-
                .unwrap_or(&default);
-

-
            if props.handle_keys {
-
                match key {
-
                    Key::Char('q') | Key::Ctrl('c') => Some(Message::Quit),
-
                    Key::Char('?') => Some(Message::OpenHelp),
-
                    Key::Char('\n') => Some(Message::ExitFromMode),
-
                    Key::Char('c') => Some(Message::Exit {
-
                        operation: Some(PatchOperation::Checkout),
-
                    }),
-
                    Key::Char('d') => Some(Message::Exit {
-
                        operation: Some(PatchOperation::Diff),
-
                    }),
-
                    Key::Char('r') => Some(Message::Exit {
-
                        operation: Some(PatchOperation::Review),
-
                    }),
-
                    _ => None,
-
                }
-
            } else {
-
                None
-
            }
-
        })
-
        .on_update(|state: &State| {
-
            PageProps::default()
-
                .handle_keys(!state.browser.is_search_shown())
-
                .to_boxed_any()
-
                .into()
-
        })
-
}
-

-
fn help_page(_state: &State, channel: &Channel<Message>) -> Widget<State, Message> {
-
    let tx = channel.tx.clone();
-

-
    let content = Container::default()
-
        .header(Header::default().to_widget(tx.clone()).on_update(|_| {
-
            HeaderProps::default()
-
                .columns([Column::new(" Help ", Constraint::Fill(1))].to_vec())
-
                .to_boxed_any()
-
                .into()
-
        }))
-
        .content(
-
            TextView::default()
-
                .to_widget(tx.clone())
-
                .on_event(|_, view_state, _| {
-
                    view_state
-
                        .and_then(|tv| tv.unwrap_textview())
-
                        .map(|tvs| Message::ScrollHelp { state: tvs })
-
                })
-
                .on_update(|state: &State| {
-
                    TextViewProps::default()
-
                        .state(Some(state.help.text.clone()))
-
                        .to_boxed_any()
-
                        .into()
-
                }),
-
        )
-
        .footer(
-
            Footer::default()
-
                .to_widget(tx.clone())
-
                .on_update(|state: &State| {
-
                    FooterProps::default()
-
                        .columns(
+
                Page::Help => {
+
                    let mut cursor = self.help.cursor();
+

+
                    let layout = Layout::vertical([
+
                        Constraint::Length(3),
+
                        Constraint::Fill(1),
+
                        Constraint::Length(1),
+
                        Constraint::Length(1),
+
                    ]);
+

+
                    ui.composite(layout, 1, |ui| {
+
                        ui.columns(
+
                            frame,
+
                            [Column::new(Span::raw(" Help ").bold(), Constraint::Fill(1))].to_vec(),
+
                            Some(Borders::Top),
+
                        );
+

+
                        let text_view = ui.text_view(
+
                            frame,
+
                            HELP.to_string(),
+
                            &mut cursor,
+
                            Some(Borders::BottomSides),
+
                        );
+
                        if text_view.changed {
+
                            ui.send_message(Message::HelpChanged {
+
                                state: TextViewState::new(cursor),
+
                            })
+
                        }
+

+
                        ui.bar(
+
                            frame,
                            [
-
                                Column::new(Text::raw(""), Constraint::Fill(1)),
                                Column::new(
-
                                    span::default(&format!("{}%", state.help.text.scroll)).dim(),
-
                                    Constraint::Min(4),
+
                                    Span::raw(" ".to_string())
+
                                        .into_left_aligned_line()
+
                                        .style(ui.theme().bar_on_black_style),
+
                                    Constraint::Fill(1),
+
                                ),
+
                                Column::new(
+
                                    Span::raw(" ")
+
                                        .into_right_aligned_line()
+
                                        .cyan()
+
                                        .dim()
+
                                        .reversed(),
+
                                    Constraint::Length(6),
                                ),
                            ]
                            .to_vec(),
-
                        )
-
                        .to_boxed_any()
-
                        .into()
-
                }),
-
        )
-
        .to_widget(tx.clone());
-

-
    let shortcuts = Shortcuts::default().to_widget(tx.clone()).on_update(|_| {
-
        ShortcutsProps::default()
-
            .shortcuts(&[("?", "close")])
-
            .to_boxed_any()
-
            .into()
-
    });
-

-
    Page::default()
-
        .content(content)
-
        .shortcuts(shortcuts)
-
        .to_widget(tx.clone())
-
        .on_event(|key, _, _| match key {
-
            Key::Esc | Key::Ctrl('c') => Some(Message::Quit),
-
            Key::Char('?') => Some(Message::LeavePage),
-
            _ => None,
-
        })
-
        .on_update(|_| PageProps::default().handle_keys(true).to_boxed_any().into())
+
                            Some(Borders::None),
+
                        );
+

+
                        ui.shortcuts(frame, &[("?", "close")], '∙');
+
                    });
+

+
                    if ui.input_global(|key| key == Key::Char('?')) {
+
                        ui.send_message(Message::PageChanged { page: Page::Main });
+
                    }
+
                    if ui.input_global(|key| key == Key::Char('q')) {
+
                        ui.send_message(Message::Quit);
+
                    }
+
                }
+
            }
+
            if ui.input_global(|key| key == Key::Ctrl('c')) {
+
                ui.send_message(Message::Quit);
+
            }
+
        });
+

+
        Ok(())
+
    }
}

-
fn help_text() -> String {
-
    r#"# Generic keybindings
+
impl App {
+
    pub fn show_patches(&self, frame: &mut Frame, ui: &mut im::Ui<Message>) {
+
        let patches = self
+
            .storage
+
            .patches
+
            .iter()
+
            .filter(|patch| self.filter.matches(patch))
+
            .cloned()
+
            .collect::<Vec<_>>();
+
        let mut selected = self.patches.selected();
+

+
        let header = [
+
            Column::new(Span::raw(" ● ").bold(), Constraint::Length(3)),
+
            Column::new(Span::raw("ID").bold(), Constraint::Length(8)),
+
            Column::new(Span::raw("Title").bold(), Constraint::Fill(1)),
+
            Column::new(Span::raw("Author").bold(), Constraint::Length(16)).hide_small(),
+
            Column::new("", Constraint::Length(16)).hide_medium(),
+
            Column::new(Span::raw("Head").bold(), Constraint::Length(8)).hide_small(),
+
            Column::new(Span::raw("+").bold(), Constraint::Length(6)).hide_small(),
+
            Column::new(Span::raw("-").bold(), Constraint::Length(6)).hide_small(),
+
            Column::new(Span::raw("Updated").bold(), Constraint::Length(16)).hide_small(),
+
        ];
+

+
        let table = ui.headered_table(
+
            frame,
+
            &mut selected,
+
            &patches,
+
            header.clone(),
+
            header,
+
            Some("No patches found".into()),
+
        );
+
        if table.changed {
+
            ui.send_message(Message::PatchesChanged {
+
                state: TableState::new(selected),
+
            });
+
        }

-
`↑,k`:      move cursor one line up
-
`↓,j:       move cursor one line down
-
`PageUp`:   move cursor one page up
-
`PageDown`: move cursor one page down
-
`Home`:     move cursor to the first line
-
`End`:      move cursor to the last line
-
`Esc`:      Cancel
-
`q`:        Quit
+
        // TODO(erikli): Should only work if table has focus
+
        if ui.input_global(|key| key == Key::Char('/')) {
+
            ui.send_message(Message::ShowSearch);
+
        }
+
    }

-
# Specific keybindings
+
    pub fn show_search_text_edit(&self, frame: &mut Frame, ui: &mut im::Ui<Message>) {
+
        let (mut search_text, mut search_cursor) = (
+
            self.search.clone().read().text,
+
            self.search.clone().read().cursor,
+
        );
+
        let mut search = self.search.clone();
+

+
        let text_edit = ui.text_edit_labeled_singleline(
+
            frame,
+
            &mut search_text,
+
            &mut search_cursor,
+
            "Search".to_string(),
+
            Some(Borders::Spacer { top: 0, left: 0 }),
+
        );
+

+
        if text_edit.changed {
+
            search.write(TextEditState {
+
                text: search_text,
+
                cursor: search_cursor,
+
            });
+
            ui.send_message(Message::UpdateSearch { search });
+
        }

-
`enter`:    Select patch (if --mode id)
-
`enter`:    Show patch
-
`c`:        Checkout patch
-
`d`:        Show patch diff
-
`/`:        Search
-
`?`:        Show help
+
        if ui.input_global(|key| key == Key::Esc) {
+
            ui.send_message(Message::HideSearch { apply: false });
+
        }
+
        if ui.input_global(|key| key == Key::Char('\n')) {
+
            ui.send_message(Message::HideSearch { apply: true });
+
        }
+
    }

-
# Searching
+
    pub fn selected_patch(&self) -> Option<&PatchItem> {
+
        let patches = self
+
            .storage
+
            .patches
+
            .iter()
+
            .filter(|patch| self.filter.matches(patch))
+
            .collect::<Vec<_>>();
+

+
        self.patches
+
            .selected()
+
            .and_then(|selected| patches.get(selected))
+
            .copied()
+
    }
+
}

-
Pattern:    is:<state> | is:authored | authors:[<did>, <did>] | <search>
-
Example:    is:open is:authored improve"#
-
        .into()
+
fn browser_context<'a>(ui: &im::Ui<Message>, app: &'a App) -> Vec<Column<'a>> {
+
    let search = app.search.read().text;
+
    let total_count = app.storage.patches.len();
+
    let filtered_count = app
+
        .storage
+
        .patches
+
        .iter()
+
        .filter(|patch| app.filter.matches(patch))
+
        .collect::<Vec<_>>()
+
        .len();
+
    let experimental = false;
+

+
    if experimental {
+
        [
+
            Column::new(
+
                Span::raw(" Search ".to_string()).cyan().dim().reversed(),
+
                Constraint::Length(8),
+
            ),
+
            Column::new(Span::raw("".to_string()), Constraint::Length(1)),
+
            Column::new(
+
                Span::raw(format!(" {search} "))
+
                    .into_left_aligned_line()
+
                    .cyan()
+
                    .dim()
+
                    .reversed(),
+
                Constraint::Length((search.chars().count() + 2) as u16),
+
            ),
+
            Column::new(Span::raw("".to_string()), Constraint::Fill(1)),
+
            Column::new(
+
                Span::raw(" 0% ")
+
                    .into_right_aligned_line()
+
                    .red()
+
                    .dim()
+
                    .reversed(),
+
                Constraint::Length(6),
+
            ),
+
        ]
+
        .to_vec()
+
    } else {
+
        let filtered_counts = format!(" {filtered_count}/{total_count} ");
+
        let state_counts =
+
            app.storage
+
                .patches
+
                .iter()
+
                .fold((0, 0, 0, 0), |counts, patch| match patch.state {
+
                    radicle::patch::State::Draft => (counts.0 + 1, counts.1, counts.2, counts.3),
+
                    radicle::patch::State::Open { conflicts: _ } => {
+
                        (counts.0, counts.1 + 1, counts.2, counts.3)
+
                    }
+
                    radicle::patch::State::Archived => (counts.0, counts.1, counts.2 + 1, counts.3),
+
                    radicle::patch::State::Merged {
+
                        revision: _,
+
                        commit: _,
+
                    } => (counts.0, counts.1, counts.2, counts.3 + 1),
+
                });
+

+
        if app.filter.is_default() {
+
            let draft = format!(" {} ", state_counts.0);
+
            let open = format!(" {} ", state_counts.1);
+
            let archived = format!(" {} ", state_counts.2);
+
            let merged = format!(" {} ", state_counts.3);
+
            [
+
                Column::new(
+
                    Span::raw(" Search ".to_string()).cyan().dim().reversed(),
+
                    Constraint::Length(8),
+
                ),
+
                Column::new(
+
                    Span::raw(format!(" {search} "))
+
                        .into_left_aligned_line()
+
                        .style(ui.theme().bar_on_black_style),
+
                    Constraint::Fill(1),
+
                ),
+
                Column::new(
+
                    Span::raw("●")
+
                        .style(ui.theme().bar_on_black_style)
+
                        .dim()
+
                        .bold(),
+
                    Constraint::Length(1),
+
                ),
+
                Column::new(
+
                    Span::raw(draft.clone())
+
                        .style(ui.theme().bar_on_black_style)
+
                        .dim(),
+
                    Constraint::Length(draft.chars().count() as u16),
+
                ),
+
                Column::new(
+
                    Span::raw("●")
+
                        .style(ui.theme().bar_on_black_style)
+
                        .green()
+
                        .dim()
+
                        .bold(),
+
                    Constraint::Length(1),
+
                ),
+
                Column::new(
+
                    Span::raw(open.clone())
+
                        .style(ui.theme().bar_on_black_style)
+
                        .dim(),
+
                    Constraint::Length(open.chars().count() as u16),
+
                ),
+
                Column::new(
+
                    Span::raw("●")
+
                        .style(ui.theme().bar_on_black_style)
+
                        .yellow()
+
                        .dim()
+
                        .bold(),
+
                    Constraint::Length(1),
+
                ),
+
                Column::new(
+
                    Span::raw(archived.clone())
+
                        .style(ui.theme().bar_on_black_style)
+
                        .dim(),
+
                    Constraint::Length(archived.chars().count() as u16),
+
                ),
+
                Column::new(
+
                    Span::raw("✔")
+
                        .style(ui.theme().bar_on_black_style)
+
                        .magenta()
+
                        .dim()
+
                        .bold(),
+
                    Constraint::Length(1),
+
                ),
+
                Column::new(
+
                    Span::raw(merged.clone())
+
                        .style(ui.theme().bar_on_black_style)
+
                        .dim(),
+
                    Constraint::Length(merged.chars().count() as u16),
+
                ),
+
                Column::new(
+
                    Span::raw(filtered_counts.clone())
+
                        .into_right_aligned_line()
+
                        .cyan()
+
                        .dim()
+
                        .reversed(),
+
                    Constraint::Length(filtered_counts.chars().count() as u16),
+
                ),
+
            ]
+
            .to_vec()
+
        } else {
+
            [
+
                Column::new(
+
                    Span::raw(" Search ".to_string()).cyan().dim().reversed(),
+
                    Constraint::Length(8),
+
                ),
+
                Column::new(
+
                    Span::raw(format!(" {search} "))
+
                        .into_left_aligned_line()
+
                        .style(ui.theme().bar_on_black_style),
+
                    Constraint::Fill(1),
+
                ),
+
                Column::new(
+
                    Span::raw(filtered_counts.clone())
+
                        .into_right_aligned_line()
+
                        .cyan()
+
                        .dim()
+
                        .reversed(),
+
                    Constraint::Length(filtered_counts.chars().count() as u16),
+
                ),
+
            ]
+
            .to_vec()
+
        }
+
    }
+
}
+

+
fn default_context<'a>(ui: &im::Ui<Message>) -> Vec<Column<'a>> {
+
    [
+
        Column::new(
+
            Span::raw(" ".to_string())
+
                .into_left_aligned_line()
+
                .style(ui.theme().bar_on_black_style),
+
            Constraint::Fill(1),
+
        ),
+
        Column::new(
+
            Span::raw(" 0% ")
+
                .into_right_aligned_line()
+
                .cyan()
+
                .dim()
+
                .reversed(),
+
            Constraint::Length(6),
+
        ),
+
    ]
+
    .to_vec()
}
modified bin/commands/patch/list/imui.rs
@@ -1,664 +1 @@
-
use std::str::FromStr;

-
use anyhow::Result;
-

-
use termion::event::Key;
-

-
use ratatui::layout::{Constraint, Layout, Position};
-
use ratatui::style::Stylize;
-
use ratatui::text::Span;
-
use ratatui::Frame;
-

-
use radicle_tui as tui;
-

-
use tui::ui::im;
-
use tui::ui::im::widget::{PanesState, TableState, TextEditState, TextViewState, Window};
-
use tui::ui::im::Borders;
-
use tui::ui::im::Show;
-
use tui::ui::{BufferedValue, Column};
-
use tui::{store, Exit};
-

-
use crate::cob::patch;
-
use crate::tui_patch::common::{Mode, PatchOperation};
-
use crate::ui::items::filter::Filter;
-
use crate::ui::items::{PatchItem, PatchItemFilter};
-

-
use super::{Context, Selection};
-

-
const HELP: &str = r#"# Generic keybindings
-

-
`↑,k`:      move cursor one line up
-
`↓,j:       move cursor one line down
-
`PageUp`:   move cursor one page up
-
`PageDown`: move cursor one page down
-
`Home`:     move cursor to the first line
-
`End`:      move cursor to the last line
-
`Esc`:      Cancel
-
`q`:        Quit
-

-
# Specific keybindings
-

-
`enter`:    Select patch (if --mode id)
-
`enter`:    Show patch
-
`c`:        Checkout patch
-
`d`:        Show patch diff
-
`/`:        Search
-
`?`:        Show help
-

-
# Searching
-

-
Pattern:    is:<state> | is:authored | authors:[<did>, <did>] | <search>
-
Example:    is:open is:authored improve"#;
-

-
#[derive(Clone, Debug)]
-
pub enum Message {
-
    Quit,
-
    Exit {
-
        operation: Option<PatchOperation>,
-
    },
-
    ExitFromMode,
-
    PatchesChanged {
-
        state: TableState,
-
    },
-
    MainGroupChanged {
-
        state: PanesState,
-
    },
-
    PageChanged {
-
        page: Page,
-
    },
-
    HelpChanged {
-
        state: TextViewState,
-
    },
-
    ShowSearch,
-
    UpdateSearch {
-
        search: BufferedValue<TextEditState>,
-
    },
-
    HideSearch {
-
        apply: bool,
-
    },
-
}
-

-
#[derive(Clone, Debug)]
-
pub enum Page {
-
    Main,
-
    Help,
-
}
-

-
#[derive(Clone, Debug)]
-
pub struct Storage {
-
    patches: Vec<PatchItem>,
-
}
-

-
#[derive(Clone, Debug)]
-
pub struct App {
-
    storage: Storage,
-
    mode: Mode,
-
    page: Page,
-
    main_group: PanesState,
-
    patches: TableState,
-
    search: BufferedValue<TextEditState>,
-
    show_search: bool,
-
    help: TextViewState,
-
    filter: PatchItemFilter,
-
}
-

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

-
    fn try_from(context: &Context) -> Result<Self, Self::Error> {
-
        let patches = patch::all(&context.profile, &context.repository)?;
-
        let search = {
-
            let raw = context.filter.to_string();
-
            raw.trim().to_string()
-
        };
-
        let filter = PatchItemFilter::from_str(&context.filter.to_string()).unwrap_or_default();
-

-
        let mut items = patches
-
            .into_iter()
-
            .flat_map(|patch| {
-
                PatchItem::new(&context.profile, &context.repository, patch.clone()).ok()
-
            })
-
            .collect::<Vec<_>>();
-

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

-
        Ok(App {
-
            storage: Storage {
-
                patches: items.clone(),
-
            },
-
            mode: context.mode.clone(),
-
            page: Page::Main,
-
            main_group: PanesState::new(3, Some(0)),
-
            patches: TableState::new(Some(0)),
-
            search: BufferedValue::new(TextEditState {
-
                text: search.clone(),
-
                cursor: search.len(),
-
            }),
-
            show_search: false,
-
            help: TextViewState::new(Position::default()),
-
            filter,
-
        })
-
    }
-
}
-

-
impl store::Update<Message> for App {
-
    type Return = Selection;
-

-
    fn update(&mut self, message: Message) -> Option<tui::Exit<Selection>> {
-
        log::debug!("[State] Received message: {message:?}");
-

-
        match message {
-
            Message::Quit => Some(Exit { value: None }),
-
            Message::Exit { operation } => self.selected_patch().map(|issue| Exit {
-
                value: Some(Selection {
-
                    operation: operation.map(|op| op.to_string()),
-
                    ids: vec![issue.id],
-
                    args: vec![],
-
                }),
-
            }),
-
            Message::ExitFromMode => {
-
                let operation = match self.mode {
-
                    Mode::Operation => Some(PatchOperation::Show.to_string()),
-
                    Mode::Id => None,
-
                };
-

-
                self.selected_patch().map(|issue| Exit {
-
                    value: Some(Selection {
-
                        operation,
-
                        ids: vec![issue.id],
-
                        args: vec![],
-
                    }),
-
                })
-
            }
-
            Message::PatchesChanged { state } => {
-
                self.patches = state;
-
                None
-
            }
-
            Message::MainGroupChanged { state } => {
-
                self.main_group = state;
-
                None
-
            }
-
            Message::PageChanged { page } => {
-
                self.page = page;
-
                None
-
            }
-
            Message::ShowSearch => {
-
                self.main_group = PanesState::new(3, None);
-
                self.show_search = true;
-
                None
-
            }
-
            Message::HideSearch { apply } => {
-
                self.main_group = PanesState::new(3, Some(0));
-
                self.show_search = false;
-

-
                if apply {
-
                    self.search.apply();
-
                } else {
-
                    self.search.reset();
-
                }
-

-
                self.filter =
-
                    PatchItemFilter::from_str(&self.search.read().text).unwrap_or_default();
-

-
                None
-
            }
-
            Message::UpdateSearch { search } => {
-
                self.search = search;
-
                self.filter =
-
                    PatchItemFilter::from_str(&self.search.read().text).unwrap_or_default();
-
                self.patches.select_first();
-
                None
-
            }
-
            Message::HelpChanged { state } => {
-
                self.help = state;
-
                None
-
            }
-
        }
-
    }
-
}
-

-
impl Show<Message> for App {
-
    fn show(&self, ctx: &im::Context<Message>, frame: &mut Frame) -> Result<()> {
-
        Window::default().show(ctx, |ui| {
-
            match self.page {
-
                Page::Main => {
-
                    let show_search = self.show_search;
-
                    let mut page_focus = if show_search { Some(1) } else { Some(0) };
-
                    let mut group_focus = self.main_group.focus();
-

-
                    ui.panes(
-
                        Layout::vertical([Constraint::Fill(1), Constraint::Length(2)]),
-
                        &mut page_focus,
-
                        |ui| {
-
                            let group = ui.panes(
-
                                im::Layout::Expandable3 { left_only: true },
-
                                &mut group_focus,
-
                                |ui| {
-
                                    self.show_patches(frame, ui);
-

-
                                    ui.text_view(
-
                                        frame,
-
                                        String::new(),
-
                                        &mut Position::default(),
-
                                        Some(Borders::All),
-
                                    );
-
                                    ui.text_view(
-
                                        frame,
-
                                        String::new(),
-
                                        &mut Position::default(),
-
                                        Some(Borders::All),
-
                                    );
-
                                },
-
                            );
-
                            if group.response.changed {
-
                                ui.send_message(Message::MainGroupChanged {
-
                                    state: PanesState::new(3, group_focus),
-
                                });
-
                            }
-

-
                            if show_search {
-
                                self.show_search_text_edit(frame, ui);
-
                            } else {
-
                                ui.layout(Layout::vertical([1, 1]), None, |ui| {
-
                                    ui.bar(
-
                                        frame,
-
                                        match group_focus {
-
                                            Some(0) => browser_context(ui, self),
-
                                            _ => default_context(ui),
-
                                        },
-
                                        Some(Borders::None),
-
                                    );
-

-
                                    ui.shortcuts(
-
                                        frame,
-
                                        &match self.mode {
-
                                            Mode::Id => {
-
                                                [("enter", "select"), ("/", "search")].to_vec()
-
                                            }
-
                                            Mode::Operation => [
-
                                                ("enter", "show"),
-
                                                ("c", "checkout"),
-
                                                ("d", "diff"),
-
                                                ("r", "review"),
-
                                                ("/", "search"),
-
                                                ("?", "help"),
-
                                            ]
-
                                            .to_vec(),
-
                                        },
-
                                        '∙',
-
                                    );
-
                                });
-

-
                                if ui.input_global(|key| key == Key::Char('q')) {
-
                                    ui.send_message(Message::Quit);
-
                                }
-
                                if ui.input_global(|key| key == Key::Char('?')) {
-
                                    ui.send_message(Message::PageChanged { page: Page::Help });
-
                                }
-
                                if ui.input_global(|key| key == Key::Char('\n')) {
-
                                    ui.send_message(Message::ExitFromMode);
-
                                }
-
                                if ui.input_global(|key| key == Key::Char('d')) {
-
                                    ui.send_message(Message::Exit {
-
                                        operation: Some(PatchOperation::Diff),
-
                                    });
-
                                }
-
                                if ui.input_global(|key| key == Key::Char('r')) {
-
                                    ui.send_message(Message::Exit {
-
                                        operation: Some(PatchOperation::Review),
-
                                    });
-
                                }
-
                                if ui.input_global(|key| key == Key::Char('c')) {
-
                                    ui.send_message(Message::Exit {
-
                                        operation: Some(PatchOperation::Checkout),
-
                                    });
-
                                }
-
                            }
-
                        },
-
                    );
-
                }
-

-
                Page::Help => {
-
                    let mut cursor = self.help.cursor();
-

-
                    let layout = Layout::vertical([
-
                        Constraint::Length(3),
-
                        Constraint::Fill(1),
-
                        Constraint::Length(1),
-
                        Constraint::Length(1),
-
                    ]);
-

-
                    ui.composite(layout, 1, |ui| {
-
                        ui.columns(
-
                            frame,
-
                            [Column::new(Span::raw(" Help ").bold(), Constraint::Fill(1))].to_vec(),
-
                            Some(Borders::Top),
-
                        );
-

-
                        let text_view = ui.text_view(
-
                            frame,
-
                            HELP.to_string(),
-
                            &mut cursor,
-
                            Some(Borders::BottomSides),
-
                        );
-
                        if text_view.changed {
-
                            ui.send_message(Message::HelpChanged {
-
                                state: TextViewState::new(cursor),
-
                            })
-
                        }
-

-
                        ui.bar(
-
                            frame,
-
                            [
-
                                Column::new(
-
                                    Span::raw(" ".to_string())
-
                                        .into_left_aligned_line()
-
                                        .style(ui.theme().bar_on_black_style),
-
                                    Constraint::Fill(1),
-
                                ),
-
                                Column::new(
-
                                    Span::raw(" ")
-
                                        .into_right_aligned_line()
-
                                        .cyan()
-
                                        .dim()
-
                                        .reversed(),
-
                                    Constraint::Length(6),
-
                                ),
-
                            ]
-
                            .to_vec(),
-
                            Some(Borders::None),
-
                        );
-

-
                        ui.shortcuts(frame, &[("?", "close")], '∙');
-
                    });
-

-
                    if ui.input_global(|key| key == Key::Char('?')) {
-
                        ui.send_message(Message::PageChanged { page: Page::Main });
-
                    }
-
                    if ui.input_global(|key| key == Key::Char('q')) {
-
                        ui.send_message(Message::Quit);
-
                    }
-
                }
-
            }
-
            if ui.input_global(|key| key == Key::Ctrl('c')) {
-
                ui.send_message(Message::Quit);
-
            }
-
        });
-

-
        Ok(())
-
    }
-
}
-

-
impl App {
-
    pub fn show_patches(&self, frame: &mut Frame, ui: &mut im::Ui<Message>) {
-
        let patches = self
-
            .storage
-
            .patches
-
            .iter()
-
            .filter(|patch| self.filter.matches(patch))
-
            .cloned()
-
            .collect::<Vec<_>>();
-
        let mut selected = self.patches.selected();
-

-
        let header = [
-
            Column::new(Span::raw(" ● ").bold(), Constraint::Length(3)),
-
            Column::new(Span::raw("ID").bold(), Constraint::Length(8)),
-
            Column::new(Span::raw("Title").bold(), Constraint::Fill(1)),
-
            Column::new(Span::raw("Author").bold(), Constraint::Length(16)).hide_small(),
-
            Column::new("", Constraint::Length(16)).hide_medium(),
-
            Column::new(Span::raw("Head").bold(), Constraint::Length(8)).hide_small(),
-
            Column::new(Span::raw("+").bold(), Constraint::Length(6)).hide_small(),
-
            Column::new(Span::raw("-").bold(), Constraint::Length(6)).hide_small(),
-
            Column::new(Span::raw("Updated").bold(), Constraint::Length(16)).hide_small(),
-
        ];
-

-
        let table = ui.headered_table(
-
            frame,
-
            &mut selected,
-
            &patches,
-
            header.clone(),
-
            header,
-
            Some("No patches found".into()),
-
        );
-
        if table.changed {
-
            ui.send_message(Message::PatchesChanged {
-
                state: TableState::new(selected),
-
            });
-
        }
-

-
        // TODO(erikli): Should only work if table has focus
-
        if ui.input_global(|key| key == Key::Char('/')) {
-
            ui.send_message(Message::ShowSearch);
-
        }
-
    }
-

-
    pub fn show_search_text_edit(&self, frame: &mut Frame, ui: &mut im::Ui<Message>) {
-
        let (mut search_text, mut search_cursor) = (
-
            self.search.clone().read().text,
-
            self.search.clone().read().cursor,
-
        );
-
        let mut search = self.search.clone();
-

-
        let text_edit = ui.text_edit_labeled_singleline(
-
            frame,
-
            &mut search_text,
-
            &mut search_cursor,
-
            "Search".to_string(),
-
            Some(Borders::Spacer { top: 0, left: 0 }),
-
        );
-

-
        if text_edit.changed {
-
            search.write(TextEditState {
-
                text: search_text,
-
                cursor: search_cursor,
-
            });
-
            ui.send_message(Message::UpdateSearch { search });
-
        }
-

-
        if ui.input_global(|key| key == Key::Esc) {
-
            ui.send_message(Message::HideSearch { apply: false });
-
        }
-
        if ui.input_global(|key| key == Key::Char('\n')) {
-
            ui.send_message(Message::HideSearch { apply: true });
-
        }
-
    }
-
}
-

-
impl App {
-
    pub fn selected_patch(&self) -> Option<&PatchItem> {
-
        let patches = self
-
            .storage
-
            .patches
-
            .iter()
-
            .filter(|patch| self.filter.matches(patch))
-
            .collect::<Vec<_>>();
-

-
        self.patches
-
            .selected()
-
            .and_then(|selected| patches.get(selected))
-
            .copied()
-
    }
-
}
-

-
fn browser_context<'a>(ui: &im::Ui<Message>, app: &'a App) -> Vec<Column<'a>> {
-
    let search = app.search.read().text;
-
    let total_count = app.storage.patches.len();
-
    let filtered_count = app
-
        .storage
-
        .patches
-
        .iter()
-
        .filter(|patch| app.filter.matches(patch))
-
        .collect::<Vec<_>>()
-
        .len();
-
    let experimental = false;
-

-
    if experimental {
-
        [
-
            Column::new(
-
                Span::raw(" Search ".to_string()).cyan().dim().reversed(),
-
                Constraint::Length(8),
-
            ),
-
            Column::new(Span::raw("".to_string()), Constraint::Length(1)),
-
            Column::new(
-
                Span::raw(format!(" {search} "))
-
                    .into_left_aligned_line()
-
                    .cyan()
-
                    .dim()
-
                    .reversed(),
-
                Constraint::Length((search.chars().count() + 2) as u16),
-
            ),
-
            Column::new(Span::raw("".to_string()), Constraint::Fill(1)),
-
            Column::new(
-
                Span::raw(" 0% ")
-
                    .into_right_aligned_line()
-
                    .red()
-
                    .dim()
-
                    .reversed(),
-
                Constraint::Length(6),
-
            ),
-
        ]
-
        .to_vec()
-
    } else {
-
        let filtered_counts = format!(" {filtered_count}/{total_count} ");
-
        let state_counts =
-
            app.storage
-
                .patches
-
                .iter()
-
                .fold((0, 0, 0, 0), |counts, patch| match patch.state {
-
                    radicle::patch::State::Draft => (counts.0 + 1, counts.1, counts.2, counts.3),
-
                    radicle::patch::State::Open { conflicts: _ } => {
-
                        (counts.0, counts.1 + 1, counts.2, counts.3)
-
                    }
-
                    radicle::patch::State::Archived => (counts.0, counts.1, counts.2 + 1, counts.3),
-
                    radicle::patch::State::Merged {
-
                        revision: _,
-
                        commit: _,
-
                    } => (counts.0, counts.1, counts.2, counts.3 + 1),
-
                });
-

-
        if app.filter.is_default() {
-
            let draft = format!(" {} ", state_counts.0);
-
            let open = format!(" {} ", state_counts.1);
-
            let archived = format!(" {} ", state_counts.2);
-
            let merged = format!(" {} ", state_counts.3);
-
            [
-
                Column::new(
-
                    Span::raw(" Search ".to_string()).cyan().dim().reversed(),
-
                    Constraint::Length(8),
-
                ),
-
                Column::new(
-
                    Span::raw(format!(" {search} "))
-
                        .into_left_aligned_line()
-
                        .style(ui.theme().bar_on_black_style),
-
                    Constraint::Fill(1),
-
                ),
-
                Column::new(
-
                    Span::raw("●")
-
                        .style(ui.theme().bar_on_black_style)
-
                        .dim()
-
                        .bold(),
-
                    Constraint::Length(1),
-
                ),
-
                Column::new(
-
                    Span::raw(draft.clone())
-
                        .style(ui.theme().bar_on_black_style)
-
                        .dim(),
-
                    Constraint::Length(draft.chars().count() as u16),
-
                ),
-
                Column::new(
-
                    Span::raw("●")
-
                        .style(ui.theme().bar_on_black_style)
-
                        .green()
-
                        .dim()
-
                        .bold(),
-
                    Constraint::Length(1),
-
                ),
-
                Column::new(
-
                    Span::raw(open.clone())
-
                        .style(ui.theme().bar_on_black_style)
-
                        .dim(),
-
                    Constraint::Length(open.chars().count() as u16),
-
                ),
-
                Column::new(
-
                    Span::raw("●")
-
                        .style(ui.theme().bar_on_black_style)
-
                        .yellow()
-
                        .dim()
-
                        .bold(),
-
                    Constraint::Length(1),
-
                ),
-
                Column::new(
-
                    Span::raw(archived.clone())
-
                        .style(ui.theme().bar_on_black_style)
-
                        .dim(),
-
                    Constraint::Length(archived.chars().count() as u16),
-
                ),
-
                Column::new(
-
                    Span::raw("✔")
-
                        .style(ui.theme().bar_on_black_style)
-
                        .magenta()
-
                        .dim()
-
                        .bold(),
-
                    Constraint::Length(1),
-
                ),
-
                Column::new(
-
                    Span::raw(merged.clone())
-
                        .style(ui.theme().bar_on_black_style)
-
                        .dim(),
-
                    Constraint::Length(merged.chars().count() as u16),
-
                ),
-
                Column::new(
-
                    Span::raw(filtered_counts.clone())
-
                        .into_right_aligned_line()
-
                        .cyan()
-
                        .dim()
-
                        .reversed(),
-
                    Constraint::Length(filtered_counts.chars().count() as u16),
-
                ),
-
            ]
-
            .to_vec()
-
        } else {
-
            [
-
                Column::new(
-
                    Span::raw(" Search ".to_string()).cyan().dim().reversed(),
-
                    Constraint::Length(8),
-
                ),
-
                Column::new(
-
                    Span::raw(format!(" {search} "))
-
                        .into_left_aligned_line()
-
                        .style(ui.theme().bar_on_black_style),
-
                    Constraint::Fill(1),
-
                ),
-
                Column::new(
-
                    Span::raw(filtered_counts.clone())
-
                        .into_right_aligned_line()
-
                        .cyan()
-
                        .dim()
-
                        .reversed(),
-
                    Constraint::Length(filtered_counts.chars().count() as u16),
-
                ),
-
            ]
-
            .to_vec()
-
        }
-
    }
-
}
-

-
fn default_context<'a>(ui: &im::Ui<Message>) -> Vec<Column<'a>> {
-
    [
-
        Column::new(
-
            Span::raw(" ".to_string())
-
                .into_left_aligned_line()
-
                .style(ui.theme().bar_on_black_style),
-
            Constraint::Fill(1),
-
        ),
-
        Column::new(
-
            Span::raw(" 0% ")
-
                .into_right_aligned_line()
-
                .cyan()
-
                .dim()
-
                .reversed(),
-
            Constraint::Length(6),
-
        ),
-
    ]
-
    .to_vec()
-
}
deleted bin/commands/patch/list/rmui.rs
@@ -1,330 +0,0 @@
-
use std::collections::HashMap;
-
use std::str::FromStr;
-
use std::vec;
-

-
use ratatui::Frame;
-
use tokio::sync::broadcast;
-

-
use termion::event::Key;
-

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

-
use radicle::patch;
-
use radicle::patch::Status;
-

-
use radicle_tui as tui;
-

-
use tui::ui::rm::widget;
-
use tui::ui::rm::widget::container::{
-
    Container, ContainerProps, Footer, FooterProps, Header, HeaderProps,
-
};
-
use tui::ui::rm::widget::list::{Table, TableProps};
-
use tui::ui::rm::widget::text::{TextField, TextFieldProps};
-
use tui::ui::rm::widget::ViewProps;
-
use tui::ui::rm::widget::{RenderProps, ToWidget, View};
-
use tui::ui::span;
-
use tui::ui::Column;
-

-
use tui::BoxedAny;
-

-
use crate::ui::items::{PatchItem, PatchItemFilter};
-

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

-
type Widget = widget::Widget<State, Message>;
-

-
#[derive(Clone, Default)]
-
pub struct BrowserProps<'a> {
-
    /// Filtered patches.
-
    patches: Vec<PatchItem>,
-
    /// Patch statistics.
-
    stats: HashMap<String, usize>,
-
    /// Header columns
-
    header: Vec<Column<'a>>,
-
    /// Table columns
-
    columns: Vec<Column<'a>>,
-
    /// If search widget should be shown.
-
    show_search: bool,
-
    /// Current search string.
-
    search: String,
-
}
-

-
impl From<&State> for BrowserProps<'_> {
-
    fn from(state: &State) -> Self {
-
        let mut draft = 0;
-
        let mut open = 0;
-
        let mut archived = 0;
-
        let mut merged = 0;
-

-
        let patches = state.browser.items();
-

-
        for patch in &patches {
-
            match patch.state {
-
                patch::State::Draft => draft += 1,
-
                patch::State::Open { conflicts: _ } => open += 1,
-
                patch::State::Archived => archived += 1,
-
                patch::State::Merged {
-
                    commit: _,
-
                    revision: _,
-
                } => merged += 1,
-
            }
-
        }
-

-
        let stats = HashMap::from([
-
            ("Draft".to_string(), draft),
-
            ("Open".to_string(), open),
-
            ("Archived".to_string(), archived),
-
            ("Merged".to_string(), merged),
-
        ]);
-

-
        Self {
-
            patches,
-
            stats,
-
            header: [
-
                Column::new(" ● ", Constraint::Length(3)),
-
                Column::new("ID", Constraint::Length(8)),
-
                Column::new("Title", Constraint::Fill(1)),
-
                Column::new("Author", Constraint::Length(16)).hide_small(),
-
                Column::new("", Constraint::Length(16)).hide_medium(),
-
                Column::new("Head", Constraint::Length(8)).hide_small(),
-
                Column::new("+", Constraint::Length(6)).hide_small(),
-
                Column::new("-", Constraint::Length(6)).hide_small(),
-
                Column::new("Updated", Constraint::Length(16)).hide_small(),
-
            ]
-
            .to_vec(),
-
            columns: [
-
                Column::new(" ● ", Constraint::Length(3)),
-
                Column::new("ID", Constraint::Length(8)),
-
                Column::new("Title", Constraint::Fill(1)),
-
                Column::new("Author", Constraint::Length(16)).hide_small(),
-
                Column::new("", Constraint::Length(16)).hide_medium(),
-
                Column::new("Head", Constraint::Length(8)).hide_small(),
-
                Column::new("+", Constraint::Length(6)).hide_small(),
-
                Column::new("-", Constraint::Length(6)).hide_small(),
-
                Column::new("Updated", Constraint::Length(16)).hide_small(),
-
            ]
-
            .to_vec(),
-
            show_search: state.browser.is_search_shown(),
-
            search: state.browser.read_search(),
-
        }
-
    }
-
}
-

-
pub struct Browser {
-
    /// Patches widget
-
    patches: Widget,
-
    /// Search widget
-
    search: Widget,
-
}
-

-
impl Browser {
-
    pub fn new(tx: broadcast::Sender<Message>) -> Self {
-
        Self {
-
            patches: Container::default()
-
                .header(Header::default().to_widget(tx.clone()).on_update(|state| {
-
                    // TODO: remove and use state directly
-
                    let props = BrowserProps::from(state);
-
                    HeaderProps::default()
-
                        .columns(props.header.clone())
-
                        .to_boxed_any()
-
                        .into()
-
                }))
-
                .content(
-
                    Table::<State, Message, PatchItem, 9>::default()
-
                        .to_widget(tx.clone())
-
                        .on_event(|_, s, _| {
-
                            let (selected, _) =
-
                                s.and_then(|s| s.unwrap_table()).unwrap_or_default();
-
                            Some(Message::SelectPatch {
-
                                selected: Some(selected),
-
                            })
-
                        })
-
                        .on_update(|state| {
-
                            // TODO: remove and use state directly
-
                            let props = BrowserProps::from(state);
-
                            TableProps::default()
-
                                .columns(props.columns)
-
                                .items(state.browser.items())
-
                                .selected(state.browser.selected())
-
                                .to_boxed_any()
-
                                .into()
-
                        }),
-
                )
-
                .footer(Footer::default().to_widget(tx.clone()).on_update(|state| {
-
                    // TODO: remove and use state directly
-
                    let props = BrowserProps::from(state);
-

-
                    FooterProps::default()
-
                        .columns(browser_footer(&props))
-
                        .to_boxed_any()
-
                        .into()
-
                }))
-
                .to_widget(tx.clone())
-
                .on_update(|state| {
-
                    ContainerProps::default()
-
                        .hide_footer(BrowserProps::from(state).show_search)
-
                        .to_boxed_any()
-
                        .into()
-
                }),
-
            search: TextField::default()
-
                .to_widget(tx.clone())
-
                .on_event(|_, s, _| {
-
                    Some(Message::UpdateSearch {
-
                        value: s.and_then(|i| i.unwrap_string()).unwrap_or_default(),
-
                    })
-
                })
-
                .on_update(|state: &State| {
-
                    TextFieldProps::default()
-
                        .text(&state.browser.read_search())
-
                        .title("Search")
-
                        .inline(true)
-
                        .to_boxed_any()
-
                        .into()
-
                }),
-
        }
-
    }
-
}
-

-
impl View for Browser {
-
    type Message = Message;
-
    type State = State;
-

-
    fn handle_event(&mut self, props: Option<&ViewProps>, key: Key) -> Option<Self::Message> {
-
        let default = BrowserProps::default();
-
        let props = props
-
            .and_then(|props| props.inner_ref::<BrowserProps>())
-
            .unwrap_or(&default);
-

-
        if props.show_search {
-
            match key {
-
                Key::Esc => {
-
                    self.search.reset();
-
                    Some(Message::CloseSearch)
-
                }
-
                Key::Char('\n') => Some(Message::ApplySearch),
-
                _ => {
-
                    self.search.handle_event(key);
-
                    None
-
                }
-
            }
-
        } else {
-
            match key {
-
                Key::Char('/') => Some(Message::OpenSearch),
-
                _ => {
-
                    self.patches.handle_event(key);
-
                    None
-
                }
-
            }
-
        }
-
    }
-

-
    fn update(&mut self, _props: Option<&ViewProps>, state: &Self::State) {
-
        self.patches.update(state);
-
        self.search.update(state);
-
    }
-

-
    fn render(&mut self, props: Option<&ViewProps>, render: RenderProps, frame: &mut Frame) {
-
        let default = BrowserProps::default();
-
        let props = props
-
            .and_then(|props| props.inner_ref::<BrowserProps>())
-
            .unwrap_or(&default);
-

-
        if props.show_search {
-
            let [table_area, search_area] =
-
                Layout::vertical([Constraint::Min(1), Constraint::Length(2)]).areas(render.area);
-
            let [_, search_area, _] = Layout::horizontal([
-
                Constraint::Length(1),
-
                Constraint::Min(1),
-
                Constraint::Length(1),
-
            ])
-
            .areas(search_area);
-

-
            self.patches.render(RenderProps::from(table_area), frame);
-
            self.search
-
                .render(RenderProps::from(search_area).focus(render.focus), frame);
-
        } else {
-
            self.patches.render(render, frame);
-
        }
-
    }
-
}
-

-
fn browser_footer<'a>(props: &BrowserProps<'a>) -> Vec<Column<'a>> {
-
    let filter = PatchItemFilter::from_str(&props.search).unwrap_or_default();
-

-
    let search = Line::from(vec![
-
        span::default(" Search ").cyan().dim().reversed(),
-
        span::default(" "),
-
        span::default(&props.search.to_string()).gray().dim(),
-
    ]);
-

-
    let draft = Line::from(vec![
-
        span::default(&props.stats.get("Draft").unwrap_or(&0).to_string()).dim(),
-
        span::default(" Draft").dim(),
-
    ]);
-

-
    let open = Line::from(vec![
-
        span::positive(&props.stats.get("Open").unwrap_or(&0).to_string()).dim(),
-
        span::default(" Open").dim(),
-
    ]);
-

-
    let merged = Line::from(vec![
-
        span::default(&props.stats.get("Merged").unwrap_or(&0).to_string())
-
            .magenta()
-
            .dim(),
-
        span::default(" Merged").dim(),
-
    ]);
-

-
    let archived = Line::from(vec![
-
        span::default(&props.stats.get("Archived").unwrap_or(&0).to_string())
-
            .yellow()
-
            .dim(),
-
        span::default(" Archived").dim(),
-
    ]);
-

-
    let sum = Line::from(vec![
-
        span::default("Σ ").dim(),
-
        span::default(&props.patches.len().to_string()).dim(),
-
    ]);
-

-
    match filter.status() {
-
        Some(state) => {
-
            let block = match state {
-
                Status::Draft => draft,
-
                Status::Open => open,
-
                Status::Merged => merged,
-
                Status::Archived => archived,
-
            };
-

-
            vec![
-
                Column::new(Text::from(search), Constraint::Fill(1)),
-
                Column::new(
-
                    Text::from(block.clone()),
-
                    Constraint::Min(block.width() as u16),
-
                ),
-
                Column::new(Text::from(sum.clone()), Constraint::Min(sum.width() as u16)),
-
            ]
-
        }
-
        None => vec![
-
            Column::new(Text::from(search), Constraint::Fill(1)),
-
            Column::new(
-
                Text::from(draft.clone()),
-
                Constraint::Min(draft.width() as u16),
-
            ),
-
            Column::new(
-
                Text::from(open.clone()),
-
                Constraint::Min(open.width() as u16),
-
            ),
-
            Column::new(
-
                Text::from(merged.clone()),
-
                Constraint::Min(merged.width() as u16),
-
            ),
-
            Column::new(
-
                Text::from(archived.clone()),
-
                Constraint::Min(archived.width() as u16),
-
            ),
-
            Column::new(Text::from(sum.clone()), Constraint::Min(sum.width() as u16)),
-
        ],
-
    }
-
}
modified bin/ui/items.rs
@@ -516,10 +516,6 @@ pub struct PatchItemFilter {
}

impl PatchItemFilter {
-
    pub fn status(&self) -> Option<patch::Status> {
-
        self.status
-
    }
-

    pub fn is_default(&self) -> bool {
        *self == PatchItemFilter::default()
    }