1use 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}