diff --git a/src/bin/main.rs b/src/bin/main.rs index 3f3318ff..6ac4203c 100644 --- a/src/bin/main.rs +++ b/src/bin/main.rs @@ -91,7 +91,6 @@ fn main() -> Result<()> { tuine::launch_with_application(app, receiver, &mut terminal)?; // FIXME: Move terminal construction INSIDE - // I think doing it in this order is safe... *thread_termination_lock.lock().unwrap() = true; thread_termination_cvar.notify_all(); diff --git a/src/lib.rs b/src/lib.rs index f7b6771f..2ca9b369 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -20,7 +20,7 @@ use std::{ }; use crossterm::{ - event::{poll, read, DisableMouseCapture, EnableMouseCapture, MouseEventKind}, + event::{read, DisableMouseCapture, EnableMouseCapture, MouseEventKind}, execute, style::Print, terminal::{ diff --git a/src/tuine/component/base/shortcut.rs b/src/tuine/component/base/shortcut.rs index 6b857ea6..1e485670 100644 --- a/src/tuine/component/base/shortcut.rs +++ b/src/tuine/component/base/shortcut.rs @@ -1,39 +1,54 @@ +use std::{ + collections::hash_map::Entry, + panic::Location, + time::{Duration, Instant}, +}; + use rustc_hash::FxHashMap; use tui::{backend::Backend, layout::Rect, Frame}; use crate::tuine::{ - Bounds, DrawContext, Event, LayoutNode, Size, StateContext, Status, TmpComponent, + Bounds, DrawContext, Event, Key, LayoutNode, Size, StateContext, StatefulComponent, Status, + TmpComponent, }; -enum MultiShortcutStep -where - Child: TmpComponent, -{ - NextStep(Event), - Action( - Box< - dyn Fn( - &mut Child, - &mut StateContext<'_>, - &DrawContext<'_>, - Event, - &mut Vec, - ) -> Status, - >, - ), +const MAX_TIMEOUT: Duration = Duration::from_millis(400); + +#[derive(Debug, PartialEq)] +enum ShortcutTriggerState { + /// Currently not waiting on any next input. + Idle, + /// Waiting for the next input, initially triggered at [`Instant`]. + Waiting { + /// When it was initially triggered. + trigger_instant: Instant, + + /// The currently built-up list of events. + current_events: Vec, + }, } -/// A [`Component`] to handle keyboard shortcuts and assign actions to them. -/// -/// Inspired by [Flutter's approach](https://docs.flutter.dev/development/ui/advanced/actions_and_shortcuts). +impl Default for ShortcutTriggerState { + fn default() -> Self { + ShortcutTriggerState::Idle + } +} + +#[derive(Debug, PartialEq, Default)] +pub struct ShortcutState { + trigger_state: ShortcutTriggerState, + forest: FxHashMap, bool>, +} + +/// Properties for a [`Shortcut`]. #[derive(Default)] -pub struct Shortcut +pub struct ShortcutProps where Child: TmpComponent, { child: Option, shortcuts: FxHashMap< - Event, + Vec, Box< dyn Fn( &mut Child, @@ -44,28 +59,27 @@ where ) -> Status, >, >, - multi_shortcuts: FxHashMap>, - enabled_multi_shortcuts: FxHashMap>, } -impl Shortcut +impl ShortcutProps where Child: TmpComponent, { + /// Creates a new [`ShortcutProps`] with a child. pub fn with_child(child: Child) -> Self { Self { child: Some(child), shortcuts: Default::default(), - multi_shortcuts: Default::default(), - enabled_multi_shortcuts: Default::default(), } } + /// Sets the child of the [`ShortcutProps`]. pub fn child(mut self, child: Option) -> Self { self.child = child; self } + /// Inserts a shortcut that only needs a single [`Event`]. pub fn shortcut( mut self, event: Event, f: Box< @@ -78,16 +92,110 @@ where ) -> Status, >, ) -> Self { - self.shortcuts.insert(event, f); + self.shortcuts.insert(vec![event], f); self } - pub fn remove_shortcut(mut self, event: &Event) -> Self { - self.shortcuts.remove(event); + /// Inserts a shortcut that can take one or more [`Event`]s. + pub fn multi_shortcut( + mut self, events: Vec, + f: Box< + dyn Fn( + &mut Child, + &mut StateContext<'_>, + &DrawContext<'_>, + Event, + &mut Vec, + ) -> Status, + >, + ) -> Self { + self.shortcuts.insert(events, f); self } } +/// A [`Component`] to handle keyboard shortcuts and assign actions to them. +/// +/// Inspired by [Flutter's approach](https://docs.flutter.dev/development/ui/advanced/actions_and_shortcuts). +pub struct Shortcut +where + Child: TmpComponent, +{ + key: Key, + child: Option, + shortcuts: FxHashMap< + Vec, + Box< + dyn Fn( + &mut Child, + &mut StateContext<'_>, + &DrawContext<'_>, + Event, + &mut Vec, + ) -> Status, + >, + >, +} + +impl StatefulComponent for Shortcut +where + Child: TmpComponent, +{ + type Properties = ShortcutProps; + + type ComponentState = ShortcutState; + + fn build(ctx: &mut crate::tuine::ViewContext<'_>, props: Self::Properties) -> Self { + let (key, state) = + ctx.register_and_mut_state::<_, Self::ComponentState>(Location::caller()); + let mut forest: FxHashMap, bool> = FxHashMap::default(); + + props.shortcuts.iter().for_each(|(events, _action)| { + if !events.is_empty() { + let mut visited = vec![]; + let last = events.len() - 1; + for (itx, event) in events.iter().enumerate() { + visited.push(*event); + match forest.entry(visited.clone()) { + Entry::Occupied(mut occupied) => { + *occupied.get_mut() = *occupied.get() || itx == last; + } + Entry::Vacant(vacant) => { + vacant.insert(itx == last); + } + } + } + } + }); + + if forest != state.forest { + // Invalidate state. + *state = ShortcutState { + trigger_state: ShortcutTriggerState::Idle, + forest, + }; + } else if let ShortcutTriggerState::Waiting { + trigger_instant, + current_events: _, + } = state.trigger_state + { + if Instant::now().duration_since(trigger_instant) > MAX_TIMEOUT { + // Invalidate state. + *state = ShortcutState { + trigger_state: ShortcutTriggerState::Idle, + forest, + }; + } + } + + Shortcut { + key, + child: props.child, + shortcuts: props.shortcuts, + } + } +} + impl<'a, Message, Child> TmpComponent for Shortcut where Child: TmpComponent, @@ -116,8 +224,63 @@ where return Status::Captured; } Status::Ignored => { - if let Some(f) = self.shortcuts.get(&event) { - return f(child, state_ctx, &child_draw_ctx, event, messages); + let state = state_ctx.mut_state::(self.key); + match &state.trigger_state { + ShortcutTriggerState::Idle => { + let current_events = vec![event]; + if let Some(&should_fire) = state.forest.get(¤t_events) { + state.trigger_state = ShortcutTriggerState::Waiting { + trigger_instant: Instant::now(), + current_events: current_events.clone(), + }; + + if should_fire { + if let Some(f) = self.shortcuts.get(¤t_events) { + return f( + child, + state_ctx, + &child_draw_ctx, + event, + messages, + ); + } + } + } + } + ShortcutTriggerState::Waiting { + trigger_instant, + current_events, + } => { + if Instant::now().duration_since(*trigger_instant) > MAX_TIMEOUT { + state.trigger_state = ShortcutTriggerState::Idle; + return self.on_event(state_ctx, draw_ctx, event, messages); + } else { + let mut current_events = current_events.clone(); + current_events.push(event); + + if let Some(&should_fire) = state.forest.get(¤t_events) { + state.trigger_state = ShortcutTriggerState::Waiting { + trigger_instant: Instant::now(), + current_events: current_events.clone(), + }; + + if should_fire { + if let Some(f) = self.shortcuts.get(¤t_events) { + return f( + child, + state_ctx, + &child_draw_ctx, + event, + messages, + ); + } + } + } else { + state.trigger_state = ShortcutTriggerState::Idle; + return self.on_event(state_ctx, draw_ctx, event, messages); + } + } + } } } } diff --git a/src/tuine/component/base/text_table/mod.rs b/src/tuine/component/base/text_table/mod.rs index 6f8529a2..0c911d8b 100644 --- a/src/tuine/component/base/text_table/mod.rs +++ b/src/tuine/component/base/text_table/mod.rs @@ -44,13 +44,13 @@ pub struct StyleSheet { #[derive(PartialEq, Default)] pub struct TextTableState { - scroll: ScrollState, + pub scroll: ScrollState, sort: SortType, } /// A sortable, scrollable table for text data. pub struct TextTable { - key: Key, + pub key: Key, column_widths: Vec, columns: Vec, show_selected_entry: bool, diff --git a/src/tuine/component/stateful.rs b/src/tuine/component/stateful.rs index 650753f7..62b89557 100644 --- a/src/tuine/component/stateful.rs +++ b/src/tuine/component/stateful.rs @@ -1,4 +1,4 @@ -use crate::tuine::{State, ViewContext}; +use crate::tuine::{State, StateContext, ViewContext}; use super::TmpComponent; diff --git a/src/tuine/component/widget/simple_table.rs b/src/tuine/component/widget/simple_table.rs index a1a26987..dd395748 100644 --- a/src/tuine/component/widget/simple_table.rs +++ b/src/tuine/component/widget/simple_table.rs @@ -1,9 +1,11 @@ +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use tui::style::Style; use crate::tuine::{ self, block, - text_table::{self, DataRow, SortType, TextTableProps}, - Block, Shortcut, StatefulComponent, TextTable, TmpComponent, ViewContext, + shortcut::ShortcutProps, + text_table::{self, DataRow, SortType, TextTableProps, TextTableState}, + Block, Event, Shortcut, StatefulComponent, Status, TextTable, TmpComponent, ViewContext, }; /// A set of styles for a [`SimpleTable`]. @@ -29,7 +31,7 @@ impl SimpleTable { ctx: &mut ViewContext<'_>, style: StyleSheet, columns: Vec, data: Vec, sort_index: usize, ) -> Self { - let shortcut = Shortcut::with_child(TextTable::build( + let text_table = TextTable::build( ctx, TextTableProps::new(columns) .rows(data) @@ -39,7 +41,38 @@ impl SimpleTable { selected_text: style.selected_text, table_header: style.table_header, }), - )); + ); + let shortcut = Shortcut::build( + ctx, + ShortcutProps::with_child(text_table) + .shortcut( + Event::Keyboard(KeyEvent::new(KeyCode::Char('G'), KeyModifiers::empty())), + Box::new(|t, s, _d, _e, _m| { + let state = s.mut_state::(t.key); + state.scroll.jump_to_last(); + Status::Captured + }), + ) + .shortcut( + Event::Keyboard(KeyEvent::new(KeyCode::Char('G'), KeyModifiers::SHIFT)), + Box::new(|t, s, _d, _e, _m| { + let state = s.mut_state::(t.key); + state.scroll.jump_to_last(); + Status::Captured + }), + ) + .multi_shortcut( + vec![ + Event::Keyboard(KeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty())), + Event::Keyboard(KeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty())), + ], + Box::new(|t, s, _d, _e, _m| { + let state = s.mut_state::(t.key); + state.scroll.jump_to_first(); + Status::Captured + }), + ), + ); Self { inner: Block::with_child(shortcut).style(block::StyleSheet {