Skip to main content

operad/interaction/
commands.rs

1//! Command metadata and keyboard shortcut routing primitives.
2//!
3//! The command identifiers are intentionally opaque to Operad. Applications own
4//! the ID namespace and can use strings, UUIDs, or any other stable identifier
5//! serialized into a [`CommandId`].
6
7use std::collections::{HashMap, HashSet};
8use std::fmt;
9use std::hash::{Hash, Hasher};
10
11use crate::platform::{
12    AppLifecycleRequest, ClipboardRequest, FileDialogRequest, NotificationRequest, OpenUrlRequest,
13    PlatformRequest, PlatformRequestId, PlatformServiceCapabilities, PlatformServiceKind,
14    PlatformServiceRequest, RepaintRequest, ScreenshotRequest,
15};
16use crate::{KeyCode, KeyModifiers};
17
18#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
19pub struct CommandId(String);
20
21impl CommandId {
22    pub fn new(id: impl Into<String>) -> Self {
23        Self(id.into())
24    }
25
26    pub fn as_str(&self) -> &str {
27        &self.0
28    }
29
30    pub fn into_string(self) -> String {
31        self.0
32    }
33}
34
35impl AsRef<str> for CommandId {
36    fn as_ref(&self) -> &str {
37        self.as_str()
38    }
39}
40
41impl From<&str> for CommandId {
42    fn from(value: &str) -> Self {
43        Self::new(value)
44    }
45}
46
47impl From<String> for CommandId {
48    fn from(value: String) -> Self {
49        Self::new(value)
50    }
51}
52
53impl From<&CommandId> for CommandId {
54    fn from(value: &CommandId) -> Self {
55        value.clone()
56    }
57}
58
59impl fmt::Display for CommandId {
60    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
61        formatter.write_str(self.as_str())
62    }
63}
64
65#[derive(Debug, Clone, PartialEq, Eq)]
66pub struct CommandMeta {
67    pub id: CommandId,
68    pub label: String,
69    pub description: Option<String>,
70    pub category: Option<String>,
71}
72
73impl CommandMeta {
74    pub fn new(id: impl Into<CommandId>, label: impl Into<String>) -> Self {
75        Self {
76            id: id.into(),
77            label: label.into(),
78            description: None,
79            category: None,
80        }
81    }
82
83    pub fn description(mut self, description: impl Into<String>) -> Self {
84        self.description = Some(description.into());
85        self
86    }
87
88    pub fn category(mut self, category: impl Into<String>) -> Self {
89        self.category = Some(category.into());
90        self
91    }
92}
93
94#[derive(Debug, Clone, PartialEq, Eq)]
95pub struct Command {
96    pub meta: CommandMeta,
97    pub enabled: bool,
98    pub disabled_reason: Option<String>,
99}
100
101impl Command {
102    pub fn new(meta: CommandMeta) -> Self {
103        Self {
104            meta,
105            enabled: true,
106            disabled_reason: None,
107        }
108    }
109
110    pub fn disabled(mut self, reason: impl Into<String>) -> Self {
111        self.enabled = false;
112        self.disabled_reason = Some(reason.into());
113        self
114    }
115
116    pub fn enabled(mut self) -> Self {
117        self.enabled = true;
118        self.disabled_reason = None;
119        self
120    }
121
122    pub fn is_enabled(&self) -> bool {
123        self.enabled
124    }
125}
126
127impl From<CommandMeta> for Command {
128    fn from(meta: CommandMeta) -> Self {
129        Self::new(meta)
130    }
131}
132
133#[derive(Debug, Clone, PartialEq)]
134pub enum CommandEffect {
135    Platform(PlatformRequest),
136    Custom(String),
137}
138
139impl CommandEffect {
140    pub fn platform(request: PlatformRequest) -> Self {
141        Self::Platform(request)
142    }
143
144    pub fn custom(effect: impl Into<String>) -> Self {
145        Self::Custom(effect.into())
146    }
147
148    pub fn clipboard(request: ClipboardRequest) -> Self {
149        Self::Platform(PlatformRequest::Clipboard(request))
150    }
151
152    pub fn file_dialog(request: FileDialogRequest) -> Self {
153        Self::Platform(PlatformRequest::FileDialog(request))
154    }
155
156    pub fn open_url(request: OpenUrlRequest) -> Self {
157        Self::Platform(PlatformRequest::OpenUrl(request))
158    }
159
160    pub fn notification(request: NotificationRequest) -> Self {
161        Self::Platform(PlatformRequest::Notification(request))
162    }
163
164    pub fn screenshot(request: ScreenshotRequest) -> Self {
165        Self::Platform(PlatformRequest::Screenshot(request))
166    }
167
168    pub fn quit() -> Self {
169        Self::Platform(PlatformRequest::AppLifecycle(AppLifecycleRequest::Quit))
170    }
171
172    pub fn close_window(window_id: impl Into<String>) -> Self {
173        Self::Platform(PlatformRequest::AppLifecycle(
174            AppLifecycleRequest::close_window(window_id),
175        ))
176    }
177
178    pub fn close_active_window() -> Self {
179        Self::Platform(PlatformRequest::AppLifecycle(
180            AppLifecycleRequest::close_active_window(),
181        ))
182    }
183
184    pub fn repaint(request: RepaintRequest) -> Self {
185        Self::Platform(PlatformRequest::Repaint(request))
186    }
187
188    pub const fn platform_kind(&self) -> Option<PlatformServiceKind> {
189        match self {
190            Self::Platform(request) => Some(request.kind()),
191            Self::Custom(_) => None,
192        }
193    }
194}
195
196#[derive(Debug, Clone, PartialEq)]
197pub enum CommandEffectInvocation {
198    Platform(PlatformServiceRequest),
199    Custom { command: CommandId, effect: String },
200}
201
202#[derive(Debug, Clone, PartialEq, Eq, Hash)]
203pub enum CommandScope {
204    Global,
205    Workspace,
206    Panel,
207    Editor,
208    Text,
209    Modal,
210    Custom(String),
211}
212
213impl CommandScope {
214    pub const BUILT_INS: [Self; 6] = [
215        Self::Global,
216        Self::Workspace,
217        Self::Panel,
218        Self::Editor,
219        Self::Text,
220        Self::Modal,
221    ];
222
223    pub fn custom(id: impl Into<String>) -> Self {
224        Self::Custom(id.into())
225    }
226
227    pub const fn hierarchy_rank(&self) -> u8 {
228        match self {
229            Self::Global => 0,
230            Self::Workspace => 10,
231            Self::Panel => 20,
232            Self::Editor => 30,
233            Self::Text => 40,
234            Self::Modal => 50,
235            Self::Custom(_) => 60,
236        }
237    }
238}
239
240#[derive(Debug, Clone, Copy, PartialEq, Eq)]
241pub struct Shortcut {
242    pub key: KeyCode,
243    pub modifiers: KeyModifiers,
244}
245
246impl Shortcut {
247    pub fn new(key: KeyCode, modifiers: KeyModifiers) -> Self {
248        Self {
249            key: normalize_key(key),
250            modifiers,
251        }
252    }
253
254    pub fn character(character: char, modifiers: KeyModifiers) -> Self {
255        Self::new(KeyCode::Character(character), modifiers)
256    }
257
258    pub fn ctrl(character: char) -> Self {
259        Self::character(
260            character,
261            KeyModifiers {
262                ctrl: true,
263                ..KeyModifiers::NONE
264            },
265        )
266    }
267
268    pub fn meta(character: char) -> Self {
269        Self::character(
270            character,
271            KeyModifiers {
272                meta: true,
273                ..KeyModifiers::NONE
274            },
275        )
276    }
277}
278
279impl Hash for Shortcut {
280    fn hash<H: Hasher>(&self, state: &mut H) {
281        hash_key_code(self.key, state);
282        self.modifiers.shift.hash(state);
283        self.modifiers.ctrl.hash(state);
284        self.modifiers.alt.hash(state);
285        self.modifiers.meta.hash(state);
286    }
287}
288
289#[derive(Debug, Clone, PartialEq, Eq)]
290pub struct ShortcutBinding {
291    pub scope: CommandScope,
292    pub shortcut: Shortcut,
293    pub command: CommandId,
294}
295
296impl ShortcutBinding {
297    pub fn new(scope: CommandScope, shortcut: Shortcut, command: impl Into<CommandId>) -> Self {
298        Self {
299            scope,
300            shortcut,
301            command: command.into(),
302        }
303    }
304}
305
306#[derive(Debug, Clone, PartialEq, Eq)]
307pub struct ShortcutConflict {
308    pub scope: CommandScope,
309    pub shortcut: Shortcut,
310    pub commands: Vec<CommandId>,
311}
312
313#[derive(Debug, Clone, PartialEq, Eq)]
314pub enum CommandRegistryError {
315    DuplicateCommand(CommandId),
316    UnknownCommand(CommandId),
317    DisabledCommand(CommandId),
318    MissingCommandEffect(CommandId),
319    UnsupportedCommandEffect {
320        command: CommandId,
321        kind: PlatformServiceKind,
322    },
323    ShortcutConflict(ShortcutConflict),
324}
325
326impl fmt::Display for CommandRegistryError {
327    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
328        match self {
329            Self::DuplicateCommand(command) => write!(formatter, "duplicate command `{command}`"),
330            Self::UnknownCommand(command) => write!(formatter, "unknown command `{command}`"),
331            Self::DisabledCommand(command) => write!(formatter, "disabled command `{command}`"),
332            Self::MissingCommandEffect(command) => {
333                write!(formatter, "missing command effect for `{command}`")
334            }
335            Self::UnsupportedCommandEffect { command, kind } => {
336                write!(
337                    formatter,
338                    "unsupported platform effect {kind:?} for `{command}`"
339                )
340            }
341            Self::ShortcutConflict(conflict) => {
342                write!(
343                    formatter,
344                    "shortcut conflict in {:?} for {:?}",
345                    conflict.scope, conflict.shortcut
346                )
347            }
348        }
349    }
350}
351
352impl std::error::Error for CommandRegistryError {}
353
354#[derive(Debug, Clone, Default)]
355pub struct CommandRegistry {
356    commands: HashMap<CommandId, Command>,
357    bindings: Vec<ShortcutBinding>,
358    effects: HashMap<CommandId, CommandEffect>,
359}
360
361impl CommandRegistry {
362    pub fn new() -> Self {
363        Self::default()
364    }
365
366    pub fn register(&mut self, command: impl Into<Command>) -> Result<(), CommandRegistryError> {
367        let command = command.into();
368        let id = command.meta.id.clone();
369
370        if self.commands.contains_key(&id) {
371            return Err(CommandRegistryError::DuplicateCommand(id));
372        }
373
374        self.commands.insert(id, command);
375        Ok(())
376    }
377
378    pub fn command(&self, id: impl Into<CommandId>) -> Option<&Command> {
379        let id = id.into();
380        self.commands.get(&id)
381    }
382
383    pub fn commands(&self) -> impl Iterator<Item = &Command> {
384        self.commands.values()
385    }
386
387    pub fn bindings(&self) -> &[ShortcutBinding] {
388        &self.bindings
389    }
390
391    pub fn effect(&self, command: impl Into<CommandId>) -> Option<&CommandEffect> {
392        let command = command.into();
393        self.effects.get(&command)
394    }
395
396    pub fn effects(&self) -> impl Iterator<Item = (&CommandId, &CommandEffect)> {
397        self.effects.iter()
398    }
399
400    pub fn bind_effect(
401        &mut self,
402        command: impl Into<CommandId>,
403        effect: CommandEffect,
404    ) -> Result<(), CommandRegistryError> {
405        let command = command.into();
406
407        if !self.commands.contains_key(&command) {
408            return Err(CommandRegistryError::UnknownCommand(command));
409        }
410
411        self.effects.insert(command, effect);
412        Ok(())
413    }
414
415    pub fn bind_platform_effect(
416        &mut self,
417        command: impl Into<CommandId>,
418        request: PlatformRequest,
419    ) -> Result<(), CommandRegistryError> {
420        self.bind_effect(command, CommandEffect::platform(request))
421    }
422
423    pub fn bind_custom_effect(
424        &mut self,
425        command: impl Into<CommandId>,
426        effect: impl Into<String>,
427    ) -> Result<(), CommandRegistryError> {
428        self.bind_effect(command, CommandEffect::custom(effect))
429    }
430
431    pub fn invoke_effect(
432        &self,
433        command: impl Into<CommandId>,
434        request_id: PlatformRequestId,
435        capabilities: PlatformServiceCapabilities,
436    ) -> Result<CommandEffectInvocation, CommandRegistryError> {
437        let command = command.into();
438        let registered = self
439            .commands
440            .get(&command)
441            .ok_or_else(|| CommandRegistryError::UnknownCommand(command.clone()))?;
442
443        if !registered.is_enabled() {
444            return Err(CommandRegistryError::DisabledCommand(command));
445        }
446
447        let effect = self
448            .effects
449            .get(&command)
450            .ok_or_else(|| CommandRegistryError::MissingCommandEffect(command.clone()))?;
451
452        match effect {
453            CommandEffect::Platform(request) => {
454                if !capabilities.supports(request) {
455                    return Err(CommandRegistryError::UnsupportedCommandEffect {
456                        command,
457                        kind: request.kind(),
458                    });
459                }
460
461                Ok(CommandEffectInvocation::Platform(
462                    PlatformServiceRequest::new(request_id, request.clone()),
463                ))
464            }
465            CommandEffect::Custom(effect) => Ok(CommandEffectInvocation::Custom {
466                command,
467                effect: effect.clone(),
468            }),
469        }
470    }
471
472    pub fn bind_shortcut(
473        &mut self,
474        scope: CommandScope,
475        shortcut: Shortcut,
476        command: impl Into<CommandId>,
477    ) -> Result<(), CommandRegistryError> {
478        let binding = ShortcutBinding::new(scope, shortcut, command);
479
480        if !self.commands.contains_key(&binding.command) {
481            return Err(CommandRegistryError::UnknownCommand(binding.command));
482        }
483
484        if let Some(conflict) = self.conflict_for_binding(&binding) {
485            return Err(CommandRegistryError::ShortcutConflict(conflict));
486        }
487
488        if !self.bindings.contains(&binding) {
489            self.bindings.push(binding);
490        }
491
492        Ok(())
493    }
494
495    pub fn set_enabled(
496        &mut self,
497        command: impl Into<CommandId>,
498        enabled: bool,
499    ) -> Result<(), CommandRegistryError> {
500        let command = command.into();
501        let registered = self
502            .commands
503            .get_mut(&command)
504            .ok_or_else(|| CommandRegistryError::UnknownCommand(command.clone()))?;
505
506        registered.enabled = enabled;
507        if enabled {
508            registered.disabled_reason = None;
509        }
510
511        Ok(())
512    }
513
514    pub fn disable(
515        &mut self,
516        command: impl Into<CommandId>,
517        reason: impl Into<String>,
518    ) -> Result<(), CommandRegistryError> {
519        let command = command.into();
520        let registered = self
521            .commands
522            .get_mut(&command)
523            .ok_or_else(|| CommandRegistryError::UnknownCommand(command.clone()))?;
524
525        registered.enabled = false;
526        registered.disabled_reason = Some(reason.into());
527        Ok(())
528    }
529
530    pub fn enable(&mut self, command: impl Into<CommandId>) -> Result<(), CommandRegistryError> {
531        self.set_enabled(command, true)
532    }
533
534    pub fn conflicts(&self) -> Vec<ShortcutConflict> {
535        let mut by_shortcut: HashMap<(CommandScope, Shortcut), HashSet<CommandId>> = HashMap::new();
536
537        for binding in &self.bindings {
538            by_shortcut
539                .entry((binding.scope.clone(), binding.shortcut))
540                .or_default()
541                .insert(binding.command.clone());
542        }
543
544        let mut conflicts = by_shortcut
545            .into_iter()
546            .filter_map(|((scope, shortcut), commands)| {
547                if commands.len() < 2 {
548                    return None;
549                }
550
551                let mut commands = commands.into_iter().collect::<Vec<_>>();
552                commands.sort();
553
554                Some(ShortcutConflict {
555                    scope,
556                    shortcut,
557                    commands,
558                })
559            })
560            .collect::<Vec<_>>();
561
562        conflicts.sort_by(|left, right| {
563            left.scope
564                .hierarchy_rank()
565                .cmp(&right.scope.hierarchy_rank())
566                .then_with(|| format!("{:?}", left.scope).cmp(&format!("{:?}", right.scope)))
567                .then_with(|| format!("{:?}", left.shortcut).cmp(&format!("{:?}", right.shortcut)))
568        });
569
570        conflicts
571    }
572
573    pub fn resolve_key(
574        &self,
575        key: KeyCode,
576        modifiers: KeyModifiers,
577        active_scopes: &[CommandScope],
578    ) -> Option<CommandId> {
579        self.resolve(Shortcut::new(key, modifiers), active_scopes)
580    }
581
582    pub fn resolve(&self, shortcut: Shortcut, active_scopes: &[CommandScope]) -> Option<CommandId> {
583        let scopes = ordered_active_scopes(active_scopes);
584
585        for scope in scopes.iter().rev() {
586            let Some(binding) = self
587                .bindings
588                .iter()
589                .find(|binding| binding.scope == *scope && binding.shortcut == shortcut)
590            else {
591                continue;
592            };
593
594            if self
595                .commands
596                .get(&binding.command)
597                .is_some_and(Command::is_enabled)
598            {
599                return Some(binding.command.clone());
600            }
601        }
602
603        None
604    }
605
606    fn conflict_for_binding(&self, binding: &ShortcutBinding) -> Option<ShortcutConflict> {
607        let mut commands = self
608            .bindings
609            .iter()
610            .filter(|existing| {
611                existing.scope == binding.scope
612                    && existing.shortcut == binding.shortcut
613                    && existing.command != binding.command
614            })
615            .map(|existing| existing.command.clone())
616            .collect::<Vec<_>>();
617
618        if commands.is_empty() {
619            return None;
620        }
621
622        commands.push(binding.command.clone());
623        commands.sort();
624        commands.dedup();
625
626        Some(ShortcutConflict {
627            scope: binding.scope.clone(),
628            shortcut: binding.shortcut,
629            commands,
630        })
631    }
632}
633
634fn ordered_active_scopes(active_scopes: &[CommandScope]) -> Vec<CommandScope> {
635    let mut scopes = Vec::<(CommandScope, usize)>::new();
636
637    upsert_scope(&mut scopes, CommandScope::Global, 0);
638    for (index, scope) in active_scopes.iter().enumerate() {
639        upsert_scope(&mut scopes, scope.clone(), index + 1);
640    }
641
642    scopes.sort_by(|(left_scope, left_index), (right_scope, right_index)| {
643        left_scope
644            .hierarchy_rank()
645            .cmp(&right_scope.hierarchy_rank())
646            .then_with(|| left_index.cmp(right_index))
647    });
648
649    scopes.into_iter().map(|(scope, _)| scope).collect()
650}
651
652fn upsert_scope(scopes: &mut Vec<(CommandScope, usize)>, scope: CommandScope, index: usize) {
653    if let Some((_, existing_index)) = scopes
654        .iter_mut()
655        .find(|(existing_scope, _)| *existing_scope == scope)
656    {
657        *existing_index = index;
658    } else {
659        scopes.push((scope, index));
660    }
661}
662
663fn normalize_key(key: KeyCode) -> KeyCode {
664    match key {
665        KeyCode::Character(character) => KeyCode::Character(character.to_ascii_lowercase()),
666        other => other,
667    }
668}
669
670fn hash_key_code<H: Hasher>(key: KeyCode, state: &mut H) {
671    match key {
672        KeyCode::Character(character) => {
673            0_u8.hash(state);
674            character.hash(state);
675        }
676        KeyCode::Backspace => 1_u8.hash(state),
677        KeyCode::Delete => 2_u8.hash(state),
678        KeyCode::ArrowLeft => 3_u8.hash(state),
679        KeyCode::ArrowRight => 4_u8.hash(state),
680        KeyCode::ArrowUp => 5_u8.hash(state),
681        KeyCode::ArrowDown => 6_u8.hash(state),
682        KeyCode::Home => 7_u8.hash(state),
683        KeyCode::End => 8_u8.hash(state),
684        KeyCode::Enter => 9_u8.hash(state),
685        KeyCode::Escape => 10_u8.hash(state),
686        KeyCode::Tab => 11_u8.hash(state),
687        KeyCode::F10 => 12_u8.hash(state),
688        KeyCode::ContextMenu => 13_u8.hash(state),
689    }
690}
691
692#[cfg(test)]
693mod tests {
694    use super::*;
695
696    fn command(id: &'static str) -> Command {
697        Command::new(CommandMeta::new(id, id))
698    }
699
700    fn registry_with(ids: &[&'static str]) -> CommandRegistry {
701        let mut registry = CommandRegistry::new();
702        for id in ids {
703            registry.register(command(id)).unwrap();
704        }
705        registry
706    }
707
708    #[test]
709    fn command_ids_are_opaque_and_metadata_is_preserved() {
710        let mut registry = CommandRegistry::new();
711
712        registry
713            .register(
714                CommandMeta::new("workspace.save", "Save Workspace")
715                    .description("Persist the active workspace")
716                    .category("File"),
717            )
718            .unwrap();
719
720        let command = registry.command("workspace.save").unwrap();
721        assert_eq!(command.meta.id.as_str(), "workspace.save");
722        assert_eq!(command.meta.label, "Save Workspace");
723        assert_eq!(
724            command.meta.description.as_deref(),
725            Some("Persist the active workspace")
726        );
727        assert_eq!(command.meta.category.as_deref(), Some("File"));
728    }
729
730    #[test]
731    fn duplicate_commands_are_rejected() {
732        let mut registry = registry_with(&["save"]);
733
734        let error = registry.register(command("save")).unwrap_err();
735
736        assert_eq!(
737            error,
738            CommandRegistryError::DuplicateCommand(CommandId::from("save"))
739        );
740    }
741
742    #[test]
743    fn same_scope_shortcut_conflicts_are_rejected() {
744        let mut registry = registry_with(&["save", "search"]);
745
746        registry
747            .bind_shortcut(CommandScope::Global, Shortcut::ctrl('s'), "save")
748            .unwrap();
749        let error = registry
750            .bind_shortcut(CommandScope::Global, Shortcut::ctrl('s'), "search")
751            .unwrap_err();
752
753        assert_eq!(
754            error,
755            CommandRegistryError::ShortcutConflict(ShortcutConflict {
756                scope: CommandScope::Global,
757                shortcut: Shortcut::ctrl('s'),
758                commands: vec![CommandId::from("save"), CommandId::from("search")],
759            })
760        );
761    }
762
763    #[test]
764    fn same_shortcut_can_be_bound_in_more_specific_scopes() {
765        let mut registry = registry_with(&["global.search", "editor.search"]);
766
767        registry
768            .bind_shortcut(CommandScope::Global, Shortcut::ctrl('f'), "global.search")
769            .unwrap();
770        registry
771            .bind_shortcut(CommandScope::Editor, Shortcut::ctrl('f'), "editor.search")
772            .unwrap();
773
774        assert_eq!(
775            registry.resolve(Shortcut::ctrl('f'), &[]),
776            Some(CommandId::from("global.search"))
777        );
778        assert_eq!(
779            registry.resolve(Shortcut::ctrl('f'), &[CommandScope::Editor]),
780            Some(CommandId::from("editor.search"))
781        );
782    }
783
784    #[test]
785    fn scope_hierarchy_wins_over_input_scope_order() {
786        let mut registry = registry_with(&["workspace.rename", "text.rename"]);
787
788        registry
789            .bind_shortcut(
790                CommandScope::Workspace,
791                Shortcut::new(KeyCode::Enter, KeyModifiers::NONE),
792                "workspace.rename",
793            )
794            .unwrap();
795        registry
796            .bind_shortcut(
797                CommandScope::Text,
798                Shortcut::new(KeyCode::Enter, KeyModifiers::NONE),
799                "text.rename",
800            )
801            .unwrap();
802
803        assert_eq!(
804            registry.resolve(
805                Shortcut::new(KeyCode::Enter, KeyModifiers::NONE),
806                &[CommandScope::Text, CommandScope::Workspace]
807            ),
808            Some(CommandId::from("text.rename"))
809        );
810        assert_eq!(
811            registry.resolve(
812                Shortcut::new(KeyCode::Enter, KeyModifiers::NONE),
813                &[CommandScope::Workspace, CommandScope::Text]
814            ),
815            Some(CommandId::from("text.rename"))
816        );
817    }
818
819    #[test]
820    fn disabled_commands_do_not_resolve() {
821        let mut registry = registry_with(&["global.cancel", "modal.close"]);
822
823        registry
824            .bind_shortcut(
825                CommandScope::Global,
826                Shortcut::new(KeyCode::Escape, KeyModifiers::NONE),
827                "global.cancel",
828            )
829            .unwrap();
830        registry
831            .bind_shortcut(
832                CommandScope::Modal,
833                Shortcut::new(KeyCode::Escape, KeyModifiers::NONE),
834                "modal.close",
835            )
836            .unwrap();
837        registry.disable("modal.close", "Nothing to close").unwrap();
838
839        assert_eq!(
840            registry.resolve(
841                Shortcut::new(KeyCode::Escape, KeyModifiers::NONE),
842                &[CommandScope::Modal]
843            ),
844            Some(CommandId::from("global.cancel"))
845        );
846    }
847
848    #[test]
849    fn character_shortcuts_are_case_normalized() {
850        let mut registry = registry_with(&["save"]);
851
852        registry
853            .bind_shortcut(CommandScope::Global, Shortcut::ctrl('S'), "save")
854            .unwrap();
855
856        assert_eq!(
857            registry.resolve_key(
858                KeyCode::Character('s'),
859                KeyModifiers {
860                    ctrl: true,
861                    ..KeyModifiers::NONE
862                },
863                &[]
864            ),
865            Some(CommandId::from("save"))
866        );
867    }
868
869    #[test]
870    fn conflicts_reports_existing_same_scope_collisions() {
871        let registry = CommandRegistry {
872            commands: HashMap::new(),
873            bindings: vec![
874                ShortcutBinding::new(CommandScope::Panel, Shortcut::ctrl('k'), "open.palette"),
875                ShortcutBinding::new(CommandScope::Panel, Shortcut::ctrl('k'), "focus.search"),
876                ShortcutBinding::new(CommandScope::Text, Shortcut::ctrl('k'), "insert.link"),
877            ],
878            effects: HashMap::new(),
879        };
880
881        assert_eq!(
882            registry.conflicts(),
883            vec![ShortcutConflict {
884                scope: CommandScope::Panel,
885                shortcut: Shortcut::ctrl('k'),
886                commands: vec![
887                    CommandId::from("focus.search"),
888                    CommandId::from("open.palette")
889                ],
890            }]
891        );
892    }
893
894    #[test]
895    fn command_effects_create_platform_requests_when_supported() {
896        let mut registry = registry_with(&["file.open", "app.quit", "capture.viewport"]);
897        let open_request =
898            FileDialogRequest::new(crate::platform::FileDialogMode::OpenFile).title("Open Project");
899        let screenshot_request =
900            ScreenshotRequest::new(crate::platform::ScreenshotTarget::Viewport);
901
902        registry
903            .bind_effect(
904                "file.open",
905                CommandEffect::file_dialog(open_request.clone()),
906            )
907            .unwrap();
908        registry
909            .bind_effect("app.quit", CommandEffect::quit())
910            .unwrap();
911        registry
912            .bind_effect(
913                "capture.viewport",
914                CommandEffect::screenshot(screenshot_request.clone()),
915            )
916            .unwrap();
917
918        let open = registry
919            .invoke_effect(
920                "file.open",
921                PlatformRequestId::new(7),
922                PlatformServiceCapabilities::DESKTOP,
923            )
924            .unwrap();
925        let quit = registry
926            .invoke_effect(
927                "app.quit",
928                PlatformRequestId::new(8),
929                PlatformServiceCapabilities::DESKTOP,
930            )
931            .unwrap();
932
933        assert_eq!(
934            open,
935            CommandEffectInvocation::Platform(PlatformServiceRequest::new(
936                PlatformRequestId::new(7),
937                PlatformRequest::FileDialog(open_request)
938            ))
939        );
940        assert_eq!(
941            quit,
942            CommandEffectInvocation::Platform(PlatformServiceRequest::new(
943                PlatformRequestId::new(8),
944                PlatformRequest::AppLifecycle(AppLifecycleRequest::Quit)
945            ))
946        );
947        assert_eq!(
948            registry.effect("capture.viewport").unwrap().platform_kind(),
949            Some(PlatformServiceKind::Screenshot)
950        );
951    }
952
953    #[test]
954    fn command_effect_invocation_checks_command_state_and_capabilities() {
955        let mut registry = registry_with(&["capture.viewport", "disabled", "no.effect"]);
956        registry
957            .bind_effect(
958                "capture.viewport",
959                CommandEffect::screenshot(ScreenshotRequest::new(
960                    crate::platform::ScreenshotTarget::Viewport,
961                )),
962            )
963            .unwrap();
964        registry
965            .bind_effect(
966                "disabled",
967                CommandEffect::clipboard(ClipboardRequest::ReadText),
968            )
969            .unwrap();
970        registry.disable("disabled", "Unavailable").unwrap();
971
972        assert_eq!(
973            registry
974                .invoke_effect(
975                    "capture.viewport",
976                    PlatformRequestId::new(1),
977                    PlatformServiceCapabilities::NONE
978                )
979                .unwrap_err(),
980            CommandRegistryError::UnsupportedCommandEffect {
981                command: CommandId::from("capture.viewport"),
982                kind: PlatformServiceKind::Screenshot,
983            }
984        );
985        assert_eq!(
986            registry
987                .invoke_effect(
988                    "disabled",
989                    PlatformRequestId::new(2),
990                    PlatformServiceCapabilities::DESKTOP
991                )
992                .unwrap_err(),
993            CommandRegistryError::DisabledCommand(CommandId::from("disabled"))
994        );
995        assert_eq!(
996            registry
997                .invoke_effect(
998                    "no.effect",
999                    PlatformRequestId::new(3),
1000                    PlatformServiceCapabilities::DESKTOP
1001                )
1002                .unwrap_err(),
1003            CommandRegistryError::MissingCommandEffect(CommandId::from("no.effect"))
1004        );
1005        assert_eq!(
1006            registry
1007                .bind_effect("missing", CommandEffect::quit())
1008                .unwrap_err(),
1009            CommandRegistryError::UnknownCommand(CommandId::from("missing"))
1010        );
1011    }
1012
1013    #[test]
1014    fn custom_command_effects_round_trip_without_platform_capabilities() {
1015        let mut registry = registry_with(&["orbifold.quantize"]);
1016
1017        registry
1018            .bind_custom_effect("orbifold.quantize", "orbifold.quantize:selected")
1019            .unwrap();
1020
1021        assert_eq!(
1022            registry
1023                .invoke_effect(
1024                    "orbifold.quantize",
1025                    PlatformRequestId::new(42),
1026                    PlatformServiceCapabilities::NONE
1027                )
1028                .unwrap(),
1029            CommandEffectInvocation::Custom {
1030                command: CommandId::from("orbifold.quantize"),
1031                effect: "orbifold.quantize:selected".to_string(),
1032            }
1033        );
1034    }
1035}