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}
191
192/// One step in a [`Action::Workflow`]. A workflow is a `Vec<WorkflowStep>`
193/// executed in order by the inject layer; `Delay` introduces a pause between
194/// the surrounding steps.
195///
196/// `PressKey` reuses [`KeyCombo`] (the same model as [`Action::CustomShortcut`])
197/// so a step can press a key chord. The other variants mirror their standalone
198/// [`Action`] counterparts.
199#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
200pub enum WorkflowStep {
201 /// Type a unicode string (see [`Action::TypeText`]).
202 TypeText(String),
203 /// Press a key chord (see [`Action::CustomShortcut`] / [`KeyCombo`]).
204 PressKey(KeyCombo),
205 /// Wait `millis` milliseconds before the next step.
206 Delay {
207 /// Pause length in milliseconds.
208 millis: u64,
209 },
210 /// Run an AppleScript (see [`Action::RunAppleScript`]).
211 RunAppleScript(String),
212 /// Run a shell command (see [`Action::RunShellCommand`]).
213 RunShellCommand(String),
214}
215
216/// X-macro table of every payload-free [`Action`] variant.
217///
218/// Each row is `Variant "Label" "i18n.key" Category Icon`, optionally followed
219/// by `not_pickable` for a row [`Action::catalog`] must omit. This is the single
220/// place a plain action is declared; payload-carrying variants (`SetDpiPreset`,
221/// `CustomShortcut`, …) build their label/category/icon from their payload and
222/// keep hand-written arms alongside the generated ones instead.
223///
224/// `macro_rules!` can only emit items into the module it is invoked from, so
225/// this table doesn't generate code itself — it forwards its rows verbatim to
226/// a `$callback!` macro chosen by the caller. `action.rs` (below) uses it to
227/// derive [`Action::label`], [`Action::category`], and [`Action::catalog`];
228/// `action_ring::icon` uses it to derive [`ActionRingIcon::for_action`](
229/// super::action_ring::ActionRingIcon::for_action). Row order is
230/// [`Action::catalog`]'s output order, grouped by category to match the
231/// popover section layout — edit rows here only, never in a callback's match.
232macro_rules! for_each_unit_action {
233 ($callback:ident) => {
234 $callback! {
235 // Mouse
236 LeftClick "Left Click" "actions.left_click" Mouse Pointer,
237 RightClick "Right Click" "actions.right_click" Mouse Pointer,
238 MiddleClick "Middle Click" "actions.middle_click" Mouse Mouse,
239 MouseBack "Back (Button 4)" "actions.back_button_4" Mouse MouseBack,
240 MouseForward "Forward (Button 5)" "actions.forward_button_5" Mouse MouseForward,
241 // Editing
242 Copy "Copy" "common.copy" Editing Copy,
243 Paste "Paste" "common.paste" Editing Paste,
244 Cut "Cut" "common.cut" Editing Cut,
245 Undo "Undo" "common.undo" Editing Undo,
246 Redo "Redo" "common.redo" Editing Redo,
247 SelectAll "Select All" "common.select_all" Editing SelectAll,
248 Find "Find" "actions.find" Editing Search,
249 Save "Save" "common.save" Editing Save,
250 // Browser
251 BrowserBack "Browser Back" "actions.browser_back" Browser ArrowLeft,
252 BrowserForward "Browser Forward" "actions.browser_forward" Browser ArrowRight,
253 NewTab "New Tab" "actions.new_tab" Browser NewTab,
254 CloseTab "Close Tab" "actions.close_tab" Browser CloseTab,
255 ReopenTab "Reopen Tab" "actions.reopen_tab" Browser ReopenTab,
256 NextTab "Next Tab" "actions.next_tab" Browser NextTab,
257 PrevTab "Previous Tab" "actions.previous_tab" Browser PreviousTab,
258 ReloadPage "Reload Page" "actions.reload_page" Browser Reload,
259 // Navigation
260 MissionControl "Mission Control" "actions.mission_control" Navigation Grid,
261 AppExpose "App Exposé" "actions.app_expose" Navigation Layers,
262 PreviousDesktop "Previous Desktop" "actions.previous_desktop" Navigation PreviousDesktop,
263 NextDesktop "Next Desktop" "actions.next_desktop" Navigation NextDesktop,
264 ShowDesktop "Show Desktop" "actions.show_desktop" Navigation Monitor,
265 LaunchpadShow "Launchpad" "actions.launchpad" Navigation Applications,
266 // System
267 None "Do Nothing" "pointer.do_nothing" System Ban,
268 LockScreen "Lock Screen" "actions.lock_screen" System Lock,
269 Screenshot "Screenshot" "actions.screenshot" System Camera,
270 CaptureRegion "Capture Region" "actions.capture_region" System Camera,
271 Sleep "Sleep" "actions.sleep" System Monitor,
272 ShowActionsRing "Actions Ring" "action_ring.actions_ring" System Grid,
273 // Media
274 PlayPause "Play / Pause" "actions.play_pause" Media Play,
275 NextTrack "Next Track" "actions.next_track" Media NextTrack,
276 PrevTrack "Previous Track" "actions.previous_track" Media PreviousTrack,
277 VolumeUp "Volume Up" "actions.volume_up" Media Volume,
278 VolumeDown "Volume Down" "actions.volume_down" Media VolumeDown,
279 MuteVolume "Mute" "actions.mute" Media Mute,
280 // DPI
281 CycleDpiPresets "Cycle DPI Presets" "pointer.cycle_dpi_presets" Dpi Gauge,
282 ToggleSmartShift "Toggle SmartShift" "pointer.toggle_smartshift" Dpi Refresh,
283 // Scroll
284 ScrollUp "Scroll Up" "actions.scroll_up" Scroll ArrowUp,
285 ScrollDown "Scroll Down" "actions.scroll_down" Scroll ArrowDown,
286 HorizontalScrollLeft "Scroll Left" "actions.scroll_left" Scroll ScrollLeft,
287 HorizontalScrollRight "Scroll Right" "actions.scroll_right" Scroll ScrollRight,
288 }
289 };
290}
291pub(super) use for_each_unit_action;
292
293/// Builds `label`, `translation_key`, `category`, and `catalog` from
294/// [`for_each_unit_action!`]'s rows, splicing in the hand-written arms for
295/// payload-carrying variants so each generated `match` still covers every
296/// [`Action`] variant exhaustively.
297macro_rules! derive_action_core {
298 ( $( $variant:ident $label:literal $translation_key:literal $category:ident $icon:ident $( $tag:ident )? ),* $(,)? ) => {
299 impl Action {
300 /// Display label for the popover row.
301 ///
302 /// Returns `String` rather than `&str` so parameterized variants (e.g.
303 /// `SetDpiPreset(i)`, `CustomShortcut(s)`) can build a label that
304 /// includes their payload.
305 #[must_use]
306 pub fn label(&self) -> String {
307 match self {
308 $( Action::$variant => $label.into(), )*
309 Action::SetDpiPreset(i) => format!("DPI Preset {}", i + 1),
310 Action::CustomShortcut(combo) => combo.rendered_label(),
311 Action::TypeText(s) => format!("Type \"{s}\""),
312 Action::RunAppleScript(_) => "Run AppleScript".into(),
313 Action::RunShellCommand(_) => "Run Command".into(),
314 Action::Workflow(steps) => format!("Workflow ({} steps)", steps.len()),
315 Action::OpenApplication(target) => format!("Open {}", target.display_name()),
316 Action::HoldShortcut(combo) => format!("Hold {}", combo.rendered_label()),
317 }
318 }
319
320 /// Stable catalog key for a payload-free action label.
321 ///
322 /// Payload-carrying actions return `None`: their display text needs
323 /// interpolation or contains user data and cannot be represented by
324 /// a key alone.
325 #[must_use]
326 pub fn translation_key(&self) -> Option<&'static str> {
327 match self {
328 $( Action::$variant => Some($translation_key), )*
329 Action::SetDpiPreset(_)
330 | Action::CustomShortcut(_)
331 | Action::TypeText(_)
332 | Action::RunAppleScript(_)
333 | Action::RunShellCommand(_)
334 | Action::Workflow(_)
335 | Action::OpenApplication(_)
336 | Action::HoldShortcut(_) => None,
337 }
338 }
339
340 /// Resolve the stable key for a payload-free English action label.
341 ///
342 /// The Actions Ring wire contract carries this English label for
343 /// compatibility with older helpers. The overlay converts it at
344 /// the localization boundary rather than changing the wire value.
345 #[must_use]
346 pub fn translation_key_for_label(label: &str) -> Option<&'static str> {
347 match label {
348 $( $label => Some($translation_key), )*
349 _ => None,
350 }
351 }
352
353 /// Which [`Category`] this action belongs to, used for popover grouping.
354 #[must_use]
355 pub fn category(&self) -> Category {
356 match self {
357 $( Action::$variant => Category::$category, )*
358 // CustomShortcut is assigned to Editing so it doesn't need a
359 // separate arm (it's not in the picker catalog).
360 Action::CustomShortcut(_)
361 | Action::TypeText(_)
362 | Action::RunAppleScript(_)
363 | Action::RunShellCommand(_)
364 | Action::Workflow(_)
365 | Action::HoldShortcut(_) => Category::Editing,
366 Action::SetDpiPreset(_) => Category::Dpi,
367 Action::OpenApplication(_) => Category::System,
368 }
369 }
370
371 /// All pickable actions in a deterministic order.
372 ///
373 /// [`Action::CustomShortcut`] is intentionally excluded — it is opened via
374 /// "Record shortcut…" (P1.3), not selected from the catalog. Table rows
375 /// tagged `not_pickable` are excluded from the catalog the same way.
376 #[must_use]
377 pub fn catalog() -> Vec<Action> {
378 [ $( derive_action_core!(@item $variant $( $tag )?) ),* ]
379 .into_iter()
380 .flatten()
381 .collect()
382 }
383 }
384 };
385 (@item $variant:ident) => {
386 Some(Action::$variant)
387 };
388 (@item $variant:ident not_pickable) => {
389 None
390 };
391}
392
393for_each_unit_action!(derive_action_core);
394
395impl Action {
396 /// The chord whose output must remain down until the originating press
397 /// ends, or `None` for an instantaneous action.
398 #[must_use]
399 pub fn held_combo(&self) -> Option<&KeyCombo> {
400 match self {
401 Self::HoldShortcut(combo) => Some(combo),
402 _ => None,
403 }
404 }
405}