Radish alpha
r
rad:z39mP9rQAaGmERfUMPULfPUi473tY
Radicle terminal user interface
Radicle
Git
bin(inbox): Rename `select` -> `list` operation
Erik Kundt committed 1 year ago
commit ab585a9755a900721a5d3c5defd3f82567050795
parent 445afd6
5 files changed +798 -795
modified bin/commands/inbox.rs
@@ -1,7 +1,7 @@
#[path = "inbox/common.rs"]
mod common;
-
#[path = "inbox/select.rs"]
-
mod select;
+
#[path = "inbox/list.rs"]
+
mod list;

use std::ffi::OsString;

@@ -21,18 +21,21 @@ pub const HELP: Help = Help {
    usage: r#"
Usage

-
    rad-tui inbox select [<option>...]
+
    rad-tui inbox list [<option>...]

-
Other options
+
List options

    --mode <MODE>           Set selection mode; see MODE below (default: operation)
    
    --sort-by <field>       Sort by `id` or `timestamp` (default: timestamp)
    --reverse, -r           Reverse the list
-
    --help                  Print help

    The MODE argument can be 'operation' or 'id'. 'operation' selects a notification id and
    an operation, whereas 'id' selects a notification id only.
+

+
Other options
+

+
    --help                  Print help    
"#,
};

@@ -41,16 +44,16 @@ pub struct Options {
}

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

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

