Skip to main content

openlogi_core/binding/
action.rs

1//! The action vocabulary a button can bind to, plus workflow steps.
2
3use serde::{Deserialize, Serialize};
4
5use super::application_target::ApplicationTarget;
6use super::category::Category;
7use super::key_combo::KeyCombo;
8
9/// What pressing a [`ButtonId`](crate::binding::ButtonId) should do.
10///
11/// Serialization uses serde's default external tagging: unit variants
12/// serialize as a bare string (`"BrowserBack"`) and the tuple variant
13/// serializes as a single-key table (`{ CustomShortcut = "my chord" }`).
14///
15/// **Stability contract:** existing variant *names* are frozen — they form the
16/// on-disk `config.toml` schema. New variants may be appended freely; removing
17/// or renaming a variant requires a `schema_version` bump and a migration.
18///
19/// This type is pure config data: OS-level event synthesis for each variant
20/// lives in the `openlogi-inject` crate (`openlogi_inject::execute`), keeping
21/// this crate platform- and IO-free.
22#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
23pub enum Action {
24    // ── System ───────────────────────────────────────────────────────────────
25    /// Suppress the input entirely — the button or wheel direction is captured
26    /// but no OS event is synthesised, so the physical input does nothing.
27    None,
28
29    // ── Mouse ────────────────────────────────────────────────────────────────
30    /// Primary mouse button.
31    LeftClick,
32    /// Secondary mouse button.
33    RightClick,
34    /// Middle mouse button (wheel click).
35    MiddleClick,
36    /// Mouse "back" side button (extra button 4). Synthesizes the real mouse
37    /// button event, which browsers and most apps interpret as "navigate back"
38    /// natively — unlike [`Action::BrowserBack`], which sends ⌘[ and is ignored
39    /// by many apps.
40    MouseBack,
41    /// Mouse "forward" side button (extra button 5). Native counterpart to
42    /// [`Action::MouseBack`]; see [`Action::BrowserForward`] for the ⌘] form.
43    MouseForward,
44
45    // ── Editing ──────────────────────────────────────────────────────────────
46    /// Copy the current selection (⌘C / Ctrl+C).
47    Copy,
48    /// Paste from the clipboard (⌘V / Ctrl+V).
49    Paste,
50    /// Cut the current selection (⌘X / Ctrl+X).
51    Cut,
52    /// Undo the last action (⌘Z / Ctrl+Z).
53    Undo,
54    /// Redo the last undone action (⌘⇧Z on macOS / Ctrl+Shift+Z on Linux).
55    ///
56    /// Note: Ctrl+Y is the dominant redo shortcut in LibreOffice and many GTK
57    /// apps. Ctrl+Shift+Z is used here because it mirrors the macOS convention
58    /// and works in GNOME text fields, browsers, and Electron apps. If Ctrl+Y
59    /// coverage is needed, a `CustomShortcut` binding is the escape hatch.
60    Redo,
61    /// Select all content (⌘A / Ctrl+A).
62    SelectAll,
63    /// Open the find / search bar (⌘F / Ctrl+F).
64    Find,
65    /// Save the current document (⌘S / Ctrl+S).
66    Save,
67
68    // ── Browser / Navigation ──────────────────────────────────────────────────
69    /// Navigate backward in browser history.
70    BrowserBack,
71    /// Navigate forward in browser history.
72    BrowserForward,
73    /// Open a new tab (⌘T / Ctrl+T).
74    NewTab,
75    /// Close the current tab (⌘W / Ctrl+W).
76    CloseTab,
77    /// Reopen the last closed tab (⌘⇧T / Ctrl+Shift+T).
78    ReopenTab,
79    /// Switch to the next tab (⌃⇥ / Ctrl+Tab).
80    NextTab,
81    /// Switch to the previous tab (⌃⇧⇥ / Ctrl+Shift+Tab).
82    PrevTab,
83    /// Reload the current page (⌘R / Ctrl+R).
84    ReloadPage,
85
86    // ── Navigation / Window ───────────────────────────────────────────────────
87    /// macOS Mission Control (⌃↑).
88    MissionControl,
89    /// macOS App Exposé — all windows for the current app (⌃↓).
90    AppExpose,
91    /// Switch to the previous desktop / Space.
92    PreviousDesktop,
93    /// Switch to the next desktop / Space.
94    NextDesktop,
95    /// Show the desktop (hide all windows).
96    ShowDesktop,
97    /// Open Launchpad.
98    LaunchpadShow,
99
100    // ── System ────────────────────────────────────────────────────────────────
101    /// Lock the screen (⌘⌃Q on macOS).
102    ///
103    /// On Linux, calls `org.freedesktop.login1.Manager.LockSession($XDG_SESSION_ID)`
104    /// on the system bus (current session only). Falls back to Super+L when
105    /// `$XDG_SESSION_ID` is unset or on non-systemd systems.
106    LockScreen,
107    /// Capture a screenshot.
108    Screenshot,
109    /// Capture a selected screen region to the clipboard.
110    ///
111    /// macOS uses Cmd+Shift+Ctrl+4; Windows uses Win+Shift+S. Linux delegates
112    /// to the desktop environment's screenshot handler via Print Screen.
113    CaptureRegion,
114
115    // ── Media ────────────────────────────────────────────────────────────────
116    /// Toggle media play/pause.
117    PlayPause,
118    /// Skip to the next track.
119    NextTrack,
120    /// Go back to the previous track.
121    PrevTrack,
122    /// Increase system volume.
123    VolumeUp,
124    /// Decrease system volume.
125    VolumeDown,
126    /// Toggle system mute.
127    MuteVolume,
128
129    // ── DPI ──────────────────────────────────────────────────────────────────
130    /// Step through the configured DPI preset list (P1.7).
131    CycleDpiPresets,
132    /// Jump to a specific zero-based preset in the device's DPI preset list.
133    /// Out-of-range indices clamp to the list length at fire time (P1.7).
134    SetDpiPreset(u8),
135    /// Toggle the HID++ SmartShift ratchet/free-spin wheel mode (P1.1).
136    ToggleSmartShift,
137
138    // ── Scroll ───────────────────────────────────────────────────────────────
139    /// Synthesise a vertical scroll-up tick.
140    ScrollUp,
141    /// Synthesise a vertical scroll-down tick.
142    ScrollDown,
143    /// Synthesise a horizontal scroll-left tick.
144    HorizontalScrollLeft,
145    /// Synthesise a horizontal scroll-right tick.
146    HorizontalScrollRight,
147
148    // ── Custom ───────────────────────────────────────────────────────────────
149    /// Replay an arbitrary recorded key chord (P1.3).
150    ///
151    /// Holds the structured chord data so `openlogi_inject::execute` can post the
152    /// real keystroke (macOS: CGEventPost with the encoded modifier flags).
153    /// The `display` field is used by [`Action::label`] so the popover
154    /// shows the user-friendly chord name.
155    CustomShortcut(KeyCombo),
156
157    // ── System (appended) ────────────────────────────────────────────────────
158    /// Put the computer to sleep. Appended after `CustomShortcut` because the
159    /// serde variant index is the wire format (see the stability contract
160    /// above) — new variants only ever go at the end.
161    Sleep,
162    /// Type an arbitrary string by emitting unicode characters (macOS
163    /// `CGEventKeyboardSetUnicodeString`). Used for macro text. Power-user
164    /// escape hatch — excluded from the default catalog.
165    TypeText(String),
166    /// Run an AppleScript via `osascript -e <source>`. Power-user escape hatch.
167    RunAppleScript(String),
168    /// Run a shell command via `/bin/sh -c <command>`. Power-user escape hatch.
169    RunShellCommand(String),
170    /// Run a timed, ordered sequence of steps — the native, no-code version of
171    /// "type 'bite me', wait 5s, press Enter, wait 5s, type more, Escape". Each
172    /// step is one of the power-user actions or a `Delay`. The sequencer
173    /// (`openlogi-inject`) runs them in order, awaiting `Delay`s. Power-user
174    /// escape hatch — excluded from the default catalog.
175    Workflow(Vec<WorkflowStep>),
176    /// Open the configured Actions Ring at the current pointer position.
177    /// The agent handles the ring session rather than the OS injector.
178    ShowActionsRing,
179    /// Open an application, folder, filesystem path, or platform URL.
180    OpenApplication(ApplicationTarget),
181    /// Hold an arbitrary recorded key chord for the lifetime of its physical
182    /// button press. This is the push-to-talk counterpart to
183    /// [`Action::CustomShortcut`], which emits an immediate down/up pair.
184    ///
185    /// Lifecycle-aware runtimes emit the chord's down edge when the press
186    /// starts and its up edge for every terminal outcome, including capture
187    /// cancellation and shutdown. Dispatchers without a release context must
188    /// degrade this action to a balanced tap rather than leave keys held.
189    HoldShortcut(KeyCombo),
190    /// Hold the chord's modifiers for the lifetime of the physical press while
191    /// tapping its ordinary key exactly once, the way a hand does on a
192    /// keyboard.
193    ///
194    /// This is the shape an application switcher needs. Cmd+Tab steps one
195    /// window forward and leaves the switcher on screen for as long as Cmd is
196    /// down. Neither existing variant can express it:
197    /// [`Action::CustomShortcut`] releases Cmd immediately, so the switcher
198    /// closes again at once, and [`Action::HoldShortcut`] holds Tab down too,
199    /// so key repeat walks the switcher to its last window and parks there.
200    ///
201    /// Lifecycle-aware runtimes emit the modifier down edges plus one tap of
202    /// the ordinary key when the press starts, and the modifier up edges for
203    /// every terminal outcome. The tap is skipped while another held chord
204    /// keeps that same key down, so the other hold is never released early.
205    /// Dispatchers without a release context degrade
206    /// this to a balanced tap of the whole chord, exactly as they do for
207    /// [`Action::HoldShortcut`].
208    TapKeyHoldingModifiers(KeyCombo),
209}
210
211/// Which of a held chord's keys stay down for the lifetime of the press.
212///
213/// Both shapes hold the chord's modifiers; they differ in the ordinary key.
214/// See [`Action::held_chord`].
215#[derive(Clone, Copy, Debug, PartialEq, Eq)]
216pub enum HeldKeys {
217    /// Modifiers and ordinary key alike stay down
218    /// ([`Action::HoldShortcut`]).
219    WholeChord,
220    /// The modifiers stay down and the ordinary key is tapped once as the
221    /// hold starts, unless another hold already keeps that key down
222    /// ([`Action::TapKeyHoldingModifiers`]).
223    ModifiersOnly,
224}
225
226/// One step in a [`Action::Workflow`]. A workflow is a `Vec<WorkflowStep>`
227/// executed in order by the inject layer; `Delay` introduces a pause between
228/// the surrounding steps.
229///
230/// `PressKey` reuses [`KeyCombo`] (the same model as [`Action::CustomShortcut`])
231/// so a step can press a key chord. The other variants mirror their standalone
232/// [`Action`] counterparts.
233#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
234pub enum WorkflowStep {
235    /// Type a unicode string (see [`Action::TypeText`]).
236    TypeText(String),
237    /// Press a key chord (see [`Action::CustomShortcut`] / [`KeyCombo`]).
238    PressKey(KeyCombo),
239    /// Wait `millis` milliseconds before the next step.
240    Delay {
241        /// Pause length in milliseconds.
242        millis: u64,
243    },
244    /// Run an AppleScript (see [`Action::RunAppleScript`]).
245    RunAppleScript(String),
246    /// Run a shell command (see [`Action::RunShellCommand`]).
247    RunShellCommand(String),
248}
249
250/// X-macro table of every payload-free [`Action`] variant.
251///
252/// Each row is `Variant "Label" "i18n.key" Category Icon`, optionally followed
253/// by `not_pickable` for a row [`Action::catalog`] must omit. This is the single
254/// place a plain action is declared; payload-carrying variants (`SetDpiPreset`,
255/// `CustomShortcut`, …) build their label/category/icon from their payload and
256/// keep hand-written arms alongside the generated ones instead.
257///
258/// `macro_rules!` can only emit items into the module it is invoked from, so
259/// this table doesn't generate code itself — it forwards its rows verbatim to
260/// a `$callback!` macro chosen by the caller. `action.rs` (below) uses it to
261/// derive [`Action::label`], [`Action::category`], and [`Action::catalog`];
262/// `action_ring::icon` uses it to derive [`ActionRingIcon::for_action`](
263/// super::action_ring::ActionRingIcon::for_action). Row order is
264/// [`Action::catalog`]'s output order, grouped by category to match the
265/// popover section layout — edit rows here only, never in a callback's match.
266macro_rules! for_each_unit_action {
267    ($callback:ident) => {
268        $callback! {
269            // Mouse
270            LeftClick "Left Click" "actions.left_click" Mouse Pointer,
271            RightClick "Right Click" "actions.right_click" Mouse Pointer,
272            MiddleClick "Middle Click" "actions.middle_click" Mouse Mouse,
273            MouseBack "Back (Button 4)" "actions.back_button_4" Mouse MouseBack,
274            MouseForward "Forward (Button 5)" "actions.forward_button_5" Mouse MouseForward,
275            // Editing
276            Copy "Copy" "common.copy" Editing Copy,
277            Paste "Paste" "common.paste" Editing Paste,
278            Cut "Cut" "common.cut" Editing Cut,
279            Undo "Undo" "common.undo" Editing Undo,
280            Redo "Redo" "common.redo" Editing Redo,
281            SelectAll "Select All" "common.select_all" Editing SelectAll,
282            Find "Find" "actions.find" Editing Search,
283            Save "Save" "common.save" Editing Save,
284            // Browser
285            BrowserBack "Browser Back" "actions.browser_back" Browser ArrowLeft,
286            BrowserForward "Browser Forward" "actions.browser_forward" Browser ArrowRight,
287            NewTab "New Tab" "actions.new_tab" Browser NewTab,
288            CloseTab "Close Tab" "actions.close_tab" Browser CloseTab,
289            ReopenTab "Reopen Tab" "actions.reopen_tab" Browser ReopenTab,
290            NextTab "Next Tab" "actions.next_tab" Browser NextTab,
291            PrevTab "Previous Tab" "actions.previous_tab" Browser PreviousTab,
292            ReloadPage "Reload Page" "actions.reload_page" Browser Reload,
293            // Navigation
294            MissionControl "Mission Control" "actions.mission_control" Navigation Grid,
295            AppExpose "App Exposé" "actions.app_expose" Navigation Layers,
296            PreviousDesktop "Previous Desktop" "actions.previous_desktop" Navigation PreviousDesktop,
297            NextDesktop "Next Desktop" "actions.next_desktop" Navigation NextDesktop,
298            ShowDesktop "Show Desktop" "actions.show_desktop" Navigation Monitor,
299            LaunchpadShow "Launchpad" "actions.launchpad" Navigation Applications,
300            // System
301            None "Do Nothing" "pointer.do_nothing" System Ban,
302            LockScreen "Lock Screen" "actions.lock_screen" System Lock,
303            Screenshot "Screenshot" "actions.screenshot" System Camera,
304            CaptureRegion "Capture Region" "actions.capture_region" System Camera,
305            Sleep "Sleep" "actions.sleep" System Monitor,
306            ShowActionsRing "Actions Ring" "action_ring.actions_ring" System Grid,
307            // Media
308            PlayPause "Play / Pause" "actions.play_pause" Media Play,
309            NextTrack "Next Track" "actions.next_track" Media NextTrack,
310            PrevTrack "Previous Track" "actions.previous_track" Media PreviousTrack,
311            VolumeUp "Volume Up" "actions.volume_up" Media Volume,
312            VolumeDown "Volume Down" "actions.volume_down" Media VolumeDown,
313            MuteVolume "Mute" "actions.mute" Media Mute,
314            // DPI
315            CycleDpiPresets "Cycle DPI Presets" "pointer.cycle_dpi_presets" Dpi Gauge,
316            ToggleSmartShift "Toggle SmartShift" "pointer.toggle_smartshift" Dpi Refresh,
317            // Scroll
318            ScrollUp "Scroll Up" "actions.scroll_up" Scroll ArrowUp,
319            ScrollDown "Scroll Down" "actions.scroll_down" Scroll ArrowDown,
320            HorizontalScrollLeft "Scroll Left" "actions.scroll_left" Scroll ScrollLeft,
321            HorizontalScrollRight "Scroll Right" "actions.scroll_right" Scroll ScrollRight,
322        }
323    };
324}
325pub(super) use for_each_unit_action;
326
327/// Builds `label`, `translation_key`, `category`, and `catalog` from
328/// [`for_each_unit_action!`]'s rows, splicing in the hand-written arms for
329/// payload-carrying variants so each generated `match` still covers every
330/// [`Action`] variant exhaustively.
331macro_rules! derive_action_core {
332    ( $( $variant:ident $label:literal $translation_key:literal $category:ident $icon:ident $( $tag:ident )? ),* $(,)? ) => {
333        impl Action {
334            /// Display label for the popover row.
335            ///
336            /// Returns `String` rather than `&str` so parameterized variants (e.g.
337            /// `SetDpiPreset(i)`, `CustomShortcut(s)`) can build a label that
338            /// includes their payload.
339            #[must_use]
340            pub fn label(&self) -> String {
341                match self {
342                    $( Action::$variant => $label.into(), )*
343                    Action::SetDpiPreset(i) => format!("DPI Preset {}", i + 1),
344                    Action::CustomShortcut(combo) => combo.rendered_label(),
345                    Action::TypeText(s) => format!("Type \"{s}\""),
346                    Action::RunAppleScript(_) => "Run AppleScript".into(),
347                    Action::RunShellCommand(_) => "Run Command".into(),
348                    Action::Workflow(steps) => format!("Workflow ({} steps)", steps.len()),
349                    Action::OpenApplication(target) => format!("Open {}", target.display_name()),
350                    Action::HoldShortcut(combo) => format!("Hold {}", combo.rendered_label()),
351                    Action::TapKeyHoldingModifiers(combo) => match combo.rendered_modifiers() {
352                        Some(modifiers) => {
353                            format!("Hold {modifiers}, tap {}", combo.rendered_key())
354                        }
355                        None => format!("Tap {}", combo.rendered_key()),
356                    },
357                }
358            }
359
360            /// Stable catalog key for a payload-free action label.
361            ///
362            /// Payload-carrying actions return `None`: their display text needs
363            /// interpolation or contains user data and cannot be represented by
364            /// a key alone.
365            #[must_use]
366            pub fn translation_key(&self) -> Option<&'static str> {
367                match self {
368                    $( Action::$variant => Some($translation_key), )*
369                    Action::SetDpiPreset(_)
370                    | Action::CustomShortcut(_)
371                    | Action::TypeText(_)
372                    | Action::RunAppleScript(_)
373                    | Action::RunShellCommand(_)
374                    | Action::Workflow(_)
375                    | Action::OpenApplication(_)
376                    | Action::HoldShortcut(_)
377                    | Action::TapKeyHoldingModifiers(_) => None,
378                }
379            }
380
381            /// Resolve the stable key for a payload-free English action label.
382            ///
383            /// The Actions Ring wire contract carries this English label for
384            /// compatibility with older helpers. The overlay converts it at
385            /// the localization boundary rather than changing the wire value.
386            #[must_use]
387            pub fn translation_key_for_label(label: &str) -> Option<&'static str> {
388                match label {
389                    $( $label => Some($translation_key), )*
390                    _ => None,
391                }
392            }
393
394            /// Which [`Category`] this action belongs to, used for popover grouping.
395            #[must_use]
396            pub fn category(&self) -> Category {
397                match self {
398                    $( Action::$variant => Category::$category, )*
399                    // CustomShortcut is assigned to Editing so it doesn't need a
400                    // separate arm (it's not in the picker catalog).
401                    Action::CustomShortcut(_)
402                    | Action::TypeText(_)
403                    | Action::RunAppleScript(_)
404                    | Action::RunShellCommand(_)
405                    | Action::Workflow(_)
406                    | Action::HoldShortcut(_)
407                    | Action::TapKeyHoldingModifiers(_) => Category::Editing,
408                    Action::SetDpiPreset(_) => Category::Dpi,
409                    Action::OpenApplication(_) => Category::System,
410                }
411            }
412
413            /// All pickable actions in a deterministic order.
414            ///
415            /// [`Action::CustomShortcut`] is intentionally excluded — it is opened via
416            /// "Record shortcut…" (P1.3), not selected from the catalog. Table rows
417            /// tagged `not_pickable` are excluded from the catalog the same way.
418            #[must_use]
419            pub fn catalog() -> Vec<Action> {
420                [ $( derive_action_core!(@item $variant $( $tag )?) ),* ]
421                    .into_iter()
422                    .flatten()
423                    .collect()
424            }
425        }
426    };
427    (@item $variant:ident) => {
428        Some(Action::$variant)
429    };
430    (@item $variant:ident not_pickable) => {
431        None
432    };
433}
434
435for_each_unit_action!(derive_action_core);
436
437impl Action {
438    /// The chord whose output must remain down until the originating press
439    /// ends, together with which of its keys stay down. `None` for an
440    /// instantaneous action.
441    #[must_use]
442    pub fn held_chord(&self) -> Option<(&KeyCombo, HeldKeys)> {
443        match self {
444            Self::HoldShortcut(combo) => Some((combo, HeldKeys::WholeChord)),
445            Self::TapKeyHoldingModifiers(combo) => Some((combo, HeldKeys::ModifiersOnly)),
446            _ => None,
447        }
448    }
449}