Radish alpha
r
Radicle terminal user interface
Radicle
Git (anonymous pull)
Log in to clone via SSH
bin: Adjust to new UI modules
Erik Kundt committed 1 year ago
commit 118e5b5cc94e5cbd8324abf9b4f4d43a5816577e
parent d1de315f957a5c7644f387a12f49f727ad6b5b1a
13 files changed +646 -640
modified bin/commands/inbox/select.rs
@@ -22,13 +22,15 @@ use radicle_tui as tui;

use tui::store;
use tui::store::StateValue;
+
use tui::ui::rm::widget::container::{Column, Container, Footer, FooterProps, Header, HeaderProps};
+
use tui::ui::rm::widget::input::TextView;
+
use tui::ui::rm::widget::input::TextViewProps;
+
use tui::ui::rm::widget::input::TextViewState;
+
use tui::ui::rm::widget::window::{
+
    Page, PageProps, Shortcuts, ShortcutsProps, Window, WindowProps,
+
};
+
use tui::ui::rm::widget::{ToWidget, Widget};
use tui::ui::span;
-
use tui::ui::widget::container::{Column, Container, Footer, FooterProps, Header, HeaderProps};
-
use tui::ui::widget::input::TextView;
-
use tui::ui::widget::input::TextViewProps;
-
use tui::ui::widget::input::TextViewState;
-
use tui::ui::widget::window::{Page, PageProps, Shortcuts, ShortcutsProps, Window, WindowProps};
-
use tui::ui::widget::{ToWidget, Widget};
use tui::{BoxedAny, Channel, Exit, PageStack};

use crate::cob::inbox;
modified bin/commands/inbox/select/ui.rs
@@ -12,14 +12,14 @@ use ratatui::text::{Line, Text};

use radicle_tui as tui;

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

use tui::{BoxedAny, Selection};

modified bin/commands/issue/select.rs
@@ -22,22 +22,24 @@ use radicle_tui as tui;

