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/// What produced a `CommandTriggered` event.
131#[derive(Debug, Clone, Copy, PartialEq, Eq)]
132pub enum CommandSource {
133    /// User pressed a keyboard shortcut.
134    Keyboard,
135    /// User clicked a menu item.
136    Menu,
137    /// User clicked a toolbar / context button.
138    Button,
139    /// Programmatically dispatched (e.g. from a test or macro-action).
140    Programmatic,
141}
142
143/// Event emitted when a command is triggered.
144///
145/// The app receives a `Vec<CommandTriggered>` (or handles them one-by-one)
146/// and converts them into domain `AppCommand` variants.
147#[derive(Debug, Clone)]
148pub struct CommandTriggered {
149    /// Which command fired.
150    pub id: CommandId,
151    /// How it was triggered.
152    pub source: CommandSource,
153}
154
155impl CommandTriggered {
156    /// Creates a `CommandTriggered` event from a command id and its trigger source.
157    pub fn new(id: CommandId, source: CommandSource) -> Self { Self { id, source } }
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163
164    #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
165    enum AppCmd {
166        ShowHelp,
167        Save,
168        Quit,
169    }
170
171    #[test]
172    fn command_id_same_value_is_equal() {
173        let a = CommandId::new(AppCmd::ShowHelp);
174        let b = CommandId::new(AppCmd::ShowHelp);
175        assert_eq!(a, b);
176    }
177
178    #[test]
179    fn command_id_different_variants_are_not_equal() {
180        let a = CommandId::new(AppCmd::Save);
181        let b = CommandId::new(AppCmd::Quit);
182        assert_ne!(a, b);
183    }
184
185    #[test]
186    fn command_id_raw_roundtrip() {
187        let id = CommandId::new(AppCmd::Save);
188        assert_eq!(CommandId::from_raw(id.raw()), id);
189    }
190
191    #[test]
192    fn command_id_hashable_in_map() {
193        let mut map = std::collections::HashMap::new();
194        map.insert(CommandId::new(AppCmd::ShowHelp), "help");
195        map.insert(CommandId::new(AppCmd::Save), "save");
196        assert_eq!(map[&CommandId::new(AppCmd::ShowHelp)], "help");
197        assert_eq!(map[&CommandId::new(AppCmd::Save)], "save");
198    }
199
200    #[test]
201    fn command_spec_builder_chain() {
202        let id = CommandId::new(AppCmd::Save);
203        let spec = CommandSpec::new(id, "Save")
204            .with_description("Save the current file")
205            .with_shortcut_hint("Ctrl+S");
206        assert_eq!(spec.id, id);
207        assert_eq!(spec.label, "Save");
208        assert_eq!(spec.description.as_deref(), Some("Save the current file"));
209        assert_eq!(spec.shortcut_hint.as_deref(), Some("Ctrl+S"));
210    }
211
212    #[test]
213    fn command_spec_minimal_has_no_optional_fields() {
214        let spec = CommandSpec::new(CommandId::new(AppCmd::Quit), "Quit");
215        assert_eq!(spec.label, "Quit");
216        assert!(spec.description.is_none());
217        assert!(spec.shortcut_hint.is_none());
218    }
219
220    #[test]
221    fn command_state_is_enabled() {
222        assert!(CommandState::Enabled.is_enabled());
223        assert!(!CommandState::Disabled.is_enabled());
224        assert!(!CommandState::Hidden.is_enabled());
225    }
226
227    #[test]
228    fn command_state_is_visible() {
229        assert!(CommandState::Enabled.is_visible());
230        assert!(CommandState::Disabled.is_visible());
231        assert!(!CommandState::Hidden.is_visible());
232    }
233
234    #[test]
235    fn command_state_default_is_enabled() {
236        assert_eq!(CommandState::default(), CommandState::Enabled);
237    }
238
239    #[test]
240    fn command_triggered_stores_id_and_source() {
241        let id = CommandId::new(AppCmd::Save);
242        let triggered = CommandTriggered::new(id, CommandSource::Keyboard);
243        assert_eq!(triggered.id, id);
244        assert_eq!(triggered.source, CommandSource::Keyboard);
245    }
246
247    #[test]
248    fn command_source_variants_are_distinct() {
249        assert_ne!(CommandSource::Keyboard, CommandSource::Menu);
250        assert_ne!(CommandSource::Button, CommandSource::Programmatic);
251    }
252}