mirror of
https://seed.radicle.dev/rad:z39mP9rQAaGmERfUMPULfPUi473tY.git
synced 2026-11-01 17:30:36 +08:00
inbox: Restructure application
This commit is contained in:
@@ -23,9 +23,10 @@ use tui::ui::items::{Filter, NotificationItem, NotificationItemFilter};
|
||||
use tui::ui::Frontend;
|
||||
use tui::Exit;
|
||||
|
||||
use ui::ListPage;
|
||||
use tui::PageStack;
|
||||
|
||||
use crate::tui_inbox::select::ui::Window;
|
||||
|
||||
use super::common::SelectionMode;
|
||||
use super::common::{Mode, RepositoryMode};
|
||||
|
||||
type Selection = tui::Selection<NotificationId>;
|
||||
@@ -42,66 +43,25 @@ pub struct App {
|
||||
context: Context,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Hash)]
|
||||
pub enum Page {
|
||||
Browse,
|
||||
Help,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct UIState {
|
||||
pub struct BrowserState {
|
||||
items: Vec<NotificationItem>,
|
||||
selected: Option<usize>,
|
||||
filter: NotificationItemFilter,
|
||||
search: store::StateValue<String>,
|
||||
page_size: usize,
|
||||
show_search: bool,
|
||||
}
|
||||
|
||||
impl Default for UIState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
page_size: 1,
|
||||
show_search: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct NotificationsState {
|
||||
items: Vec<NotificationItem>,
|
||||
selected: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct HelpState {
|
||||
show: bool,
|
||||
progress: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct State {
|
||||
notifications: NotificationsState,
|
||||
help: HelpState,
|
||||
mode: Mode,
|
||||
project: Project,
|
||||
filter: NotificationItemFilter,
|
||||
search: StateValue<String>,
|
||||
ui: UIState,
|
||||
}
|
||||
|
||||
impl State {
|
||||
pub fn shortcuts(&self) -> Vec<(&str, &str)> {
|
||||
if self.ui.show_search {
|
||||
vec![("esc", "cancel"), ("enter", "apply")]
|
||||
} else if self.help.show {
|
||||
vec![("?", "close")]
|
||||
} else {
|
||||
match self.mode.selection() {
|
||||
SelectionMode::Id => vec![("enter", "select"), ("/", "search")],
|
||||
SelectionMode::Operation => vec![
|
||||
("enter", "show"),
|
||||
("c", "clear"),
|
||||
("/", "search"),
|
||||
("?", "help"),
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl BrowserState {
|
||||
pub fn notifications(&self) -> Vec<NotificationItem> {
|
||||
self.notifications
|
||||
.items
|
||||
self.items
|
||||
.iter()
|
||||
.filter(|patch| self.filter.matches(patch))
|
||||
.cloned()
|
||||
@@ -109,6 +69,21 @@ impl State {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct HelpState {
|
||||
progress: usize,
|
||||
page_size: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct State {
|
||||
mode: Mode,
|
||||
project: Project,
|
||||
pages: PageStack<Page>,
|
||||
browser: BrowserState,
|
||||
help: HelpState,
|
||||
}
|
||||
|
||||
impl TryFrom<&Context> for State {
|
||||
type Error = anyhow::Error;
|
||||
|
||||
@@ -200,19 +175,21 @@ impl TryFrom<&Context> for State {
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
notifications: NotificationsState {
|
||||
mode: context.mode.clone(),
|
||||
project,
|
||||
pages: PageStack::new(vec![Page::Browse]),
|
||||
browser: BrowserState {
|
||||
items: notifications,
|
||||
selected: Some(0),
|
||||
filter,
|
||||
search,
|
||||
show_search: false,
|
||||
page_size: 1,
|
||||
},
|
||||
help: HelpState {
|
||||
show: false,
|
||||
progress: 0,
|
||||
page_size: 1,
|
||||
},
|
||||
mode: mode.clone(),
|
||||
project,
|
||||
filter,
|
||||
search,
|
||||
ui: UIState::default(),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -220,13 +197,14 @@ impl TryFrom<&Context> for State {
|
||||
pub enum Action {
|
||||
Exit { selection: Option<Selection> },
|
||||
Select { selected: Option<usize> },
|
||||
PageSize(usize),
|
||||
BrowserPageSize(usize),
|
||||
HelpPageSize(usize),
|
||||
OpenSearch,
|
||||
UpdateSearch { value: String },
|
||||
ApplySearch,
|
||||
CloseSearch,
|
||||
OpenHelp,
|
||||
CloseHelp,
|
||||
LeavePage,
|
||||
ScrollHelp { progress: usize },
|
||||
}
|
||||
|
||||
@@ -237,43 +215,47 @@ impl store::State<Action, Selection> for State {
|
||||
match action {
|
||||
Action::Exit { selection } => Some(Exit { value: selection }),
|
||||
Action::Select { selected } => {
|
||||
self.notifications.selected = selected;
|
||||
self.browser.selected = selected;
|
||||
None
|
||||
}
|
||||
Action::PageSize(size) => {
|
||||
self.ui.page_size = size;
|
||||
Action::BrowserPageSize(size) => {
|
||||
self.browser.page_size = size;
|
||||
None
|
||||
}
|
||||
Action::HelpPageSize(size) => {
|
||||
self.help.page_size = size;
|
||||
None
|
||||
}
|
||||
Action::OpenSearch => {
|
||||
self.ui.show_search = true;
|
||||
self.browser.show_search = true;
|
||||
None
|
||||
}
|
||||
Action::UpdateSearch { value } => {
|
||||
self.search.write(value);
|
||||
self.filter =
|
||||
NotificationItemFilter::from_str(&self.search.read()).unwrap_or_default();
|
||||
self.browser.search.write(value);
|
||||
self.browser.filter = NotificationItemFilter::from_str(&self.browser.search.read())
|
||||
.unwrap_or_default();
|
||||
|
||||
None
|
||||
}
|
||||
Action::ApplySearch => {
|
||||
self.search.apply();
|
||||
self.ui.show_search = false;
|
||||
self.browser.search.apply();
|
||||
self.browser.show_search = false;
|
||||
None
|
||||
}
|
||||
Action::CloseSearch => {
|
||||
self.search.reset();
|
||||
self.ui.show_search = false;
|
||||
self.filter =
|
||||
NotificationItemFilter::from_str(&self.search.read()).unwrap_or_default();
|
||||
self.browser.search.reset();
|
||||
self.browser.show_search = false;
|
||||
self.browser.filter = NotificationItemFilter::from_str(&self.browser.search.read())
|
||||
.unwrap_or_default();
|
||||
|
||||
None
|
||||
}
|
||||
Action::OpenHelp => {
|
||||
self.help.show = true;
|
||||
self.pages.push(Page::Help);
|
||||
None
|
||||
}
|
||||
Action::CloseHelp => {
|
||||
self.help.show = false;
|
||||
Action::LeavePage => {
|
||||
self.pages.pop();
|
||||
None
|
||||
}
|
||||
Action::ScrollHelp { progress } => {
|
||||
@@ -297,7 +279,7 @@ impl App {
|
||||
|
||||
tokio::try_join!(
|
||||
store.main_loop(state, terminator, action_rx, interrupt_rx.resubscribe()),
|
||||
frontend.main_loop::<State, ListPage<terminal::Backend>, Selection>(
|
||||
frontend.main_loop::<State, Window<terminal::Backend>, Selection>(
|
||||
state_rx,
|
||||
interrupt_rx.resubscribe()
|
||||
),
|
||||
|
||||
+406
-273
@@ -16,7 +16,9 @@ use radicle_tui as tui;
|
||||
|
||||
use tui::ui::items::{NotificationItem, NotificationItemFilter, NotificationState};
|
||||
use tui::ui::span;
|
||||
use tui::ui::widget::container::{Container, Footer, FooterProps, Header, HeaderProps};
|
||||
use tui::ui::widget::container::{
|
||||
Container, ContainerProps, Footer, FooterProps, Header, HeaderProps,
|
||||
};
|
||||
use tui::ui::widget::input::{TextField, TextFieldProps, TextFieldState};
|
||||
use tui::ui::widget::text::{Paragraph, ParagraphProps, ParagraphState};
|
||||
use tui::ui::widget::{self, TableUtils};
|
||||
@@ -28,217 +30,108 @@ use tui::Selection;
|
||||
|
||||
use crate::tui_inbox::common::{InboxOperation, Mode, RepositoryMode, SelectionMode};
|
||||
|
||||
use super::{Action, State};
|
||||
use super::{Action, Page, State};
|
||||
|
||||
type BoxedWidget<B> = widget::BoxedWidget<B, State, Action>;
|
||||
|
||||
pub struct ListPageProps {
|
||||
show_search: bool,
|
||||
show_help: bool,
|
||||
help_progress: usize,
|
||||
page_size: usize,
|
||||
focus: bool,
|
||||
#[derive(Clone)]
|
||||
pub struct WindowProps {
|
||||
page: Page,
|
||||
}
|
||||
|
||||
impl From<&State> for ListPageProps {
|
||||
impl From<&State> for WindowProps {
|
||||
fn from(state: &State) -> Self {
|
||||
Self {
|
||||
show_search: state.ui.show_search,
|
||||
show_help: state.help.show,
|
||||
help_progress: state.help.progress,
|
||||
page_size: state.ui.page_size,
|
||||
focus: false,
|
||||
page: state.pages.peek().unwrap_or(&Page::Browse).clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ListPage<B: Backend> {
|
||||
impl Properties for WindowProps {}
|
||||
|
||||
pub struct Window<B: Backend> {
|
||||
/// Internal properties
|
||||
props: ListPageProps,
|
||||
props: WindowProps,
|
||||
/// Message sender
|
||||
action_tx: UnboundedSender<Action>,
|
||||
_action_tx: UnboundedSender<Action>,
|
||||
/// Custom update handler
|
||||
on_update: Option<UpdateCallback<State>>,
|
||||
/// Additional custom event handler
|
||||
on_change: Option<EventCallback<Action>>,
|
||||
/// Patches widget
|
||||
notifications: BoxedWidget<B>,
|
||||
/// Search widget
|
||||
search: BoxedWidget<B>,
|
||||
/// Help widget
|
||||
help: BoxedWidget<B>,
|
||||
/// Shortcut widget
|
||||
shortcuts: BoxedWidget<B>,
|
||||
/// All pages known
|
||||
pages: HashMap<Page, BoxedWidget<B>>,
|
||||
}
|
||||
|
||||
impl<'a: 'static, B: Backend + 'a> View<State, Action> for ListPage<B> {
|
||||
impl<'a: 'static, B> View<State, Action> for Window<B>
|
||||
where
|
||||
B: Backend + 'a,
|
||||
{
|
||||
fn new(state: &State, action_tx: UnboundedSender<Action>) -> Self
|
||||
where
|
||||
Self: Sized,
|
||||
{
|
||||
Self {
|
||||
action_tx: action_tx.clone(),
|
||||
props: ListPageProps::from(state),
|
||||
notifications: Notifications::new(state, action_tx.clone()).to_boxed(),
|
||||
search: Search::new(state, action_tx.clone()).to_boxed(),
|
||||
help: Container::new(state, action_tx.clone())
|
||||
.header(
|
||||
Header::new(state, action_tx.clone())
|
||||
.on_update(|state| {
|
||||
let props = ListPageProps::from(state);
|
||||
|
||||
HeaderProps::default()
|
||||
.columns([Column::new(" Help ", Constraint::Fill(1))].to_vec())
|
||||
.focus(props.focus)
|
||||
.to_boxed()
|
||||
})
|
||||
.to_boxed(),
|
||||
)
|
||||
.content(
|
||||
Paragraph::new(state, action_tx.clone())
|
||||
.on_update(|state| {
|
||||
let props = ListPageProps::from(state);
|
||||
|
||||
ParagraphProps::default()
|
||||
.text(&help_text())
|
||||
.page_size(props.page_size)
|
||||
.focus(props.focus)
|
||||
.to_boxed()
|
||||
})
|
||||
.on_change(|state, action_tx| {
|
||||
state.downcast_ref::<ParagraphState>().and_then(|state| {
|
||||
action_tx
|
||||
.send(Action::ScrollHelp {
|
||||
progress: state.progress,
|
||||
})
|
||||
.ok()
|
||||
});
|
||||
})
|
||||
.to_boxed(),
|
||||
)
|
||||
.footer(
|
||||
Footer::new(state, action_tx.clone())
|
||||
.on_update(|state| {
|
||||
let props = ListPageProps::from(state);
|
||||
|
||||
FooterProps::default()
|
||||
.columns(
|
||||
[
|
||||
Column::new(Text::raw(""), Constraint::Fill(1)),
|
||||
Column::new(
|
||||
span::default(format!("{}%", props.help_progress))
|
||||
.dim(),
|
||||
Constraint::Min(4),
|
||||
),
|
||||
]
|
||||
.to_vec(),
|
||||
)
|
||||
.focus(props.focus)
|
||||
.to_boxed()
|
||||
})
|
||||
.to_boxed(),
|
||||
)
|
||||
.to_boxed(),
|
||||
shortcuts: Shortcuts::new(state, action_tx.clone())
|
||||
.on_update(|state| {
|
||||
ShortcutsProps::default()
|
||||
.shortcuts(&state.shortcuts())
|
||||
.to_boxed()
|
||||
})
|
||||
.to_boxed(),
|
||||
_action_tx: action_tx.clone(),
|
||||
props: WindowProps::from(state),
|
||||
pages: HashMap::from([
|
||||
(
|
||||
Page::Browse,
|
||||
BrowsePage::new(state, action_tx.clone()).to_boxed() as BoxedWidget<B>,
|
||||
),
|
||||
(
|
||||
Page::Help,
|
||||
HelpPage::new(state, action_tx.clone()).to_boxed() as BoxedWidget<B>,
|
||||
),
|
||||
]),
|
||||
on_update: None,
|
||||
on_change: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn on_change(mut self, callback: EventCallback<Action>) -> Self {
|
||||
self.on_change = Some(callback);
|
||||
self
|
||||
}
|
||||
|
||||
fn on_update(mut self, callback: UpdateCallback<State>) -> Self {
|
||||
self.on_update = Some(callback);
|
||||
self
|
||||
}
|
||||
|
||||
fn update(&mut self, state: &State) {
|
||||
self.props = ListPageProps::from(state);
|
||||
fn on_change(mut self, callback: EventCallback<Action>) -> Self {
|
||||
self.on_change = Some(callback);
|
||||
self
|
||||
}
|
||||
|
||||
self.notifications.update(state);
|
||||
self.search.update(state);
|
||||
self.help.update(state);
|
||||
self.shortcuts.update(state);
|
||||
fn update(&mut self, state: &State) {
|
||||
self.props = WindowProps::from(state);
|
||||
|
||||
if let Some(page) = self.pages.get_mut(&self.props.page) {
|
||||
page.update(state);
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_key_event(&mut self, key: termion::event::Key) {
|
||||
if self.props.show_search {
|
||||
self.search.handle_key_event(key);
|
||||
} else if self.props.show_help {
|
||||
match key {
|
||||
Key::Esc | Key::Ctrl('c') => {
|
||||
let _ = self.action_tx.send(Action::Exit { selection: None });
|
||||
}
|
||||
Key::Char('?') => {
|
||||
let _ = self.action_tx.send(Action::CloseHelp);
|
||||
}
|
||||
_ => {
|
||||
self.help.handle_key_event(key);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
match key {
|
||||
Key::Esc | Key::Ctrl('c') => {
|
||||
let _ = self.action_tx.send(Action::Exit { selection: None });
|
||||
}
|
||||
Key::Char('/') => {
|
||||
let _ = self.action_tx.send(Action::OpenSearch);
|
||||
}
|
||||
Key::Char('?') => {
|
||||
let _ = self.action_tx.send(Action::OpenHelp);
|
||||
}
|
||||
_ => {
|
||||
self.notifications.handle_key_event(key);
|
||||
}
|
||||
}
|
||||
if let Some(page) = self.pages.get_mut(&self.props.page) {
|
||||
page.handle_key_event(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a: 'static, B> Widget<B, State, Action> for ListPage<B>
|
||||
impl<'a: 'static, B> Widget<B, State, Action> for Window<B>
|
||||
where
|
||||
B: Backend + 'a,
|
||||
{
|
||||
fn render(&self, frame: &mut ratatui::Frame, _area: Rect, props: Option<&dyn Any>) {
|
||||
fn render(&self, frame: &mut ratatui::Frame, _area: Rect, props: Option<Box<dyn Any>>) {
|
||||
let props = props
|
||||
.and_then(|props| props.downcast_ref::<ListPageProps>())
|
||||
.unwrap_or(&self.props);
|
||||
.and_then(|props| WindowProps::from_boxed_any(props))
|
||||
.unwrap_or(self.props.clone());
|
||||
|
||||
let area = frame.size();
|
||||
let layout = tui::ui::layout::default_page(area, 0u16, 1u16);
|
||||
|
||||
let page_size = area.height.saturating_sub(6) as usize;
|
||||
|
||||
if props.show_search {
|
||||
let component_layout = Layout::vertical([Constraint::Min(1), Constraint::Length(2)])
|
||||
.split(layout.component);
|
||||
|
||||
self.notifications.render(frame, component_layout[0], None);
|
||||
self.search.render(frame, component_layout[1], None);
|
||||
} else if self.props.show_help {
|
||||
self.help.render(frame, layout.component, None);
|
||||
} else {
|
||||
self.notifications.render(frame, layout.component, None);
|
||||
}
|
||||
|
||||
self.shortcuts.render(frame, layout.shortcuts, None);
|
||||
|
||||
if page_size != props.page_size {
|
||||
let _ = self.action_tx.send(Action::PageSize(page_size));
|
||||
if let Some(page) = self.pages.get(&props.page) {
|
||||
page.render(frame, area, None);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct NotificationsProps<'a> {
|
||||
#[derive(Clone)]
|
||||
struct BrowsePageProps<'a> {
|
||||
notifications: Vec<NotificationItem>,
|
||||
selected: Option<usize>,
|
||||
mode: Mode,
|
||||
@@ -250,14 +143,15 @@ struct NotificationsProps<'a> {
|
||||
page_size: usize,
|
||||
search: String,
|
||||
show_search: bool,
|
||||
shortcuts: Vec<(&'a str, &'a str)>,
|
||||
}
|
||||
|
||||
impl<'a> From<&State> for NotificationsProps<'a> {
|
||||
impl<'a> From<&State> for BrowsePageProps<'a> {
|
||||
fn from(state: &State) -> Self {
|
||||
let mut seen = 0;
|
||||
let mut unseen = 0;
|
||||
|
||||
let notifications = state.notifications();
|
||||
let notifications = state.browser.notifications();
|
||||
|
||||
// Compute statistics
|
||||
for notification in ¬ifications {
|
||||
@@ -272,7 +166,6 @@ impl<'a> From<&State> for NotificationsProps<'a> {
|
||||
|
||||
Self {
|
||||
notifications,
|
||||
selected: state.notifications.selected,
|
||||
mode: state.mode.clone(),
|
||||
stats,
|
||||
columns: [
|
||||
@@ -291,36 +184,48 @@ impl<'a> From<&State> for NotificationsProps<'a> {
|
||||
cutoff: 200,
|
||||
cutoff_after: 5,
|
||||
focus: false,
|
||||
page_size: state.ui.page_size,
|
||||
show_search: state.ui.show_search,
|
||||
search: state.search.read(),
|
||||
search: state.browser.search.read(),
|
||||
page_size: state.browser.page_size,
|
||||
show_search: state.browser.show_search,
|
||||
selected: state.browser.selected,
|
||||
shortcuts: match state.mode.selection() {
|
||||
SelectionMode::Id => vec![("enter", "select"), ("/", "search")],
|
||||
SelectionMode::Operation => vec![
|
||||
("enter", "show"),
|
||||
("c", "clear"),
|
||||
("/", "search"),
|
||||
("?", "help"),
|
||||
],
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Properties for NotificationsProps<'a> {}
|
||||
impl<'a> Properties for BrowsePageProps<'a> {}
|
||||
|
||||
struct Notifications<'a, B: Backend> {
|
||||
struct BrowsePage<'a, B> {
|
||||
/// Internal properties
|
||||
props: NotificationsProps<'a>,
|
||||
props: BrowsePageProps<'a>,
|
||||
/// Message sender
|
||||
action_tx: UnboundedSender<Action>,
|
||||
/// Custom update handler
|
||||
on_update: Option<UpdateCallback<State>>,
|
||||
/// Additional custom event handler
|
||||
on_change: Option<EventCallback<Action>>,
|
||||
/// Table widget
|
||||
table: BoxedWidget<B>,
|
||||
/// Footer widget w/ context
|
||||
footer: BoxedWidget<B>,
|
||||
/// Notifications widget
|
||||
notifications: BoxedWidget<B>,
|
||||
/// Search widget
|
||||
search: BoxedWidget<B>,
|
||||
/// Shortcut widget
|
||||
shortcuts: BoxedWidget<B>,
|
||||
}
|
||||
|
||||
impl<'a: 'static, B> View<State, Action> for Notifications<'a, B>
|
||||
impl<'a: 'static, B> View<State, Action> for BrowsePage<'a, B>
|
||||
where
|
||||
B: Backend + 'a,
|
||||
{
|
||||
fn new(state: &State, action_tx: UnboundedSender<Action>) -> Self {
|
||||
let props = NotificationsProps::from(state);
|
||||
let props = BrowsePageProps::from(state);
|
||||
let name = match state.mode.repository() {
|
||||
RepositoryMode::Contextual => state.project.name().to_string(),
|
||||
RepositoryMode::All => "All repositories".to_string(),
|
||||
@@ -329,44 +234,58 @@ where
|
||||
|
||||
Self {
|
||||
action_tx: action_tx.clone(),
|
||||
props: NotificationsProps::from(state),
|
||||
table: Box::<Table<B, State, Action, NotificationItem>>::new(
|
||||
Table::new(state, action_tx.clone())
|
||||
.header(
|
||||
Header::new(state, action_tx.clone())
|
||||
.columns(
|
||||
[
|
||||
Column::new("", Constraint::Length(0)),
|
||||
Column::new(Text::from(name), Constraint::Fill(1)),
|
||||
]
|
||||
.to_vec(),
|
||||
)
|
||||
.cutoff(props.cutoff, props.cutoff_after)
|
||||
.focus(props.focus)
|
||||
.to_boxed(),
|
||||
)
|
||||
.on_change(|state, action_tx| {
|
||||
state.downcast_ref::<TableState>().and_then(|state| {
|
||||
action_tx
|
||||
.send(Action::Select {
|
||||
selected: state.selected(),
|
||||
})
|
||||
.ok()
|
||||
});
|
||||
})
|
||||
.on_update(|state| {
|
||||
let props = NotificationsProps::from(state);
|
||||
props: BrowsePageProps::from(state),
|
||||
notifications: Container::new(state, action_tx.clone())
|
||||
.header(
|
||||
Header::new(state, action_tx.clone())
|
||||
.columns(
|
||||
[
|
||||
Column::new("", Constraint::Length(0)),
|
||||
Column::new(Text::from(name), Constraint::Fill(1)),
|
||||
]
|
||||
.to_vec(),
|
||||
)
|
||||
.cutoff(props.cutoff, props.cutoff_after)
|
||||
.focus(props.focus)
|
||||
.to_boxed(),
|
||||
)
|
||||
.content(Box::<Table<State, Action, NotificationItem>>::new(
|
||||
Table::new(state, action_tx.clone())
|
||||
.on_change(|state, action_tx| {
|
||||
state.downcast_ref::<TableState>().and_then(|state| {
|
||||
action_tx
|
||||
.send(Action::Select {
|
||||
selected: state.selected(),
|
||||
})
|
||||
.ok()
|
||||
});
|
||||
})
|
||||
.on_update(|state| {
|
||||
let props = BrowsePageProps::from(state);
|
||||
|
||||
TableProps::default()
|
||||
.columns(props.columns)
|
||||
.items(state.notifications())
|
||||
.footer(!state.ui.show_search)
|
||||
.page_size(state.ui.page_size)
|
||||
.cutoff(props.cutoff, props.cutoff_after)
|
||||
.to_boxed()
|
||||
}),
|
||||
),
|
||||
footer: Footer::new(state, action_tx).to_boxed(),
|
||||
TableProps::default()
|
||||
.columns(props.columns)
|
||||
.items(state.browser.notifications())
|
||||
.footer(!state.browser.show_search)
|
||||
.page_size(state.browser.page_size)
|
||||
.cutoff(props.cutoff, props.cutoff_after)
|
||||
.to_boxed()
|
||||
}),
|
||||
))
|
||||
.footer(
|
||||
Footer::new(state, action_tx.clone())
|
||||
.on_update(|state| {
|
||||
let props = BrowsePageProps::from(state);
|
||||
|
||||
FooterProps::default()
|
||||
.columns(Self::build_footer(&props, props.selected))
|
||||
.to_boxed()
|
||||
})
|
||||
.to_boxed(),
|
||||
)
|
||||
.to_boxed(),
|
||||
search: Search::new(state, action_tx.clone()).to_boxed(),
|
||||
shortcuts: Shortcuts::new(state, action_tx.clone()).to_boxed(),
|
||||
on_update: None,
|
||||
on_change: None,
|
||||
}
|
||||
@@ -384,58 +303,72 @@ where
|
||||
|
||||
fn update(&mut self, state: &State) {
|
||||
// TODO call mapper here instead?
|
||||
self.props = NotificationsProps::from(state);
|
||||
self.props = BrowsePageProps::from(state);
|
||||
|
||||
self.table.update(state);
|
||||
self.footer.update(state);
|
||||
self.notifications.update(state);
|
||||
self.search.update(state);
|
||||
self.shortcuts.update(state);
|
||||
}
|
||||
|
||||
fn handle_key_event(&mut self, key: Key) {
|
||||
match key {
|
||||
Key::Char('\n') => {
|
||||
self.props
|
||||
.selected
|
||||
.and_then(|selected| self.props.notifications.get(selected))
|
||||
.and_then(|notif| {
|
||||
let selection = match self.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),
|
||||
};
|
||||
if self.props.show_search {
|
||||
self.search.handle_key_event(key);
|
||||
} else {
|
||||
match key {
|
||||
Key::Esc | Key::Ctrl('c') => {
|
||||
let _ = self.action_tx.send(Action::Exit { selection: None });
|
||||
}
|
||||
Key::Char('?') => {
|
||||
let _ = self.action_tx.send(Action::OpenHelp);
|
||||
}
|
||||
Key::Char('/') => {
|
||||
let _ = self.action_tx.send(Action::OpenSearch);
|
||||
}
|
||||
Key::Char('\n') => {
|
||||
self.props
|
||||
.selected
|
||||
.and_then(|selected| self.props.notifications.get(selected))
|
||||
.and_then(|notif| {
|
||||
let selection = match self.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),
|
||||
};
|
||||
|
||||
self.action_tx
|
||||
.send(Action::Exit {
|
||||
selection: Some(selection),
|
||||
})
|
||||
.ok()
|
||||
});
|
||||
}
|
||||
Key::Char('c') => {
|
||||
self.props
|
||||
.selected
|
||||
.and_then(|selected| self.props.notifications.get(selected))
|
||||
.and_then(|notif| {
|
||||
self.action_tx
|
||||
.send(Action::Exit {
|
||||
selection: Some(
|
||||
Selection::default()
|
||||
.with_operation(InboxOperation::Clear.to_string())
|
||||
.with_id(notif.id),
|
||||
),
|
||||
})
|
||||
.ok()
|
||||
});
|
||||
}
|
||||
_ => {
|
||||
self.table.handle_key_event(key);
|
||||
self.action_tx
|
||||
.send(Action::Exit {
|
||||
selection: Some(selection),
|
||||
})
|
||||
.ok()
|
||||
});
|
||||
}
|
||||
Key::Char('c') => {
|
||||
self.props
|
||||
.selected
|
||||
.and_then(|selected| self.props.notifications.get(selected))
|
||||
.and_then(|notif| {
|
||||
self.action_tx
|
||||
.send(Action::Exit {
|
||||
selection: Some(
|
||||
Selection::default()
|
||||
.with_operation(InboxOperation::Clear.to_string())
|
||||
.with_id(notif.id),
|
||||
),
|
||||
})
|
||||
.ok()
|
||||
});
|
||||
}
|
||||
_ => {
|
||||
self.notifications.handle_key_event(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a, B: Backend> Notifications<'a, B> {
|
||||
fn build_footer(props: &NotificationsProps<'a>, selected: Option<usize>) -> Vec<Column<'a>> {
|
||||
impl<'a, B: Backend> BrowsePage<'a, B> {
|
||||
fn build_footer(props: &BrowsePageProps<'a>, selected: Option<usize>) -> Vec<Column<'a>> {
|
||||
let search = Line::from(
|
||||
[
|
||||
span::default(" Search ".to_string())
|
||||
@@ -509,36 +442,58 @@ impl<'a, B: Backend> Notifications<'a, B> {
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a: 'static, B> Widget<B, State, Action> for Notifications<'a, B>
|
||||
impl<'a: 'static, B> Widget<B, State, Action> for BrowsePage<'a, B>
|
||||
where
|
||||
B: Backend + 'a,
|
||||
{
|
||||
fn render(&self, frame: &mut ratatui::Frame, area: Rect, props: Option<&dyn Any>) {
|
||||
fn render(&self, frame: &mut ratatui::Frame, area: Rect, props: Option<Box<dyn Any>>) {
|
||||
let props = props
|
||||
.and_then(|props| props.downcast_ref::<NotificationsProps>())
|
||||
.unwrap_or(&self.props);
|
||||
.and_then(|props| BrowsePageProps::from_boxed_any(props))
|
||||
.unwrap_or(self.props.clone());
|
||||
|
||||
let header_height = 3_usize;
|
||||
let page_size = area.height.saturating_sub(6) as usize;
|
||||
|
||||
let page_size = if props.show_search {
|
||||
self.table.render(frame, area, None);
|
||||
let [content_area, shortcuts_area] =
|
||||
Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).areas(area);
|
||||
|
||||
(area.height as usize).saturating_sub(header_height)
|
||||
} else {
|
||||
let layout = Layout::vertical([Constraint::Min(1), Constraint::Length(3)]).split(area);
|
||||
if props.show_search {
|
||||
let [table_area, search_area] =
|
||||
Layout::vertical([Constraint::Min(1), Constraint::Length(2)]).areas(content_area);
|
||||
|
||||
self.table.render(frame, layout[0], None);
|
||||
self.footer.render(
|
||||
self.notifications.render(
|
||||
frame,
|
||||
layout[1],
|
||||
Some(&FooterProps::default().columns(Self::build_footer(props, props.selected))),
|
||||
table_area,
|
||||
Some(
|
||||
ContainerProps::default()
|
||||
.hide_footer(props.show_search)
|
||||
.to_boxed(),
|
||||
),
|
||||
);
|
||||
self.search.render(frame, search_area, None);
|
||||
} else {
|
||||
self.notifications.render(
|
||||
frame,
|
||||
content_area,
|
||||
Some(
|
||||
ContainerProps::default()
|
||||
.hide_footer(props.show_search)
|
||||
.to_boxed(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
(area.height as usize).saturating_sub(header_height)
|
||||
};
|
||||
self.shortcuts.render(
|
||||
frame,
|
||||
shortcuts_area,
|
||||
Some(
|
||||
ShortcutsProps::default()
|
||||
.shortcuts(&props.shortcuts)
|
||||
.to_boxed(),
|
||||
),
|
||||
);
|
||||
|
||||
if page_size != props.page_size {
|
||||
let _ = self.action_tx.send(Action::PageSize(page_size));
|
||||
let _ = self.action_tx.send(Action::BrowserPageSize(page_size));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -564,14 +519,14 @@ impl<B: Backend> View<State, Action> for Search<B> {
|
||||
state.downcast_ref::<TextFieldState>().and_then(|state| {
|
||||
action_tx
|
||||
.send(Action::UpdateSearch {
|
||||
value: state.text.clone(),
|
||||
value: state.text.clone().unwrap_or_default(),
|
||||
})
|
||||
.ok()
|
||||
});
|
||||
})
|
||||
.on_update(|state| {
|
||||
TextFieldProps::default()
|
||||
.text(&state.search.read().to_string())
|
||||
.text(&state.browser.search.read().to_string())
|
||||
.title("Search")
|
||||
.inline(true)
|
||||
.to_boxed()
|
||||
@@ -618,7 +573,7 @@ impl<B> Widget<B, State, Action> for Search<B>
|
||||
where
|
||||
B: Backend,
|
||||
{
|
||||
fn render(&self, frame: &mut ratatui::Frame, area: Rect, _props: Option<&dyn Any>) {
|
||||
fn render(&self, frame: &mut ratatui::Frame, area: Rect, _props: Option<Box<dyn Any>>) {
|
||||
let layout = Layout::horizontal(Constraint::from_mins([0]))
|
||||
.horizontal_margin(1)
|
||||
.split(area);
|
||||
@@ -627,6 +582,182 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct HelpPageProps<'a> {
|
||||
focus: bool,
|
||||
page_size: usize,
|
||||
help_progress: usize,
|
||||
shortcuts: Vec<(&'a str, &'a str)>,
|
||||
}
|
||||
|
||||
impl<'a> From<&State> for HelpPageProps<'a> {
|
||||
fn from(state: &State) -> Self {
|
||||
Self {
|
||||
focus: false,
|
||||
page_size: state.help.page_size,
|
||||
help_progress: state.help.progress,
|
||||
shortcuts: vec![("?", "close")],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Properties for HelpPageProps<'a> {}
|
||||
|
||||
pub struct HelpPage<'a, B>
|
||||
where
|
||||
B: Backend,
|
||||
{
|
||||
/// Internal properties
|
||||
props: HelpPageProps<'a>,
|
||||
/// Message sender
|
||||
action_tx: UnboundedSender<Action>,
|
||||
/// Custom update handler
|
||||
on_update: Option<UpdateCallback<State>>,
|
||||
/// Additional custom event handler
|
||||
on_change: Option<EventCallback<Action>>,
|
||||
/// Content widget
|
||||
content: BoxedWidget<B>,
|
||||
/// Shortcut widget
|
||||
shortcuts: BoxedWidget<B>,
|
||||
}
|
||||
|
||||
impl<'a: 'static, B> View<State, Action> for HelpPage<'a, B>
|
||||
where
|
||||
B: Backend + 'a,
|
||||
{
|
||||
fn new(state: &State, action_tx: UnboundedSender<Action>) -> Self
|
||||
where
|
||||
Self: Sized,
|
||||
{
|
||||
Self {
|
||||
action_tx: action_tx.clone(),
|
||||
props: HelpPageProps::from(state),
|
||||
content: Container::new(state, action_tx.clone())
|
||||
.header(
|
||||
Header::new(state, action_tx.clone())
|
||||
.on_update(|state| {
|
||||
let props = HelpPageProps::from(state);
|
||||
|
||||
HeaderProps::default()
|
||||
.columns([Column::new(" Help ", Constraint::Fill(1))].to_vec())
|
||||
.focus(props.focus)
|
||||
.to_boxed()
|
||||
})
|
||||
.to_boxed(),
|
||||
)
|
||||
.content(
|
||||
Paragraph::new(state, action_tx.clone())
|
||||
.on_update(|state| {
|
||||
let props = HelpPageProps::from(state);
|
||||
|
||||
ParagraphProps::default()
|
||||
.text(&help_text())
|
||||
.page_size(props.page_size)
|
||||
.focus(props.focus)
|
||||
.to_boxed()
|
||||
})
|
||||
.on_change(|state, action_tx| {
|
||||
state.downcast_ref::<ParagraphState>().and_then(|state| {
|
||||
action_tx
|
||||
.send(Action::ScrollHelp {
|
||||
progress: state.progress,
|
||||
})
|
||||
.ok()
|
||||
});
|
||||
})
|
||||
.to_boxed(),
|
||||
)
|
||||
.footer(
|
||||
Footer::new(state, action_tx.clone())
|
||||
.on_update(|state| {
|
||||
let props = HelpPageProps::from(state);
|
||||
|
||||
FooterProps::default()
|
||||
.columns(
|
||||
[
|
||||
Column::new(Text::raw(""), Constraint::Fill(1)),
|
||||
Column::new(
|
||||
span::default(format!("{}%", props.help_progress))
|
||||
.dim(),
|
||||
Constraint::Min(4),
|
||||
),
|
||||
]
|
||||
.to_vec(),
|
||||
)
|
||||
.focus(props.focus)
|
||||
.to_boxed()
|
||||
})
|
||||
.to_boxed(),
|
||||
)
|
||||
.to_boxed(),
|
||||
shortcuts: Shortcuts::new(state, action_tx.clone()).to_boxed(),
|
||||
on_update: None,
|
||||
on_change: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn on_update(mut self, callback: UpdateCallback<State>) -> Self {
|
||||
self.on_update = Some(callback);
|
||||
self
|
||||
}
|
||||
|
||||
fn on_change(mut self, callback: EventCallback<Action>) -> Self {
|
||||
self.on_change = Some(callback);
|
||||
self
|
||||
}
|
||||
|
||||
fn update(&mut self, state: &State) {
|
||||
self.props = HelpPageProps::from(state);
|
||||
|
||||
self.content.update(state);
|
||||
}
|
||||
|
||||
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('?') => {
|
||||
let _ = self.action_tx.send(Action::LeavePage);
|
||||
}
|
||||
_ => {
|
||||
self.content.handle_key_event(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a: 'static, B> Widget<B, State, Action> for HelpPage<'a, B>
|
||||
where
|
||||
B: Backend + 'a,
|
||||
{
|
||||
fn render(&self, frame: &mut ratatui::Frame, area: Rect, props: Option<Box<dyn Any>>) {
|
||||
let props = props
|
||||
.and_then(|props| HelpPageProps::from_boxed_any(props))
|
||||
.unwrap_or(self.props.clone());
|
||||
|
||||
let page_size = area.height.saturating_sub(6) as usize;
|
||||
|
||||
let [content_area, shortcuts_area] =
|
||||
Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).areas(area);
|
||||
|
||||
self.content.render(frame, content_area, None);
|
||||
self.shortcuts.render(
|
||||
frame,
|
||||
shortcuts_area,
|
||||
Some(
|
||||
ShortcutsProps::default()
|
||||
.shortcuts(&props.shortcuts)
|
||||
.to_boxed(),
|
||||
),
|
||||
);
|
||||
|
||||
if page_size != props.page_size {
|
||||
let _ = self.action_tx.send(Action::HelpPageSize(page_size));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn help_text() -> Text<'static> {
|
||||
Text::from(
|
||||
[
|
||||
@@ -752,6 +883,8 @@ fn help_text() -> Text<'static> {
|
||||
]
|
||||
.to_vec(),
|
||||
),
|
||||
Line::raw(""),
|
||||
Line::raw(""),
|
||||
]
|
||||
.to_vec(),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user