use tui::store;
use tui::store::StateValue;
-
use tui::ui::span;
-
use tui::ui::theme::Theme;
-
use tui::ui::widget::container::{
+
use tui::ui::rm::widget::container::{
    Column, Container, ContainerProps, Footer, FooterProps, Header, HeaderProps, SectionGroup,
    SectionGroupProps, SplitContainer, SplitContainerFocus, SplitContainerProps,
};
-
use tui::ui::widget::input::{TextView, TextViewProps, TextViewState};
-
use tui::ui::widget::list::{Tree, TreeProps};
-
use tui::ui::widget::window::{Page, PageProps, Shortcuts, ShortcutsProps, Window, WindowProps};
-
use tui::ui::widget::{PredefinedLayout, ToWidget, Widget};
+
use tui::ui::rm::widget::input::{TextView, TextViewProps, TextViewState};
+
use tui::ui::rm::widget::list::{Tree, TreeProps};
+
use tui::ui::rm::widget::window::{
+
    Page, PageProps, Shortcuts, ShortcutsProps, Window, WindowProps,
+
};
+
use tui::ui::rm::widget::{PredefinedLayout, ToWidget, Widget};
+
use tui::ui::span;
+
use tui::ui::theme::Theme;
use tui::{BoxedAny, Channel, Exit, PageStack};

use crate::cob::issue;
use crate::settings::{self, ThemeBundle, ThemeMode};
use crate::ui::items::{CommentItem, IssueItem, IssueItemFilter};
-
use crate::ui::widget::{BrowserState, IssueDetails, IssueDetailsProps};
+
use crate::ui::rm::{BrowserState, IssueDetails, IssueDetailsProps};
use crate::ui::TerminalInfo;

use self::ui::{Browser, BrowserProps};
modified bin/commands/issue/select/ui.rs
@@ -14,15 +14,15 @@ use ratatui::text::{Line, Text};

use radicle_tui as tui;

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

use tui::BoxedAny;

modified bin/commands/patch/select.rs
@@ -1,15 +1,13 @@
#[path = "select/imui.rs"]
mod imui;
-
#[path = "select/ui.rs"]
-
mod ui;
+
#[path = "select/rmui.rs"]
+
mod rmui;

use std::str::FromStr;

use anyhow::Result;

-
use radicle::patch::PatchId;
-
use radicle::storage::git::Repository;
-
use radicle::Profile;
+
use termion::event::Key;

use radicle_tui as tui;

@@ -17,23 +15,27 @@ use ratatui::layout::Constraint;
use ratatui::style::Stylize;
use ratatui::text::Text;

-
use termion::event::Key;
use tui::store;
+
use tui::ui::rm::widget::container::{Column, Container, Footer, FooterProps, Header, HeaderProps};
+
use tui::ui::rm::widget::input::{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::span;
-
use tui::ui::widget::container::{Column, Container, Footer, FooterProps, Header, HeaderProps};
-
use tui::ui::widget::input::{TextView, TextViewProps, TextViewState};
-
use tui::ui::widget::window::{Page, PageProps, Shortcuts, ShortcutsProps, Window, WindowProps};
-
use tui::ui::widget::{ToWidget, Widget};

use tui::{BoxedAny, Channel, Exit, PageStack};

-
use self::ui::{Browser, BrowserProps};
+
use radicle::patch::PatchId;
+
use radicle::storage::git::Repository;
+
use radicle::Profile;

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

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

type Selection = tui::Selection<PatchId>;

modified bin/commands/patch/select/imui.rs
@@ -13,7 +13,7 @@ use ratatui::Frame;

use radicle_tui as tui;

-
use tui::ui::widget::container::Column;
+
use tui::ui::rm::widget::container::Column;
use tui::{store, Exit};

use tui::ui::im;
added bin/commands/patch/select/rmui.rs
@@ -0,0 +1,329 @@
+
use std::collections::HashMap;
+
use std::str::FromStr;
+
use std::vec;
+

+
use ratatui::Frame;
+
use tokio::sync::mpsc::UnboundedSender;
+

+
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::{
+
    Column, Container, ContainerProps, Footer, FooterProps, Header, HeaderProps,
+
};
+
use tui::ui::rm::widget::input::{TextField, TextFieldProps};
+
use tui::ui::rm::widget::list::{Table, TableProps};
+
use tui::ui::rm::widget::ViewProps;
+
use tui::ui::rm::widget::{RenderProps, ToWidget, View};
+
use tui::ui::span;
+

+
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<'a> From<&State> for BrowserProps<'a> {
+
    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: UnboundedSender<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)),
+
        ],
+
    }
+
}
deleted bin/commands/patch/select/ui.rs
@@ -1,329 +0,0 @@
-
use std::collections::HashMap;
-
use std::str::FromStr;
-
use std::vec;
-

-
use ratatui::Frame;
-
use tokio::sync::mpsc::UnboundedSender;
-

-
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::span;
-
use tui::ui::widget;
-
use tui::ui::widget::container::{
-
    Column, Container, ContainerProps, Footer, FooterProps, Header, HeaderProps,
-
};
-
use tui::ui::widget::input::{TextField, TextFieldProps};
-
use tui::ui::widget::list::{Table, TableProps};
-
use tui::ui::widget::ViewProps;
-
use tui::ui::widget::{RenderProps, ToWidget, View};
-

