Skip to main content

tui_lipan/app/input/
command_registry.rs

1//! Runtime command registry used by command palette style UIs.
2
3use std::cell::{Cell, RefCell};
4use std::collections::HashMap;
5use std::fmt;
6use std::rc::Rc;
7use std::sync::Arc;
8
9use crate::app::input::key_dispatch::CommandConflictPolicy;
10use crate::app::input::keymap::{Action, Keymap};
11use crate::callback::Callback;
12use crate::callback::ScopeId;
13use crate::core::event::KeyEvent;
14use crate::input::{ChordMatcher, ChordResult, KeyBinding, KeyBindings};
15
16/// Stable identifier for a registered command.
17#[derive(Clone, Debug, Eq, Hash, PartialEq)]
18pub struct CommandId(Arc<str>);
19
20impl CommandId {
21    /// Borrow this id as `&str`.
22    pub fn as_str(&self) -> &str {
23        &self.0
24    }
25}
26
27impl From<&str> for CommandId {
28    fn from(value: &str) -> Self {
29        Self(Arc::from(value))
30    }
31}
32
33impl From<String> for CommandId {
34    fn from(value: String) -> Self {
35        Self(Arc::from(value))
36    }
37}
38
39impl From<Arc<str>> for CommandId {
40    fn from(value: Arc<str>) -> Self {
41        Self(value)
42    }
43}
44
45impl fmt::Display for CommandId {
46    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
47        f.write_str(self.as_str())
48    }
49}
50
51/// One command entry in the runtime registry.
52#[derive(Clone)]
53pub struct CommandEntry {
54    /// Stable command id.
55    pub id: CommandId,
56    /// Label shown in command palettes.
57    pub label: Arc<str>,
58    /// Optional longer description.
59    pub description: Option<Arc<str>>,
60    /// Optional category/group label.
61    pub category: Option<Arc<str>>,
62    /// Optional keybinding hint shown on the right.
63    pub keybinding_hint: Option<Arc<str>>,
64    /// Executable keyboard shortcuts for this command.
65    pub shortcuts: KeyBindings,
66    /// Relative priority when resolving shortcut conflicts.
67    pub priority: i32,
68    /// Whether this command is currently actionable.
69    pub enabled: bool,
70    pub(crate) scope: Option<ScopeId>,
71    /// Callback executed when command is run.
72    pub handler: Callback<()>,
73}
74
75impl fmt::Debug for CommandEntry {
76    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
77        f.debug_struct("CommandEntry")
78            .field("id", &self.id)
79            .field("label", &self.label)
80            .field("description", &self.description)
81            .field("category", &self.category)
82            .field("keybinding_hint", &self.keybinding_hint)
83            .field("shortcuts", &self.shortcuts)
84            .field("priority", &self.priority)
85            .field("enabled", &self.enabled)
86            .field("scope", &self.scope)
87            .finish()
88    }
89}
90
91impl CommandEntry {
92    /// Create a command entry builder.
93    pub fn builder(id: impl Into<CommandId>) -> CommandBuilder {
94        CommandBuilder::new(id)
95    }
96}
97
98/// Builder for [`CommandEntry`].
99#[derive(Clone)]
100pub struct CommandBuilder {
101    id: CommandId,
102    label: Arc<str>,
103    description: Option<Arc<str>>,
104    category: Option<Arc<str>>,
105    keybinding_hint: Option<Arc<str>>,
106    shortcut_bindings: Vec<KeyBinding>,
107    priority: i32,
108    enabled: bool,
109    scope: Option<ScopeId>,
110    handler: Callback<()>,
111}
112
113impl CommandBuilder {
114    /// Create a builder with required id.
115    pub fn new(id: impl Into<CommandId>) -> Self {
116        Self {
117            id: id.into(),
118            label: Arc::from(""),
119            description: None,
120            category: None,
121            keybinding_hint: None,
122            shortcut_bindings: Vec::new(),
123            priority: 0,
124            enabled: true,
125            scope: None,
126            handler: Callback::new(|_| {}),
127        }
128    }
129
130    /// Set command label.
131    pub fn label(mut self, label: impl Into<Arc<str>>) -> Self {
132        self.label = label.into();
133        self
134    }
135
136    /// Set optional description.
137    pub fn description(mut self, description: impl Into<Arc<str>>) -> Self {
138        self.description = Some(description.into());
139        self
140    }
141
142    /// Set optional category.
143    pub fn category(mut self, category: impl Into<Arc<str>>) -> Self {
144        self.category = Some(category.into());
145        self
146    }
147
148    /// Set optional keybinding hint text for display only.
149    pub fn keybinding_hint(mut self, hint: impl Into<Arc<str>>) -> Self {
150        self.keybinding_hint = Some(hint.into());
151        self
152    }
153
154    /// Set optional keybinding hint text directly.
155    pub fn keybinding_hint_opt(mut self, hint: Option<Arc<str>>) -> Self {
156        self.keybinding_hint = hint;
157        self
158    }
159
160    /// Set keybinding hint derived from the configured keymap for an action.
161    pub fn keybinding_from_keymap(mut self, keymap: &Keymap, action: Action) -> Self {
162        self.keybinding_hint = keymap
163            .binding_for_action(action)
164            .map(|binding| Arc::<str>::from(binding.canonical_lowercase()));
165        self
166    }
167
168    /// Add an executable keyboard shortcut.
169    pub fn shortcut(mut self, binding: KeyBinding) -> Self {
170        self.shortcut_bindings.push(binding);
171        self
172    }
173
174    /// Add multiple executable keyboard shortcuts.
175    pub fn shortcuts(mut self, bindings: KeyBindings) -> Self {
176        self.shortcut_bindings.extend(bindings.iter().cloned());
177        self
178    }
179
180    /// Set relative shortcut conflict priority.
181    pub fn priority(mut self, priority: i32) -> Self {
182        self.priority = priority;
183        self
184    }
185
186    /// Set initial enabled state.
187    pub fn enabled(mut self, enabled: bool) -> Self {
188        self.enabled = enabled;
189        self
190    }
191
192    /// Set command handler callback.
193    pub fn handler(mut self, handler: Callback<()>) -> Self {
194        self.handler = handler;
195        self
196    }
197
198    /// Build the final command entry.
199    pub fn build(self) -> CommandEntry {
200        CommandEntry {
201            id: self.id,
202            label: self.label,
203            description: self.description,
204            category: self.category,
205            keybinding_hint: self.keybinding_hint,
206            shortcuts: KeyBindings::from_bindings(self.shortcut_bindings),
207            priority: self.priority,
208            enabled: self.enabled,
209            scope: self.scope,
210            handler: self.handler,
211        }
212    }
213}
214
215/// Shared runtime command registry.
216#[derive(Clone, Default)]
217pub struct CommandRegistry {
218    entries: Rc<RefCell<HashMap<CommandId, CommandEntry>>>,
219    order: Rc<RefCell<Vec<CommandId>>>,
220    generation: Rc<Cell<u64>>,
221}
222
223impl CommandRegistry {
224    /// Create an empty registry.
225    pub fn new() -> Self {
226        Self::default()
227    }
228
229    /// Register a command (replaces same id while preserving registration order).
230    pub fn register(&self, entry: CommandEntry) {
231        let id = entry.id.clone();
232        let mut entries = self.entries.borrow_mut();
233        let mut order = self.order.borrow_mut();
234        let replacing = entries.contains_key(&id);
235        entries.insert(id.clone(), entry);
236        if !replacing {
237            order.push(id);
238        }
239        drop(entries);
240        drop(order);
241        self.bump_generation();
242    }
243
244    pub(crate) fn register_for_scope(&self, scope: ScopeId, entry: CommandEntry) {
245        self.register(CommandEntry {
246            scope: Some(scope),
247            ..entry
248        });
249    }
250
251    /// Remove a command by id.
252    pub fn unregister(&self, id: impl Into<CommandId>) -> Option<CommandEntry> {
253        let id = id.into();
254        let removed = self.entries.borrow_mut().remove(&id);
255        if removed.is_some() {
256            self.order.borrow_mut().retain(|existing| existing != &id);
257            self.bump_generation();
258        }
259        removed
260    }
261
262    /// Enable/disable a registered command.
263    pub fn set_enabled(&self, id: impl Into<CommandId>, enabled: bool) -> bool {
264        let id = id.into();
265        let mut entries = self.entries.borrow_mut();
266        let Some(entry) = entries.get_mut(&id) else {
267            return false;
268        };
269        if entry.enabled != enabled {
270            entry.enabled = enabled;
271            self.bump_generation();
272        }
273        true
274    }
275
276    /// Execute a command by id when present and enabled.
277    pub fn execute(&self, id: impl Into<CommandId>) -> bool {
278        let id = id.into();
279        let to_run = {
280            let entries = self.entries.borrow();
281            let Some(entry) = entries.get(&id) else {
282                return false;
283            };
284            if !entry.enabled {
285                return false;
286            }
287            entry.handler.clone()
288        };
289        to_run.emit(());
290        true
291    }
292
293    /// Resolve enabled commands whose shortcuts match a single key event.
294    pub fn matching_enabled_shortcuts(
295        &self,
296        key: KeyEvent,
297        policy: CommandConflictPolicy,
298    ) -> Vec<CommandId> {
299        let entries = self.entries.borrow();
300        let order = self.order.borrow();
301        let mut matches = Vec::new();
302
303        for id in order.iter() {
304            let Some(entry) = entries.get(id) else {
305                continue;
306            };
307            if !entry.enabled {
308                continue;
309            }
310            if entry
311                .shortcuts
312                .iter()
313                .any(|binding| binding.step_count() == 1 && binding.matches_sequence(&[key]))
314            {
315                matches.push(id.clone());
316            }
317        }
318
319        resolve_shortcut_conflicts(matches, &entries, policy)
320    }
321
322    pub(crate) fn shortcut_entries(&self) -> Vec<(KeyBinding, CommandId)> {
323        let entries = self.entries.borrow();
324        let order = self.order.borrow();
325        let mut out = Vec::new();
326        for id in order.iter() {
327            let Some(entry) = entries.get(id) else {
328                continue;
329            };
330            if !entry.enabled {
331                continue;
332            }
333            for binding in entry.shortcuts.iter().cloned() {
334                out.push((binding, id.clone()));
335            }
336        }
337        out
338    }
339
340    pub(crate) fn ordered_entries(&self) -> Vec<CommandEntry> {
341        let entries = self.entries.borrow();
342        self.order
343            .borrow()
344            .iter()
345            .filter_map(|id| entries.get(id).cloned())
346            .collect()
347    }
348
349    /// Snapshot all registered entries.
350    pub fn entries(&self) -> Vec<CommandEntry> {
351        self.ordered_entries()
352    }
353
354    pub(crate) fn unregister_scope(&self, scope: ScopeId) {
355        let before = self.entries.borrow().len();
356        self.entries
357            .borrow_mut()
358            .retain(|_, entry| entry.scope != Some(scope));
359        self.order
360            .borrow_mut()
361            .retain(|id| self.entries.borrow().contains_key(id));
362        if self.entries.borrow().len() != before {
363            self.bump_generation();
364        }
365    }
366
367    /// Monotonic generation used by listeners to detect updates.
368    pub fn generation(&self) -> u64 {
369        self.generation.get()
370    }
371
372    fn bump_generation(&self) {
373        self.generation
374            .set(self.generation.get().wrapping_add(1).max(1));
375    }
376}
377
378fn resolve_shortcut_conflicts(
379    matches: Vec<CommandId>,
380    entries: &HashMap<CommandId, CommandEntry>,
381    policy: CommandConflictPolicy,
382) -> Vec<CommandId> {
383    if matches.len() <= 1 {
384        return matches;
385    }
386
387    match policy {
388        CommandConflictPolicy::FirstRegistered => vec![matches[0].clone()],
389        CommandConflictPolicy::HighestPriority => {
390            let winner = matches.into_iter().max_by(|left, right| {
391                let left_priority = entries.get(left).map(|entry| entry.priority).unwrap_or(0);
392                let right_priority = entries.get(right).map(|entry| entry.priority).unwrap_or(0);
393                left_priority.cmp(&right_priority)
394            });
395            winner.into_iter().collect()
396        }
397    }
398}
399
400/// Result of feeding a key into [`CommandShortcutRuntime`].
401#[derive(Clone, Debug, Eq, PartialEq)]
402pub(crate) enum CommandShortcutResult {
403    None,
404    Pending,
405    Matched(CommandId),
406    Mismatch,
407}
408
409/// Stateful matcher for executable app command shortcuts.
410pub(crate) struct CommandShortcutRuntime {
411    matcher: ChordMatcher<CommandId>,
412    registry_generation: u64,
413    conflict_policy: CommandConflictPolicy,
414}
415
416impl CommandShortcutRuntime {
417    pub(crate) fn new(registry: &CommandRegistry, conflict_policy: CommandConflictPolicy) -> Self {
418        Self {
419            matcher: ChordMatcher::new(registry.shortcut_entries()),
420            registry_generation: registry.generation(),
421            conflict_policy,
422        }
423    }
424
425    pub(crate) fn sync_registry(&mut self, registry: &CommandRegistry) {
426        let generation = registry.generation();
427        if self.registry_generation == generation {
428            return;
429        }
430        self.matcher = ChordMatcher::new(registry.shortcut_entries());
431        self.registry_generation = generation;
432        self.matcher.reset();
433    }
434
435    pub(crate) fn reset(&mut self) {
436        self.matcher.reset();
437    }
438
439    pub(crate) fn feed(
440        &mut self,
441        key: KeyEvent,
442        registry: &CommandRegistry,
443    ) -> CommandShortcutResult {
444        self.sync_registry(registry);
445        let was_pending = self.matcher.is_pending();
446        match self.matcher.feed(&key) {
447            ChordResult::None => {
448                if was_pending {
449                    CommandShortcutResult::Mismatch
450                } else {
451                    CommandShortcutResult::None
452                }
453            }
454            ChordResult::Pending => CommandShortcutResult::Pending,
455            ChordResult::Matched(id) => {
456                if !was_pending {
457                    let resolved = registry.matching_enabled_shortcuts(key, self.conflict_policy);
458                    return resolved
459                        .into_iter()
460                        .next()
461                        .map(CommandShortcutResult::Matched)
462                        .unwrap_or(CommandShortcutResult::None);
463                }
464                let resolved = resolve_shortcut_conflicts(
465                    vec![(*id).clone()],
466                    &registry.entries.borrow(),
467                    self.conflict_policy,
468                );
469                if let Some(winner) = resolved.into_iter().next() {
470                    CommandShortcutResult::Matched(winner)
471                } else {
472                    CommandShortcutResult::None
473                }
474            }
475        }
476    }
477
478    pub(crate) fn is_pending(&self) -> bool {
479        self.matcher.is_pending()
480    }
481}
482
483#[cfg(test)]
484mod tests {
485    use std::cell::Cell;
486    use std::rc::Rc;
487    use std::str::FromStr;
488
489    use super::{
490        CommandBuilder, CommandId, CommandRegistry, CommandShortcutResult, CommandShortcutRuntime,
491    };
492    use crate::app::input::key_dispatch::CommandConflictPolicy;
493    use crate::callback::Callback;
494    use crate::core::event::{KeyCode, KeyEvent, KeyMods};
495    use crate::input::KeyBinding;
496
497    fn ctrl_key_for_test(ch: char) -> KeyEvent {
498        KeyEvent {
499            code: KeyCode::Char(ch),
500            mods: KeyMods {
501                ctrl: true,
502                ..KeyMods::default()
503            },
504        }
505    }
506
507    fn key_event_for_test(ch: char) -> KeyEvent {
508        KeyEvent {
509            code: KeyCode::Char(ch),
510            mods: KeyMods::default(),
511        }
512    }
513
514    #[test]
515    fn register_replaces_existing_entry_by_id() {
516        let registry = CommandRegistry::new();
517        let hit_a = Rc::new(Cell::new(false));
518        let hit_b = Rc::new(Cell::new(false));
519
520        let hit_a_cb = Rc::clone(&hit_a);
521        registry.register(
522            CommandBuilder::new("app.test")
523                .label("A")
524                .handler(Callback::new(move |_| hit_a_cb.set(true)))
525                .build(),
526        );
527
528        let hit_b_cb = Rc::clone(&hit_b);
529        registry.register(
530            CommandBuilder::new("app.test")
531                .label("B")
532                .handler(Callback::new(move |_| hit_b_cb.set(true)))
533                .build(),
534        );
535
536        assert_eq!(registry.entries().len(), 1);
537        assert!(registry.execute("app.test"));
538        assert!(!hit_a.get());
539        assert!(hit_b.get());
540    }
541
542    #[test]
543    fn generation_increments_on_mutations() {
544        let registry = CommandRegistry::new();
545        assert_eq!(registry.generation(), 0);
546
547        registry.register(CommandBuilder::new("a").label("A").build());
548        let g1 = registry.generation();
549        assert!(g1 > 0);
550
551        assert!(registry.set_enabled("a", false));
552        let g2 = registry.generation();
553        assert!(g2 > g1);
554
555        assert!(registry.unregister("a").is_some());
556        let g3 = registry.generation();
557        assert!(g3 > g2);
558    }
559
560    #[test]
561    fn execute_runs_handler_when_enabled() {
562        let registry = CommandRegistry::new();
563        let called = Rc::new(Cell::new(false));
564        let called_cb = Rc::clone(&called);
565
566        registry.register(
567            CommandBuilder::new("run")
568                .label("Run")
569                .handler(Callback::new(move |_| called_cb.set(true)))
570                .build(),
571        );
572
573        assert!(registry.execute("run"));
574        assert!(called.get());
575    }
576
577    #[test]
578    fn disabled_command_does_not_execute_until_enabled() {
579        let registry = CommandRegistry::new();
580        let called = Rc::new(Cell::new(false));
581        let called_cb = Rc::clone(&called);
582
583        registry.register(
584            CommandBuilder::new("toggle")
585                .label("Toggle")
586                .enabled(false)
587                .handler(Callback::new(move |_| called_cb.set(true)))
588                .build(),
589        );
590
591        assert!(!registry.execute("toggle"));
592        assert!(!called.get());
593
594        assert!(registry.set_enabled("toggle", true));
595        assert!(registry.execute("toggle"));
596        assert!(called.get());
597    }
598
599    #[test]
600    fn command_shortcut_conflict_is_stable_first_registered_by_default() {
601        let registry = CommandRegistry::new();
602        registry.register(
603            CommandBuilder::new("first")
604                .shortcut(KeyBinding::from_str("ctrl-k").unwrap())
605                .build(),
606        );
607        registry.register(
608            CommandBuilder::new("second")
609                .shortcut(KeyBinding::from_str("ctrl-k").unwrap())
610                .build(),
611        );
612
613        let matches = registry.matching_enabled_shortcuts(
614            ctrl_key_for_test('k'),
615            CommandConflictPolicy::FirstRegistered,
616        );
617        assert_eq!(matches, vec![CommandId::from("first")]);
618    }
619
620    #[test]
621    fn command_shortcut_conflict_can_use_highest_priority() {
622        let registry = CommandRegistry::new();
623        registry.register(
624            CommandBuilder::new("low")
625                .priority(0)
626                .shortcut(KeyBinding::from_str("ctrl-k").unwrap())
627                .build(),
628        );
629        registry.register(
630            CommandBuilder::new("high")
631                .priority(10)
632                .shortcut(KeyBinding::from_str("ctrl-k").unwrap())
633                .build(),
634        );
635
636        let matches = registry.matching_enabled_shortcuts(
637            ctrl_key_for_test('k'),
638            CommandConflictPolicy::HighestPriority,
639        );
640        assert_eq!(matches, vec![CommandId::from("high")]);
641    }
642
643    #[test]
644    fn command_shortcut_runtime_supports_chords_and_resets_on_generation_change() {
645        let registry = CommandRegistry::new();
646        registry.register(
647            CommandBuilder::new("mux.detach")
648                .shortcut(KeyBinding::from_str("ctrl-a d").unwrap())
649                .build(),
650        );
651        let mut runtime =
652            CommandShortcutRuntime::new(&registry, CommandConflictPolicy::FirstRegistered);
653        assert!(matches!(
654            runtime.feed(ctrl_key_for_test('a'), &registry),
655            CommandShortcutResult::Pending
656        ));
657        registry.unregister("mux.detach");
658        assert!(matches!(
659            runtime.feed(key_event_for_test('d'), &registry),
660            CommandShortcutResult::None
661        ));
662    }
663}