#[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; #[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, selected: Option, filter: NotificationItemFilter, search: BufferedValue, show_search: bool, } impl BrowserState { pub fn notifications(&self) -> Vec { 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, browser: BrowserState, help: HelpState, } impl TryFrom<&Context> for State { type Error = anyhow::Error; fn try_from(context: &Context) -> Result { 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::>(); 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::>() } 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::>() } }; // 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 }, Select { selected: Option }, OpenSearch, UpdateSearch { value: String }, ApplySearch, CloseSearch, OpenHelp, LeavePage, ScrollHelp { state: TextViewState }, } impl store::Update for State { type Return = Selection; fn update(&mut self, message: Message) -> Option> { 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> { 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) -> Widget { 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::()) .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) -> Widget { 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: | is:patch | is:issue | Example: is:unseen is:patch Print"# .into() }