-
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<'a> From<&State> for BrowserProps<'a> {
-
    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: UnboundedSender<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.rs
@@ -1,7 +1,7 @@
pub mod format;
pub mod im;
pub mod items;
-
pub mod widget;
+
pub mod rm;

#[derive(Clone, Debug)]
pub struct TerminalInfo {
modified bin/ui/im.rs
@@ -7,8 +7,8 @@ use radicle_tui as tui;

use tui::ui::im::widget::{TableState, TextEditState, Widget};
use tui::ui::im::{Borders, BufferedValue, Response, Ui};
-
use tui::ui::widget::container::Column;
-
use tui::ui::widget::list::ToRow;
+
use tui::ui::rm::widget::container::Column;
+
use tui::ui::rm::widget::list::ToRow;

pub struct UiExt<'a>(&'a mut Ui);

modified bin/ui/items.rs
@@ -28,9 +28,9 @@ use tui_tree_widget::TreeItem;

use radicle_tui as tui;

+
use tui::ui::rm::widget::list::{ToRow, ToTree};
use tui::ui::span;
use tui::ui::theme::style;
-
use tui::ui::widget::list::{ToRow, ToTree};

use super::super::git;
use super::format;
added bin/ui/rm.rs
@@ -0,0 +1,267 @@
+
use std::marker::PhantomData;
+
use std::str::FromStr;
+

+
use radicle::issue::{self, CloseReason};
+
use ratatui::layout::{Constraint, Layout};
+
use ratatui::style::Stylize;
+
use ratatui::text::{Line, Span, Text};
+
use ratatui::widgets::Row;
+
use ratatui::Frame;
+

+
use radicle_tui as tui;
+

+
use tui::store;
+
use tui::ui::rm::widget::{RenderProps, View, ViewProps};
+
use tui::ui::theme::style;
+
use tui::ui::{layout, span};
+

+
use super::format;
+
use super::items::IssueItem;
+

+
use crate::ui::items::Filter;
+

+
/// A `BrowserState` represents the internal state of a browser widget.
+
/// A browser widget would consist of 2 child widgets: a list of items and a
+
/// buffered search field. The search fields value is used to build an
+
/// item filter that the item list reacts on dynamically.
+
#[derive(Clone, Debug)]
+
pub struct BrowserState<I, F> {
+
    items: Vec<I>,
+
    selected: Option<usize>,
+
    filter: F,
+
    search: store::StateValue<String>,
+
    show_search: bool,
+
}
+

+
impl<I, F> Default for BrowserState<I, F>
+
where
+
    I: Clone,
+
    F: Filter<I> + Default + FromStr,
+
{
+
    fn default() -> Self {
+
        Self {
+
            items: vec![],
+
            selected: None,
+
            filter: F::default(),
+
            search: store::StateValue::new(String::default()),
+
            show_search: false,
+
        }
+
    }
+
}
+

+
impl<I, F> BrowserState<I, F>
+
where
+
    I: Clone,
+
    F: Filter<I> + Default + FromStr,
+
{
+
    pub fn build(items: Vec<I>, filter: F, search: store::StateValue<String>) -> Self {
+
        let selected = items.first().map(|_| 0);
+

+
        Self {
+
            items,
+
            selected,
+
            filter,
+
            search,
+
            ..Default::default()
+
        }
+
    }
+

+
    pub fn items(&self) -> Vec<I> {
+
        self.items_ref().into_iter().cloned().collect()
+
    }
+

+
    pub fn items_ref(&self) -> Vec<&I> {
+
        self.items
+
            .iter()
+
            .filter(|patch| self.filter.matches(patch))
+
            .collect()
+
    }
+

+
    pub fn selected(&self) -> Option<usize> {
+
        self.selected
+
    }
+

+
    pub fn selected_item(&self) -> Option<&I> {
+
        self.selected
+
            .and_then(|selected| self.items_ref().get(selected).copied())
+
    }
+

+
    pub fn select_item(&mut self, selected: Option<usize>) -> Option<&I> {
+
        self.selected = selected;
+
        self.selected_item()
+
    }
+

+
    pub fn select_first_item(&mut self) -> Option<&I> {
+
        self.selected.and_then(|selected| {
+
            if selected > self.items_ref().len() {
+
                self.selected = Some(0);
+
                self.items_ref().first().cloned()
+
            } else {
+
                self.items_ref().get(selected).cloned()
+
            }
+
        })
+
    }
+

+
    fn filter_items(&mut self) {
+
        self.filter = F::from_str(&self.search.read()).unwrap_or_default();
+
    }
+

+
    pub fn update_search(&mut self, value: String) {
+
        self.search.write(value);
+
        self.filter_items();
+
    }
+

+
    pub fn show_search(&mut self) {
+
        self.show_search = true;
+
    }
+

+
    pub fn hide_search(&mut self) {
+
        self.show_search = false;
+
    }
+

+
    pub fn apply_search(&mut self) {
+
        self.search.apply();
+
    }
+

+
    pub fn reset_search(&mut self) {
+
        self.search.reset();
+
        self.filter_items();
+
    }
+

+
    pub fn is_search_shown(&self) -> bool {
+
        self.show_search
+
    }
+

+
    pub fn read_search(&self) -> String {
+
        self.search.read()
+
    }
+
}
+

+
#[derive(Clone, Default)]
+
pub struct IssueDetailsProps {
+
    issue: Option<IssueItem>,
+
    dim: bool,
+
}
+

+
impl IssueDetailsProps {
+
    pub fn issue(mut self, issue: Option<IssueItem>) -> Self {
+
        self.issue = issue;
+
        self
+
    }
+

+
    pub fn dim(mut self, dim: bool) -> Self {
+
        self.dim = dim;
+
        self
+
    }
+
}
+

+
pub struct IssueDetails<S, M> {
+
    /// Phantom
+
    phantom: PhantomData<(S, M)>,
+
}
+

+
impl<S, M> Default for IssueDetails<S, M> {
+
    fn default() -> Self {
+
        Self {
+
            phantom: PhantomData,
+
        }
+
    }
+
}
+

+
impl<S, M> View for IssueDetails<S, M> {
+
    type State = S;
+
    type Message = M;
+

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

+
        let [area] = Layout::default()
+
            .constraints([Constraint::Min(1)])
+
            .horizontal_margin(1)
+
            .areas(render.area);
+

+
        if let Some(issue) = props.issue.as_ref() {
+
            let author = match &issue.author.alias {
+
                Some(alias) => {
+
                    if issue.author.you {
+
                        span::alias(&format!("{}", alias))
+
                    } else {
+
                        span::alias(alias)
+
                    }
+
                }
+
                None => match &issue.author.human_nid {
+
                    Some(nid) => span::alias(nid).dim(),
+
                    None => span::blank(),
+
                },
+
            };
+

+
            let did = match &issue.author.human_nid {
+
                Some(nid) => {
+
                    if issue.author.you {
+
                        span::alias("(you)").dim().italic()
+
                    } else {
+
                        span::alias(nid).dim()
+
                    }
+
                }
+
                None => span::blank(),
+
            };
+

+
            let labels = format::labels(&issue.labels);
+

+
            let status = match issue.state {
+
                issue::State::Open => Text::styled("open", style::green()),
+
                issue::State::Closed { reason } => match reason {
+
                    CloseReason::Solved => Line::from(
+
                        [
+
                            Span::styled("closed", style::red()),
+
                            Span::raw(" "),
+
                            Span::styled("(solved)", style::red().italic().dim()),
+
                        ]
+
                        .to_vec(),
+
                    )
+
                    .into(),
+
                    CloseReason::Other => Text::styled("closed", style::red()),
+
                },
+
            };
+

+
            let table = ratatui::widgets::Table::new(
+
                [
+
                    Row::new([
+
                        Text::raw("Title").cyan(),
+
                        Text::raw(issue.title.clone()).bold(),
+
                    ]),
+
                    Row::new([
+
                        Text::raw("Issue").cyan(),
+
                        Text::raw(issue.id.to_string()).bold(),
+
                    ]),
+
                    Row::new([
+
                        Text::raw("Author").cyan(),
+
                        Line::from([author, " ".into(), did].to_vec()).into(),
+
                    ]),
+
                    Row::new([Text::raw("Labels").cyan(), Text::from(labels).blue()]),
+
                    Row::new([Text::raw("Status").cyan(), status]),
+
                ],
+
                [Constraint::Length(8), Constraint::Fill(1)],
+
            );
+

+
            let table = if !render.focus && props.dim {
+
                table.dim()
+
            } else {
+
                table
+
            };
+

+
            frame.render_widget(table, area);
+
        } else {
+
            let center = layout::centered_rect(render.area, 50, 10);
+
            let hint = Text::from(span::default("No issue selected"))
+
                .centered()
+
                .light_magenta()
+
                .dim();
+

+
            frame.render_widget(hint, center);
+
        }
+
    }
+
}
deleted bin/ui/widget.rs
@@ -1,267 +0,0 @@
-
use std::marker::PhantomData;
-
use std::str::FromStr;
-

