Skip to main content

egui_command/
lib.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! `egui-command` — pure command model, no egui dependency.
4//!
5//! Defines the core types for representing user-facing commands:
6//! their identity, specification (metadata), state, and trigger events.
7//!
8//! # Architecture
9//! ```text
10//! egui-event  (typed event bus)
11//!     ↓
12//! egui-command  (this crate — command model)
13//!     ↓
14//! egui-command-binding  (egui integration: shortcut → CommandId)
15//!     ↓
16//! app  (AppCommand enum, business logic)
17//! ```
18
19/// Opaque command identifier.  Wrap an enum variant (or a `u32`) to make it
20/// comparable and hashable without storing strings at runtime.
21///
22/// # Example
23/// ```rust
24/// use egui_command::CommandId;
25///
26/// #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
27/// enum AppCmd {
28///     ShowHelp,
29///     RenameProfile,
30/// }
31///
32/// let id = CommandId::new(AppCmd::ShowHelp);
33/// assert_eq!(id, CommandId::new(AppCmd::ShowHelp));
34/// assert_ne!(id, CommandId::new(AppCmd::RenameProfile));
35/// ```
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
37pub struct CommandId(u64);
38
39impl CommandId {
40    /// Create a `CommandId` from any value that can be hashed.
41    ///
42    /// Uses `FxHasher` — a deterministic, platform-stable hasher — so that the
43    /// same value always produces the same `CommandId` across process restarts,
44    /// Rust versions, and platforms.
45    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    /// Raw numeric value.
54    ///
55    /// The underlying hash is stable within a build (same input → same output
56    /// across runs, versions, and platforms when using the same `FxHasher`).
57    /// Suitable for in-memory keying; treat persistence across binary upgrades
58    /// with caution unless the hashed type's discriminant is stable.
59    pub fn raw(self) -> u64 { self.0 }
60
61    /// Construct from a raw value (e.g. round-tripping through an integer key).
62    pub fn from_raw(v: u64) -> Self { Self(v) }
63}
64
65/// Human-readable metadata for a command.
66///
67/// Used by UI widgets (menu items, toolbar buttons, help overlays) to render
68/// labels, tooltips, and shortcut hints without knowing about egui or input
69/// handling.
70#[derive(Debug, Clone)]
71pub struct CommandSpec {
72    /// Stable identifier.
73    pub id: CommandId,
74    /// Short display label shown in menus / buttons.
75    pub label: String,
76    /// Optional longer description for tooltips / help text.
77    pub description: Option<String>,
78    /// Human-readable shortcut hint ("F2", "Ctrl+S", …).  Display-only;
79    /// actual shortcut matching lives in `egui-command-binding`.
80    pub shortcut_hint: Option<String>,
81}
82
83impl CommandSpec {
84    /// Minimal constructor — just an id and a label.
85    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    /// Builder: set description.
95    pub fn with_description(mut self, desc: impl Into<String>) -> Self {
96        self.description = Some(desc.into());
97        self
98    }
99
100    /// Builder: set the shortcut hint string.
101    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/// Runtime availability state of a command.
108///
109/// The app is responsible for computing and storing this; `egui-command-binding`
110/// reads it to grey-out or hide menu items.
111#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
112pub enum CommandState {
113    /// Normal — can be triggered.
114    #[default]
115    Enabled,
116    /// Visually present but not actionable (greyed out).
117    Disabled,
118    /// Hidden from menus / toolbar.
119    Hidden,
120}
121
122impl CommandState {
123    /// Returns `true` if the command can currently be triggered (not disabled or hidden).
124    pub fn is_enabled(self) -> bool { self == CommandState::Enabled }
125
126    /// Returns `true` if the command should be shown in menus and toolbars.
127    pub fn is_visible(self) -> bool { self != CommandState::Hidden }
128}
129
130/// Registry that maps command identifiers to their specs and runtime states.
131///
132/// `CommandRegistry<C>` is the single source of truth for all registered
133/// commands in an application.  It stores the human-readable [`CommandSpec`]
134/// and the runtime [`CommandState`] for each command, keyed by
135/// [`CommandId`].
136///
137/// # Type Parameter
138/// `C` is your application's command enum (or any `Copy + Hash + Eq` type
139/// that can be converted into a [`CommandId`] via `Into<CommandId>`).
140///
141/// # Example
142/// ```rust
143/// use egui_command::{CommandId, CommandRegistry, CommandSpec, CommandState};
144///
145/// #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
146/// enum AppCmd {
147///     Save,
148///     Quit,
149/// }
150///
151/// impl From<AppCmd> for CommandId {
152///     fn from(c: AppCmd) -> Self { CommandId::new(c) }
153/// }
154///
155/// let registry = CommandRegistry::new()
156///     .with(
157///         AppCmd::Save,
158///         CommandSpec::new(CommandId::new(AppCmd::Save), "Save"),
159///     )
160///     .with(
161///         AppCmd::Quit,
162///         CommandSpec::new(CommandId::new(AppCmd::Quit), "Quit"),
163///     );
164///
165/// assert!(registry.spec(AppCmd::Save).is_some());
166/// assert_eq!(registry.state(AppCmd::Save), Some(CommandState::Enabled));
167/// ```
168#[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    /// Create an empty registry.
177    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    /// Register a command with its spec, setting state to
186    /// [`CommandState::Enabled`] if not already present.
187    ///
188    /// Returns `&mut Self` to allow chained `register` calls.
189    ///
190    /// # Panics
191    ///
192    /// Panics if `spec.id != cmd.into()` — i.e. the spec was built for a
193    /// different command than the one being registered.
194    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    /// Builder-style registration.  Consumes and returns `Self` so that
207    /// registrations can be chained on construction:
208    ///
209    /// ```rust
210    /// # use egui_command::{CommandId, CommandRegistry, CommandSpec};
211    /// # #[derive(Clone, Copy, Hash, Eq, PartialEq)] enum C { A }
212    /// # impl From<C> for CommandId { fn from(c: C) -> Self { CommandId::new(c) } }
213    /// let reg = CommandRegistry::new().with(C::A, CommandSpec::new(CommandId::new(C::A), "A"));
214    /// ```
215    pub fn with(mut self, cmd: C, spec: CommandSpec) -> Self {
216        self.register(cmd, spec);
217        self
218    }
219
220    /// Look up the [`CommandSpec`] for a command.
221    ///
222    /// Returns `None` if the command was never registered.
223    pub fn spec(&self, cmd: C) -> Option<&CommandSpec> { self.specs.get(&cmd.into()) }
224
225    /// Look up the [`CommandSpec`] by raw [`CommandId`].
226    pub fn spec_by_id(&self, id: CommandId) -> Option<&CommandSpec> { self.specs.get(&id) }
227
228    /// Look up the current [`CommandState`] for a command.
229    ///
230    /// Returns `None` if the command was never registered.
231    pub fn state(&self, cmd: C) -> Option<CommandState> { self.states.get(&cmd.into()).copied() }
232
233    /// Look up the current [`CommandState`] by raw [`CommandId`].
234    pub fn state_by_id(&self, id: CommandId) -> Option<CommandState> {
235        self.states.get(&id).copied()
236    }
237
238    /// Update the runtime state of a registered command.
239    ///
240    /// Has no effect if the command has not been registered.
241    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    /// Update the runtime state by raw [`CommandId`].
249    ///
250    /// Has no effect if the id is not registered.
251    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    /// Iterate over all registered `(CommandId, &CommandSpec)` pairs.
258    pub fn iter_specs(&self) -> impl Iterator<Item = (CommandId, &CommandSpec)> {
259        self.specs.iter().map(|(&id, spec)| (id, spec))
260    }
261
262    /// Mutable look up of a [`CommandSpec`] by raw [`CommandId`].
263    ///
264    /// Returns `None` if the id has not been registered.
265    pub fn spec_by_id_mut(&mut self, id: CommandId) -> Option<&mut CommandSpec> {
266        self.specs.get_mut(&id)
267    }
268}
269
270/// What produced a `CommandTriggered` event.
271#[derive(Debug, Clone, Copy, PartialEq, Eq)]
272pub enum CommandSource {
273    /// User pressed a keyboard shortcut.
274    Keyboard,
275    /// User clicked a menu item.
276    Menu,
277    /// User clicked a toolbar / context button.
278    Button,
279    /// Programmatically dispatched (e.g. from a test or macro-action).
280    Programmatic,
281}
282
283/// Event emitted when a command is triggered.
284///
285/// The app receives a `Vec<CommandTriggered>` (or handles them one-by-one)
286/// and converts them into domain `AppCommand` variants.
287#[derive(Debug, Clone)]
288pub struct CommandTriggered {
289    /// Which command fired.
290    pub id: CommandId,
291    /// How it was triggered.
292    pub source: CommandSource,
293}
294
295impl CommandTriggered {
296    /// Creates a `CommandTriggered` event from a command id and its trigger source.
297    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}