1#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
37pub struct CommandId(u64);
38
39impl CommandId {
40 pub fn new<T: std::hash::Hash>(value: T) -> Self {
46 use {
47 rustc_hash::FxHasher,
48 std::hash::{BuildHasher, BuildHasherDefault},
49 };
50 Self(BuildHasherDefault::<FxHasher>::default().hash_one(value))
51 }
52
53 pub fn raw(self) -> u64 { self.0 }
60
61 pub fn from_raw(v: u64) -> Self { Self(v) }
63}
64
65#[derive(Debug, Clone)]
71pub struct CommandSpec {
72 pub id: CommandId,
74 pub label: String,
76 pub description: Option<String>,
78 pub shortcut_hint: Option<String>,
81}
82
83impl CommandSpec {
84 pub fn new(id: CommandId, label: impl Into<String>) -> Self {
86 Self {
87 id,
88 label: label.into(),
89 description: None,
90 shortcut_hint: None,
91 }
92 }
93
94 pub fn with_description(mut self, desc: impl Into<String>) -> Self {
96 self.description = Some(desc.into());
97 self
98 }
99
100 pub fn with_shortcut_hint(mut self, hint: impl Into<String>) -> Self {
102 self.shortcut_hint = Some(hint.into());
103 self
104 }
105}
106
107#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
112pub enum CommandState {
113 #[default]
115 Enabled,
116 Disabled,
118 Hidden,
120}
121
122impl CommandState {
123 pub fn is_enabled(self) -> bool { self == CommandState::Enabled }
125
126 pub fn is_visible(self) -> bool { self != CommandState::Hidden }
128}
129
130#[derive(Debug, Default)]
169pub struct CommandRegistry<C: Copy + std::hash::Hash + Eq + Into<CommandId>> {
170 specs: std::collections::HashMap<CommandId, CommandSpec>,
171 states: std::collections::HashMap<CommandId, CommandState>,
172 _phantom: std::marker::PhantomData<C>,
173}
174
175impl<C: Copy + std::hash::Hash + Eq + Into<CommandId>> CommandRegistry<C> {
176 pub fn new() -> Self {
178 Self {
179 specs: std::collections::HashMap::new(),
180 states: std::collections::HashMap::new(),
181 _phantom: std::marker::PhantomData,
182 }
183 }
184
185 pub fn register(&mut self, cmd: C, spec: CommandSpec) -> &mut Self {
195 let id: CommandId = cmd.into();
196 assert_eq!(
197 spec.id, id,
198 "CommandSpec::id does not match the registered command; \
199 build the spec with CommandId::new(cmd) or CommandSpec::new(id, label)"
200 );
201 self.states.entry(id).or_insert(CommandState::Enabled);
202 self.specs.insert(id, spec);
203 self
204 }
205
206 pub fn with(mut self, cmd: C, spec: CommandSpec) -> Self {
216 self.register(cmd, spec);
217 self
218 }
219
220 pub fn spec(&self, cmd: C) -> Option<&CommandSpec> { self.specs.get(&cmd.into()) }
224
225 pub fn spec_by_id(&self, id: CommandId) -> Option<&CommandSpec> { self.specs.get(&id) }
227
228 pub fn state(&self, cmd: C) -> Option<CommandState> { self.states.get(&cmd.into()).copied() }
232
233 pub fn state_by_id(&self, id: CommandId) -> Option<CommandState> {
235 self.states.get(&id).copied()
236 }
237
238 pub fn set_state(&mut self, cmd: C, state: CommandState) {
242 let id: CommandId = cmd.into();
243 if self.specs.contains_key(&id) {
244 self.states.insert(id, state);
245 }
246 }
247
248 pub fn set_state_by_id(&mut self, id: CommandId, state: CommandState) {
252 if self.specs.contains_key(&id) {
253 self.states.insert(id, state);
254 }
255 }
256
257 pub fn iter_specs(&self) -> impl Iterator<Item = (CommandId, &CommandSpec)> {
259 self.specs.iter().map(|(&id, spec)| (id, spec))
260 }
261
262 pub fn spec_by_id_mut(&mut self, id: CommandId) -> Option<&mut CommandSpec> {
266 self.specs.get_mut(&id)
267 }
268}
269
270#[derive(Debug, Clone, Copy, PartialEq, Eq)]
272pub enum CommandSource {
273 Keyboard,
275 Menu,
277 Button,
279 Programmatic,
281}
282
283#[derive(Debug, Clone)]
288pub struct CommandTriggered {
289 pub id: CommandId,
291 pub source: CommandSource,
293}
294
295impl CommandTriggered {
296 pub fn new(id: CommandId, source: CommandSource) -> Self { Self { id, source } }
298}
299
300#[cfg(test)]
301mod tests {
302 use super::*;
303
304 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
305 enum AppCmd {
306 ShowHelp,
307 Save,
308 Quit,
309 }
310
311 #[test]
312 fn command_id_same_value_is_equal() {
313 let a = CommandId::new(AppCmd::ShowHelp);
314 let b = CommandId::new(AppCmd::ShowHelp);
315 assert_eq!(a, b);
316 }
317
318 #[test]
319 fn command_id_different_variants_are_not_equal() {
320 let a = CommandId::new(AppCmd::Save);
321 let b = CommandId::new(AppCmd::Quit);
322 assert_ne!(a, b);
323 }
324
325 #[test]
326 fn command_id_raw_roundtrip() {
327 let id = CommandId::new(AppCmd::Save);
328 assert_eq!(CommandId::from_raw(id.raw()), id);
329 }
330
331 #[test]
332 fn command_id_hashable_in_map() {
333 let mut map = std::collections::HashMap::new();
334 map.insert(CommandId::new(AppCmd::ShowHelp), "help");
335 map.insert(CommandId::new(AppCmd::Save), "save");
336 assert_eq!(map[&CommandId::new(AppCmd::ShowHelp)], "help");
337 assert_eq!(map[&CommandId::new(AppCmd::Save)], "save");
338 }
339
340 #[test]
341 fn command_spec_builder_chain() {
342 let id = CommandId::new(AppCmd::Save);
343 let spec = CommandSpec::new(id, "Save")
344 .with_description("Save the current file")
345 .with_shortcut_hint("Ctrl+S");
346 assert_eq!(spec.id, id);
347 assert_eq!(spec.label, "Save");
348 assert_eq!(spec.description.as_deref(), Some("Save the current file"));
349 assert_eq!(spec.shortcut_hint.as_deref(), Some("Ctrl+S"));
350 }
351
352 #[test]
353 fn command_spec_minimal_has_no_optional_fields() {
354 let spec = CommandSpec::new(CommandId::new(AppCmd::Quit), "Quit");
355 assert_eq!(spec.label, "Quit");
356 assert!(spec.description.is_none());
357 assert!(spec.shortcut_hint.is_none());
358 }
359
360 #[test]
361 fn command_state_is_enabled() {
362 assert!(CommandState::Enabled.is_enabled());
363 assert!(!CommandState::Disabled.is_enabled());
364 assert!(!CommandState::Hidden.is_enabled());
365 }
366
367 #[test]
368 fn command_state_is_visible() {
369 assert!(CommandState::Enabled.is_visible());
370 assert!(CommandState::Disabled.is_visible());
371 assert!(!CommandState::Hidden.is_visible());
372 }
373
374 #[test]
375 fn command_state_default_is_enabled() {
376 assert_eq!(CommandState::default(), CommandState::Enabled);
377 }
378
379 #[test]
380 fn command_triggered_stores_id_and_source() {
381 let id = CommandId::new(AppCmd::Save);
382 let triggered = CommandTriggered::new(id, CommandSource::Keyboard);
383 assert_eq!(triggered.id, id);
384 assert_eq!(triggered.source, CommandSource::Keyboard);
385 }
386
387 #[test]
388 fn command_source_variants_are_distinct() {
389 assert_ne!(CommandSource::Keyboard, CommandSource::Menu);
390 assert_ne!(CommandSource::Button, CommandSource::Programmatic);
391 }
392
393 impl From<AppCmd> for CommandId {
394 fn from(c: AppCmd) -> Self { CommandId::new(c) }
395 }
396
397 fn make_spec(cmd: AppCmd, label: &str) -> CommandSpec {
398 CommandSpec::new(CommandId::new(cmd), label)
399 }
400
401 #[test]
402 fn registry_register_and_query_spec() {
403 let mut reg = CommandRegistry::new();
404 reg.register(AppCmd::Save, make_spec(AppCmd::Save, "Save"));
405 assert!(reg.spec(AppCmd::Save).is_some());
406 assert_eq!(reg.spec(AppCmd::Save).unwrap().label, "Save");
407 }
408
409 #[test]
410 fn registry_unregistered_returns_none() {
411 let reg: CommandRegistry<AppCmd> = CommandRegistry::new();
412 assert!(reg.spec(AppCmd::Quit).is_none());
413 assert!(reg.state(AppCmd::Quit).is_none());
414 }
415
416 #[test]
417 fn registry_default_state_is_enabled() {
418 let mut reg = CommandRegistry::new();
419 reg.register(AppCmd::Save, make_spec(AppCmd::Save, "Save"));
420 assert_eq!(reg.state(AppCmd::Save), Some(CommandState::Enabled));
421 }
422
423 #[test]
424 fn registry_set_state_updates_value() {
425 let mut reg = CommandRegistry::new();
426 reg.register(AppCmd::Save, make_spec(AppCmd::Save, "Save"));
427 reg.set_state(AppCmd::Save, CommandState::Disabled);
428 assert_eq!(reg.state(AppCmd::Save), Some(CommandState::Disabled));
429 }
430
431 #[test]
432 fn registry_set_state_unregistered_is_noop() {
433 let mut reg: CommandRegistry<AppCmd> = CommandRegistry::new();
434 reg.set_state(AppCmd::Quit, CommandState::Hidden);
435 assert!(reg.state(AppCmd::Quit).is_none());
436 }
437
438 #[test]
439 fn registry_builder_chain() {
440 let reg = CommandRegistry::new()
441 .with(AppCmd::ShowHelp, make_spec(AppCmd::ShowHelp, "Help"))
442 .with(AppCmd::Save, make_spec(AppCmd::Save, "Save"))
443 .with(AppCmd::Quit, make_spec(AppCmd::Quit, "Quit"));
444 assert!(reg.spec(AppCmd::ShowHelp).is_some());
445 assert!(reg.spec(AppCmd::Save).is_some());
446 assert!(reg.spec(AppCmd::Quit).is_some());
447 }
448
449 #[test]
450 fn registry_register_id_mismatch_panics() {
451 let mut reg = CommandRegistry::new();
452 let wrong_id = CommandId::new(AppCmd::Quit);
453 let spec = CommandSpec::new(wrong_id, "Save");
454 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
455 reg.register(AppCmd::Save, spec);
456 }));
457 assert!(result.is_err(), "expected panic on id mismatch");
458 }
459
460 #[test]
461 fn registry_iter_specs_covers_all_registered() {
462 let reg = CommandRegistry::new()
463 .with(AppCmd::Save, make_spec(AppCmd::Save, "Save"))
464 .with(AppCmd::Quit, make_spec(AppCmd::Quit, "Quit"));
465 let ids: Vec<CommandId> = reg.iter_specs().map(|(id, _)| id).collect();
466 assert_eq!(ids.len(), 2);
467 assert!(ids.contains(&CommandId::new(AppCmd::Save)));
468 assert!(ids.contains(&CommandId::new(AppCmd::Quit)));
469 }
470
471 #[test]
472 fn registry_spec_by_id() {
473 let mut reg = CommandRegistry::new();
474 reg.register(AppCmd::Save, make_spec(AppCmd::Save, "Save"));
475 let id = CommandId::new(AppCmd::Save);
476 assert!(reg.spec_by_id(id).is_some());
477 assert_eq!(reg.spec_by_id(id).unwrap().label, "Save");
478 }
479}