-
use radicle::issue::{self, CloseReason};
-
use ratatui::layout::{Constraint, Layout};
-
use ratatui::style::Stylize;
-
use ratatui::text::{Line, Span, Text};
-
use ratatui::widgets::Row;
-
use ratatui::Frame;
-

-
use radicle_tui as tui;
-

-
use tui::store;
-
use tui::ui::theme::style;
-
use tui::ui::widget::{RenderProps, View, ViewProps};
-
use tui::ui::{layout, span};
-

-
use super::format;
-
use super::items::IssueItem;
-

-
use crate::ui::items::Filter;
-

-
/// A `BrowserState` represents the internal state of a browser widget.
-
/// A browser widget would consist of 2 child widgets: a list of items and a
-
/// buffered search field. The search fields value is used to build an
-
/// item filter that the item list reacts on dynamically.
-
#[derive(Clone, Debug)]
-
pub struct BrowserState<I, F> {
-
    items: Vec<I>,
-
    selected: Option<usize>,
-
    filter: F,
-
    search: store::StateValue<String>,
-
    show_search: bool,
-
}
-

-
impl<I, F> Default for BrowserState<I, F>
-
where
-
    I: Clone,
-
    F: Filter<I> + Default + FromStr,
-
{
-
    fn default() -> Self {
-
        Self {
-
            items: vec![],
-
            selected: None,
-
            filter: F::default(),
-
            search: store::StateValue::new(String::default()),
-
            show_search: false,
-
        }
-
    }
-
}
-