#[derive(Debug, Default, Clone, PartialEq, Eq)]
-
pub struct SelectOptions {
+
pub struct ListOptions {
    mode: Mode,
    filter: inbox::Filter,
    sort_by: inbox::SortBy,
@@ -65,7 +68,7 @@ impl Args for Options {
        let mut repository_mode = None;
        let mut reverse = None;
        let mut field = None;
-
        let mut select_opts = SelectOptions::default();
+
        let mut list_opts = ListOptions::default();

        while let Some(arg) = parser.next()? {
            match arg {
@@ -74,7 +77,7 @@ impl Args for Options {
                }

                // select options.
-
                Long("mode") | Short('m') if op == Some(OperationName::Select) => {
+
                Long("mode") | Short('m') if op == Some(OperationName::List) => {
                    let val = parser.value()?;
                    let val = val.to_str().unwrap_or_default();

@@ -83,7 +86,7 @@ impl Args for Options {
                        "id" => SelectionMode::Id,
                        unknown => anyhow::bail!("unknown mode '{}'", unknown),
                    };
-
                    select_opts.mode = select_opts.mode.with_selection(selection_mode)
+
                    list_opts.mode = list_opts.mode.with_selection(selection_mode)
                }

                Long("reverse") | Short('r') => {
@@ -110,17 +113,17 @@ impl Args for Options {
                }

                Value(val) if op.is_none() => match val.to_string_lossy().as_ref() {
-
                    "select" => op = Some(OperationName::Select),
+
                    "list" => op = Some(OperationName::List),
                    unknown => anyhow::bail!("unknown operation '{}'", unknown),
                },
                _ => return Err(anyhow!(arg.unexpected())),
            }
        }

-
        select_opts.mode = select_opts
+
        list_opts.mode = list_opts
            .mode
            .with_repository(repository_mode.unwrap_or_default());
-
        select_opts.sort_by = if let Some(field) = field {
+
        list_opts.sort_by = if let Some(field) = field {
            inbox::SortBy {
                field,
                reverse: reverse.unwrap_or(false),
@@ -130,7 +133,7 @@ impl Args for Options {
        };

        let op = match op.ok_or_else(|| anyhow!("an operation must be provided"))? {
-
            OperationName::Select => Operation::Select { opts: select_opts },
+
            OperationName::List => Operation::List { opts: list_opts },
        };
        Ok((Options { op }, vec![]))
    }
@@ -144,30 +147,30 @@ pub async fn run(options: Options, ctx: impl terminal::Context) -> anyhow::Resul
        .map_err(|_| anyhow!("this command must be run in the context of a project"))?;

    match options.op {
-
        Operation::Select { opts } => {
+
        Operation::List { opts } => {
            let profile = ctx.profile()?;
            let repository = profile.storage.repository(rid).unwrap();

            if let Err(err) = crate::log::enable() {
                println!("{}", err);
            }
-
            log::info!("Starting patch selection interface in project {}..", rid);
+
            log::info!("Starting inbox listing interface in project {}..", rid);

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

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

            log::info!("About to print to `stderr`: {}", output);
-
            log::info!("Exiting inbox selection interface..");
+
            log::info!("Exiting inbox listing interface..");

            eprint!("{output}");
        }
added bin/commands/inbox/list.rs
@@ -0,0 +1,447 @@
+
#[path = "list/ui.rs"]
+
mod ui;
+

+
use std::str::FromStr;
+

+
use anyhow::Result;
+

+
use ratatui::Viewport;
+
use termion::event::Key;
+

+
use ratatui::layout::Constraint;
+
use ratatui::style::Stylize;
+
use ratatui::text::Text;
+

+
use radicle::identity::Project;
+
use radicle::node::notifications::NotificationId;
+
use radicle::storage::git::Repository;
+
use radicle::storage::ReadRepository;
+
use radicle::storage::ReadStorage;
+
use radicle::Profile;
+

+
use radicle_tui as tui;
+

+
use tui::store;
+
use tui::ui::rm::widget::container::{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::BufferedValue;
+
use tui::ui::Column;
+
use tui::{BoxedAny, Channel, Exit, PageStack};
+

+
use crate::cob::inbox;
+
use crate::ui::items::{Filter, NotificationItem, NotificationItemFilter};
+

+
use self::ui::Browser;
+
use self::ui::BrowserProps;
+

+
use super::common::SelectionMode;
+
use super::common::{Mode, RepositoryMode};
+

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

+
#[allow(dead_code)]
+
pub struct Context {
+
    pub profile: Profile,
+
    pub repository: Repository,
+
    pub mode: Mode,
+
    pub filter: inbox::Filter,
+
    pub sort_by: inbox::SortBy,
+
}
+

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

+
#[derive(Clone, Debug, Eq, PartialEq, Hash)]
+
pub enum AppPage {
+
    Browse,
+
    Help,
+
}
+

+
#[derive(Clone, Debug)]
+
pub struct BrowserState {
+
    items: Vec<NotificationItem>,
+
    selected: Option<usize>,
+
    filter: NotificationItemFilter,
+
    search: BufferedValue<String>,
+
    show_search: bool,
+
}
+

+
impl BrowserState {
+
    pub fn notifications(&self) -> Vec<NotificationItem> {
+
        self.items
+
            .iter()
+
            .filter(|patch| self.filter.matches(patch))
+
            .cloned()
+
            .collect()
+
    }
+
}
+

+
#[derive(Clone, Debug)]
+
pub struct HelpState {
+
    text: TextViewState,
+
}
+

+
#[derive(Clone, Debug)]
+
pub struct State {
+
    mode: Mode,
+
    project: Project,
+
    pages: PageStack<AppPage>,
+
    browser: BrowserState,
+
    help: HelpState,
+
}
+

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

+
    fn try_from(context: &Context) -> Result<Self, Self::Error> {
+
        let doc = context.repository.identity_doc()?;
+
        let project = doc.project()?;
+

+
        let search = BufferedValue::new(String::new());
+
        let filter = NotificationItemFilter::from_str(&search.read()).unwrap_or_default();
+

+
        let mut notifications = match &context.mode.repository() {
+
            RepositoryMode::All => {
+
                let mut repos = context.profile.storage.repositories()?;
+
                repos.sort_by_key(|r| r.rid);
+

+
                let mut notifs = vec![];
+
                for repo in repos {
+
                    let repo = context.profile.storage.repository(repo.rid)?;
+

+
                    let items = inbox::all(&repo, &context.profile)?
+
                        .iter()
+
                        .map(|notif| NotificationItem::new(&context.profile, &repo, notif))
+
                        .filter_map(|item| item.ok())
+
                        .flatten()
+
                        .collect::<Vec<_>>();
+

+
                    notifs.extend(items);
+
                }
+

+
                notifs
+
            }
+
            RepositoryMode::Contextual => {
+
                let notifs = inbox::all(&context.repository, &context.profile)?;
+

+
                notifs
+
                    .iter()
+
                    .map(|notif| {
+
                        NotificationItem::new(&context.profile, &context.repository, notif)
+
                    })
+
                    .filter_map(|item| item.ok())
+
                    .flatten()
+
                    .collect::<Vec<_>>()
+
            }
+
            RepositoryMode::ByRepo((rid, _)) => {
+
                let repo = context.profile.storage.repository(*rid)?;
+
                let notifs = inbox::all(&repo, &context.profile)?;
+

+
                notifs
+
                    .iter()
+
                    .map(|notif| NotificationItem::new(&context.profile, &repo, notif))
+
                    .filter_map(|item| item.ok())
+
                    .flatten()
+
                    .collect::<Vec<_>>()
+
            }
+
        };
+

+
        // Set project name
+
        let mode = match &context.mode.repository() {
+
            RepositoryMode::ByRepo((rid, _)) => {
+
                let project = context
+
                    .profile
+
                    .storage
+
                    .repository(*rid)?
+
                    .identity_doc()?
+
                    .project()?;
+
                let name = project.name().to_string();
+

+
                context
+
                    .mode
+
                    .clone()
+
                    .with_repository(RepositoryMode::ByRepo((*rid, Some(name))))
+
            }
+
            _ => context.mode.clone(),
+
        };
+

+
        // Apply sorting
+
        match context.sort_by.field {
+
            "timestamp" => notifications.sort_by(|a, b| a.timestamp.cmp(&b.timestamp)),
+
            "id" => notifications.sort_by(|a, b| a.id.cmp(&b.id)),
+
            _ => {}
+
        }
+
        if context.sort_by.reverse {
+
            notifications.reverse();
+
        }
+

+
        // Sort by project if all notifications are shown
+
        if let RepositoryMode::All = mode.repository() {
+
            notifications.sort_by(|a, b| a.project.cmp(&b.project));
+
        }
+

+
        Ok(Self {
+
            mode: context.mode.clone(),
+
            project,
+
            pages: PageStack::new(vec![AppPage::Browse]),
+
            browser: BrowserState {
+
                items: notifications,
+
                selected: Some(0),
+
                filter,
+
                search,
+
                show_search: false,
+
            },
+
            help: HelpState {
+
                text: TextViewState::default().content(help_text()),
+
            },
+
        })
+
    }
+
}
+

+
#[derive(Clone, Debug)]
+
pub enum Message {
+
    Exit { selection: Option<Selection> },
+
    Select { selected: Option<usize> },
+
    OpenSearch,
+
    UpdateSearch { value: String },
+
    ApplySearch,
+
    CloseSearch,
+
    OpenHelp,
+
    LeavePage,
+
    ScrollHelp { state: TextViewState },
+
}
+

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

+
    fn update(&mut self, message: Message) -> Option<Exit<Selection>> {
+
        match message {
+
            Message::Exit { selection } => Some(Exit { value: selection }),
+
            Message::Select { selected } => {
+
                self.browser.selected = selected;
+
                None
+
            }
+
            Message::OpenSearch => {
+
                self.browser.show_search = true;
+
                None
+
            }
+
            Message::UpdateSearch { value } => {
+
                self.browser.search.write(value);
+
                self.browser.filter = NotificationItemFilter::from_str(&self.browser.search.read())
+
                    .unwrap_or_default();
+

+
                if let Some(selected) = self.browser.selected {
+
                    if selected > self.browser.notifications().len() {
+
                        self.browser.selected = Some(0);
+
                    }
+
                }
+

+
                None
+
            }
+
            Message::ApplySearch => {
+
                self.browser.search.apply();
+
                self.browser.show_search = false;
+
                None
+
            }
+
            Message::CloseSearch => {
+
                self.browser.search.reset();
+
                self.browser.show_search = false;
+
                self.browser.filter = NotificationItemFilter::from_str(&self.browser.search.read())
+
                    .unwrap_or_default();
+

+
                None
+
            }
+
            Message::OpenHelp => {
+
                self.pages.push(AppPage::Help);
+
                None
+
            }
+
            Message::LeavePage => {
+
                self.pages.pop();
+
                None
+
            }
+
            Message::ScrollHelp { state } => {
+
                self.help.text = state;
+
                None
+
            }
+
        }
+
    }
+
}
+

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

+
    pub async fn run(&self) -> Result<Option<Selection>> {
+
        let channel = Channel::default();
+
        let state = State::try_from(&self.context)?;
+
        let tx = channel.tx.clone();
+

+
        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: &State| {
+
                WindowProps::default()
+
                    .current_page(state.pages.peek().unwrap_or(&AppPage::Browse).clone())
+
                    .to_boxed_any()
+
                    .into()
+
            });
+

+
        tui::rm(state, window, Viewport::Inline(20), channel).await
+
    }
+
}
+

+
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.show_search {
+
                vec![("esc", "cancel"), ("enter", "apply")]
+
            } else {
+
                match state.mode.selection() {
+
                    SelectionMode::Id => vec![("enter", "select"), ("/", "search")],
+
                    SelectionMode::Operation => vec![
+
                        ("enter", "show"),
+
                        ("c", "clear"),
+
                        ("/", "search"),
+
                        ("?", "help"),
+
                    ],
+
                }
+
            };
+

+
            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::Esc | Key::Ctrl('c') => Some(Message::Exit { selection: None }),
+
                    Key::Char('?') => Some(Message::OpenHelp),
+
                    _ => None,
+
                }
+
            } else {
+
                None
+
            }
+
        })
+
        .on_update(|state: &State| {
+
            PageProps::default()
+
                .handle_keys(!state.browser.show_search)
+
                .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(|_, vs, _| {
+
                    vs.and_then(|vs| vs.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(
+
                            [
+
                                Column::new(Text::raw(""), Constraint::Fill(1)),
+
                                Column::new(
+
                                    span::default(&format!("{}%", state.help.text.scroll)).dim(),
+
                                    Constraint::Min(4),
+
                                ),
+
                            ]
+
                            .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::Exit { selection: None }),
+
            Key::Char('?') => Some(Message::LeavePage),
+
            _ => None,
+
        })
+
        .on_update(|_| PageProps::default().handle_keys(true).to_boxed_any().into())
+
}
+

+
fn help_text() -> String {
+
    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`:      Quit / cancel
+

+
# Specific keybindings
+

+
`enter`:    Select notification (if --mode id)
+
`enter`:    Show notification
+
`c`:        Clear notifications
+
`/`:        Search
+
`?`:        Show help
+

+
# Searching
+

+
Pattern:    is:<state> | is:patch | is:issue | <search>
+
Example:    is:unseen is:patch Print"#
+
        .into()
+
}
added bin/commands/inbox/list/ui.rs
@@ -0,0 +1,328 @@
+
use std::collections::HashMap;
+
use std::str::FromStr;
+

+
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_tui as tui;
+

+
use tui::ui::rm::widget::container::{
+
    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::{self, ViewProps};
+
use tui::ui::rm::widget::{RenderProps, ToWidget, View};
+
use tui::ui::span;
+
use tui::ui::Column;
+

+
use tui::{BoxedAny, Selection};
+

+
use crate::tui_inbox::common::{InboxOperation, Mode, RepositoryMode, SelectionMode};
+
use crate::ui::items::{NotificationItem, NotificationItemFilter, NotificationState};
+

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

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

+
#[derive(Clone, Default)]
+
pub struct BrowserProps<'a> {
+
    /// Application mode: openation and id or id only.
+
    mode: Mode,
+
    /// Table title
+
    header: String,
+
    /// Filtered notifications.
+
    notifications: Vec<NotificationItem>,
+
    /// Current (selected) table index
+
    selected: Option<usize>,
+
    /// Notification statistics.
+
    stats: HashMap<String, usize>,
+
    /// 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 header = match state.mode.repository() {
+
            RepositoryMode::Contextual => state.project.name().to_string(),
+
            RepositoryMode::All => "All repositories".to_string(),
+
            RepositoryMode::ByRepo((_, name)) => name.clone().unwrap_or_default(),
+
        };
+

+
        let notifications = state.browser.notifications();
+

+
        // Compute statistics
+
        let mut seen = 0;
+
        let mut unseen = 0;
+
        for notification in &notifications {
+
            if notification.seen {
+
                seen += 1;
+
            } else {
+
                unseen += 1;
+
            }
+
        }
+
        let stats = HashMap::from([("Seen".to_string(), seen), ("Unseen".to_string(), unseen)]);
+

+
        Self {
+
            mode: state.mode.clone(),
+
            header,
+
            notifications,
+
            selected: state.browser.selected,
+
            stats,
+
            columns: [
+
                Column::new("", Constraint::Length(5)),
+
                Column::new("", Constraint::Length(3)),
+
                Column::new("", Constraint::Fill(5)),
+
                Column::new("", Constraint::Fill(1))
+
                    .skip(*state.mode.repository() != RepositoryMode::All),
+
                Column::new("", Constraint::Fill(1))
+
                    .hide_small()
+
                    .hide_medium(),
+
                Column::new("", Constraint::Length(8)),
+
                Column::new("", Constraint::Length(10)),
+
                Column::new("", Constraint::Min(12)).hide_small(),
+
                Column::new("", Constraint::Min(14)).hide_small(),
+
            ]
+
            .to_vec(),
+
            search: state.browser.search.read(),
+
            show_search: state.browser.show_search,
+
        }
+
    }
+
}
+

+
pub struct Browser {
+
    /// Notification widget
+
    notifications: Widget,
+
    /// Search widget
+
    search: Widget,
+
}
+

+
impl Browser {
+
    pub fn new(tx: UnboundedSender<Message>) -> Self {
+
        Self {
+
            notifications: 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(
+
                            [
+
                                Column::new("", Constraint::Length(0)),
+
                                Column::new(Text::from(props.header), Constraint::Fill(1)),
+
                            ]
+
                            .to_vec(),
+
                        )
+
                        .to_boxed_any()
+
                        .into()
+
                }))
+
                .content(
+
                    Table::<State, Message, NotificationItem, 9>::default()
+
                        .to_widget(tx.clone())
+
                        .on_event(|_, s, _| {
+
                            let (selected, _) =
+
                                s.and_then(|s| s.unwrap_table()).unwrap_or_default();
+
                            Some(Message::Select {
+
                                selected: Some(selected),
+
                            })
+
                        })
+
                        .on_update(|state| {
+
                            let props = BrowserProps::from(state);
+

+
                            TableProps::default()
+
                                .columns(props.columns)
+
                                .items(state.browser.notifications())
+
                                .selected(state.browser.selected)
+
                                .to_boxed_any()
+
                                .into()
+
                        }),
+
                )
+
                .footer(Footer::default().to_widget(tx.clone()).on_update(|state| {
+
                    let props = BrowserProps::from(state);
+

+
                    FooterProps::default()
+
                        .columns(browse_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.search.read().to_string())
+
                        .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),
+
                Key::Char('\n') => props
+
                    .selected
+
                    .and_then(|selected| props.notifications.get(selected))
+
                    .map(|notif| {
+
                        let selection = match props.mode.selection() {
+
                            SelectionMode::Operation => Selection::default()
+
                                .with_operation(InboxOperation::Show.to_string())
+
                                .with_id(notif.id),
+
                            SelectionMode::Id => Selection::default().with_id(notif.id),
+
                        };
+

+
                        Message::Exit {
+
                            selection: Some(selection),
+
                        }
+
                    }),
+
                Key::Char('c') => props
+
                    .selected
+
                    .and_then(|selected| props.notifications.get(selected))
+
                    .map(|notif| Message::Exit {
+
                        selection: Some(
+
                            Selection::default()
+
                                .with_operation(InboxOperation::Clear.to_string())
+
                                .with_id(notif.id),
+
                        ),
+
                    }),
+
                _ => {
+
                    self.notifications.handle_event(key);
+
                    None
+
                }
+
            }
+
        }
+
    }
+

+
    fn update(&mut self, _props: Option<&ViewProps>, state: &Self::State) {
+
        self.notifications.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.notifications
+
                .render(RenderProps::from(table_area), frame);
+
            self.search
+
                .render(RenderProps::from(search_area).focus(render.focus), frame);
+
        } else {
+
            self.notifications.render(render, frame);
+
        }
+
    }
+
}
+

+
fn browse_footer<'a>(props: &BrowserProps<'a>) -> Vec<Column<'a>> {
+
    let search = Line::from(vec![
+
        span::default(" Search ").cyan().dim().reversed(),
+
        span::default(" "),
+
        span::default(&props.search.to_string()).gray().dim(),
+
    ]);
+

+
    let seen = Line::from(vec![
+
        span::positive(&props.stats.get("Seen").unwrap_or(&0).to_string()).dim(),
+
        span::default(" Seen").dim(),
+
    ]);
+
    let unseen = Line::from(vec![
+
        span::positive(&props.stats.get("Unseen").unwrap_or(&0).to_string())
+
            .magenta()
+
            .dim(),
+
        span::default(" Unseen").dim(),
+
    ]);
+
    let sum = Line::from(vec![
+
        span::default("Σ ").dim(),
+
        span::default(&props.notifications.len().to_string()).dim(),
+
    ]);
+

+
    match NotificationItemFilter::from_str(&props.search)
+
        .unwrap_or_default()
+
        .state()
+
    {
+
        Some(state) => {
+
            let block = match state {
+
                NotificationState::Seen => seen,
+
                NotificationState::Unseen => unseen,
+
            };
+

+
            [
+
                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)),
+
            ]
+
            .to_vec()
+
        }
+
        None => [
+
            Column::new(Text::from(search), Constraint::Fill(1)),
+
            Column::new(
+
                Text::from(seen.clone()),
+
                Constraint::Min(seen.width() as u16),
+
            ),
+
            Column::new(
+
                Text::from(unseen.clone()),
+
                Constraint::Min(unseen.width() as u16),
+
            ),
+
            Column::new(Text::from(sum.clone()), Constraint::Min(sum.width() as u16)),
+
        ]
+
        .to_vec(),
+
    }
+
}
deleted bin/commands/inbox/select.rs
@@ -1,447 +0,0 @@
-
#[path = "select/ui.rs"]
-
mod ui;
-

-
use std::str::FromStr;
-

-
use anyhow::Result;
-

-
use ratatui::Viewport;
-
use termion::event::Key;
-

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

-
use radicle::identity::Project;
-
use radicle::node::notifications::NotificationId;
-
use radicle::storage::git::Repository;
-
use radicle::storage::ReadRepository;
-
use radicle::storage::ReadStorage;
-
use radicle::Profile;
-

-
use radicle_tui as tui;
-

-
use tui::store;
-
use tui::ui::rm::widget::container::{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::BufferedValue;
-
use tui::ui::Column;
-
use tui::{BoxedAny, Channel, Exit, PageStack};
-

-
use crate::cob::inbox;
-
use crate::ui::items::{Filter, NotificationItem, NotificationItemFilter};
-

-
use self::ui::Browser;
-
use self::ui::BrowserProps;
-

-
use super::common::SelectionMode;
-
use super::common::{Mode, RepositoryMode};
-

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

-
#[allow(dead_code)]
-
pub struct Context {
-
    pub profile: Profile,
-
    pub repository: Repository,
-
    pub mode: Mode,
-
    pub filter: inbox::Filter,
-
    pub sort_by: inbox::SortBy,
-
}
-

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

-
#[derive(Clone, Debug, Eq, PartialEq, Hash)]
-
pub enum AppPage {
-
    Browse,
-
    Help,
-
}
-

-
#[derive(Clone, Debug)]
-
pub struct BrowserState {
-
    items: Vec<NotificationItem>,
-
    selected: Option<usize>,
-
    filter: NotificationItemFilter,
-
    search: BufferedValue<String>,
-
    show_search: bool,
-
}
-

-
impl BrowserState {
-
    pub fn notifications(&self) -> Vec<NotificationItem> {
-
        self.items
-
            .iter()
-
            .filter(|patch| self.filter.matches(patch))
-
            .cloned()
-
            .collect()
-
    }
-
}
-

-
#[derive(Clone, Debug)]
-
pub struct HelpState {
-
    text: TextViewState,
-
}
-

-
#[derive(Clone, Debug)]
-
pub struct State {
-
    mode: Mode,
-
    project: Project,
-
    pages: PageStack<AppPage>,
-
    browser: BrowserState,
-
    help: HelpState,
-
}
-

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

-
    fn try_from(context: &Context) -> Result<Self, Self::Error> {
-
        let doc = context.repository.identity_doc()?;
-
        let project = doc.project()?;
-

-
        let search = BufferedValue::new(String::new());
-
        let filter = NotificationItemFilter::from_str(&search.read()).unwrap_or_default();
-

-
        let mut notifications = match &context.mode.repository() {
-
            RepositoryMode::All => {
-
                let mut repos = context.profile.storage.repositories()?;
-
                repos.sort_by_key(|r| r.rid);
-

-
                let mut notifs = vec![];
-
                for repo in repos {
-
                    let repo = context.profile.storage.repository(repo.rid)?;
-

-
                    let items = inbox::all(&repo, &context.profile)?
-
                        .iter()
-
                        .map(|notif| NotificationItem::new(&context.profile, &repo, notif))
-
                        .filter_map(|item| item.ok())
-
                        .flatten()
-
                        .collect::<Vec<_>>();
-

-
                    notifs.extend(items);
-
                }
-

-
                notifs
-
            }
-
            RepositoryMode::Contextual => {
-
                let notifs = inbox::all(&context.repository, &context.profile)?;
-

-
                notifs
-
                    .iter()
-
                    .map(|notif| {
-
                        NotificationItem::new(&context.profile, &context.repository, notif)
-
                    })
-
                    .filter_map(|item| item.ok())
-
                    .flatten()
-
                    .collect::<Vec<_>>()
-
            }
-
            RepositoryMode::ByRepo((rid, _)) => {
-
                let repo = context.profile.storage.repository(*rid)?;
-
                let notifs = inbox::all(&repo, &context.profile)?;
-

-
                notifs
-
                    .iter()
-
                    .map(|notif| NotificationItem::new(&context.profile, &repo, notif))
-
                    .filter_map(|item| item.ok())
-
                    .flatten()
-
                    .collect::<Vec<_>>()
-
            }
-
        };
-

-
        // Set project name
-
        let mode = match &context.mode.repository() {
-
            RepositoryMode::ByRepo((rid, _)) => {
-
                let project = context
-
                    .profile
-
                    .storage
-
                    .repository(*rid)?
-
                    .identity_doc()?
-
                    .project()?;
-
                let name = project.name().to_string();
-

-
                context
-
                    .mode
-
                    .clone()
-
                    .with_repository(RepositoryMode::ByRepo((*rid, Some(name))))
-
            }
-
            _ => context.mode.clone(),
-
        };
-

-
        // Apply sorting
-
        match context.sort_by.field {
-
            "timestamp" => notifications.sort_by(|a, b| a.timestamp.cmp(&b.timestamp)),
-
            "id" => notifications.sort_by(|a, b| a.id.cmp(&b.id)),
-
            _ => {}
-
        }
-
        if context.sort_by.reverse {
-
            notifications.reverse();
-
        }
-

-
        // Sort by project if all notifications are shown
-
        if let RepositoryMode::All = mode.repository() {
-
            notifications.sort_by(|a, b| a.project.cmp(&b.project));
-
        }
-

-
        Ok(Self {
-
            mode: context.mode.clone(),
-
            project,
-
            pages: PageStack::new(vec![AppPage::Browse]),
-
            browser: BrowserState {
-
                items: notifications,
-
                selected: Some(0),
-
                filter,
-
                search,
-
                show_search: false,
-
            },
-
            help: HelpState {
-
                text: TextViewState::default().content(help_text()),
-
            },
-
        })
-
    }
-
}
-

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

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

-
    fn update(&mut self, message: Message) -> Option<Exit<Selection>> {
-
        match message {
-
            Message::Exit { selection } => Some(Exit { value: selection }),
-
            Message::Select { selected } => {
-
                self.browser.selected = selected;
-
                None
-
            }
-
            Message::OpenSearch => {
-
                self.browser.show_search = true;
-
                None
-
            }
-
            Message::UpdateSearch { value } => {
-
                self.browser.search.write(value);
-
                self.browser.filter = NotificationItemFilter::from_str(&self.browser.search.read())
-
                    .unwrap_or_default();
-

-
                if let Some(selected) = self.browser.selected {
-
                    if selected > self.browser.notifications().len() {
-
                        self.browser.selected = Some(0);
-
                    }
-
                }
-

-
                None
-
            }
-
            Message::ApplySearch => {
-
                self.browser.search.apply();
-
                self.browser.show_search = false;
-
                None
-
            }
-
            Message::CloseSearch => {
-
                self.browser.search.reset();
-
                self.browser.show_search = false;
-
                self.browser.filter = NotificationItemFilter::from_str(&self.browser.search.read())
-
                    .unwrap_or_default();
-

-
                None
-
            }
-
            Message::OpenHelp => {
-
                self.pages.push(AppPage::Help);
-
                None
-
            }
-
            Message::LeavePage => {
-
                self.pages.pop();
-
                None
-
            }
-
            Message::ScrollHelp { state } => {
-
                self.help.text = state;
-
                None
-
            }
-
        }
-
    }
-
}
-

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

-
    pub async fn run(&self) -> Result<Option<Selection>> {
-
        let channel = Channel::default();
-
        let state = State::try_from(&self.context)?;
-
        let tx = channel.tx.clone();
-

-
        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: &State| {
-
                WindowProps::default()
-
                    .current_page(state.pages.peek().unwrap_or(&AppPage::Browse).clone())
-
                    .to_boxed_any()
-
                    .into()
-
            });
-

-
        tui::rm(state, window, Viewport::Inline(20), channel).await
-
    }
-
}
-

-
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.show_search {
-
                vec![("esc", "cancel"), ("enter", "apply")]
-
            } else {
-
                match state.mode.selection() {
-
                    SelectionMode::Id => vec![("enter", "select"), ("/", "search")],
-
                    SelectionMode::Operation => vec![
-
                        ("enter", "show"),
-
                        ("c", "clear"),
-
                        ("/", "search"),
-
                        ("?", "help"),
-
                    ],
-
                }
-
            };
-

-
            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::Esc | Key::Ctrl('c') => Some(Message::Exit { selection: None }),
-
                    Key::Char('?') => Some(Message::OpenHelp),
-
                    _ => None,
-
                }
-
            } else {
-
                None
-
            }
-
        })
-
        .on_update(|state: &State| {
-
            PageProps::default()
-
                .handle_keys(!state.browser.show_search)
-
                .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(|_, vs, _| {
-
                    vs.and_then(|vs| vs.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(
-
                            [
-
                                Column::new(Text::raw(""), Constraint::Fill(1)),
-
                                Column::new(
-
                                    span::default(&format!("{}%", state.help.text.scroll)).dim(),
-
                                    Constraint::Min(4),
-
                                ),
-
                            ]
-
                            .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::Exit { selection: None }),
-
            Key::Char('?') => Some(Message::LeavePage),
-
            _ => None,
-
        })
-
        .on_update(|_| PageProps::default().handle_keys(true).to_boxed_any().into())
-
}
-

-
fn help_text() -> String {
-
    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`:      Quit / cancel
-

-
# Specific keybindings
-

-
`enter`:    Select notification (if --mode id)
-
`enter`:    Show notification
-
`c`:        Clear notifications
-
`/`:        Search
-
`?`:        Show help
-

-
# Searching
-

-
Pattern:    is:<state> | is:patch | is:issue | <search>
-
Example:    is:unseen is:patch Print"#
-
        .into()
-
}
deleted bin/commands/inbox/select/ui.rs
@@ -1,328 +0,0 @@
-
use std::collections::HashMap;
-
use std::str::FromStr;
-

-
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_tui as tui;
-

-
use tui::ui::rm::widget::container::{
-
    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::{self, ViewProps};
-
use tui::ui::rm::widget::{RenderProps, ToWidget, View};
-
use tui::ui::span;
-
use tui::ui::Column;
-

-
use tui::{BoxedAny, Selection};
-

-
use crate::tui_inbox::common::{InboxOperation, Mode, RepositoryMode, SelectionMode};
-
use crate::ui::items::{NotificationItem, NotificationItemFilter, NotificationState};
-

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

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

-
#[derive(Clone, Default)]
-
pub struct BrowserProps<'a> {
-
    /// Application mode: openation and id or id only.
-
    mode: Mode,
-
    /// Table title
-
    header: String,
-
    /// Filtered notifications.
-
    notifications: Vec<NotificationItem>,
-
    /// Current (selected) table index
-
    selected: Option<usize>,
-
    /// Notification statistics.
-
    stats: HashMap<String, usize>,
-
    /// 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 header = match state.mode.repository() {
-
            RepositoryMode::Contextual => state.project.name().to_string(),
-
            RepositoryMode::All => "All repositories".to_string(),
-
            RepositoryMode::ByRepo((_, name)) => name.clone().unwrap_or_default(),
-
        };
-

-
        let notifications = state.browser.notifications();
-

-
        // Compute statistics
-
        let mut seen = 0;
-
        let mut unseen = 0;
-
        for notification in &notifications {
-
            if notification.seen {
-
                seen += 1;
-
            } else {
-
                unseen += 1;
-
            }
-
        }
-
        let stats = HashMap::from([("Seen".to_string(), seen), ("Unseen".to_string(), unseen)]);
-

-
        Self {
-
            mode: state.mode.clone(),
-
            header,
-
            notifications,
-
            selected: state.browser.selected,
-
            stats,
-
            columns: [
-
                Column::new("", Constraint::Length(5)),
-
                Column::new("", Constraint::Length(3)),
-
                Column::new("", Constraint::Fill(5)),
-
                Column::new("", Constraint::Fill(1))
-
                    .skip(*state.mode.repository() != RepositoryMode::All),
-
                Column::new("", Constraint::Fill(1))
-
                    .hide_small()
-
                    .hide_medium(),
-
                Column::new("", Constraint::Length(8)),
-
                Column::new("", Constraint::Length(10)),
-
                Column::new("", Constraint::Min(12)).hide_small(),
-
                Column::new("", Constraint::Min(14)).hide_small(),
-
            ]
-
            .to_vec(),
-
            search: state.browser.search.read(),
-
            show_search: state.browser.show_search,
-
        }
-
    }
-
}
-

-
pub struct Browser {
-
    /// Notification widget
-
    notifications: Widget,
-
    /// Search widget
-
    search: Widget,
-
}
-

-
impl Browser {
-
    pub fn new(tx: UnboundedSender<Message>) -> Self {
-
        Self {
-
            notifications: 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(
-
                            [
-
                                Column::new("", Constraint::Length(0)),
-
                                Column::new(Text::from(props.header), Constraint::Fill(1)),
-
                            ]
-
                            .to_vec(),
-
                        )
-
                        .to_boxed_any()
-
                        .into()
-
                }))
-
                .content(
-
                    Table::<State, Message, NotificationItem, 9>::default()
-
                        .to_widget(tx.clone())
-
                        .on_event(|_, s, _| {
-
                            let (selected, _) =
-
                                s.and_then(|s| s.unwrap_table()).unwrap_or_default();
-
                            Some(Message::Select {
-
                                selected: Some(selected),
-
                            })
-
                        })
-
                        .on_update(|state| {
-
                            let props = BrowserProps::from(state);
-

-
                            TableProps::default()
-
                                .columns(props.columns)
-
                                .items(state.browser.notifications())
-
                                .selected(state.browser.selected)
-
                                .to_boxed_any()
-
                                .into()
-
                        }),
-
                )
-
                .footer(Footer::default().to_widget(tx.clone()).on_update(|state| {
-
                    let props = BrowserProps::from(state);
-

-
                    FooterProps::default()
-
                        .columns(browse_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.search.read().to_string())
-
                        .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),
-
                Key::Char('\n') => props
-
                    .selected
-
                    .and_then(|selected| props.notifications.get(selected))
-
                    .map(|notif| {
-
                        let selection = match props.mode.selection() {
-
                            SelectionMode::Operation => Selection::default()
-
                                .with_operation(InboxOperation::Show.to_string())
-
                                .with_id(notif.id),
-
                            SelectionMode::Id => Selection::default().with_id(notif.id),
-
                        };
-

-
                        Message::Exit {
-
                            selection: Some(selection),
-
                        }
-
                    }),
-
                Key::Char('c') => props
-
                    .selected
-
                    .and_then(|selected| props.notifications.get(selected))
-
                    .map(|notif| Message::Exit {
-
                        selection: Some(
-
                            Selection::default()
-
                                .with_operation(InboxOperation::Clear.to_string())
-
                                .with_id(notif.id),
-
                        ),
-
                    }),
-
                _ => {
-
                    self.notifications.handle_event(key);
-
                    None
-
                }
-
            }
-
        }
-
    }
-

-
    fn update(&mut self, _props: Option<&ViewProps>, state: &Self::State) {
-
        self.notifications.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.notifications
-
                .render(RenderProps::from(table_area), frame);
-
            self.search
-
                .render(RenderProps::from(search_area).focus(render.focus), frame);
-
        } else {
-
            self.notifications.render(render, frame);
-
        }
-
    }
