openlogi_core/binding/effect.rs
1//! A platform-neutral synthesis IR.
2//!
3//! [`Action`] has one variant per user-facing behaviour (52 of them), but the
4//! three `openlogi-inject` backends don't care about most of that
5//! granularity — they care about *mechanism*: "press this chord", "click
6//! this mouse button", "fire this media key", "there is no portable way to
7//! do this, use the OS-specific path". [`Action::effect`] is the single
8//! exhaustive match that sorts every variant into one of those buckets, so
9//! each backend matches on ~10-variant [`Effect`] instead of re-deriving the
10//! full `Action` vocabulary three times.
11//!
12//! [`Shortcut`], [`MediaKey`], and [`NativeAction`] are semantic, not
13//! mechanical: the same named shortcut is not the same chord on every OS
14//! (`BrowserBack` is ⌘\[ on macOS, Alt+Left on Linux, and a dedicated
15//! virtual key with no modifier on Windows), so each backend owns its own
16//! lookup table from these enums to its platform's key/API. The
17//! exhaustiveness check on those enums is what keeps all three backends
18//! honest when a variant is added — a backend that forgets a case fails to
19//! compile rather than silently no-op-ing.
20
21use super::action::{Action, WorkflowStep};
22use super::key_combo::KeyCombo;
23
24/// What firing an [`Action`] should do, independent of platform.
25///
26/// `openlogi_inject`'s per-OS backends match on this instead of on
27/// [`Action`] directly.
28#[derive(Clone, Copy, Debug, PartialEq, Eq)]
29pub enum Effect<'a> {
30 /// Suppress the input entirely: no OS event at all.
31 None,
32 /// Synthesise a physical mouse button click.
33 Click(MouseButton),
34 /// Fire a named shortcut. Each backend maps this to its own platform
35 /// chord (or, for the rare shortcut with no chord at all on some OS, a
36 /// dedicated native code path) — see that backend's `combo` table.
37 Shortcut(Shortcut),
38 /// Press an already-resolved keyboard chord: a user-recorded
39 /// [`Action::CustomShortcut`], or a workflow's `PressKey` step.
40 Key(&'a KeyCombo),
41 /// A user-recorded chord whose output is held by a lifecycle-aware
42 /// runtime. A one-shot executor treats this as [`Effect::Key`] so direct
43 /// dispatch remains balanced when no matching release can arrive.
44 HeldKey(&'a KeyCombo),
45 /// Synthesise one scroll tick. `dx`/`dy` are unit direction (-1/0/1);
46 /// each backend applies its own tick magnitude.
47 Scroll {
48 /// Horizontal direction: -1 left, 1 right, 0 none.
49 dx: i8,
50 /// Vertical direction: -1 down, 1 up, 0 none.
51 dy: i8,
52 },
53 /// Fire a media/volume key. Every backend reaches these through a
54 /// dedicated OS mechanism rather than an ordinary keyboard chord.
55 Media(MediaKey),
56 /// A window-manager or power action with no shared cross-platform
57 /// chord — each backend has its own dedicated handling, which may be a
58 /// debug-logged no-op where the OS has no equivalent at all.
59 Native(NativeAction),
60 /// A power-user scripting escape hatch.
61 Script(Script<'a>),
62 /// Type this text via unicode input.
63 Text(&'a str),
64 /// Handled entirely by the agent/hook layer — DPI presets, SmartShift,
65 /// the Actions Ring, and launching an application. The injector logs
66 /// and does nothing.
67 ///
68 /// [`Action::OpenApplication`] is included here even though
69 /// `openlogi_inject::execute` does open it: that happens in the
70 /// platform-independent dispatcher *before* a backend ever sees the
71 /// action (the config target is opened via the `opener` crate, not
72 /// through any per-OS synthesis path), so from a backend's point of
73 /// view it is exactly as much a no-op as the DPI actions.
74 AgentSide,
75}
76
77/// A physical mouse button an [`Effect::Click`] should press.
78#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
79pub enum MouseButton {
80 /// Primary button.
81 Left,
82 /// Secondary button.
83 Right,
84 /// Wheel-click button.
85 Middle,
86 /// Extra "back" side button (button 4).
87 Back,
88 /// Extra "forward" side button (button 5).
89 Forward,
90}
91
92/// A named shortcut whose chord is the same *concept* on every OS but not
93/// the same keys.
94///
95/// Each backend owns a `Shortcut -> KeyCombo` table (plus, for the couple of
96/// shortcuts a given OS has no ordinary chord for, a small override) rather
97/// than sharing one table — see the per-backend `combo`/`press_shortcut`.
98#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, strum::VariantArray)]
99pub enum Shortcut {
100 /// Copy the selection.
101 Copy,
102 /// Paste from the clipboard.
103 Paste,
104 /// Cut the selection.
105 Cut,
106 /// Undo the last action.
107 Undo,
108 /// Redo the last undone action.
109 Redo,
110 /// Select all content.
111 SelectAll,
112 /// Open find/search.
113 Find,
114 /// Save the current document.
115 Save,
116 /// Navigate backward in browser history.
117 BrowserBack,
118 /// Navigate forward in browser history.
119 BrowserForward,
120 /// Open a new tab.
121 NewTab,
122 /// Close the current tab.
123 CloseTab,
124 /// Reopen the last closed tab.
125 ReopenTab,
126 /// Switch to the next tab.
127 NextTab,
128 /// Switch to the previous tab.
129 PrevTab,
130 /// Reload the current page.
131 ReloadPage,
132}
133
134impl Shortcut {
135 /// Every named shortcut, in declaration order.
136 ///
137 /// `#[derive(strum::VariantArray)]` generates this straight from the
138 /// enum's variant list at compile time, so it cannot go stale the way a
139 /// hand-written array literal could: there is no second, independently
140 /// editable list for it to drift from — a variant that's missing here
141 /// would mean the enum itself doesn't have it. The single shared
142 /// iteration source for each backend's `Shortcut -> KeyCombo`
143 /// table-completeness test.
144 pub const ALL: &'static [Shortcut] = <Shortcut as strum::VariantArray>::VARIANTS;
145}
146
147/// A media/volume key.
148///
149/// Every backend reaches these through a dedicated OS mechanism — NX
150/// system-defined keys on macOS, MPRIS/XF86 keys on Linux, dedicated media
151/// virtual keys on Windows — rather than an ordinary keyboard chord.
152#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
153pub enum MediaKey {
154 /// Toggle play/pause.
155 PlayPause,
156 /// Skip to the next track.
157 NextTrack,
158 /// Go back to the previous track.
159 PrevTrack,
160 /// Increase system volume.
161 VolumeUp,
162 /// Decrease system volume.
163 VolumeDown,
164 /// Toggle system mute.
165 Mute,
166}
167
168/// A window-manager or power action with no shared cross-platform chord.
169///
170/// Each backend reaches it through its own dedicated OS API — or, where the
171/// OS has no equivalent at all, a debug-logged no-op.
172#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
173pub enum NativeAction {
174 /// Show all windows across spaces (macOS Mission Control).
175 MissionControl,
176 /// Show all windows of the frontmost app (macOS App Exposé).
177 AppExpose,
178 /// Switch to the previous desktop/space.
179 PreviousDesktop,
180 /// Switch to the next desktop/space.
181 NextDesktop,
182 /// Hide all windows to reveal the desktop.
183 ShowDesktop,
184 /// Open the application launcher.
185 LaunchpadShow,
186 /// Lock the screen.
187 LockScreen,
188 /// Capture a full-screen screenshot.
189 Screenshot,
190 /// Capture a selected screen region.
191 CaptureRegion,
192 /// Put the computer to sleep.
193 Sleep,
194}
195
196/// A power-user scripting escape hatch, borrowed from the originating
197/// [`Action::RunAppleScript`], [`Action::RunShellCommand`], or
198/// [`Action::Workflow`].
199#[derive(Clone, Copy, Debug, PartialEq, Eq)]
200pub enum Script<'a> {
201 /// Run an AppleScript source string. macOS-only; other backends warn.
202 AppleScript(&'a str),
203 /// Run a shell command string.
204 ShellCommand(&'a str),
205 /// Run an ordered sequence of workflow steps.
206 Workflow(&'a [WorkflowStep]),
207}
208
209impl Action {
210 /// Classify this action into the platform-neutral [`Effect`] IR that
211 /// `openlogi-inject`'s backends dispatch on.
212 ///
213 /// This is the single exhaustive match over the full [`Action`]
214 /// vocabulary that a new backend needs — see the module docs.
215 #[must_use]
216 pub fn effect(&self) -> Effect<'_> {
217 match self {
218 Action::None => Effect::None,
219
220 Action::LeftClick => Effect::Click(MouseButton::Left),
221 Action::RightClick => Effect::Click(MouseButton::Right),
222 Action::MiddleClick => Effect::Click(MouseButton::Middle),
223 Action::MouseBack => Effect::Click(MouseButton::Back),
224 Action::MouseForward => Effect::Click(MouseButton::Forward),
225
226 Action::Copy => Effect::Shortcut(Shortcut::Copy),
227 Action::Paste => Effect::Shortcut(Shortcut::Paste),
228 Action::Cut => Effect::Shortcut(Shortcut::Cut),
229 Action::Undo => Effect::Shortcut(Shortcut::Undo),
230 Action::Redo => Effect::Shortcut(Shortcut::Redo),
231 Action::SelectAll => Effect::Shortcut(Shortcut::SelectAll),
232 Action::Find => Effect::Shortcut(Shortcut::Find),
233 Action::Save => Effect::Shortcut(Shortcut::Save),
234
235 Action::BrowserBack => Effect::Shortcut(Shortcut::BrowserBack),
236 Action::BrowserForward => Effect::Shortcut(Shortcut::BrowserForward),
237 Action::NewTab => Effect::Shortcut(Shortcut::NewTab),
238 Action::CloseTab => Effect::Shortcut(Shortcut::CloseTab),
239 Action::ReopenTab => Effect::Shortcut(Shortcut::ReopenTab),
240 Action::NextTab => Effect::Shortcut(Shortcut::NextTab),
241 Action::PrevTab => Effect::Shortcut(Shortcut::PrevTab),
242 Action::ReloadPage => Effect::Shortcut(Shortcut::ReloadPage),
243
244 Action::MissionControl => Effect::Native(NativeAction::MissionControl),
245 Action::AppExpose => Effect::Native(NativeAction::AppExpose),
246 Action::PreviousDesktop => Effect::Native(NativeAction::PreviousDesktop),
247 Action::NextDesktop => Effect::Native(NativeAction::NextDesktop),
248 Action::ShowDesktop => Effect::Native(NativeAction::ShowDesktop),
249 Action::LaunchpadShow => Effect::Native(NativeAction::LaunchpadShow),
250
251 Action::LockScreen => Effect::Native(NativeAction::LockScreen),
252 Action::Screenshot => Effect::Native(NativeAction::Screenshot),
253 Action::CaptureRegion => Effect::Native(NativeAction::CaptureRegion),
254 Action::Sleep => Effect::Native(NativeAction::Sleep),
255
256 Action::PlayPause => Effect::Media(MediaKey::PlayPause),
257 Action::NextTrack => Effect::Media(MediaKey::NextTrack),
258 Action::PrevTrack => Effect::Media(MediaKey::PrevTrack),
259 Action::VolumeUp => Effect::Media(MediaKey::VolumeUp),
260 Action::VolumeDown => Effect::Media(MediaKey::VolumeDown),
261 Action::MuteVolume => Effect::Media(MediaKey::Mute),
262
263 // DPI/SmartShift/the Actions Ring/OpenApplication are all handled
264 // above (or beside) the injector — see `Effect::AgentSide`.
265 Action::CycleDpiPresets
266 | Action::SetDpiPreset(_)
267 | Action::ToggleSmartShift
268 | Action::ShowActionsRing
269 | Action::OpenApplication(_) => Effect::AgentSide,
270
271 Action::ScrollUp => Effect::Scroll { dx: 0, dy: 1 },
272 Action::ScrollDown => Effect::Scroll { dx: 0, dy: -1 },
273 Action::HorizontalScrollLeft => Effect::Scroll { dx: -1, dy: 0 },
274 Action::HorizontalScrollRight => Effect::Scroll { dx: 1, dy: 0 },
275
276 Action::CustomShortcut(combo) => Effect::Key(combo),
277 Action::HoldShortcut(combo) => Effect::HeldKey(combo),
278
279 Action::TypeText(text) => Effect::Text(text),
280 Action::RunAppleScript(src) => Effect::Script(Script::AppleScript(src)),
281 Action::RunShellCommand(cmd) => Effect::Script(Script::ShellCommand(cmd)),
282 Action::Workflow(steps) => Effect::Script(Script::Workflow(steps)),
283 }
284 }
285}