-
impl<I, F> BrowserState<I, F>
-
where
-
    I: Clone,
-
    F: Filter<I> + Default + FromStr,
-
{
-
    pub fn build(items: Vec<I>, filter: F, search: store::StateValue<String>) -> Self {
-
        let selected = items.first().map(|_| 0);
-

-
        Self {
-
            items,
-
            selected,
-
            filter,
-
            search,
-
            ..Default::default()
-
        }
-
    }
-

-
    pub fn items(&self) -> Vec<I> {
-
        self.items_ref().into_iter().cloned().collect()
-
    }
-

-
    pub fn items_ref(&self) -> Vec<&I> {
-
        self.items
-
            .iter()
-
            .filter(|patch| self.filter.matches(patch))
-
            .collect()
-
    }
-

-
    pub fn selected(&self) -> Option<usize> {
-
        self.selected
-
    }
-

-
    pub fn selected_item(&self) -> Option<&I> {
-
        self.selected
-
            .and_then(|selected| self.items_ref().get(selected).copied())
-
    }
-

-
    pub fn select_item(&mut self, selected: Option<usize>) -> Option<&I> {
-
        self.selected = selected;
-
        self.selected_item()
-
    }
-

-
    pub fn select_first_item(&mut self) -> Option<&I> {
-
        self.selected.and_then(|selected| {
-
            if selected > self.items_ref().len() {
-
                self.selected = Some(0);
-
                self.items_ref().first().cloned()
-
            } else {
-
                self.items_ref().get(selected).cloned()
-
            }
-
        })
-
    }
-

-
    fn filter_items(&mut self) {
-
        self.filter = F::from_str(&self.search.read()).unwrap_or_default();
-
    }
-

-
    pub fn update_search(&mut self, value: String) {
-
        self.search.write(value);
-
        self.filter_items();
-
    }
-

-
    pub fn show_search(&mut self) {
-
        self.show_search = true;
-
    }
-

-
    pub fn hide_search(&mut self) {
-
        self.show_search = false;
-
    }
-

-
    pub fn apply_search(&mut self) {
-
        self.search.apply();
-
    }
-

-
    pub fn reset_search(&mut self) {
-
        self.search.reset();
-
        self.filter_items();
-
    }
-

-
    pub fn is_search_shown(&self) -> bool {
-
        self.show_search
-
    }
-

-
    pub fn read_search(&self) -> String {
-
        self.search.read()
-
    }
-
}
-