-
}
-

-
fn browse_footer<'a>(props: &BrowserProps<'a>) -> Vec<Column<'a>> {
-
    let search = Line::from(vec![
-
        span::default(" Search ").cyan().dim().reversed(),
-
        span::default(" "),
-
        span::default(&props.search.to_string()).gray().dim(),
-
    ]);
-

-
    let seen = Line::from(vec![
-
        span::positive(&props.stats.get("Seen").unwrap_or(&0).to_string()).dim(),
-
        span::default(" Seen").dim(),
-
    ]);
-
    let unseen = Line::from(vec![
-
        span::positive(&props.stats.get("Unseen").unwrap_or(&0).to_string())
-
            .magenta()
-
            .dim(),
-
        span::default(" Unseen").dim(),
-
    ]);
-
    let sum = Line::from(vec![
-
        span::default("Σ ").dim(),
-
        span::default(&props.notifications.len().to_string()).dim(),
-
    ]);
-

-
    match NotificationItemFilter::from_str(&props.search)
-
        .unwrap_or_default()
-
        .state()
-
    {
-
        Some(state) => {
-
            let block = match state {
-
                NotificationState::Seen => seen,
-
                NotificationState::Unseen => unseen,
-
            };
-

-
            [
-
                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)),
-
            ]
-
            .to_vec()
-
        }
-
        None => [
-
            Column::new(Text::from(search), Constraint::Fill(1)),
-
            Column::new(
-
                Text::from(seen.clone()),
-
                Constraint::Min(seen.width() as u16),
-
            ),
-
            Column::new(
-
                Text::from(unseen.clone()),
-
                Constraint::Min(unseen.width() as u16),
-
            ),
-
            Column::new(Text::from(sum.clone()), Constraint::Min(sum.width() as u16)),
-
        ]
-
        .to_vec(),
-
    }
-
}