inbox: Add search widget

This commit is contained in:
Erik Kundt
2024-03-20 23:58:57 +01:00
parent 0140229c5a
commit 3c8eada3ba
2 changed files with 297 additions and 94 deletions
+37 -9
View File
@@ -13,7 +13,8 @@ use radicle::Profile;
use radicle_tui as tui;
use tui::common::cob::inbox::{self};
use tui::flux::store::{State, Store};
use tui::flux::store;
use tui::flux::store::StateValue;
use tui::flux::task::{self, Interrupted};
use tui::flux::ui::items::NotificationItem;
use tui::flux::ui::Frontend;
@@ -40,24 +41,29 @@ pub struct App {
#[derive(Clone, Debug)]
pub struct UIState {
page_size: usize,
show_search: bool,
}
impl Default for UIState {
fn default() -> Self {
Self { page_size: 1 }
Self {
page_size: 1,
show_search: false,
}
}
}
#[derive(Clone, Debug)]
pub struct InboxState {
pub struct State {
notifications: Vec<NotificationItem>,
selected: Option<NotificationItem>,
mode: Mode,
project: Project,
search: StateValue<String>,
ui: UIState,
}
impl TryFrom<&Context> for InboxState {
impl TryFrom<&Context> for State {
type Error = anyhow::Error;
fn try_from(context: &Context) -> Result<Self, Self::Error> {
@@ -151,6 +157,7 @@ impl TryFrom<&Context> for InboxState {
selected,
mode: mode.clone(),
project,
search: StateValue::new(String::new()),
ui: UIState::default(),
})
}
@@ -160,9 +167,13 @@ pub enum Action {
Exit { selection: Option<Selection> },
Select { item: NotificationItem },
PageSize(usize),
OpenSearch,
UpdateSearch { value: String },
ApplySearch,
CloseSearch,
}
impl State<Action, Selection> for InboxState {
impl store::State<Action, Selection> for State {
fn tick(&self) {}
fn handle_action(&mut self, action: Action) -> Option<Exit<Selection>> {
@@ -176,6 +187,24 @@ impl State<Action, Selection> for InboxState {
self.ui.page_size = size;
None
}
Action::OpenSearch => {
self.ui.show_search = true;
None
}
Action::UpdateSearch { value } => {
self.search.write(value);
None
}
Action::ApplySearch => {
self.search.apply();
self.ui.show_search = false;
None
}
Action::CloseSearch => {
self.search.reset();
self.ui.show_search = false;
None
}
}
}
}
@@ -187,14 +216,13 @@ impl App {
pub async fn run(&self) -> Result<Option<Selection>> {
let (terminator, mut interrupt_rx) = task::create_termination();
let (store, state_rx) = Store::<Action, InboxState, Selection>::new();
let (store, state_rx) = store::Store::<Action, State, Selection>::new();
let (frontend, action_rx) = Frontend::<Action>::new();
let state = InboxState::try_from(&self.context)?;
let state = State::try_from(&self.context)?;
tokio::try_join!(
store.main_loop(state, terminator, action_rx, interrupt_rx.resubscribe()),
frontend
.main_loop::<InboxState, ListPage, Selection>(state_rx, interrupt_rx.resubscribe()),
frontend.main_loop::<State, ListPage, Selection>(state_rx, interrupt_rx.resubscribe()),
)?;
if let Ok(reason) = interrupt_rx.recv().await {
+260 -85
View File
@@ -1,11 +1,12 @@
use std::collections::HashMap;
use std::str::FromStr;
use tokio::sync::mpsc::UnboundedSender;
use termion::event::Key;
use ratatui::backend::Backend;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::Stylize;
use ratatui::text::Line;
@@ -13,9 +14,10 @@ use radicle::identity::Project;
use radicle_tui as tui;
use tui::flux::ui::items::NotificationItem;
use tui::flux::ui::items::{NotificationItem, NotificationItemFilter, NotificationState};
use tui::flux::ui::span;
use tui::flux::ui::widget::container::{Footer, FooterProps, Header, HeaderProps};
use tui::flux::ui::widget::input::{TextField, TextFieldProps};
use tui::flux::ui::widget::{
Render, Shortcut, Shortcuts, ShortcutsProps, Table, TableProps, Widget,
};
@@ -23,18 +25,20 @@ use tui::Selection;
use crate::tui_inbox::common::{Mode, RepositoryMode, SelectionMode};
use super::{Action, InboxState};
use super::{Action, State};
pub struct ListPageProps {
selected: Option<NotificationItem>,
mode: Mode,
show_search: bool,
}
impl From<&InboxState> for ListPageProps {
fn from(state: &InboxState) -> Self {
impl From<&State> for ListPageProps {
fn from(state: &State) -> Self {
Self {
selected: state.selected.clone(),
mode: state.mode.clone(),
show_search: state.ui.show_search,
}
}
}
@@ -46,12 +50,14 @@ pub struct ListPage {
props: ListPageProps,
/// Notification widget
notifications: Notifications,
/// Search widget
search: Search,
/// Shortcut widget
shortcuts: Shortcuts<Action>,
}
impl Widget<InboxState, Action> for ListPage {
fn new(state: &InboxState, action_tx: UnboundedSender<Action>) -> Self
impl Widget<State, Action> for ListPage {
fn new(state: &State, action_tx: UnboundedSender<Action>) -> Self
where
Self: Sized,
{
@@ -59,12 +65,13 @@ impl Widget<InboxState, Action> for ListPage {
action_tx: action_tx.clone(),
props: ListPageProps::from(state),
notifications: Notifications::new(state, action_tx.clone()),
search: Search::new(state, action_tx.clone()),
shortcuts: Shortcuts::new(state, action_tx.clone()),
}
.move_with_state(state)
}
fn move_with_state(self, state: &InboxState) -> Self
fn move_with_state(self, state: &State) -> Self
where
Self: Sized,
{
@@ -81,39 +88,46 @@ impl Widget<InboxState, Action> for ListPage {
}
fn handle_key_event(&mut self, key: termion::event::Key) {
match key {
Key::Esc | Key::Ctrl('c') => {
let _ = self.action_tx.send(Action::Exit { selection: None });
}
Key::Char('\n') => {
if let Some(selected) = &self.props.selected {
let selection = match self.props.mode.selection() {
SelectionMode::Operation => Selection::default()
.with_operation("show".to_string())
.with_id(selected.id),
SelectionMode::Id => Selection::default().with_id(selected.id),
};
let _ = self.action_tx.send(Action::Exit {
selection: Some(selection),
});
if self.props.show_search {
<Search as Widget<State, Action>>::handle_key_event(&mut self.search, key)
} else {
match key {
Key::Esc | Key::Ctrl('c') => {
let _ = self.action_tx.send(Action::Exit { selection: None });
}
}
Key::Char('c') => {
if let Some(selected) = &self.props.selected {
let _ = self.action_tx.send(Action::Exit {
selection: Some(
Selection::default()
.with_operation("clear".to_string())
Key::Char('\n') => {
if let Some(selected) = &self.props.selected {
let selection = match self.props.mode.selection() {
SelectionMode::Operation => Selection::default()
.with_operation("show".to_string())
.with_id(selected.id),
),
});
SelectionMode::Id => Selection::default().with_id(selected.id),
};
let _ = self.action_tx.send(Action::Exit {
selection: Some(selection),
});
}
}
Key::Char('c') => {
if let Some(selected) = &self.props.selected {
let _ = self.action_tx.send(Action::Exit {
selection: Some(
Selection::default()
.with_operation("clear".to_string())
.with_id(selected.id),
),
});
}
}
Key::Char('/') => {
let _ = self.action_tx.send(Action::OpenSearch);
}
_ => {
<Notifications as Widget<State, Action>>::handle_key_event(
&mut self.notifications,
key,
);
}
}
_ => {
<Notifications as Widget<InboxState, Action>>::handle_key_event(
&mut self.notifications,
key,
);
}
}
}
@@ -124,14 +138,37 @@ impl Render<()> for ListPage {
let area = frame.size();
let layout = tui::flux::ui::layout::default_page(area, 0u16, 1u16);
let shortcuts = match self.props.mode.selection() {
SelectionMode::Id => vec![Shortcut::new("enter", "select")],
SelectionMode::Operation => {
vec![Shortcut::new("enter", "show"), Shortcut::new("c", "clear")]
let shortcuts = if self.props.show_search {
vec![
Shortcut::new("esc", "back"),
Shortcut::new("enter", "search"),
]
} else {
match self.props.mode.selection() {
SelectionMode::Id => vec![
Shortcut::new("enter", "select"),
Shortcut::new("/", "search"),
],
SelectionMode::Operation => vec![
Shortcut::new("enter", "show"),
Shortcut::new("c", "clear"),
Shortcut::new("/", "search"),
],
}
};
self.notifications.render::<B>(frame, layout.component, ());
if self.props.show_search {
let component_layout = Layout::vertical([Constraint::Min(1), Constraint::Length(2)])
.split(layout.component);
self.notifications
.render::<B>(frame, component_layout[0], ());
self.search
.render::<B>(frame, component_layout[1], SearchProps {});
} else {
self.notifications.render::<B>(frame, layout.component, ());
}
self.shortcuts.render::<B>(
frame,
layout.shortcuts,
@@ -152,13 +189,25 @@ struct NotificationsProps {
cutoff_after: usize,
focus: bool,
page_size: usize,
search: String,
show_search: bool,
}
impl From<&InboxState> for NotificationsProps {
fn from(state: &InboxState) -> Self {
impl From<&State> for NotificationsProps {
fn from(state: &State) -> Self {
let mut seen = 0;
let mut unseen = 0;
// Filter by search string
let filter = NotificationItemFilter::from_str(&state.search.read()).unwrap_or_default();
let notifications = state
.notifications
.clone()
.into_iter()
.filter(|issue| filter.matches(issue))
.collect::<Vec<_>>();
// Compute statistics
for notification in &state.notifications {
if notification.seen {
seen += 1;
@@ -166,10 +215,11 @@ impl From<&InboxState> for NotificationsProps {
unseen += 1;
}
}
let stats = HashMap::from([("Seen".to_string(), seen), ("Unseen".to_string(), unseen)]);
Self {
notifications: state.notifications.clone(),
notifications,
mode: state.mode.clone(),
project: state.project.clone(),
stats,
@@ -177,6 +227,8 @@ impl From<&InboxState> for NotificationsProps {
cutoff_after: 5,
focus: false,
page_size: state.ui.page_size,
show_search: state.ui.show_search,
search: state.search.read(),
}
}
}
@@ -194,8 +246,8 @@ struct Notifications {
footer: Footer<Action>,
}
impl Widget<InboxState, Action> for Notifications {
fn new(state: &InboxState, action_tx: UnboundedSender<Action>) -> Self {
impl Widget<State, Action> for Notifications {
fn new(state: &State, action_tx: UnboundedSender<Action>) -> Self {
Self {
action_tx: action_tx.clone(),
props: NotificationsProps::from(state),
@@ -205,14 +257,23 @@ impl Widget<InboxState, Action> for Notifications {
}
}
fn move_with_state(self, state: &InboxState) -> Self
fn move_with_state(self, state: &State) -> Self
where
Self: Sized,
{
let props = NotificationsProps::from(state);
let mut table = self.table.move_with_state(state);
if let Some(selected) = table.selected() {
if selected > props.notifications.len() {
table.begin();
}
}
Self {
props: NotificationsProps::from(state),
props,
table,
header: self.header.move_with_state(state),
table: self.table.move_with_state(state),
footer: self.footer.move_with_state(state),
..self
}
@@ -299,7 +360,7 @@ impl Notifications {
TableProps {
items: self.props.notifications.to_vec(),
has_header: true,
has_footer: true,
has_footer: !self.props.show_search,
widths,
focus: self.props.focus,
cutoff: self.props.cutoff,
@@ -324,7 +385,7 @@ impl Notifications {
TableProps {
items: self.props.notifications.to_vec(),
has_header: true,
has_footer: true,
has_footer: !self.props.show_search,
widths,
focus: self.props.focus,
cutoff: self.props.cutoff,
@@ -335,7 +396,18 @@ impl Notifications {
}
fn render_footer<B: Backend>(&self, frame: &mut ratatui::Frame, area: Rect) {
let filter = Line::from([span::blank()].to_vec());
let search = if self.props.search.is_empty() {
Line::from([span::default(self.props.search.to_string()).magenta().dim()].to_vec())
} else {
Line::from(
[
span::default(" / ".to_string()).magenta().dim(),
span::default(self.props.search.to_string()).magenta().dim(),
]
.to_vec(),
)
};
let seen = Line::from(
[
span::positive(self.props.stats.get("Seen").unwrap_or(&0).to_string()).dim(),
@@ -358,50 +430,153 @@ impl Notifications {
.progress_percentage(self.props.notifications.len(), self.props.page_size);
let progress = span::default(format!("{}%", progress)).dim();
self.footer.render::<B>(
frame,
area,
FooterProps {
cells: [
filter.into(),
seen.clone().into(),
unseen.clone().into(),
progress.clone().into(),
],
widths: [
Constraint::Fill(1),
Constraint::Min(seen.width() as u16),
Constraint::Min(unseen.width() as u16),
Constraint::Min(4),
],
focus: self.props.focus,
cutoff: self.props.cutoff,
cutoff_after: self.props.cutoff_after,
},
);
match NotificationItemFilter::from_str(&self.props.search)
.unwrap_or_default()
.state()
{
Some(state) => {
let block = match state {
NotificationState::Seen => seen,
NotificationState::Unseen => unseen,
};
self.footer.render::<B>(
frame,
area,
FooterProps {
cells: [search.into(), block.clone().into(), progress.clone().into()],
widths: [
Constraint::Fill(1),
Constraint::Min(block.width() as u16),
Constraint::Min(4),
],
focus: self.props.focus,
cutoff: self.props.cutoff,
cutoff_after: self.props.cutoff_after,
},
);
}
None => {
self.footer.render::<B>(
frame,
area,
FooterProps {
cells: [
search.into(),
seen.clone().into(),
unseen.clone().into(),
progress.clone().into(),
],
widths: [
Constraint::Fill(1),
Constraint::Min(seen.width() as u16),
Constraint::Min(unseen.width() as u16),
Constraint::Min(4),
],
focus: self.props.focus,
cutoff: self.props.cutoff,
cutoff_after: self.props.cutoff_after,
},
);
}
}
}
}
impl Render<()> for Notifications {
fn render<B: Backend>(&self, frame: &mut ratatui::Frame, area: Rect, _props: ()) {
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints(vec![
let page_size = if self.props.show_search {
let layout = Layout::vertical([Constraint::Length(3), Constraint::Min(1)]).split(area);
self.render_header::<B>(frame, layout[0]);
self.render_list::<B>(frame, layout[1]);
layout[1].height as usize
} else {
let layout = Layout::vertical([
Constraint::Length(3),
Constraint::Min(1),
Constraint::Length(3),
])
.split(area);
self.render_header::<B>(frame, layout[0]);
self.render_list::<B>(frame, layout[1]);
self.render_footer::<B>(frame, layout[2]);
self.render_header::<B>(frame, layout[0]);
self.render_list::<B>(frame, layout[1]);
self.render_footer::<B>(frame, layout[2]);
layout[1].height as usize
};
let page_size = layout[1].height as usize;
if page_size != self.props.page_size {
let _ = self
.action_tx
.send(Action::PageSize(layout[1].height as usize));
let _ = self.action_tx.send(Action::PageSize(page_size));
}
}
}
pub struct SearchProps {}
pub struct Search {
pub action_tx: UnboundedSender<Action>,
pub input: TextField,
}
impl Widget<State, Action> for Search {
fn new(state: &State, action_tx: UnboundedSender<Action>) -> Self
where
Self: Sized,
{
let mut input = TextField::new(state, action_tx.clone());
input.set_text(&state.search.read().to_string());
Self { action_tx, input }.move_with_state(state)
}
fn move_with_state(self, state: &State) -> Self
where
Self: Sized,
{
let mut input = <TextField as Widget<State, Action>>::move_with_state(self.input, state);
input.set_text(&state.search.read().to_string());
Self { input, ..self }
}
fn name(&self) -> &str {
"filter-popup"
}
fn handle_key_event(&mut self, key: termion::event::Key) {
match key {
Key::Esc => {
let _ = self.action_tx.send(Action::CloseSearch);
}
Key::Char('\n') => {
let _ = self.action_tx.send(Action::ApplySearch);
}
_ => {
<TextField as Widget<State, Action>>::handle_key_event(&mut self.input, key);
let _ = self.action_tx.send(Action::UpdateSearch {
value: self.input.text().to_string(),
});
}
}
}
}
impl Render<SearchProps> for Search {
fn render<B: Backend>(&self, frame: &mut ratatui::Frame, area: Rect, _props: SearchProps) {
let layout = Layout::horizontal(Constraint::from_mins([0]))
.horizontal_margin(1)
.split(area);
self.input.render::<B>(
frame,
layout[0],
TextFieldProps {
titles: ("/".into(), "Search".into()),
show_cursor: true,
inline_label: true,
},
);
}
}