-
#[derive(Clone, Default)]
-
pub struct IssueDetailsProps {
-
    issue: Option<IssueItem>,
-
    dim: bool,
-
}
-

-
impl IssueDetailsProps {
-
    pub fn issue(mut self, issue: Option<IssueItem>) -> Self {
-
        self.issue = issue;
-
        self
-
    }
-

-
    pub fn dim(mut self, dim: bool) -> Self {
-
        self.dim = dim;
-
        self
-
    }
-
}
-

-
pub struct IssueDetails<S, M> {
-
    /// Phantom
-
    phantom: PhantomData<(S, M)>,
-
}
-

-
impl<S, M> Default for IssueDetails<S, M> {
-
    fn default() -> Self {
-
        Self {
-
            phantom: PhantomData,
-
        }
-
    }
-
}
-

-
impl<S, M> View for IssueDetails<S, M> {
-
    type State = S;
-
    type Message = M;
-

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

-
        let [area] = Layout::default()
-
            .constraints([Constraint::Min(1)])
-
            .horizontal_margin(1)
-
            .areas(render.area);
-

-
        if let Some(issue) = props.issue.as_ref() {
-
            let author = match &issue.author.alias {
-
                Some(alias) => {
-
                    if issue.author.you {
-
                        span::alias(&format!("{}", alias))
-
                    } else {
-
                        span::alias(alias)
-
                    }
-
                }
-
                None => match &issue.author.human_nid {
-
                    Some(nid) => span::alias(nid).dim(),
-
                    None => span::blank(),
-
                },
-
            };
-

-
            let did = match &issue.author.human_nid {
-
                Some(nid) => {
-
                    if issue.author.you {
-
                        span::alias("(you)").dim().italic()
-
                    } else {
-
                        span::alias(nid).dim()
-
                    }
-
                }
-
                None => span::blank(),
-
            };
-

-
            let labels = format::labels(&issue.labels);
-

-
            let status = match issue.state {
-
                issue::State::Open => Text::styled("open", style::green()),
-
                issue::State::Closed { reason } => match reason {
-
                    CloseReason::Solved => Line::from(
-
                        [
-
                            Span::styled("closed", style::red()),
-
                            Span::raw(" "),
-
                            Span::styled("(solved)", style::red().italic().dim()),
-
                        ]
-
                        .to_vec(),
-
                    )
-
                    .into(),
-
                    CloseReason::Other => Text::styled("closed", style::red()),
-
                },
-
            };
-

-
            let table = ratatui::widgets::Table::new(
-
                [
-
                    Row::new([
-
                        Text::raw("Title").cyan(),
-
                        Text::raw(issue.title.clone()).bold(),
-
                    ]),
-
                    Row::new([
-
                        Text::raw("Issue").cyan(),
-
                        Text::raw(issue.id.to_string()).bold(),
-
                    ]),
-
                    Row::new([
-
                        Text::raw("Author").cyan(),
-
                        Line::from([author, " ".into(), did].to_vec()).into(),
-
                    ]),
-
                    Row::new([Text::raw("Labels").cyan(), Text::from(labels).blue()]),
-
                    Row::new([Text::raw("Status").cyan(), status]),
-
                ],
-
                [Constraint::Length(8), Constraint::Fill(1)],
-
            );
-

-
            let table = if !render.focus && props.dim {
-
                table.dim()
-
            } else {
-
                table
-
            };
-

-
            frame.render_widget(table, area);
-
        } else {
-
            let center = layout::centered_rect(render.area, 50, 10);
-
            let hint = Text::from(span::default("No issue selected"))
-
                .centered()
-
                .light_magenta()
-
                .dim();
-

-
            frame.render_widget(hint, center);
-
        }
-
    }
-
}