mirror of
https://seed.radicle.dev/rad:z39mP9rQAaGmERfUMPULfPUi473tY.git
synced 2026-11-12 14:40:42 +08:00
inbox: Add search widget
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user