//! Types that flow between the menu engine and frontends. //! //! - [`Action`]: commands sent into the menu (keypresses, //! script actions). //! - [`MenuEvent`]: notifications sent out to subscribers //! (state changes, terminal events). //! - [`ViewState`]: snapshot of what the frontend should //! render right now. use std::sync::Arc; use serde::ser::SerializeSeq; use serde::Serialize; use serde_json::Value; use crate::hook::HookResponse; /// Input mode. Insert mode sends keystrokes to the filter, /// normal mode uses vim-style navigation keybinds. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum Mode { #[default] Insert, Normal, Visual, } /// A command the menu should process. Frontends and headless /// scripts both produce these. The menu loop consumes them /// sequentially. #[derive(Debug, Clone, PartialEq)] pub enum Action { UpdateFilter(String), MoveUp(usize), MoveDown(usize), MoveToTop, MoveToBottom, PageUp(usize), PageDown(usize), HalfPageUp(usize), HalfPageDown(usize), SetMode(Mode), ToggleSelect, SelectRange { start: usize, end: usize }, ClearSelections, SelectAll, UndoSelection, RedoSelection, Confirm, Quicklist, Cancel, Resize { height: u16 }, AddItems(Vec), ReplaceItems(Vec), RemoveItems(Vec), ProcessHookResponse(HookResponse), CloseMenu, StreamingDone, } /// Modifier keys held during an action. Serializes as an /// array of strings (e.g. `["shift", "ctrl"]`). Empty /// modifiers serialize as `[]`. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub struct Modifiers { pub shift: bool, pub ctrl: bool, pub alt: bool, } impl Modifiers { /// True when no modifiers are held. pub fn is_empty(&self) -> bool { !self.shift && !self.ctrl && !self.alt } } impl Serialize for Modifiers { fn serialize(&self, serializer: S) -> Result { let count = self.shift as usize + self.ctrl as usize + self.alt as usize; let mut seq = serializer.serialize_seq(Some(count))?; if self.shift { seq.serialize_element("shift")?; } if self.ctrl { seq.serialize_element("ctrl")?; } if self.alt { seq.serialize_element("alt")?; } seq.end() } } /// An [`Action`] paired with the modifier keys that were /// held when it was triggered. Frontends produce these, /// the menu engine consumes them. #[derive(Debug, Clone, PartialEq)] pub struct ModifiedAction { pub action: Action, pub modifiers: Modifiers, } impl ModifiedAction { /// Wrap an action with default (empty) modifiers. pub fn new(action: Action) -> Self { Self { action, modifiers: Modifiers::default(), } } /// Wrap an action with specific modifiers. pub fn with_modifiers(action: Action, modifiers: Modifiers) -> Self { Self { action, modifiers } } } /// Broadcast from the menu loop to all subscribers /// (frontends, tests). #[derive(Debug, Clone)] pub enum MenuEvent { StateChanged(ViewState), Selected(Vec<(Value, usize)>), Quicklist(Vec), Cancelled, } /// Snapshot of the menu's visible state. Sent on every state /// change so frontends can render without querying back into /// the engine. #[must_use] #[derive(Debug, Clone)] pub struct ViewState { pub visible_items: Vec, /// Index of the cursor within `visible_items` /// (not the full list). pub cursor: usize, pub filter_text: Arc, pub total_items: usize, pub total_filtered: usize, pub mode: Mode, pub selection_count: usize, pub multi_enabled: bool, /// True while stdin is still streaming items in. pub streaming: bool, /// Column headers for table mode. None when not in /// table mode. pub columns: Option>, /// Monotonically increasing counter. Each call to /// `build_view_state()` bumps this, so frontends can /// detect duplicate broadcasts and skip redundant redraws. pub generation: u64, } /// Column header with display name and computed width for /// table mode rendering. #[must_use] #[derive(Debug, Clone)] pub struct ColumnHeader { pub display_name: String, pub width: u16, } /// A single item in the current viewport window. Has the /// display label pre-resolved and the position in the /// filtered list. #[must_use] #[derive(Debug, Clone)] pub struct VisibleItem { pub label: String, pub formatted_text: Option, pub index: usize, pub selected: bool, /// Column cell values for table mode. None when not in /// table mode. pub column_values: Option>, } /// Final outcome of [`crate::menu::MenuRunner::run`]. The /// user either picked something or bailed. #[must_use] #[derive(Debug)] pub enum MenuResult { Selected { items: Vec<(Value, usize)>, filter_text: String, modifiers: Modifiers, }, Quicklist { items: Vec<(Value, usize)>, filter_text: String, modifiers: Modifiers, }, Cancelled { modifiers: Modifiers, }, } #[cfg(test)] mod tests { use super::*; use serde_json::json; #[test] fn modifiers_empty_serializes_as_empty_array() { let mods = Modifiers::default(); let json = serde_json::to_value(&mods).unwrap_or_default(); assert_eq!(json, json!([])); } #[test] fn modifiers_single_serializes() { let mods = Modifiers { shift: true, ctrl: false, alt: false, }; let json = serde_json::to_value(&mods).unwrap_or_default(); assert_eq!(json, json!(["shift"])); } #[test] fn modifiers_multiple_serializes() { let mods = Modifiers { shift: true, ctrl: true, alt: false, }; let json = serde_json::to_value(&mods).unwrap_or_default(); assert_eq!(json, json!(["shift", "ctrl"])); } #[test] fn modifiers_all_serializes() { let mods = Modifiers { shift: true, ctrl: true, alt: true, }; let json = serde_json::to_value(&mods).unwrap_or_default(); assert_eq!(json, json!(["shift", "ctrl", "alt"])); } #[test] fn modifiers_is_empty() { assert!(Modifiers::default().is_empty()); assert!(!Modifiers { shift: true, ctrl: false, alt: false } .is_empty()); } #[test] fn modified_action_new_has_empty_modifiers() { let ma = ModifiedAction::new(Action::Confirm); assert!(ma.modifiers.is_empty()); } #[test] fn modified_action_with_modifiers() { let mods = Modifiers { shift: true, ctrl: false, alt: false, }; let ma = ModifiedAction::with_modifiers(Action::Confirm, mods); assert!(ma.modifiers.shift); assert!(!ma.modifiers.ctrl); } }