diff --git a/Cargo.lock b/Cargo.lock index 34574838..9feccd59 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4907,6 +4907,7 @@ version = "0.6.18" dependencies = [ "etcetera", "fs4", + "plist", "serde", "tempfile", "thiserror 2.0.18", diff --git a/crates/openlogi-agent-core/src/hook_runtime.rs b/crates/openlogi-agent-core/src/hook_runtime.rs index 5c45584d..d1c1e1ed 100644 --- a/crates/openlogi-agent-core/src/hook_runtime.rs +++ b/crates/openlogi-agent-core/src/hook_runtime.rs @@ -12,8 +12,9 @@ use std::sync::{Arc, RwLock}; use openlogi_core::binding::{ Action, ButtonId, GestureDirection, SwipeAccumulator, default_binding, }; +use openlogi_core::config::{KeyModifiers, KeyTrigger}; use openlogi_hid::CaptureChannel; -use openlogi_hook::{EventDisposition, Hook, MouseEvent}; +use openlogi_hook::{EventDisposition, Hook, HookEvent, MouseEvent}; use tracing::{info, warn}; use crate::DpiCycleState; @@ -38,6 +39,24 @@ pub struct HookMaps { /// (orchestrator), the OS-hook callback, and the gesture watcher. pub type SharedHookMaps = Arc>; +/// Shared keyboard trigger→action map for the function-key remapper. Unlike +/// mouse bindings these are not per-app-profile (M1 scope — per the spec's +/// non-goals), so a single map suffices. Keyed by the config `KeyTrigger` +/// (keycode + modifiers). +pub type SharedKeyboardBindings = Arc>>; + +/// Convert the hook-layer modifier state into the config-layer type (the two +/// live in different crates — core is leaf-level and duplicates the four +/// bools). Drop-in identity once the field names align. +fn convert_modifiers(m: openlogi_hook::KeyModifiers) -> KeyModifiers { + KeyModifiers { + shift: m.shift, + control: m.control, + option: m.option, + command: m.command, + } +} + /// Tracks which OS-hook button (Middle/Back/Forward) is mid-hold and defers the /// swipe detection itself to a shared [`SwipeAccumulator`], which commits a swipe /// *mid-motion* like the HID++ gesture-button path in `openlogi-hid`. This wrapper @@ -99,6 +118,7 @@ thread_local! { /// granted or on an unsupported platform — the app continues without crashing. pub fn start( hooks: SharedHookMaps, + keyboard_bindings: SharedKeyboardBindings, dpi_cycle: Arc>, capture: CaptureChannel, ) -> Option { @@ -113,113 +133,145 @@ pub fn start( // The per-hold pointer accumulator lives in the thread-local `HOLD`; the // callback must never block — see the freeze-hazard note in `macos.rs`. let result = Hook::start(move |event| match event { - MouseEvent::Button { id, pressed } => { - // The CGEventTap only sees standard buttons 0-4. We remap - // Middle/Back/Forward; the primary L/R clicks always pass through - // (suppressing them would brick the mouse), and the DPI / thumb / - // dedicated gesture button aren't visible to the tap at all — the - // dedicated gesture button is captured separately over HID++. - if !id.is_os_hook_button() { - return EventDisposition::PassThrough; - } - - // Gesture button: suppress the native click and begin a hold. The - // swipe commits mid-motion in the `Moved` arm; here, on release, we - // only fire the plain `Click` when no swipe committed. The cursor is - // free to drift via the pass-through `Moved` events during the hold. - if pressed { - let is_gesture = hooks.read().is_ok_and(|m| m.gestures.contains_key(&id)); - if is_gesture { - HOLD.with_borrow_mut(|h| h.begin(id)); - return EventDisposition::Suppress; + HookEvent::Mouse(mouse_event) => match mouse_event { + MouseEvent::Button { id, pressed } => { + // The CGEventTap only sees standard buttons 0-4. We remap + // Middle/Back/Forward; the primary L/R clicks always pass through + // (suppressing them would brick the mouse), and the DPI / thumb / + // dedicated gesture button aren't visible to the tap at all — the + // dedicated gesture button is captured separately over HID++. + if !id.is_os_hook_button() { + return EventDisposition::PassThrough; } - } else { - // Release: end the hold and release the `HOLD` borrow *before* any - // dispatch — the callback must stay lock-light, since a - // synthesized event could otherwise re-enter the tap and re-borrow - // `HOLD` (a RefCell double-borrow panic, freeze hazard). - let ended = HOLD.with_borrow_mut(|h| h.end(id)); - if let Some(was_click) = ended { - if was_click { - // No swipe committed → fire the plain click. Resolve to an - // owned action (so no lock is held across dispatch), then - // dispatch with the guard already dropped. - let action = hooks - .read() - .ok() - .map(|m| resolve_gesture_click(&m.gestures, id)); - if let Some(action) = action { - info!(button = %id, action = %action.label(), "gesture click → executing bound action"); - dispatch_action(&action, &dpi_cycle, &capture); + + // Gesture button: suppress the native click and begin a hold. The + // swipe commits mid-motion in the `Moved` arm; here, on release, we + // only fire the plain `Click` when no swipe committed. The cursor is + // free to drift via the pass-through `Moved` events during the hold. + if pressed { + let is_gesture = hooks.read().is_ok_and(|m| m.gestures.contains_key(&id)); + if is_gesture { + HOLD.with_borrow_mut(|h| h.begin(id)); + return EventDisposition::Suppress; + } + } else { + // Release: end the hold and release the `HOLD` borrow *before* any + // dispatch — the callback must stay lock-light, since a + // synthesized event could otherwise re-enter the tap and re-borrow + // `HOLD` (a RefCell double-borrow panic, freeze hazard). + let ended = HOLD.with_borrow_mut(|h| h.end(id)); + if let Some(was_click) = ended { + if was_click { + // No swipe committed → fire the plain click. Resolve to an + // owned action (so no lock is held across dispatch), then + // dispatch with the guard already dropped. + let action = hooks + .read() + .ok() + .map(|m| resolve_gesture_click(&m.gestures, id)); + if let Some(action) = action { + info!(button = %id, action = %action.label(), "gesture click → executing bound action"); + dispatch_action(&action, &dpi_cycle, &capture); + } } + return EventDisposition::Suppress; } - return EventDisposition::Suppress; } - } - // Single-action button. - let action = hooks.read().ok().and_then(|m| m.bindings.get(&id).cloned()); - let Some(action) = action else { - // Unbound → leave the physical button to the OS. - return EventDisposition::PassThrough; - }; + // Single-action button. + let action = hooks.read().ok().and_then(|m| m.bindings.get(&id).cloned()); + let Some(action) = action else { + // Unbound → leave the physical button to the OS. + return EventDisposition::PassThrough; + }; - // A button left on its own native click (e.g. Middle → MiddleClick) - // should just do that click; suppressing and re-synthesising it - // would be pointless churn. - if is_native_click(id, &action) { - return EventDisposition::PassThrough; - } + // A button left on its own native click (e.g. Middle → MiddleClick) + // should just do that click; suppressing and re-synthesising it + // would be pointless churn. + if is_native_click(id, &action) { + return EventDisposition::PassThrough; + } - if pressed { - info!(button = %id, action = %action.label(), "button → executing bound action"); - dispatch_action(&action, &dpi_cycle, &capture); + if pressed { + info!(button = %id, action = %action.label(), "button → executing bound action"); + dispatch_action(&action, &dpi_cycle, &capture); + } + EventDisposition::Suppress } - EventDisposition::Suppress - } - MouseEvent::Moved { delta_x, delta_y } => { - // Feed an in-progress hold; a committed swipe fires here, mid-motion. - // Always pass through so the cursor keeps moving — the swipe is read, - // not consumed (the B2 cursor-drift tradeoff vs. a HID++ raw-XY divert - // that would freeze the pointer). - let commit = HOLD.with_borrow_mut(|h| h.accumulate(delta_x, delta_y)); - if let Some((button, dir)) = commit { - // Resolve to an owned action and drop the read guard before - // dispatch (same lock-light rule as the release arm). The button - // can leave the gesture set mid-hold (a per-app rebuild); the - // commit has already armed `fired`, so the release won't fire a - // click. Fall back to the same click action the release path uses - // so the suppressed press is never swallowed into nothing — - // symmetric with `resolve_gesture_click`. - let action = hooks.read().ok().map(|m| { - m.gestures - .get(&button) - .and_then(|dirs| dirs.get(&dir).cloned()) - .unwrap_or_else(|| resolve_gesture_click(&m.gestures, button)) - }); - if let Some(action) = action { - info!(button = %button, ?dir, action = %action.label(), "gesture swipe → executing bound action"); + MouseEvent::Moved { delta_x, delta_y } => { + // Feed an in-progress hold; a committed swipe fires here, mid-motion. + // Always pass through so the cursor keeps moving — the swipe is read, + // not consumed (the B2 cursor-drift tradeoff vs. a HID++ raw-XY divert + // that would freeze the pointer). + let commit = HOLD.with_borrow_mut(|h| h.accumulate(delta_x, delta_y)); + if let Some((button, dir)) = commit { + // Resolve to an owned action and drop the read guard before + // dispatch (same lock-light rule as the release arm). The button + // can leave the gesture set mid-hold (a per-app rebuild); the + // commit has already armed `fired`, so the release won't fire a + // click. Fall back to the same click action the release path uses + // so the suppressed press is never swallowed into nothing — + // symmetric with `resolve_gesture_click`. + let action = hooks.read().ok().map(|m| { + m.gestures + .get(&button) + .and_then(|dirs| dirs.get(&dir).cloned()) + .unwrap_or_else(|| resolve_gesture_click(&m.gestures, button)) + }); + if let Some(action) = action { + info!(button = %button, ?dir, action = %action.label(), "gesture swipe → executing bound action"); + dispatch_action(&action, &dpi_cycle, &capture); + } + } + EventDisposition::PassThrough + } + MouseEvent::CaptureInterrupted => { + // The OS dropped events (tap disabled); cancel any hold so a lost + // button-up can't later commit a phantom swipe off ordinary motion. + HOLD.with_borrow_mut(HoldState::cancel); + EventDisposition::PassThrough + } + MouseEvent::Scroll { .. } => EventDisposition::PassThrough, + }, + // Function-key remapper: on key-down, look up a [keyboard.bindings] + // entry for this keycode + modifier mask. A match fires its action + // (suppressing the original key so it doesn't also type / trigger its + // native function); an unmatched key passes through untouched. Key-up + // is ignored to avoid double-firing the action. + HookEvent::Key(openlogi_hook::KeyEvent { + keycode, + pressed, + modifiers, + }) => { + if !pressed { + return EventDisposition::PassThrough; + } + let trigger = KeyTrigger { + keycode, + modifiers: convert_modifiers(modifiers), + }; + match keyboard_bindings + .read() + .ok() + .and_then(|m| m.get(&trigger).cloned()) + { + Some(action) => { + info!(keycode, action = %action.label(), "key → executing bound action"); dispatch_action(&action, &dpi_cycle, &capture); + EventDisposition::Suppress } + None => EventDisposition::PassThrough, } - EventDisposition::PassThrough - } - MouseEvent::CaptureInterrupted => { - // The OS dropped events (tap disabled); cancel any hold so a lost - // button-up can't later commit a phantom swipe off ordinary motion. - HOLD.with_borrow_mut(HoldState::cancel); - EventDisposition::PassThrough } - MouseEvent::Scroll { .. } => EventDisposition::PassThrough, }); match result { Ok(hook) => { - info!("OS mouse hook installed"); + info!("OS input hook installed"); Some(hook) } Err(e) => { - warn!(error = %e, "could not install OS mouse hook — events will not be captured"); + warn!(error = %e, "could not install OS input hook — events will not be captured"); None } } diff --git a/crates/openlogi-agent-core/src/ipc.rs b/crates/openlogi-agent-core/src/ipc.rs index 37e79e7c..7bc56327 100644 --- a/crates/openlogi-agent-core/src/ipc.rs +++ b/crates/openlogi-agent-core/src/ipc.rs @@ -26,7 +26,8 @@ use serde::{Deserialize, Serialize}; /// v6: `Capabilities::scroll_inversion` added. /// v7: pairing commands return typed acceptance errors. /// v8: [`WriteError`] carries typed HID++ operation failures. -pub const PROTOCOL_VERSION: u32 = 8; +/// v9: agent owns global keyboard bindings and function-key dispatch. +pub const PROTOCOL_VERSION: u32 = 9; /// Where the agent's device enumeration stands. The distinction matters /// because an empty inventory list is ambiguous on its own: the GUI must keep diff --git a/crates/openlogi-agent-core/src/orchestrator.rs b/crates/openlogi-agent-core/src/orchestrator.rs index 019d08bb..69ef13ab 100644 --- a/crates/openlogi-agent-core/src/orchestrator.rs +++ b/crates/openlogi-agent-core/src/orchestrator.rs @@ -54,6 +54,9 @@ pub struct SharedRuntime { /// rebuild publishes both atomically (see [`HookMaps`]). Also read by the /// gesture watcher for the thumb-wheel/DPI-button single actions. pub hook_maps: SharedHookMaps, + /// Function-key remapper bindings (keycode+modifiers → action). Not + /// per-app-profile in M1 (spec non-goal), so a single shared map. + pub keyboard_bindings: crate::hook_runtime::SharedKeyboardBindings, pub gesture_bindings: GestureBindings, pub dpi_cycle: Arc>, pub thumbwheel_sensitivity: Arc, @@ -102,6 +105,7 @@ impl Orchestrator { pub fn new(config: Config) -> Self { let shared = SharedRuntime { hook_maps: Arc::new(RwLock::new(HookMaps::default())), + keyboard_bindings: Arc::new(RwLock::new(config.keyboard.bindings.clone())), gesture_bindings: Arc::new(RwLock::new(BTreeMap::new())), dpi_cycle: Arc::new(RwLock::new(DpiCycleState::default())), thumbwheel_sensitivity: Arc::new(AtomicI32::new( @@ -175,6 +179,15 @@ impl Orchestrator { }, "dpi_cycle", ); + // Keyboard F-key bindings are global (not per-device), so they key off + // the top-level config map rather than the selected device. Published + // here so `reload_config` (GUI commit) takes effect live, not only on + // agent restart. + write_value( + &self.shared.keyboard_bindings, + self.config.keyboard.bindings.clone(), + "keyboard_bindings", + ); self.shared.thumbwheel_sensitivity.store( self.config.app_settings.thumbwheel_sensitivity, Ordering::Relaxed, diff --git a/crates/openlogi-agent-core/src/transport.rs b/crates/openlogi-agent-core/src/transport.rs index 6ec093ba..51c03602 100644 --- a/crates/openlogi-agent-core/src/transport.rs +++ b/crates/openlogi-agent-core/src/transport.rs @@ -21,9 +21,10 @@ use tarpc::tokio_serde::formats::Bincode; /// Resolve the IPC endpoint name. /// /// On Unix this is the filesystem Unix-domain socket at -/// [`agent_socket_path`](openlogi_core::paths::agent_socket_path) (preserving -/// the existing `~/.config/openlogi/agent.sock` location, so macOS/Linux see no -/// behavior change). On Windows it is a named pipe in the OS namespace +/// [`agent_socket_path`](openlogi_core::paths::agent_socket_path). Production +/// builds keep `~/.config/openlogi/agent.sock`; local macOS `.dev` bundles use +/// the sibling `openlogi-dev` profile so development agents cannot occupy the +/// installed app's endpoint. On Windows it is a named pipe in the OS namespace /// (`\\.\pipe\openlogi-agent.sock`). /// /// # Errors diff --git a/crates/openlogi-agent-core/tests/wire_format.rs b/crates/openlogi-agent-core/tests/wire_format.rs index e0c159b7..9935c554 100644 --- a/crates/openlogi-agent-core/tests/wire_format.rs +++ b/crates/openlogi-agent-core/tests/wire_format.rs @@ -61,7 +61,7 @@ fn assert_wire(value: &T, golden: &str) { /// that makes that visible in the same diff. #[test] fn protocol_version_is_pinned() { - assert_eq!(PROTOCOL_VERSION, 8); + assert_eq!(PROTOCOL_VERSION, 9); } /// tarpc encodes the request enum's variant index, so trait *method order* is diff --git a/crates/openlogi-agent/src/main.rs b/crates/openlogi-agent/src/main.rs index 1cb44d0d..ddcb02ed 100644 --- a/crates/openlogi-agent/src/main.rs +++ b/crates/openlogi-agent/src/main.rs @@ -198,6 +198,7 @@ async fn run(config: Config) { info!("accessibility granted — installing OS mouse hook"); hook = hook_runtime::start( shared.hook_maps.clone(), + shared.keyboard_bindings.clone(), shared.dpi_cycle.clone(), shared.capture_channel.clone(), ); diff --git a/crates/openlogi-agent/src/pairing.rs b/crates/openlogi-agent/src/pairing.rs index ef365590..4e3b9119 100644 --- a/crates/openlogi-agent/src/pairing.rs +++ b/crates/openlogi-agent/src/pairing.rs @@ -266,6 +266,7 @@ mod tests { fn shared_runtime() -> SharedRuntime { SharedRuntime { hook_maps: Arc::new(RwLock::new(HookMaps::default())), + keyboard_bindings: Arc::new(RwLock::new(std::collections::HashMap::new())), gesture_bindings: Arc::new(RwLock::new(BTreeMap::new())), dpi_cycle: Arc::new(RwLock::new(DpiCycleState::default())), thumbwheel_sensitivity: Arc::new(0.into()), diff --git a/crates/openlogi-core/Cargo.toml b/crates/openlogi-core/Cargo.toml index c8e1e8c2..afc5c96a 100644 --- a/crates/openlogi-core/Cargo.toml +++ b/crates/openlogi-core/Cargo.toml @@ -18,6 +18,9 @@ tracing = { workspace = true } fs4 = { version = "1.1.0", features = ["sync"] } etcetera = "0.11.0" +[target.'cfg(target_os = "macos")'.dependencies] +plist = "1.9.0" + [dev-dependencies] tempfile = "3" tracing-subscriber = { workspace = true } diff --git a/crates/openlogi-core/src/binding.rs b/crates/openlogi-core/src/binding.rs index 1463492a..50c27d9d 100644 --- a/crates/openlogi-core/src/binding.rs +++ b/crates/openlogi-core/src/binding.rs @@ -481,6 +481,41 @@ pub enum Action { /// The `display` field is used by [`Action::label`] so the popover /// shows the user-friendly chord name. CustomShortcut(KeyCombo), + /// Type an arbitrary string by emitting unicode characters (macOS + /// `CGEventKeyboardSetUnicodeString`). Used for macro text. Power-user + /// escape hatch — excluded from the default catalog. + TypeText(String), + /// Run an AppleScript via `osascript -e `. Power-user escape hatch. + RunAppleScript(String), + /// Run a shell command via `/bin/sh -c `. Power-user escape hatch. + RunShellCommand(String), + /// Run a timed, ordered sequence of steps — the native, no-code version of + /// "type 'bite me', wait 5s, press Enter, wait 5s, type more, Escape". Each + /// step is one of the power-user actions or a `Delay`. The sequencer + /// (`openlogi-inject`) runs them in order, awaiting `Delay`s. Power-user + /// escape hatch — excluded from the default catalog. + Workflow(Vec), +} + +/// One step in a [`Action::Workflow`]. A workflow is a `Vec` +/// executed in order by the inject layer; `Delay` introduces a pause between +/// the surrounding steps. +/// +/// `PressKey` reuses [`KeyCombo`] (the same model as [`Action::CustomShortcut`]) +/// so a step can press a key chord. The other variants mirror their standalone +/// [`Action`] counterparts. +#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum WorkflowStep { + /// Type a unicode string (see [`Action::TypeText`]). + TypeText(String), + /// Press a key chord (see [`Action::CustomShortcut`] / [`KeyCombo`]). + PressKey(KeyCombo), + /// Wait `millis` milliseconds before the next step. + Delay { millis: u64 }, + /// Run an AppleScript (see [`Action::RunAppleScript`]). + RunAppleScript(String), + /// Run a shell command (see [`Action::RunShellCommand`]). + RunShellCommand(String), } /// A modifier + virtual-key keystroke captured by the P1.3 recorder UI or @@ -723,6 +758,10 @@ impl Action { Action::HorizontalScrollLeft => "Scroll Left".into(), Action::HorizontalScrollRight => "Scroll Right".into(), Action::CustomShortcut(combo) => combo.rendered_label(), + Action::TypeText(s) => format!("Type \"{s}\"").into(), + Action::RunAppleScript(_) => "Run AppleScript".into(), + Action::RunShellCommand(_) => "Run Command".into(), + Action::Workflow(steps) => format!("Workflow ({} steps)", steps.len()).into(), } } @@ -745,7 +784,11 @@ impl Action { | Action::SelectAll | Action::Find | Action::Save - | Action::CustomShortcut(_) => Category::Editing, + | Action::CustomShortcut(_) + | Action::TypeText(_) + | Action::RunAppleScript(_) + | Action::RunShellCommand(_) + | Action::Workflow(_) => Category::Editing, Action::BrowserBack | Action::BrowserForward | Action::NewTab @@ -956,6 +999,91 @@ mod tests { } } + #[test] + fn power_user_action_labels_and_category() { + assert_eq!(Action::TypeText("hi".into()).label(), "Type \"hi\""); + assert_eq!( + Action::RunAppleScript("osascript".into()).label(), + "Run AppleScript" + ); + assert_eq!( + Action::RunShellCommand("echo hi".into()).label(), + "Run Command" + ); + // All three are power-user escape hatches: classed as Editing so a + // hand-authored binding has a home group, but never in the default + // catalog (asserted below). + assert_eq!(Action::TypeText("x".into()).category(), Category::Editing); + assert_eq!( + Action::RunAppleScript("x".into()).category(), + Category::Editing + ); + assert_eq!( + Action::RunShellCommand("x".into()).category(), + Category::Editing + ); + } + + #[test] + fn power_user_actions_excluded_from_catalog() { + let cat = Action::catalog(); + assert!(cat.iter().all(|a| !matches!( + a, + Action::TypeText(_) | Action::RunAppleScript(_) | Action::RunShellCommand(_) + ))); + } + + #[test] + fn power_user_actions_roundtrip_toml() { + for action in [ + Action::TypeText("hello".into()), + Action::RunAppleScript("beep".into()), + Action::RunShellCommand("date".into()), + ] { + let toml = toml::to_string(&action).unwrap(); + let back: Action = toml::from_str(&toml).unwrap(); + assert_eq!(action, back); + } + } + + #[test] + fn workflow_label_category_and_catalog_exclusion() { + let wf = Action::Workflow(vec![ + WorkflowStep::TypeText("bite me".into()), + WorkflowStep::Delay { millis: 5000 }, + WorkflowStep::PressKey(KeyCombo { + modifiers: 0, + key_code: 0x24, // Return + display: String::new(), + }), + ]); + assert_eq!(wf.label(), "Workflow (3 steps)"); + assert_eq!(wf.category(), Category::Editing); + // Excluded from the default catalog like the other power-user actions. + assert!( + Action::catalog() + .iter() + .all(|a| !matches!(a, Action::Workflow(_))) + ); + } + + #[test] + fn workflow_roundtrips_toml() { + let wf = Action::Workflow(vec![ + WorkflowStep::TypeText("bite me".into()), + WorkflowStep::Delay { millis: 5000 }, + WorkflowStep::PressKey(KeyCombo { + modifiers: KeyCombo::MOD_SHIFT, + key_code: 0x24, + display: "⇧↩".into(), + }), + WorkflowStep::RunShellCommand("echo done".into()), + ]); + let toml = toml::to_string(&wf).unwrap(); + let back: Action = toml::from_str(&toml).unwrap(); + assert_eq!(wf, back); + } + // ── Binding (merged model) serde routing ────────────────────────────────── /// On-disk shape: a `ButtonId` → [`Binding`] map, as `DeviceConfig.bindings` diff --git a/crates/openlogi-core/src/config.rs b/crates/openlogi-core/src/config.rs index 87702587..79fb86ca 100644 --- a/crates/openlogi-core/src/config.rs +++ b/crates/openlogi-core/src/config.rs @@ -34,6 +34,169 @@ use crate::paths::{self, PathsError}; /// so a forward file fails loudly instead of silently losing bindings. pub const SCHEMA_VERSION: u32 = 3; +/// Top-level config document. +/// Detectable modifier state for a keyboard trigger. A leaf-level duplicate of +/// `openlogi_hook::KeyModifiers` — core must not depend on hook, so the four +/// bools are mirrored here and converted at the agent boundary (which depends +/// on both crates). `Fn` is absent: firmware-internal, unusable as a trigger +/// (function-key-remapper spec, Appendix A). +#[derive( + Debug, Clone, Copy, Default, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, +)] +pub struct KeyModifiers { + pub shift: bool, + pub control: bool, + pub option: bool, + pub command: bool, +} + +impl KeyModifiers { + pub fn is_empty(&self) -> bool { + !self.shift && !self.control && !self.option && !self.command + } +} + +/// A keyboard trigger: a keycode plus an optional modifier mask. The parse +/// format is `[mod+]+key`, e.g. `"f1"`, `"shift+cmd+f5"`. Modifier names: +/// `shift`, `control` (alias `ctrl`), `option` (alias `alt`), `command` +/// (alias `cmd`). Key names: `esc`, `f1`..`f19` (macOS virtual keycodes). +/// +/// Serializes as its string form (via `Display`) so it can be a TOML map key: +/// `[keyboard.bindings]` keys are `"f1"`, `"shift+f2"`, etc. +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct KeyTrigger { + pub keycode: u16, + pub modifiers: KeyModifiers, +} + +impl std::fmt::Display for KeyTrigger { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let mut parts: Vec<&str> = Vec::new(); + let m = &self.modifiers; + if m.shift { + parts.push("shift"); + } + if m.control { + parts.push("control"); + } + if m.option { + parts.push("option"); + } + if m.command { + parts.push("command"); + } + parts.push(keycode_to_name(self.keycode).ok_or(std::fmt::Error)?); + write!(f, "{}", parts.join("+")) + } +} + +/// Reverse lookup for the parse table — needed so `Display` can render a +/// parsed trigger back to its canonical name. +fn keycode_to_name(code: u16) -> Option<&'static str> { + Some(match code { + 0x35 => "esc", + 0x7A => "f1", + 0x78 => "f2", + 0x63 => "f3", + 0x76 => "f4", + 0x60 => "f5", + 0x61 => "f6", + 0x62 => "f7", + 0x64 => "f8", + 0x65 => "f9", + 0x6D => "f10", + 0x67 => "f11", + 0x6F => "f12", + 0x69 => "f13", + 0x6B => "f14", + 0x71 => "f15", + 0x6A => "f16", + 0x40 => "f17", + 0x4F => "f18", + 0x50 => "f19", + _ => return None, + }) +} + +// String-form serde so KeyTrigger can be a TOML map key. +impl Serialize for KeyTrigger { + fn serialize(&self, s: S) -> Result { + s.collect_str(self) + } +} +impl<'de> Deserialize<'de> for KeyTrigger { + fn deserialize>(d: D) -> Result { + let s = String::deserialize(d)?; + s.parse().map_err(serde::de::Error::custom) + } +} + +/// Error returned by [`KeyTrigger`]'s `FromStr` impl. +#[derive(Debug)] +pub struct ParseTriggerError(pub String); +impl std::fmt::Display for ParseTriggerError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "invalid key trigger: {}", self.0) + } +} +impl std::error::Error for ParseTriggerError {} + +impl std::str::FromStr for KeyTrigger { + type Err = ParseTriggerError; + fn from_str(s: &str) -> Result { + let mut mods = KeyModifiers::default(); + let parts: Vec<&str> = s.split('+').map(str::trim).collect(); + if parts.is_empty() || parts.iter().any(|p| p.is_empty()) { + return Err(ParseTriggerError("empty segment".into())); + } + // All but the last segment must be modifiers; the last is the key. + let (mod_parts, key_part) = parts.split_at(parts.len() - 1); + for part in mod_parts { + match part.to_ascii_lowercase().as_str() { + "shift" => mods.shift = true, + "control" | "ctrl" => mods.control = true, + "option" | "alt" => mods.option = true, + "command" | "cmd" => mods.command = true, + other => return Err(ParseTriggerError(format!("unknown modifier '{other}'"))), + } + } + let keycode = match key_part[0].to_ascii_lowercase().as_str() { + "esc" => 0x35, + "f1" => 0x7A, + "f2" => 0x78, + "f3" => 0x63, + "f4" => 0x76, + "f5" => 0x60, + "f6" => 0x61, + "f7" => 0x62, + "f8" => 0x64, + "f9" => 0x65, + "f10" => 0x6D, + "f11" => 0x67, + "f12" => 0x6F, + "f13" => 0x69, + "f14" => 0x6B, + "f15" => 0x71, + "f16" => 0x6A, + "f17" => 0x40, + "f18" => 0x4F, + "f19" => 0x50, + other => return Err(ParseTriggerError(format!("unknown key '{other}'"))), + }; + Ok(KeyTrigger { + keycode, + modifiers: mods, + }) + } +} + +/// The top-level `[keyboard]` table. Bindings are keyed by [`KeyTrigger`]. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct KeyboardConfig { + #[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")] + pub bindings: std::collections::HashMap, +} + /// Top-level config document. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Config { @@ -48,6 +211,11 @@ pub struct Config { pub selected_device: Option, #[serde(default)] pub devices: BTreeMap, + /// Keyboard remappings, independent of device. The function-key remapper + /// (M1) reads this; `#[serde(default)]` keeps older configs without a + /// `[keyboard]` section loading unchanged. + #[serde(default)] + pub keyboard: KeyboardConfig, } impl Default for Config { @@ -57,6 +225,7 @@ impl Default for Config { app_settings: AppSettings::default(), selected_device: None, devices: BTreeMap::new(), + keyboard: KeyboardConfig::default(), } } } @@ -647,6 +816,27 @@ impl Config { .insert(button, binding); } + /// Records (or, with `action = None`, clears) the F-key `trigger` binding + /// in the global `[keyboard]` map. Keyboard bindings are device-agnostic — + /// one map applies across all keyboards — so this mirrors [`Self::set_binding`] + /// minus the device key. + pub fn set_keyboard_binding(&mut self, trigger: KeyTrigger, action: Option) { + match action { + Some(a) => { + self.keyboard.bindings.insert(trigger, a); + } + None => { + self.keyboard.bindings.remove(&trigger); + } + } + } + + /// The global keyboard F-key bindings (read accessor). + #[must_use] + pub fn keyboard_bindings(&self) -> &std::collections::HashMap { + &self.keyboard.bindings + } + /// Returns the gesture sub-bindings for `device_key`'s gesture button, or an /// empty map if it isn't in gesture mode. Derived from the unified /// [`DeviceConfig::bindings`]; kept as a convenience for the agent-side @@ -1012,6 +1202,104 @@ mod tests { Config::load_from_path(&path).expect("load") } + #[test] + fn key_trigger_parses_bare_and_modified() { + // Bare function key — F1 is macOS keycode 0x7A. + let t: KeyTrigger = "f1".parse().unwrap(); + assert_eq!(t.keycode, 0x7A); + assert!(t.modifiers.is_empty()); + + // Modifier-qualified, in any order, with aliases. + let t: KeyTrigger = "shift+cmd+f5".parse().unwrap(); + assert_eq!(t.keycode, 0x60); // F5 + assert!(t.modifiers.shift && t.modifiers.command); + assert!(!t.modifiers.control && !t.modifiers.option); + + let t: KeyTrigger = "ctrl+alt+f2".parse().unwrap(); + assert!(t.modifiers.control && t.modifiers.option); + + // Esc. + assert_eq!("esc".parse::().unwrap().keycode, 0x35); + } + + #[test] + fn key_trigger_parses_and_displays_extended_function_keys() { + let f13: KeyTrigger = "f13".parse().unwrap(); + let f17: KeyTrigger = "command+f17".parse().unwrap(); + let f19: KeyTrigger = "f19".parse().unwrap(); + + assert_eq!(f13.keycode, 0x69); + assert_eq!(f17.keycode, 0x40); + assert_eq!(f17.to_string(), "command+f17"); + assert_eq!(f19.keycode, 0x50); + assert_eq!(f19.to_string(), "f19"); + } + + #[test] + fn key_trigger_rejects_unknown() { + assert!("f99".parse::().is_err()); + assert!("shift+".parse::().is_err()); + assert!("".parse::().is_err()); + } + + #[test] + fn keyboard_section_roundtrips_through_config() { + let mut config = Config::default(); + config + .keyboard + .bindings + .insert("f1".parse().unwrap(), Action::TypeText("hello".into())); + config + .keyboard + .bindings + .insert("shift+f2".parse().unwrap(), Action::VolumeUp); + config + .keyboard + .bindings + .insert("f17".parse().unwrap(), Action::MissionControl); + + let roundtripped = write_and_read(&config); + assert_eq!(roundtripped.keyboard.bindings.len(), 3); + assert_eq!( + roundtripped + .keyboard + .bindings + .get(&"f1".parse::().unwrap()), + Some(&Action::TypeText("hello".into())) + ); + assert_eq!( + roundtripped + .keyboard + .bindings + .get(&"f17".parse::().unwrap()), + Some(&Action::MissionControl) + ); + } + + #[test] + fn set_keyboard_binding_inserts_and_clears() { + let mut config = Config::default(); + let f1: KeyTrigger = "f1".parse().unwrap(); + + // Insert. + config.set_keyboard_binding(f1.clone(), Some(Action::VolumeUp)); + assert_eq!(config.keyboard_bindings().get(&f1), Some(&Action::VolumeUp)); + assert_eq!(config.keyboard_bindings().len(), 1); + + // Overwrite. + config.set_keyboard_binding(f1.clone(), Some(Action::MuteVolume)); + assert_eq!( + config.keyboard_bindings().get(&f1), + Some(&Action::MuteVolume) + ); + assert_eq!(config.keyboard_bindings().len(), 1); + + // Clear via None. + config.set_keyboard_binding(f1.clone(), None); + assert!(config.keyboard_bindings().get(&f1).is_none()); + assert!(config.keyboard_bindings().is_empty()); + } + #[test] fn missing_file_yields_default() { let dir = tempfile::tempdir().expect("tempdir"); diff --git a/crates/openlogi-core/src/paths.rs b/crates/openlogi-core/src/paths.rs index 99006e63..e68ef050 100644 --- a/crates/openlogi-core/src/paths.rs +++ b/crates/openlogi-core/src/paths.rs @@ -11,14 +11,21 @@ //! On Windows `$HOME` falls back to `%USERPROFILE%`, so paths resolve to //! `%USERPROFILE%\.config\openlogi` etc. — best-effort until a real Windows //! port lands. +//! +//! Local packaged macOS builds stamped with `.dev` bundle identifiers use the +//! same layout under an `openlogi-dev` app directory. use std::path::PathBuf; +use std::sync::OnceLock; use etcetera::{BaseStrategy, base_strategy::Xdg}; use thiserror::Error; -/// Subdirectory created under each XDG base directory. +/// Production subdirectory created under each XDG base directory. const APP_DIR: &str = "openlogi"; +/// Local macOS `.dev` bundles use a separate profile so development agents +/// cannot take over the installed app's socket, lock, config, or asset cache. +const DEV_APP_DIR: &str = "openlogi-dev"; #[derive(Debug, Error)] pub enum PathsError { @@ -30,6 +37,57 @@ fn xdg() -> Result { Xdg::new().map_err(|_| PathsError::HomeNotFound) } +fn app_dir() -> &'static str { + static IS_DEV_PROFILE: OnceLock = OnceLock::new(); + if *IS_DEV_PROFILE.get_or_init(is_dev_profile) { + DEV_APP_DIR + } else { + APP_DIR + } +} + +fn is_dev_profile() -> bool { + match std::env::var("OPENLOGI_PROFILE") { + Ok(value) if value == "dev" => return true, + Ok(value) if matches!(value.as_str(), "prod" | "production") => return false, + _ => {} + } + + #[cfg(target_os = "macos")] + { + if let Some(identifier) = current_bundle_identifier() { + return identifier.ends_with(".dev"); + } + } + + false +} + +#[cfg(target_os = "macos")] +fn current_bundle_identifier() -> Option { + let exe = std::env::current_exe().ok()?; + for ancestor in exe.ancestors() { + if ancestor.extension().and_then(|ext| ext.to_str()) != Some("app") { + continue; + } + + let info = ancestor.join("Contents/Info.plist"); + let Ok(plist) = plist::Value::from_file(info) else { + continue; + }; + let Some(identifier) = plist + .as_dictionary() + .and_then(|dictionary| dictionary.get("CFBundleIdentifier")) + .and_then(plist::Value::as_string) + else { + continue; + }; + return Some(identifier.to_owned()); + } + + None +} + /// The raw XDG config home directory (without the `openlogi` subdirectory). /// /// Honours an absolute `$XDG_CONFIG_HOME`; falls back to `~/.config`. @@ -42,8 +100,9 @@ pub fn xdg_config_home() -> Result { /// Directory holding the user's `config.toml`. /// /// `$XDG_CONFIG_HOME/openlogi`, default `~/.config/openlogi`. +/// Local macOS `.dev` bundles use `openlogi-dev` instead. pub fn config_dir() -> Result { - Ok(xdg_config_home()?.join(APP_DIR)) + Ok(xdg_config_home()?.join(app_dir())) } /// Full path to the user config file. @@ -55,16 +114,18 @@ pub fn config_path() -> Result { /// lives under `data_dir()/assets`. /// /// `$XDG_DATA_HOME/openlogi`, default `~/.local/share/openlogi`. +/// Local macOS `.dev` bundles use `openlogi-dev` instead. pub fn data_dir() -> Result { - Ok(xdg()?.data_dir().join(APP_DIR)) + Ok(xdg()?.data_dir().join(app_dir())) } /// Directory for runtime sockets — the background agent's IPC endpoint. pub fn runtime_dir() -> Result { let xdg = xdg()?; - Ok(xdg - .runtime_dir() - .map_or_else(|| xdg.config_dir().join(APP_DIR), |dir| dir.join(APP_DIR))) + Ok(xdg.runtime_dir().map_or_else( + || xdg.config_dir().join(app_dir()), + |dir| dir.join(app_dir()), + )) } /// Path to the background agent's Unix-domain IPC socket: the GUI connects here diff --git a/crates/openlogi-gui/action-icons/terminal.svg b/crates/openlogi-gui/action-icons/terminal.svg new file mode 100644 index 00000000..ac07084d --- /dev/null +++ b/crates/openlogi-gui/action-icons/terminal.svg @@ -0,0 +1,15 @@ + + + + + diff --git a/crates/openlogi-gui/bundle/gui-dev/Info.plist b/crates/openlogi-gui/bundle/gui-dev/Info.plist index 91256e34..5e55fbfa 100644 --- a/crates/openlogi-gui/bundle/gui-dev/Info.plist +++ b/crates/openlogi-gui/bundle/gui-dev/Info.plist @@ -2,8 +2,8 @@ - CFBundleNameOpenLogi - CFBundleDisplayNameOpenLogi + CFBundleNameOpenLogi Dev + CFBundleDisplayNameOpenLogi Dev CFBundleExecutableopenlogi-gui CFBundleIdentifierorg.openlogi.openlogi.dev CFBundleIconFileAppIcon diff --git a/crates/openlogi-gui/locales/da.yml b/crates/openlogi-gui/locales/da.yml index 2e2f041f..5052962b 100644 --- a/crates/openlogi-gui/locales/da.yml +++ b/crates/openlogi-gui/locales/da.yml @@ -286,3 +286,10 @@ _version: 1 "No themes match “%{query}”.": "No themes match “%{query}”." "Color theme": "Color theme" "Interface language": "Interface language" +"Power User": "Power User" +"Type Text…": "Type Text…" +"Run AppleScript…": "Run AppleScript…" +"Run Shell Command…": "Run Shell Command…" +"Workflow…": "Workflow…" +"Save Workflow": "Save Workflow" +"+ Add Step": "+ Add Step" diff --git a/crates/openlogi-gui/locales/de.yml b/crates/openlogi-gui/locales/de.yml index 99480ebe..d1300508 100644 --- a/crates/openlogi-gui/locales/de.yml +++ b/crates/openlogi-gui/locales/de.yml @@ -286,3 +286,10 @@ _version: 1 "No themes match “%{query}”.": "No themes match “%{query}”." "Color theme": "Color theme" "Interface language": "Interface language" +"Power User": "Power User" +"Type Text…": "Type Text…" +"Run AppleScript…": "Run AppleScript…" +"Run Shell Command…": "Run Shell Command…" +"Workflow…": "Workflow…" +"Save Workflow": "Save Workflow" +"+ Add Step": "+ Add Step" diff --git a/crates/openlogi-gui/locales/el.yml b/crates/openlogi-gui/locales/el.yml index 3b96ae1c..745d267e 100644 --- a/crates/openlogi-gui/locales/el.yml +++ b/crates/openlogi-gui/locales/el.yml @@ -286,3 +286,10 @@ _version: 1 "No themes match “%{query}”.": "No themes match “%{query}”." "Color theme": "Color theme" "Interface language": "Interface language" +"Power User": "Power User" +"Type Text…": "Type Text…" +"Run AppleScript…": "Run AppleScript…" +"Run Shell Command…": "Run Shell Command…" +"Workflow…": "Workflow…" +"Save Workflow": "Save Workflow" +"+ Add Step": "+ Add Step" diff --git a/crates/openlogi-gui/locales/en.yml b/crates/openlogi-gui/locales/en.yml index 7b567aa7..52bb01f2 100644 --- a/crates/openlogi-gui/locales/en.yml +++ b/crates/openlogi-gui/locales/en.yml @@ -286,3 +286,10 @@ _version: 1 "No themes match “%{query}”.": "No themes match “%{query}”." "Color theme": "Color theme" "Interface language": "Interface language" +"Power User": "Power User" +"Type Text…": "Type Text…" +"Run AppleScript…": "Run AppleScript…" +"Run Shell Command…": "Run Shell Command…" +"Workflow…": "Workflow…" +"Save Workflow": "Save Workflow" +"+ Add Step": "+ Add Step" diff --git a/crates/openlogi-gui/locales/es.yml b/crates/openlogi-gui/locales/es.yml index 9374362f..8d954298 100644 --- a/crates/openlogi-gui/locales/es.yml +++ b/crates/openlogi-gui/locales/es.yml @@ -286,3 +286,10 @@ _version: 1 "No themes match “%{query}”.": "No themes match “%{query}”." "Color theme": "Color theme" "Interface language": "Interface language" +"Power User": "Power User" +"Type Text…": "Type Text…" +"Run AppleScript…": "Run AppleScript…" +"Run Shell Command…": "Run Shell Command…" +"Workflow…": "Workflow…" +"Save Workflow": "Save Workflow" +"+ Add Step": "+ Add Step" diff --git a/crates/openlogi-gui/locales/fi.yml b/crates/openlogi-gui/locales/fi.yml index 44fe3b8c..f139d26f 100644 --- a/crates/openlogi-gui/locales/fi.yml +++ b/crates/openlogi-gui/locales/fi.yml @@ -286,3 +286,10 @@ _version: 1 "No themes match “%{query}”.": "No themes match “%{query}”." "Color theme": "Color theme" "Interface language": "Interface language" +"Power User": "Power User" +"Type Text…": "Type Text…" +"Run AppleScript…": "Run AppleScript…" +"Run Shell Command…": "Run Shell Command…" +"Workflow…": "Workflow…" +"Save Workflow": "Save Workflow" +"+ Add Step": "+ Add Step" diff --git a/crates/openlogi-gui/locales/fr.yml b/crates/openlogi-gui/locales/fr.yml index 1317cd65..fdd55a3d 100644 --- a/crates/openlogi-gui/locales/fr.yml +++ b/crates/openlogi-gui/locales/fr.yml @@ -286,3 +286,10 @@ _version: 1 "No themes match “%{query}”.": "No themes match “%{query}”." "Color theme": "Color theme" "Interface language": "Interface language" +"Power User": "Power User" +"Type Text…": "Type Text…" +"Run AppleScript…": "Run AppleScript…" +"Run Shell Command…": "Run Shell Command…" +"Workflow…": "Workflow…" +"Save Workflow": "Save Workflow" +"+ Add Step": "+ Add Step" diff --git a/crates/openlogi-gui/locales/it.yml b/crates/openlogi-gui/locales/it.yml index 08b290b7..90f54c53 100644 --- a/crates/openlogi-gui/locales/it.yml +++ b/crates/openlogi-gui/locales/it.yml @@ -286,3 +286,10 @@ _version: 1 "No themes match “%{query}”.": "No themes match “%{query}”." "Color theme": "Color theme" "Interface language": "Interface language" +"Power User": "Power User" +"Type Text…": "Type Text…" +"Run AppleScript…": "Run AppleScript…" +"Run Shell Command…": "Run Shell Command…" +"Workflow…": "Workflow…" +"Save Workflow": "Save Workflow" +"+ Add Step": "+ Add Step" diff --git a/crates/openlogi-gui/locales/ja.yml b/crates/openlogi-gui/locales/ja.yml index 61c9d9d0..3fb0017c 100644 --- a/crates/openlogi-gui/locales/ja.yml +++ b/crates/openlogi-gui/locales/ja.yml @@ -286,3 +286,10 @@ _version: 1 "No themes match “%{query}”.": "No themes match “%{query}”." "Color theme": "Color theme" "Interface language": "Interface language" +"Power User": "Power User" +"Type Text…": "Type Text…" +"Run AppleScript…": "Run AppleScript…" +"Run Shell Command…": "Run Shell Command…" +"Workflow…": "Workflow…" +"Save Workflow": "Save Workflow" +"+ Add Step": "+ Add Step" diff --git a/crates/openlogi-gui/locales/ko.yml b/crates/openlogi-gui/locales/ko.yml index c4504c0e..79051f27 100644 --- a/crates/openlogi-gui/locales/ko.yml +++ b/crates/openlogi-gui/locales/ko.yml @@ -286,3 +286,10 @@ _version: 1 "No themes match “%{query}”.": "No themes match “%{query}”." "Color theme": "Color theme" "Interface language": "Interface language" +"Power User": "Power User" +"Type Text…": "Type Text…" +"Run AppleScript…": "Run AppleScript…" +"Run Shell Command…": "Run Shell Command…" +"Workflow…": "Workflow…" +"Save Workflow": "Save Workflow" +"+ Add Step": "+ Add Step" diff --git a/crates/openlogi-gui/locales/nb.yml b/crates/openlogi-gui/locales/nb.yml index 2f0e55c4..4bd0524e 100644 --- a/crates/openlogi-gui/locales/nb.yml +++ b/crates/openlogi-gui/locales/nb.yml @@ -286,3 +286,10 @@ _version: 1 "No themes match “%{query}”.": "No themes match “%{query}”." "Color theme": "Color theme" "Interface language": "Interface language" +"Power User": "Power User" +"Type Text…": "Type Text…" +"Run AppleScript…": "Run AppleScript…" +"Run Shell Command…": "Run Shell Command…" +"Workflow…": "Workflow…" +"Save Workflow": "Save Workflow" +"+ Add Step": "+ Add Step" diff --git a/crates/openlogi-gui/locales/nl.yml b/crates/openlogi-gui/locales/nl.yml index 51f32483..bfb7325f 100644 --- a/crates/openlogi-gui/locales/nl.yml +++ b/crates/openlogi-gui/locales/nl.yml @@ -286,3 +286,10 @@ _version: 1 "No themes match “%{query}”.": "No themes match “%{query}”." "Color theme": "Color theme" "Interface language": "Interface language" +"Power User": "Power User" +"Type Text…": "Type Text…" +"Run AppleScript…": "Run AppleScript…" +"Run Shell Command…": "Run Shell Command…" +"Workflow…": "Workflow…" +"Save Workflow": "Save Workflow" +"+ Add Step": "+ Add Step" diff --git a/crates/openlogi-gui/locales/pl.yml b/crates/openlogi-gui/locales/pl.yml index fbec6b45..25db0f18 100644 --- a/crates/openlogi-gui/locales/pl.yml +++ b/crates/openlogi-gui/locales/pl.yml @@ -286,3 +286,10 @@ _version: 1 "No themes match “%{query}”.": "No themes match “%{query}”." "Color theme": "Color theme" "Interface language": "Interface language" +"Power User": "Power User" +"Type Text…": "Type Text…" +"Run AppleScript…": "Run AppleScript…" +"Run Shell Command…": "Run Shell Command…" +"Workflow…": "Workflow…" +"Save Workflow": "Save Workflow" +"+ Add Step": "+ Add Step" diff --git a/crates/openlogi-gui/locales/pt-BR.yml b/crates/openlogi-gui/locales/pt-BR.yml index 4508e5ae..18546daf 100644 --- a/crates/openlogi-gui/locales/pt-BR.yml +++ b/crates/openlogi-gui/locales/pt-BR.yml @@ -286,3 +286,10 @@ _version: 1 "No themes match “%{query}”.": "No themes match “%{query}”." "Color theme": "Color theme" "Interface language": "Interface language" +"Power User": "Power User" +"Type Text…": "Type Text…" +"Run AppleScript…": "Run AppleScript…" +"Run Shell Command…": "Run Shell Command…" +"Workflow…": "Workflow…" +"Save Workflow": "Save Workflow" +"+ Add Step": "+ Add Step" diff --git a/crates/openlogi-gui/locales/pt-PT.yml b/crates/openlogi-gui/locales/pt-PT.yml index 0a13f08c..024bbc2b 100644 --- a/crates/openlogi-gui/locales/pt-PT.yml +++ b/crates/openlogi-gui/locales/pt-PT.yml @@ -286,3 +286,10 @@ _version: 1 "No themes match “%{query}”.": "No themes match “%{query}”." "Color theme": "Color theme" "Interface language": "Interface language" +"Power User": "Power User" +"Type Text…": "Type Text…" +"Run AppleScript…": "Run AppleScript…" +"Run Shell Command…": "Run Shell Command…" +"Workflow…": "Workflow…" +"Save Workflow": "Save Workflow" +"+ Add Step": "+ Add Step" diff --git a/crates/openlogi-gui/locales/ru.yml b/crates/openlogi-gui/locales/ru.yml index 32e1be75..94c381ef 100644 --- a/crates/openlogi-gui/locales/ru.yml +++ b/crates/openlogi-gui/locales/ru.yml @@ -286,3 +286,10 @@ _version: 1 "No themes match “%{query}”.": "No themes match “%{query}”." "Color theme": "Color theme" "Interface language": "Interface language" +"Power User": "Power User" +"Type Text…": "Type Text…" +"Run AppleScript…": "Run AppleScript…" +"Run Shell Command…": "Run Shell Command…" +"Workflow…": "Workflow…" +"Save Workflow": "Save Workflow" +"+ Add Step": "+ Add Step" diff --git a/crates/openlogi-gui/locales/sv.yml b/crates/openlogi-gui/locales/sv.yml index cc6cf27f..1e9c48e5 100644 --- a/crates/openlogi-gui/locales/sv.yml +++ b/crates/openlogi-gui/locales/sv.yml @@ -286,3 +286,10 @@ _version: 1 "No themes match “%{query}”.": "No themes match “%{query}”." "Color theme": "Color theme" "Interface language": "Interface language" +"Power User": "Power User" +"Type Text…": "Type Text…" +"Run AppleScript…": "Run AppleScript…" +"Run Shell Command…": "Run Shell Command…" +"Workflow…": "Workflow…" +"Save Workflow": "Save Workflow" +"+ Add Step": "+ Add Step" diff --git a/crates/openlogi-gui/locales/zh-CN.yml b/crates/openlogi-gui/locales/zh-CN.yml index aa217b46..ae4822b5 100644 --- a/crates/openlogi-gui/locales/zh-CN.yml +++ b/crates/openlogi-gui/locales/zh-CN.yml @@ -286,3 +286,10 @@ _version: 1 "No themes match “%{query}”.": "没有匹配“%{query}”的主题。" "Color theme": "配色主题" "Interface language": "界面语言" +"Power User": "Power User" +"Type Text…": "Type Text…" +"Run AppleScript…": "Run AppleScript…" +"Run Shell Command…": "Run Shell Command…" +"Workflow…": "Workflow…" +"Save Workflow": "Save Workflow" +"+ Add Step": "+ Add Step" diff --git a/crates/openlogi-gui/locales/zh-HK.yml b/crates/openlogi-gui/locales/zh-HK.yml index e11ad21a..ea324a48 100644 --- a/crates/openlogi-gui/locales/zh-HK.yml +++ b/crates/openlogi-gui/locales/zh-HK.yml @@ -286,3 +286,10 @@ _version: 1 "No themes match “%{query}”.": "沒有符合「%{query}」的主題。" "Color theme": "配色主題" "Interface language": "介面語言" +"Power User": "Power User" +"Type Text…": "Type Text…" +"Run AppleScript…": "Run AppleScript…" +"Run Shell Command…": "Run Shell Command…" +"Workflow…": "Workflow…" +"Save Workflow": "Save Workflow" +"+ Add Step": "+ Add Step" diff --git a/crates/openlogi-gui/locales/zh-TW.yml b/crates/openlogi-gui/locales/zh-TW.yml index 5d51239b..38fc0e2a 100644 --- a/crates/openlogi-gui/locales/zh-TW.yml +++ b/crates/openlogi-gui/locales/zh-TW.yml @@ -286,3 +286,10 @@ _version: 1 "No themes match “%{query}”.": "沒有符合「%{query}」的主題。" "Color theme": "配色主題" "Interface language": "介面語言" +"Power User": "Power User" +"Type Text…": "Type Text…" +"Run AppleScript…": "Run AppleScript…" +"Run Shell Command…": "Run Shell Command…" +"Workflow…": "Workflow…" +"Save Workflow": "Save Workflow" +"+ Add Step": "+ Add Step" diff --git a/crates/openlogi-gui/src/app.rs b/crates/openlogi-gui/src/app.rs index 5a171e9d..f1087550 100644 --- a/crates/openlogi-gui/src/app.rs +++ b/crates/openlogi-gui/src/app.rs @@ -31,6 +31,7 @@ use crate::components::carousel::Carousel; use crate::components::dpi_panel::DpiPanel; use crate::components::lighting_panel::LightingPanel; use crate::components::smartshift_panel::SmartShiftPanel; +use crate::keyboard_model::function_row::FunctionRowView; use crate::mouse_model::view::MouseModelView; use crate::state::{AgentLink, AppState, DeviceRecord}; use crate::theme::{self, FOOTER_H, HEADER_H, Palette, SelectableStyle as _}; @@ -66,6 +67,8 @@ enum Route { enum DetailTab { /// The mouse model with clickable button hotspots. Buttons, + /// The keyboard function-row remapper with clickable F-key bubbles. + Keys, /// Pointer tuning — DPI and presets. Pointer, /// RGB lighting — color, brightness, on/off. @@ -85,22 +88,24 @@ impl DetailTab { /// measured capabilities; we presume a set from their kind so a sleeping /// mouse still shows its (host-side) button bindings. /// - /// The Buttons panel renders a *mouse-model* silhouette with hotspots. It is - /// only useful for pointer-type devices (Mouse / Trackball) or when the device - /// has a resolved asset that provides its own correct layout. A keyboard that - /// exposes ReprogControls via HID++ but has no asset would get the generic - /// mouse fallback hotspots — confusing and wrong. Suppress the Buttons tab for - /// such devices until a proper keyboard-layout UI is available. + /// The Buttons panel renders a mouse-model silhouette with hotspots. It is + /// only useful for pointer-type devices; keyboards get the Keys panel + /// instead, even when they expose ReprogControls over HID++. fn tabs_for(record: &DeviceRecord) -> Vec { let caps = record .capabilities .unwrap_or_else(|| Capabilities::presumed_from_kind(record.kind)); - let can_show_mouse_model = record.asset.is_some() - || matches!(record.kind, DeviceKind::Mouse | DeviceKind::Trackball); + let can_show_mouse_model = matches!(record.kind, DeviceKind::Mouse | DeviceKind::Trackball); let mut tabs = Vec::new(); if caps.buttons && can_show_mouse_model { tabs.push(Self::Buttons); } + // A keyboard gets the function-row remapper only when the device + // reports remappable-button capability. Lighting-only keyboards should + // not surface a dead Keys panel. + if matches!(record.kind, DeviceKind::Keyboard) && caps.buttons { + tabs.push(Self::Keys); + } if caps.pointer { tabs.push(Self::Pointer); } @@ -122,6 +127,7 @@ impl DetailTab { fn label(self) -> SharedString { match self { Self::Buttons => tr!("Buttons"), + Self::Keys => tr!("Keys"), Self::Pointer => tr!("Pointer"), Self::Lighting => tr!("Lighting"), Self::Device => tr!("Device"), @@ -134,6 +140,7 @@ pub struct AppView { focus_handle: FocusHandle, route: Route, mouse_model: Entity, + keyboard_model: Entity, dpi_panel: Entity, smartshift_panel: Entity, lighting_panel: Entity, @@ -178,6 +185,7 @@ impl AppView { } let mouse_model = cx.new(MouseModelView::new); + let keyboard_model = cx.new(FunctionRowView::new); let dpi_panel = cx.new(DpiPanel::new); let smartshift_panel = cx.new(SmartShiftPanel::new); let lighting_panel = cx.new(LightingPanel::new); @@ -186,6 +194,7 @@ impl AppView { focus_handle, route: Route::Home, mouse_model, + keyboard_model, dpi_panel, smartshift_panel, lighting_panel, @@ -423,6 +432,7 @@ impl Render for AppView { detail_header(record.as_ref(), &tabs, active, pal, cx).into_any_element(), detail_content( &self.mouse_model, + &self.keyboard_model, &self.dpi_panel, &self.smartshift_panel, &self.lighting_panel, @@ -901,6 +911,7 @@ fn main_window_title(show_device: bool, cx: &Context) -> SharedString { /// tab set, so this only has to render the chosen section. fn detail_content( mouse_model: &Entity, + keyboard_model: &Entity, dpi_panel: &Entity, smartshift_panel: &Entity, lighting_panel: &Entity, @@ -910,6 +921,7 @@ fn detail_content( ) -> impl IntoElement { match active { DetailTab::Buttons => buttons_tab(mouse_model).into_any_element(), + DetailTab::Keys => keys_tab(keyboard_model).into_any_element(), DetailTab::Pointer => pointer_tab(dpi_panel, smartshift_panel, pal, cx).into_any_element(), DetailTab::Lighting => lighting_tab(lighting_panel, pal).into_any_element(), DetailTab::Device => device_tab(pal, cx).into_any_element(), @@ -957,6 +969,27 @@ fn buttons_tab(mouse_model: &Entity) -> impl IntoElement { .child(div().w_full().max_w(px(760.)).child(mouse_model.clone())) } +/// Keys tab: the function-row remapper for a keyboard. Same centred, max-width +/// column as [`buttons_tab`] so the keyboard photo + bubbles read as a sibling +/// of the mouse-model screen. F-key bindings are global (`AppState`'s keyboard +/// map), but the panel surfaces them in the device context where the user +/// expects keyboard configuration to live. +fn keys_tab(keyboard_model: &Entity) -> impl IntoElement { + v_flex() + .flex_1() + .w_full() + .min_h_0() + .items_center() + .justify_center() + .p_6() + .child( + div() + .w_full() + .max_w(px(1040.)) + .child(keyboard_model.clone()), + ) +} + /// Pointer tab: the DPI panel, the SmartShift wheel controls, and the /// scroll-wheel preferences, each in a titled card. Use a responsive two-column /// grid that still fits the window's 720 px minimum width, so these short @@ -1725,6 +1758,12 @@ fn accessibility_status(pal: Palette, granted: bool) -> AnyElement { #[cfg(test)] mod tests { + use std::path::PathBuf; + + use openlogi_assets::Metadata; + + use crate::asset::ResolvedAsset; + use super::{ Capabilities, DetailTab, DeviceKind, DeviceRecord, DeviceRoute, connection_icon_path, }; @@ -1775,6 +1814,20 @@ mod tests { } } + fn resolved_asset(kind: DeviceKind) -> ResolvedAsset { + ResolvedAsset { + depot: "test".to_string(), + display_name: "Test".to_string(), + kind, + image_path: PathBuf::from("test.png"), + hero_image_path: None, + glow: None, + metadata: Metadata::default(), + png_width: 1, + png_height: 1, + } + } + /// Tabs follow measured capabilities, not kind — the core of the #127 fix. /// A device the Bolt register mislabels as Keyboard but whose 0x0005 probe /// returns Mouse ends up with kind=Mouse; measured caps drive the tabs. @@ -1812,6 +1865,36 @@ mod tests { assert!(tabs.contains(&DetailTab::Lighting)); } + #[test] + fn keyboard_with_buttons_shows_keys_tab() { + let caps = Some(Capabilities { + buttons: true, + pointer: false, + lighting: true, + scroll_inversion: false, + }); + let tabs = DetailTab::tabs_for(&record(DeviceKind::Keyboard, caps)); + assert!(tabs.contains(&DetailTab::Keys)); + } + + #[test] + fn keyboard_with_asset_hides_buttons_tab() { + let caps = Some(Capabilities { + buttons: true, + pointer: false, + lighting: true, + scroll_inversion: false, + }); + let mut keyboard = record(DeviceKind::Keyboard, caps); + keyboard.asset = Some(resolved_asset(DeviceKind::Keyboard)); + + let tabs = DetailTab::tabs_for(&keyboard); + + assert!(!tabs.contains(&DetailTab::Buttons)); + assert!(tabs.contains(&DetailTab::Keys)); + assert!(tabs.contains(&DetailTab::Lighting)); + } + /// Each panel is independent: a lighting-only device (e.g. a keyboard with /// RGB but no remappable keys yet) shows only Lighting + Device. #[test] diff --git a/crates/openlogi-gui/src/app_assets.rs b/crates/openlogi-gui/src/app_assets.rs index bfb93b7a..85d94f52 100644 --- a/crates/openlogi-gui/src/app_assets.rs +++ b/crates/openlogi-gui/src/app_assets.rs @@ -71,6 +71,7 @@ const ACTION_ICONS: &[(&str, &[u8])] = &[ ("action-icons/square-arrow-right.svg", include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/action-icons/square-arrow-right.svg"))), ("action-icons/square-plus.svg", include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/action-icons/square-plus.svg"))), ("action-icons/square-x.svg", include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/action-icons/square-x.svg"))), + ("action-icons/terminal.svg", include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/action-icons/terminal.svg"))), ("action-icons/undo-2.svg", include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/action-icons/undo-2.svg"))), ("action-icons/unifying.svg", include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/action-icons/unifying.svg"))), ("action-icons/volume-1.svg", include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/action-icons/volume-1.svg"))), diff --git a/crates/openlogi-gui/src/keyboard_model/editors.rs b/crates/openlogi-gui/src/keyboard_model/editors.rs new file mode 100644 index 00000000..429cc313 --- /dev/null +++ b/crates/openlogi-gui/src/keyboard_model/editors.rs @@ -0,0 +1,370 @@ +//! Inline editors for the parameterised power-user actions, shown inside the +//! config panel (the side inspector) once one is selected from the list. +//! +//! Each editor reuses the shared [`menu_card`] surface. Draft state lives on +//! the [`FunctionRowView`] so it survives re-rendering. Closing the editor +//! returns to the action list; the panel itself closes when the key is +//! deselected. +//! +//! [`menu_card`]: crate::mouse_model::picker::menu_card + +use std::rc::Rc; + +use gpui::{ + AnyElement, BorrowAppContext as _, Context, Entity, FontWeight, IntoElement, ParentElement, + StatefulInteractiveElement as _, Styled, div, px, svg, +}; +use gpui_component::{ + Icon, IconName, Sizable as _, + button::{Button, ButtonVariants}, + h_flex, + input::Input, + input::InputState, + v_flex, +}; +use openlogi_core::binding::{Action, KeyCombo, WorkflowStep}; +use openlogi_core::config::KeyTrigger; + +use crate::keyboard_model::function_row::FunctionRowView; +use crate::mouse_model::picker::{PickFn, divider, menu_card, menu_row, scroll_list, title}; +use crate::state::AppState; +use crate::theme::Palette; + +/// Which power-user editor is showing for the selected key. +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub enum PowerUserKind { + TypeText, + RunAppleScript, + RunShellCommand, + Workflow, +} + +impl PowerUserKind { + fn heading(self) -> &'static str { + match self { + Self::TypeText => "Type Text", + Self::RunAppleScript => "Run AppleScript", + Self::RunShellCommand => "Run Shell Command", + Self::Workflow => "Workflow", + } + } +} + +pub(crate) fn text_editor_placeholder(kind: PowerUserKind) -> &'static str { + match kind { + PowerUserKind::TypeText => "Text to type…", + PowerUserKind::RunAppleScript => "display dialog \"Hello\"", + PowerUserKind::RunShellCommand => "echo hello", + PowerUserKind::Workflow => "", + } +} + +pub(crate) fn text_editor_seed(action: Option<&Action>, kind: PowerUserKind) -> String { + match (action, kind) { + (Some(Action::TypeText(text)), PowerUserKind::TypeText) + | (Some(Action::RunAppleScript(text)), PowerUserKind::RunAppleScript) + | (Some(Action::RunShellCommand(text)), PowerUserKind::RunShellCommand) => text.clone(), + _ => String::new(), + } +} + +pub(crate) fn workflow_editor_seed(action: Option<&Action>) -> Vec { + match action { + Some(Action::Workflow(steps)) => steps.clone(), + _ => Vec::new(), + } +} + +/// Render the editor card for `kind`, replacing the panel's action list. +pub fn editor_card( + trigger: KeyTrigger, + kind: PowerUserKind, + text_state: Option>, + workflow_draft: Vec, + view: &Entity, + pal: Palette, + cx: &mut Context, +) -> AnyElement { + match kind { + PowerUserKind::Workflow => workflow_editor_card(trigger, workflow_draft, view, pal, cx), + _ => match text_state { + Some(state) => text_editor_card(trigger, kind, state, view, pal, cx), + None => menu_card(pal) + .w(px(300.)) + .child(title(tr!("Editor unavailable"), pal)) + .into_any_element(), + }, + } +} + +/// The TypeText / RunAppleScript / RunShellCommand editors share a single text +/// field; only the commit wrapping differs. +fn text_editor_card( + trigger: KeyTrigger, + kind: PowerUserKind, + text_state: Entity, + view: &Entity, + pal: Palette, + cx: &mut Context, +) -> AnyElement { + let heading = kind.heading(); + let key_name = trigger.to_string(); + + menu_card(pal) + .w(px(300.)) + .child(title( + tr!("%{action} · %{key}", action => heading, key => key_name), + pal, + )) + .child(divider(pal)) + .child( + v_flex() + .p_2() + .gap_2() + .child(div().child(Input::new(&text_state).cleanable(true))) + .child(editor_action_row(trigger, kind, view, pal, cx)), + ) + .into_any_element() +} + +/// Cancel (back to list) + Save (commit the drafted text). +fn editor_action_row( + trigger: KeyTrigger, + kind: PowerUserKind, + view: &Entity, + _pal: Palette, + _cx: &mut Context, +) -> AnyElement { + let view_save = view.clone(); + let trigger_save = trigger.clone(); + let view_cancel = view.clone(); + + h_flex() + .gap_2() + .justify_end() + .child( + Button::new("editor-cancel") + .ghost() + .label(tr!("Cancel")) + .on_click(move |_e, _window, cx| { + view_cancel.update(cx, |v, vcx| v.close_editor(vcx)); + }), + ) + .child( + Button::new("editor-save") + .primary() + .label(tr!("Save")) + .on_click(move |_e, _window, cx| { + let text = view_save + .read(cx) + .text_state() + .map(|s| s.read(cx).value().to_string()) + .unwrap_or_default(); + let action = match kind { + PowerUserKind::TypeText => Action::TypeText(text), + PowerUserKind::RunAppleScript => Action::RunAppleScript(text), + PowerUserKind::RunShellCommand => Action::RunShellCommand(text), + PowerUserKind::Workflow => return, + }; + cx.update_global::(|state, _| { + state.commit_keyboard_binding(trigger_save.clone(), Some(action)); + }); + view_save.update(cx, |v, vcx| v.close_editor(vcx)); + }), + ) + .into_any_element() +} + +/// The Workflow editor: a list of steps with add/remove. +fn workflow_editor_card( + trigger: KeyTrigger, + steps: Vec, + view: &Entity, + pal: Palette, + cx: &mut Context, +) -> AnyElement { + let key_name = trigger.to_string(); + + let mut rows: Vec = Vec::new(); + for (idx, step) in steps.iter().enumerate() { + rows.push(workflow_step_row(idx, step.clone(), view, pal, cx)); + } + + menu_card(pal) + .w(px(320.)) + .child(title(tr!("Workflow · %{key}", key => key_name), pal)) + .child(divider(pal)) + .child(scroll_list("workflow-steps", rows)) + .child( + h_flex() + .p_2() + .gap_2() + .justify_between() + .child( + Button::new("wf-add-step") + .ghost() + .small() + .label(tr!("+ Add Step")) + .on_click({ + let v = view.clone(); + move |_e, _w, cx| { + v.update(cx, |v, vcx| { + v.push_workflow_step( + WorkflowStep::TypeText(String::new()), + vcx, + ); + }); + } + }), + ) + .child( + Button::new("wf-save") + .primary() + .label(tr!("Save Workflow")) + .on_click({ + let v = view.clone(); + let trigger = trigger.clone(); + move |_e, _window, cx| { + let steps = v.read(cx).workflow_draft().to_vec(); + let action = Action::Workflow(steps); + cx.update_global::(|state, _| { + state.commit_keyboard_binding(trigger.clone(), Some(action)); + }); + v.update(cx, |v, vcx| v.close_editor(vcx)); + } + }), + ), + ) + .into_any_element() +} + +/// One Workflow step row: type chip + payload preview + remove button. +fn workflow_step_row( + idx: usize, + step: WorkflowStep, + view: &Entity, + pal: Palette, + _cx: &mut Context, +) -> AnyElement { + let (type_label, glyph): (&'static str, &'static str) = match &step { + WorkflowStep::TypeText(_) => ("Type Text", "action-icons/keyboard.svg"), + WorkflowStep::PressKey(_) => ("Press Key", "action-icons/keyboard.svg"), + WorkflowStep::Delay { .. } => ("Delay", "action-icons/chevrons-right.svg"), + WorkflowStep::RunAppleScript(_) => ("AppleScript", "action-icons/terminal.svg"), + WorkflowStep::RunShellCommand(_) => ("Shell", "action-icons/terminal.svg"), + }; + let view_remove = view.clone(); + + menu_row(("wf-step", idx), pal, false) + .child( + h_flex() + .w_full() + .items_center() + .gap_2() + .child( + svg() + .path(glyph) + .size_4() + .flex_none() + .text_color(pal.text_muted), + ) + .child( + div() + .text_xs() + .font_weight(FontWeight::MEDIUM) + .text_color(pal.text_muted) + .child(type_label), + ) + .child(div().flex_1().child(step_preview(&step, pal))), + ) + .child( + Icon::new(IconName::Close) + .size_3() + .text_color(pal.text_muted), + ) + .on_click(move |_e, _w, cx| { + view_remove.update(cx, |v, vcx| v.remove_workflow_step(idx, vcx)); + }) + .into_any_element() +} + +fn step_preview(step: &WorkflowStep, pal: Palette) -> AnyElement { + let text: String = match step { + WorkflowStep::TypeText(s) => { + if s.is_empty() { + "…".to_string() + } else { + format!("“{s}”") + } + } + WorkflowStep::PressKey(k) => key_combo_preview(k), + WorkflowStep::Delay { millis } => format!("{millis} ms"), + WorkflowStep::RunAppleScript(s) | WorkflowStep::RunShellCommand(s) => { + if s.is_empty() { + "…".to_string() + } else { + s.clone() + } + } + }; + div() + .text_xs() + .text_color(pal.text_primary) + .child(text) + .into_any_element() +} + +fn key_combo_preview(combo: &KeyCombo) -> String { + if !combo.display.is_empty() { + combo.display.clone() + } else if combo.key_code == 0 { + "—".to_string() + } else { + format!("key 0x{:02X}", combo.key_code) + } +} + +#[allow(dead_code, reason = "kept for parity with the mouse picker")] +fn _silence_pickfn() -> PickFn { + Rc::new(|_a: Action, _w: &mut gpui::Window, _cx: &mut gpui::App| {}) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn text_editor_seed_only_uses_matching_power_user_action() { + assert_eq!( + text_editor_seed( + Some(&Action::RunAppleScript( + "tell app \"Finder\" to activate".into() + )), + PowerUserKind::RunAppleScript, + ), + "tell app \"Finder\" to activate" + ); + assert_eq!( + text_editor_seed( + Some(&Action::RunShellCommand("echo nope".into())), + PowerUserKind::RunAppleScript, + ), + "" + ); + } + + #[test] + fn workflow_editor_seed_only_uses_workflow_action() { + let steps = vec![WorkflowStep::TypeText("hello".into())]; + assert_eq!( + workflow_editor_seed(Some(&Action::Workflow(steps.clone()))), + steps + ); + assert!( + workflow_editor_seed(Some(&Action::RunAppleScript( + "display dialog \"Hello\"".into() + ))) + .is_empty() + ); + } +} diff --git a/crates/openlogi-gui/src/keyboard_model/function_row.rs b/crates/openlogi-gui/src/keyboard_model/function_row.rs new file mode 100644 index 00000000..28364142 --- /dev/null +++ b/crates/openlogi-gui/src/keyboard_model/function_row.rs @@ -0,0 +1,1115 @@ +//! The keyboard function-row remapper view — the Keys tab body. +//! +//! A two-pane inspector model (the "pro-tool" layout): the keyboard photo sits +//! beside a row of mouse-style callout bubbles, and clicking a function key +//! **selects** it (no popover). A tall, scrollable config panel slides in on the +//! right while the keyboard physically makes room. Only one key is selected at a +//! time. +//! +//! F-key bindings are global (`AppState`'s keyboard map), committed via +//! [`AppState::commit_keyboard_binding`]. The panel lists the same action +//! catalog the mouse picker uses, plus a Power User section. + +use std::rc::Rc; + +use gpui::{ + AnyElement, AppContext as _, BorrowAppContext as _, Bounds, Context, Entity, FontWeight, + InteractiveElement, IntoElement, ParentElement, PathBuilder, Render, + StatefulInteractiveElement as _, Styled, Subscription, Window, canvas, div, hsla, point, + prelude::FluentBuilder as _, px, rgb, svg, +}; +use gpui_component::{h_flex, input::InputState, v_flex}; +use openlogi_core::binding::WorkflowStep; +use openlogi_core::config::{KeyModifiers, KeyTrigger}; + +use crate::asset::ResolvedAsset; +use crate::data::mouse_buttons::Action; +use crate::keyboard_model::editors::{ + PowerUserKind, text_editor_placeholder, text_editor_seed, workflow_editor_seed, +}; +use crate::mouse_model::picker::{ + PickFn, action_icon_path, action_rows, divider, menu_card, menu_row, scroll_list, + section_header, +}; +use crate::state::AppState; +use crate::theme::{self, ACCENT_BLUE, Palette}; +use gpui::ease_in_out; +use gpui::{Animation, AnimationExt, img}; + +/// The programmable top-row keys visible on MX Keys-class keyboards: Esc, then +/// F1-F19. Each entry is the display label (on the key) + the [`KeyTrigger`] +/// keycode it binds. +const FUNCTION_KEYS: [(&str, u16); 20] = [ + ("Esc", 0x35), + ("F1", 0x7A), + ("F2", 0x78), + ("F3", 0x63), + ("F4", 0x76), + ("F5", 0x60), + ("F6", 0x61), + ("F7", 0x62), + ("F8", 0x64), + ("F9", 0x65), + ("F10", 0x6D), + ("F11", 0x67), + ("F12", 0x6F), + ("F13", 0x69), + ("F14", 0x6B), + ("F15", 0x71), + ("F16", 0x6A), + ("F17", 0x40), + ("F18", 0x4F), + ("F19", 0x50), +]; + +/// Width of the config panel (CSS px) when a key is selected. +const PANEL_W: f32 = 320.; +/// Duration of the keyboard slide + panel slide animation. +const SLIDE_MS: u64 = 180; +/// Authored keyboard render width in the Keys inspector. +const KEYBOARD_W: f32 = 700.; +/// Approximate keyboard render height used for hit/leader overlays. +const KEYBOARD_IMG_H: f32 = 220.; +/// Space above the keyboard reserved for function-key callouts. +const CALLOUT_BAND_H: f32 = 118.; +const KEYBOARD_TOTAL_H: f32 = CALLOUT_BAND_H + KEYBOARD_IMG_H; +const KEY_CALLOUT_W: f32 = 60.; +const KEY_CALLOUT_H: f32 = 48.; +const KEY_CALLOUT_TOP_UPPER: f32 = 4.; +const KEY_CALLOUT_TOP_LOWER: f32 = 50.; +const KEY_TARGET_W: f32 = 30.; +const KEY_TARGET_H: f32 = 30.; +const KEY_HOTSPOT_DOT: f32 = 12.; +const FALLBACK_KEY_Y_FRAC: f32 = 0.153; +/// Logitech key markers are authored against a tighter internal keyboard +/// image. The rendered `front.png` includes a little more top/left padding, so +/// the raw marker lands high-left of the visible keycap center. +const FRONT_MARKER_X_OFFSET_FRAC: f32 = 0.02; +const FRONT_MARKER_Y_OFFSET_FRAC: f32 = 0.023; +/// Even-spacing fallback band (fractions of image width) when no metadata. +const EVEN_SPACING_START: f32 = 0.04; +const EVEN_SPACING_END: f32 = 0.96; + +/// The function-row remapper view. +pub struct FunctionRowView { + /// The single selected key index (0 = Esc), or `None` when nothing is + /// selected (no panel shown). + selected_key: Option, + /// The hovered function-row key index, shared by callout bubbles, key hit + /// zones, and leader lines. + hovered_key: Option, + /// Which power-user editor is showing in the panel, if any. + active_editor: Option, + /// Lazily-created [`InputState`] for the text editors. + text_state: Option>, + /// Draft copy of the Workflow steps under edit. + workflow_draft: Vec, + _state_obs: Subscription, +} + +impl FunctionRowView { + /// Create the view. + pub fn new(cx: &mut Context) -> Self { + let state_obs = cx.observe_global::(|_view, cx| cx.notify()); + Self { + selected_key: None, + hovered_key: None, + active_editor: None, + text_state: None, + workflow_draft: Vec::new(), + _state_obs: state_obs, + } + } + + /// Select a key (or deselect with `None`), opening/closing the panel. + pub(crate) fn select_key(&mut self, idx: Option, cx: &mut Context) { + // Changing selection also drops any open editor + its drafts. + if self.selected_key != idx { + self.active_editor = None; + self.text_state = None; + self.workflow_draft.clear(); + } + self.selected_key = idx; + cx.notify(); + } + + /// Toggle a key selection from a click on either its callout or key hit + /// target. + pub(crate) fn click_key(&mut self, idx: usize, cx: &mut Context) { + self.select_key(next_selection_after_click(self.selected_key, idx), cx); + } + + #[allow(dead_code, reason = "public accessor for the selection state")] + pub(crate) fn selected_key(&self) -> Option { + self.selected_key + } + + pub(crate) fn set_hovered_key(&mut self, idx: Option, cx: &mut Context) { + if self.hovered_key != idx { + self.hovered_key = idx; + cx.notify(); + } + } + + pub(crate) fn open_editor(&mut self, kind: PowerUserKind, cx: &mut Context) { + self.active_editor = Some(kind); + self.text_state = None; + self.workflow_draft.clear(); + cx.notify(); + } + + pub(crate) fn close_editor(&mut self, cx: &mut Context) { + self.active_editor = None; + self.text_state = None; + self.workflow_draft.clear(); + cx.notify(); + } + + pub(crate) fn text_state(&self) -> Option> { + self.text_state.clone() + } + + pub(crate) fn new_text_state( + &mut self, + seed: String, + placeholder: &str, + window: &mut Window, + cx: &mut Context, + ) -> Entity { + let state = cx.new(|cx| { + let mut s = InputState::new(window, cx).placeholder(tr!(placeholder)); + if !seed.is_empty() { + s.set_value(seed, window, cx); + } + s + }); + self.text_state = Some(state.clone()); + state + } + + pub(crate) fn workflow_draft(&self) -> &[WorkflowStep] { + &self.workflow_draft + } + + pub(crate) fn push_workflow_step(&mut self, step: WorkflowStep, cx: &mut Context) { + self.workflow_draft.push(step); + cx.notify(); + } + + pub(crate) fn remove_workflow_step(&mut self, idx: usize, cx: &mut Context) { + if idx < self.workflow_draft.len() { + self.workflow_draft.remove(idx); + cx.notify(); + } + } +} + +impl Render for FunctionRowView { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let pal = theme::palette(cx); + let (asset, bindings): (Option, Vec<(KeyTrigger, Action)>) = cx + .try_global::() + .map(|s| { + ( + s.current_record().and_then(|r| r.asset.clone()), + s.keyboard_bindings + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect(), + ) + }) + .unwrap_or_default(); + + let points = key_points(asset.as_ref()); + let slots: Vec = FUNCTION_KEYS + .iter() + .enumerate() + .map(|(idx, (label, keycode))| { + let trigger = KeyTrigger { + keycode: *keycode, + modifiers: KeyModifiers::default(), + }; + let bound = bindings + .iter() + .find(|(k, _)| *k == trigger) + .map(|(_, a)| a.clone()); + KeySlot { + idx, + label, + trigger, + x_frac: points[idx].x_frac, + y_frac: points[idx].y_frac, + bound, + } + }) + .collect(); + + let selected = self.selected_key; + let hovered = self.hovered_key; + let active_editor = self.active_editor; + if let (Some(selected_idx), Some(kind)) = (selected, active_editor) + && let Some(slot) = slots.get(selected_idx) + { + let current_action = bindings + .iter() + .find(|(trigger, _)| trigger == &slot.trigger) + .map(|(_, action)| action); + match kind { + PowerUserKind::Workflow => { + if self.workflow_draft.is_empty() { + self.workflow_draft = workflow_editor_seed(current_action); + } + } + _ => { + if self.text_state.is_none() { + self.new_text_state( + text_editor_seed(current_action, kind), + text_editor_placeholder(kind), + window, + cx, + ); + } + } + } + } + let text_state = self.text_state.clone(); + let workflow_draft = self.workflow_draft.clone(); + let view = cx.entity(); + + // The whole row animates as one: when a key is selected the right-side + // panel grows in and the keyboard nudges left to make room. + v_flex().w_full().items_center().child(inspector_row( + slots, + asset, + selected, + hovered, + active_editor, + text_state, + workflow_draft, + &view, + &pal, + window, + cx, + )) + } +} + +/// One function-row key with its resolved layout + binding. +#[derive(Clone)] +struct KeySlot { + idx: usize, + label: &'static str, + trigger: KeyTrigger, + x_frac: f32, + y_frac: f32, + bound: Option, +} + +/// The two-pane row: keyboard photo + (when a key is selected) the side panel. +fn inspector_row( + slots: Vec, + asset: Option, + selected: Option, + hovered: Option, + active_editor: Option, + text_state: Option>, + workflow_draft: Vec, + view: &Entity, + pal: &Palette, + window: &mut Window, + cx: &mut Context, +) -> impl IntoElement { + let keyboard = keyboard_pane(slots.clone(), asset.as_ref(), selected, hovered, view, pal); + + // When nothing is selected, just the keyboard, full width. + if selected.is_none() { + return h_flex() + .w_full() + .justify_center() + .child(keyboard) + .into_any_element(); + } + + let panel = config_panel( + selected.unwrap(), + &slots, + active_editor, + text_state, + workflow_draft, + view, + pal, + window, + cx, + ); + + // The panel grows in from width 0 → PANEL_W over SLIDE_MS, easing in/out, + // always on the right as a stable inspector. + let animated_panel = div().overflow_hidden().child(panel).with_animation( + "panel-slide", + Animation::new(std::time::Duration::from_millis(SLIDE_MS)).with_easing(ease_in_out), + |el, delta| el.w(px(PANEL_W * delta)), + ); + + h_flex() + .w_full() + .gap_5() + .items_center() + .justify_center() + .child(keyboard) + .child(animated_panel) + .into_any_element() +} + +/// The keyboard photo with callout bubbles above each function key, leader +/// lines, and invisible click-targets over the real keys. +fn keyboard_pane( + slots: Vec, + asset: Option<&ResolvedAsset>, + selected: Option, + hovered: Option, + view: &Entity, + pal: &Palette, +) -> impl IntoElement { + let img_path = asset.map(|a| a.image_path.clone()); + let view_clone = view.clone(); + + div() + .relative() + .w(px(KEYBOARD_W)) + .h(px(KEYBOARD_TOTAL_H)) + .child( + div() + .absolute() + .top(px(CALLOUT_BAND_H)) + .left(px(0.)) + .child(image_or_fallback(img_path, KEYBOARD_W, pal)), + ) + .child(keyboard_leader_canvas(slots.clone(), selected, hovered)) + .children(slots.iter().cloned().map(|s| { + let highlighted = key_is_highlighted(s.idx, selected, hovered); + key_callout(s, highlighted, &view_clone, pal) + })) + // Click-targets overlay, centered on each key's marker point. + .child( + div() + .absolute() + .top(px(CALLOUT_BAND_H)) + .left(px(0.)) + .w(px(KEYBOARD_W)) + .h(px(KEYBOARD_IMG_H)) + .children(slots.into_iter().map(|s| { + let highlighted = key_is_highlighted(s.idx, selected, hovered); + key_click_target(s, highlighted, &view_clone, pal) + })), + ) +} + +/// One callout bubble above a function key. +fn key_callout( + slot: KeySlot, + highlighted: bool, + view: &Entity, + pal: &Palette, +) -> AnyElement { + let idx = slot.idx; + let left = callout_left_px(slot.x_frac, KEYBOARD_W, KEY_CALLOUT_W); + let top = callout_top_px(idx); + let view_hover = view.clone(); + let view_click = view.clone(); + let binding = binding_label(slot.bound.as_ref()); + let binding_icon = slot.bound.as_ref().map(action_icon_path); + + v_flex() + .id(("key-callout", idx)) + .absolute() + .top(px(top)) + .left(px(left)) + .w(px(KEY_CALLOUT_W)) + .h(px(KEY_CALLOUT_H)) + .px_1() + .justify_center() + .items_center() + .gap(px(1.)) + .rounded_md() + .border_1() + .border_color(if highlighted { + rgb(ACCENT_BLUE).into() + } else { + pal.border + }) + .bg(if highlighted { + theme::accent_tint() + } else { + pal.surface_hover + }) + .cursor_pointer() + .hover(move |s| { + s.bg(if highlighted { + theme::accent_tint_hover() + } else { + pal.surface + }) + }) + .child( + div() + .text_xs() + .font_weight(FontWeight::SEMIBOLD) + .text_color(if highlighted { + rgb(ACCENT_BLUE).into() + } else { + pal.text_primary + }) + .child(slot.label), + ) + .child( + h_flex() + .items_center() + .justify_center() + .gap(px(2.)) + .max_w(px(KEY_CALLOUT_W - 8.)) + .when_some(binding_icon, |row, icon| { + row.child(svg().path(icon).size(px(9.)).flex_none().text_color( + if highlighted { + rgb(ACCENT_BLUE).into() + } else { + pal.text_muted + }, + )) + }) + .child( + div() + .min_w_0() + .overflow_hidden() + .text_ellipsis() + .whitespace_nowrap() + .text_xs() + .text_color(if highlighted { + rgb(ACCENT_BLUE).into() + } else { + pal.text_muted + }) + .child(binding), + ), + ) + .on_hover(move |hovered, _window, cx| { + let next = (*hovered).then_some(idx); + view_hover.update(cx, |v, vcx| v.set_hovered_key(next, vcx)); + }) + .on_click(move |_ev, _window, cx| { + view_click.update(cx, |v, vcx| v.click_key(idx, vcx)); + }) + .into_any_element() +} + +/// One invisible click-target over a function key. Selecting it opens the +/// panel; hover/selection draws only a subtle keycap ring on the photo. +fn key_click_target( + slot: KeySlot, + highlighted: bool, + view: &Entity, + _pal: &Palette, +) -> AnyElement { + let idx = slot.idx; + let x_frac = slot.x_frac; + let y_frac = slot.y_frac; + let view_hover = view.clone(); + let view_click = view.clone(); + let left = key_target_left_px(x_frac, KEY_TARGET_W); + let top = key_target_top_px(y_frac, KEY_TARGET_H); + + div() + .id(("key-target", idx)) + .absolute() + .top(px(top)) + .left(px(left)) + .w(px(KEY_TARGET_W)) + .h(px(KEY_TARGET_H)) + .flex() + .items_center() + .justify_center() + .cursor_pointer() + .when(highlighted, |el| { + el.child( + div() + .w_full() + .h_full() + .flex() + .items_center() + .justify_center() + .child( + div() + .w(px(KEY_HOTSPOT_DOT)) + .h(px(KEY_HOTSPOT_DOT)) + .rounded_full() + .border_1() + .border_color(gpui::Hsla::from(rgb(ACCENT_BLUE))) + .bg(gpui::Hsla::from(rgb(ACCENT_BLUE))), + ) + .rounded_full() + .border_1() + .border_color(theme::accent_tint_hover()) + .bg(theme::accent_tint()), + ) + }) + .on_hover(move |hovered, _window, cx| { + let next = (*hovered).then_some(idx); + view_hover.update(cx, |v, vcx| v.set_hovered_key(next, vcx)); + }) + .on_click(move |_ev, _window, cx| { + view_click.update(cx, |v, vcx| v.click_key(idx, vcx)); + }) + .into_any_element() +} + +fn binding_label(action: Option<&Action>) -> gpui::SharedString { + action + .map(|a| match a { + Action::CustomShortcut(combo) => combo.rendered_label().into(), + _ => tr!(a.label()).into(), + }) + .unwrap_or_else(|| tr!("Off").into()) +} + +fn keyboard_leader_canvas( + slots: Vec, + selected: Option, + hovered: Option, +) -> impl IntoElement { + let guides: Vec<(usize, f32, f32)> = + slots.iter().map(|s| (s.idx, s.x_frac, s.y_frac)).collect(); + canvas( + move |_bounds, _, _| (guides, selected, hovered), + move |bounds, payload, window, _app| { + let (guides, selected, hovered) = payload; + paint_keyboard_leaders(bounds, guides, selected, hovered, window); + }, + ) + .absolute() + .inset_0() + .w(px(KEYBOARD_W)) + .h(px(KEYBOARD_TOTAL_H)) +} + +fn paint_keyboard_leaders( + bounds: Bounds, + guides: Vec<(usize, f32, f32)>, + selected: Option, + hovered: Option, + window: &mut Window, +) { + for (idx, x_frac, y_frac) in guides { + let highlighted = key_is_highlighted(idx, selected, hovered); + let key_x = x_frac * KEYBOARD_W; + let key_y = CALLOUT_BAND_H + (y_frac * KEYBOARD_IMG_H); + let callout_x = callout_left_px(x_frac, KEYBOARD_W, KEY_CALLOUT_W) + KEY_CALLOUT_W / 2.; + let callout_bottom = callout_top_px(idx) + KEY_CALLOUT_H; + let start = bounds.origin + point(px(callout_x), px(callout_bottom)); + let elbow = bounds.origin + point(px(callout_x), px(CALLOUT_BAND_H - 14.)); + let end = bounds.origin + point(px(key_x), px(key_y)); + + let mut path = PathBuilder::stroke(if highlighted { px(2.) } else { px(1.) }); + path.move_to(start); + path.line_to(elbow); + path.line_to(end); + if let Ok(path) = path.build() { + if highlighted { + window.paint_path(path, rgb(ACCENT_BLUE)); + } else { + window.paint_path(path, hsla(0., 0., 0.55, 0.35)); + } + } + } +} + +fn next_selection_after_click(current: Option, clicked: usize) -> Option { + (current != Some(clicked)).then_some(clicked) +} + +fn key_is_highlighted(idx: usize, selected: Option, hovered: Option) -> bool { + selected == Some(idx) || hovered == Some(idx) +} + +fn callout_left_px(x_frac: f32, image_w: f32, callout_w: f32) -> f32 { + (x_frac * image_w - callout_w / 2.0).clamp(0.0, image_w - callout_w) +} + +fn key_target_left_px(x_frac: f32, target_w: f32) -> f32 { + (x_frac * KEYBOARD_W - target_w / 2.0).clamp(0.0, KEYBOARD_W - target_w) +} + +fn key_target_top_px(y_frac: f32, target_h: f32) -> f32 { + (y_frac * KEYBOARD_IMG_H - target_h / 2.0).clamp(0.0, KEYBOARD_IMG_H - target_h) +} + +fn callout_top_px(idx: usize) -> f32 { + if callout_lane_is_lower(idx) { + KEY_CALLOUT_TOP_LOWER + } else { + KEY_CALLOUT_TOP_UPPER + } +} + +fn callout_lane_is_lower(idx: usize) -> bool { + idx % 2 == 0 +} + +/// The scrollable config panel for the selected key. Lists the same action +/// catalog the mouse picker uses, plus a Power User section. Renders the rows +/// directly (no popover) in a tall card. +fn config_panel( + selected_idx: usize, + slots: &[KeySlot], + active_editor: Option, + text_state: Option>, + workflow_draft: Vec, + view: &Entity, + pal: &Palette, + _window: &mut Window, + cx: &mut Context, +) -> impl IntoElement { + let slot = &slots[selected_idx]; + let trigger = slot.trigger.clone(); + let key_name = trigger.to_string(); + + // If an editor is active, render it instead of the list. + if let Some(kind) = active_editor { + return crate::keyboard_model::editors::editor_card( + trigger, + kind, + text_state, + workflow_draft, + view, + *pal, + cx, + ); + } + + let current = cx + .try_global::() + .and_then(|s| s.keyboard_bindings.get(&trigger).cloned()); + + let view_for_pick = view.clone(); + let trigger_for_pick = trigger.clone(); + let on_pick: PickFn = Rc::new(move |action, _window, cx| { + cx.update_global::(|state, _| { + state.commit_keyboard_binding(trigger_for_pick.clone(), Some(action)); + }); + view_for_pick.update(cx, |_, vcx| vcx.notify()); + }); + + let rows = panel_action_rows(current.as_ref(), &on_pick, view, pal); + + menu_card(*pal) + .w(px(PANEL_W)) + .max_h(px(500.)) + .child(title_header(&key_name, pal)) + .child(divider(*pal)) + .child(scroll_list("key-panel-scroll", rows)) + .into_any_element() +} + +/// The panel's title — shows which key is selected, e.g. "F1". +fn title_header(key_name: &str, pal: &Palette) -> impl IntoElement { + h_flex() + .items_center() + .justify_between() + .px_2() + .pb_1() + .child( + div() + .text_xs() + .font_weight(FontWeight::SEMIBOLD) + .text_color(pal.text_muted) + .child(tr!("Bind %{name}", name => key_name)), + ) +} + +/// The action rows + a Power User section, mirroring the picker's list but +/// adapted for the panel context (no popover dismissal). +fn panel_action_rows( + current: Option<&Action>, + on_pick: &PickFn, + view: &Entity, + pal: &Palette, +) -> Vec { + let mut children = action_rows("panel-action", current, on_pick, *pal); + children.push(section_header(&tr!("Power User"), *pal)); + + let power_user_actions: &[(PowerUserKind, &str, &'static str)] = &[ + ( + PowerUserKind::TypeText, + "Type Text…", + "action-icons/keyboard.svg", + ), + ( + PowerUserKind::RunAppleScript, + "Run AppleScript…", + "action-icons/terminal.svg", + ), + ( + PowerUserKind::RunShellCommand, + "Run Shell Command…", + "action-icons/terminal.svg", + ), + ( + PowerUserKind::Workflow, + "Workflow…", + "action-icons/list-checks.svg", + ), + ]; + + for (idx, (kind, label, icon_path)) in power_user_actions.iter().enumerate() { + let kind = *kind; + let view = view.clone(); + let selected = matches!( + (current, kind), + (Some(Action::TypeText(_)), PowerUserKind::TypeText) + | ( + Some(Action::RunAppleScript(_)), + PowerUserKind::RunAppleScript + ) + | ( + Some(Action::RunShellCommand(_)), + PowerUserKind::RunShellCommand + ) + | (Some(Action::Workflow(_)), PowerUserKind::Workflow) + ); + children.push( + menu_row(format!("panel-power-{idx}"), *pal, selected) + .child( + h_flex() + .items_center() + .gap_2() + .child( + svg() + .path(*icon_path) + .size_4() + .flex_none() + .text_color(pal.text_muted), + ) + .child(div().child((*label).to_string())), + ) + .when(selected, |s| { + s.child( + gpui_component::Icon::new(gpui_component::IconName::Check) + .size_3() + .text_color(rgb(ACCENT_BLUE)), + ) + }) + .on_click(move |_ev, _window, cx| { + view.update(cx, |v, vcx| v.open_editor(kind, vcx)); + }) + .into_any_element(), + ); + } + children +} + +#[derive(Clone, Copy, Debug)] +struct KeyPoint { + x_frac: f32, + y_frac: f32, +} + +/// Resolve key marker points as fractions [0..1] of the rendered image. Prefer +/// asset metadata's top-row markers; fall back to even spacing on the same row. +fn key_points(asset: Option<&ResolvedAsset>) -> Vec { + if let Some(a) = asset { + let key_markers = sorted_marker_points(a, &["device_keys_image", "device_buttons_image"]); + let easy_switch_markers = sorted_marker_points(a, &["device_easyswitch_image"]); + + if key_markers.len() >= 16 && easy_switch_markers.len() >= 3 { + let mut out = Vec::with_capacity(FUNCTION_KEYS.len()); + out.push(synthesized_esc_point(key_markers[0])); + out.extend( + key_markers[..12] + .iter() + .copied() + .map(calibrated_marker_point), + ); + out.extend( + easy_switch_markers[..3] + .iter() + .copied() + .map(calibrated_marker_point), + ); + out.extend( + key_markers[key_markers.len() - 4..] + .iter() + .copied() + .map(calibrated_marker_point), + ); + if out.len() == FUNCTION_KEYS.len() { + return out; + } + } + + if key_markers.len() >= FUNCTION_KEYS.len() - 1 { + let f1_to_f19 = &key_markers[..FUNCTION_KEYS.len() - 1]; + let mut out = Vec::with_capacity(FUNCTION_KEYS.len()); + out.push(synthesized_esc_point(f1_to_f19[0])); + out.extend(f1_to_f19.iter().copied().map(calibrated_marker_point)); + return out; + } + } + fallback_key_points() +} + +#[cfg(test)] +fn key_x_fractions(asset: Option<&ResolvedAsset>) -> Vec { + key_points(asset) + .into_iter() + .map(|point| point.x_frac) + .collect() +} + +fn sorted_marker_points(asset: &ResolvedAsset, image_keys: &[&str]) -> Vec { + let mut markers: Vec = asset + .metadata + .images + .iter() + .filter(|img| image_keys.contains(&img.key.as_str())) + .flat_map(|img| img.assignments.iter()) + .map(|asg| KeyPoint { + x_frac: asg.marker.x / 100.0, + y_frac: asg.marker.y / 100.0, + }) + .collect(); + markers.sort_by(|a, b| { + a.x_frac + .partial_cmp(&b.x_frac) + .unwrap_or(std::cmp::Ordering::Equal) + }); + markers +} + +fn synthesized_esc_point(first_function_key: KeyPoint) -> KeyPoint { + KeyPoint { + x_frac: synthesized_esc_x(first_function_key.x_frac), + y_frac: calibrated_marker_point(first_function_key).y_frac, + } +} + +fn calibrated_marker_point(raw: KeyPoint) -> KeyPoint { + KeyPoint { + x_frac: (raw.x_frac + FRONT_MARKER_X_OFFSET_FRAC).clamp(0.0, 1.0), + y_frac: (raw.y_frac + FRONT_MARKER_Y_OFFSET_FRAC).clamp(0.0, 1.0), + } +} + +fn synthesized_esc_x(first_function_key_x: f32) -> f32 { + (first_function_key_x - 0.045).max(0.02) +} + +fn fallback_key_x_fractions() -> Vec { + let step = (EVEN_SPACING_END - EVEN_SPACING_START) / (FUNCTION_KEYS.len() - 1) as f32; + (0..FUNCTION_KEYS.len()) + .map(|i| EVEN_SPACING_START + (i as f32) * step) + .collect() +} + +fn fallback_key_points() -> Vec { + fallback_key_x_fractions() + .into_iter() + .map(|x_frac| KeyPoint { + x_frac, + y_frac: FALLBACK_KEY_Y_FRAC, + }) + .collect() +} + +/// The keyboard image, or a labeled placeholder when no asset resolved. +fn image_or_fallback( + img_path: Option, + img_w: f32, + pal: &Palette, +) -> AnyElement { + match img_path { + Some(path) if path.exists() => img(path) + .w(px(img_w)) + .h(px(KEYBOARD_IMG_H)) + .into_any_element(), + Some(_) | None => div() + .w(px(img_w)) + .h(px(160.)) + .rounded_md() + .border_1() + .border_color(pal.border) + .bg(pal.surface) + .flex() + .items_center() + .justify_center() + .text_color(pal.text_muted) + .child(tr!("No keyboard image available")) + .into_any_element(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use openlogi_assets::{Assignment, Direction, ImageEntry, Metadata, Origin, Point}; + use openlogi_core::device::DeviceKind; + use std::path::PathBuf; + + #[test] + fn clicking_the_selected_key_closes_the_panel() { + assert_eq!(next_selection_after_click(None, 3), Some(3)); + assert_eq!(next_selection_after_click(Some(3), 3), None); + assert_eq!(next_selection_after_click(Some(3), 4), Some(4)); + } + + #[test] + fn hover_or_selection_highlights_a_key() { + assert!(key_is_highlighted(2, Some(2), None)); + assert!(key_is_highlighted(2, None, Some(2))); + assert!(key_is_highlighted(2, Some(2), Some(7))); + assert!(!key_is_highlighted(2, Some(1), Some(7))); + } + + #[test] + fn function_row_covers_esc_through_f19() { + let labels: Vec<&str> = FUNCTION_KEYS.iter().map(|(label, _)| *label).collect(); + + assert_eq!(FUNCTION_KEYS.len(), 20); + assert_eq!(labels.first(), Some(&"Esc")); + assert_eq!(labels.last(), Some(&"F19")); + assert!(labels.contains(&"F13")); + assert!(labels.contains(&"F19")); + } + + #[test] + fn fallback_key_positions_cover_the_full_top_row() { + let positions = key_x_fractions(None); + + assert_eq!(positions.len(), 20); + assert_eq!(positions.first().copied(), Some(EVEN_SPACING_START)); + assert_eq!(positions.last().copied(), Some(EVEN_SPACING_END)); + } + + #[test] + fn mx_keys_markers_merge_function_and_easy_switch_groups() { + let key_markers = vec![ + 9.0, 13.4, 17.8, 22.3, 26.7, 31.15, 35.55, 40.05, 44.55, 49.1, 53.5, 57.9, 62.35, 81.5, + 85.9, 90.3, 94.7, + ]; + let easy_switch_markers = vec![67.5, 71.92, 76.3]; + let asset = asset_with_markers(&key_markers, &easy_switch_markers); + + let positions = key_x_fractions(Some(&asset)); + + assert_eq!(positions.len(), 20); + assert_approx_eq(positions[0], 0.045); + assert_approx_eq(positions[1], 0.11); + assert_approx_eq(positions[12], 0.599); + assert_approx_eq(positions[13], 0.695); + assert_approx_eq(positions[15], 0.783); + assert_approx_eq(positions[16], 0.835); + assert_approx_eq(positions[19], 0.967); + assert!( + positions.windows(2).all(|pair| pair[0] < pair[1]), + "positions should stay in physical left-to-right order" + ); + } + + #[test] + fn mx_keys_markers_preserve_key_center_points() { + let key_markers = vec![ + 9.0, 13.4, 17.8, 22.3, 26.7, 31.15, 35.55, 40.05, 44.55, 49.1, 53.5, 57.9, 62.35, 81.5, + 85.9, 90.3, 94.7, + ]; + let easy_switch_markers = vec![67.5, 71.92, 76.3]; + let asset = asset_with_markers(&key_markers, &easy_switch_markers); + + let points = key_points(Some(&asset)); + + assert_eq!(points.len(), 20); + assert_approx_eq(points[19].x_frac, 0.967); + assert_approx_eq(points[19].y_frac, 0.153); + assert_approx_eq(key_target_top_px(points[19].y_frac, 30.0), 18.66); + } + + #[test] + fn callout_left_edge_is_clamped_to_keyboard_width() { + assert_eq!(callout_left_px(0.0, 700.0, 64.0), 0.0); + assert_eq!(callout_left_px(0.5, 700.0, 64.0), 318.0); + assert_eq!(callout_left_px(1.0, 700.0, 64.0), 636.0); + } + + #[test] + fn function_key_callouts_stagger_even_lower_odd_upper() { + assert!(callout_top_px(0) > callout_top_px(1)); + assert_eq!(callout_top_px(0), callout_top_px(2)); + assert_eq!(callout_top_px(1), callout_top_px(3)); + } + + #[test] + fn staggered_function_key_callout_rows_fit_the_keyboard_width() { + let lower_count = FUNCTION_KEYS + .iter() + .enumerate() + .filter(|(idx, _)| callout_lane_is_lower(*idx)) + .count(); + let upper_count = FUNCTION_KEYS.len() - lower_count; + assert!( + KEY_CALLOUT_W * lower_count as f32 <= KEYBOARD_W, + "lower callout lane overlaps before spacing is considered" + ); + assert!( + KEY_CALLOUT_W * upper_count as f32 <= KEYBOARD_W, + "upper callout lane overlaps before spacing is considered" + ); + } + + fn asset_with_markers(key_markers: &[f32], easy_switch_markers: &[f32]) -> ResolvedAsset { + ResolvedAsset { + depot: "mx_keys_s_for_mac".to_string(), + display_name: "MX Keys S for Mac".to_string(), + kind: DeviceKind::Keyboard, + image_path: PathBuf::from("/tmp/mx-keys.png"), + hero_image_path: None, + glow: None, + metadata: Metadata { + images: vec![ + ImageEntry { + key: "device_keys_image".to_string(), + origin: Origin { + width: 1872, + height: 728, + }, + assignments: assignments_from_markers(key_markers), + }, + ImageEntry { + key: "device_easyswitch_image".to_string(), + origin: Origin { + width: 1872, + height: 728, + }, + assignments: assignments_from_markers(easy_switch_markers), + }, + ], + }, + png_width: 1872, + png_height: 728, + } + } + + fn assignments_from_markers(markers: &[f32]) -> Vec { + markers + .iter() + .enumerate() + .map(|(idx, x)| Assignment { + slot_name: format!("slot-{idx}"), + marker: Point { x: *x, y: 13.0 }, + label: Direction { x: -1, y: -1 }, + }) + .collect() + } + + fn assert_approx_eq(actual: f32, expected: f32) { + assert!( + (actual - expected).abs() < 0.0001, + "expected {expected}, got {actual}" + ); + } +} diff --git a/crates/openlogi-gui/src/keyboard_model/mod.rs b/crates/openlogi-gui/src/keyboard_model/mod.rs new file mode 100644 index 00000000..8ab6e360 --- /dev/null +++ b/crates/openlogi-gui/src/keyboard_model/mod.rs @@ -0,0 +1,15 @@ +//! Keyboard remapper UI — the global function-key binding surface. +//! +//! Mirrors [`crate::mouse_model`]: a hardware-style diagram whose clickable +//! hotspots (here, function-row key-caps) each open the same action picker the +//! mouse buttons use. The key difference is scope — mouse bindings are +//! per-device under `config.devices[key].bindings`, while keyboard F-key +//! bindings are global (`config.keyboard.bindings`) and apply across all +//! keyboards, so the picker commits via [`AppState::commit_keyboard_binding`] +//! rather than [`AppState::commit_binding`]. +//! +//! [`AppState::commit_binding`]: crate::state::AppState::commit_binding +//! [`AppState::commit_keyboard_binding`]: crate::state::AppState::commit_keyboard_binding + +pub mod editors; +pub mod function_row; diff --git a/crates/openlogi-gui/src/main.rs b/crates/openlogi-gui/src/main.rs index b1a38617..5b17ce8e 100644 --- a/crates/openlogi-gui/src/main.rs +++ b/crates/openlogi-gui/src/main.rs @@ -38,6 +38,7 @@ mod data; mod diagnostics; mod i18n; mod ipc_client; +mod keyboard_model; mod mouse_model; mod platform; mod state; diff --git a/crates/openlogi-gui/src/mouse_model/picker.rs b/crates/openlogi-gui/src/mouse_model/picker.rs index cfdec04d..aae3106e 100644 --- a/crates/openlogi-gui/src/mouse_model/picker.rs +++ b/crates/openlogi-gui/src/mouse_model/picker.rs @@ -38,11 +38,11 @@ use crate::theme::{self, ACCENT_BLUE, Palette, SelectableStyle}; /// Floor width for the [`action_picker`] popover. The action labels drive the /// actual width; this only stops the list from collapsing too narrow. Matches /// gpui-component's own `PopupMenu` floor (`min_w(rems(8.))`). -const POPOVER_W: f32 = 128.; +pub(crate) const POPOVER_W: f32 = 128.; /// Cap the scrollable action list height. The catalog has 29+ entries across /// half a dozen categories; without a cap the list overflows the window. -const POPOVER_LIST_MAX_H: f32 = 360.; +pub(crate) const POPOVER_LIST_MAX_H: f32 = 360.; /// Build the popover body that re-binds a single `btn`. /// @@ -115,7 +115,7 @@ pub fn gesture_overview( /// card uses `rounded_lg` (8px). The shadow is gpui's soft `shadow_md`, not a /// hard drop. Not stateful (no interaction → no element id, so two sibling cards /// can't collide on one). -fn menu_card(pal: Palette) -> gpui::Div { +pub(crate) fn menu_card(pal: Palette) -> gpui::Div { v_flex() .bg(pal.surface) .border_1() @@ -268,11 +268,11 @@ fn flyout_card( /// Commit callback invoked when a row is clicked. Boxed so the row builder can /// be shared between the button picker and any future custom picker, which /// differ only in what they do after committing. -type PickFn = Rc; +pub(crate) type PickFn = Rc; /// The action catalog grouped by [`Category`], preserving catalog order within /// each group and first-seen order across groups. -fn grouped_catalog() -> Vec<(Category, Vec)> { +pub(crate) fn grouped_catalog() -> Vec<(Category, Vec)> { let mut sections: Vec<(Category, Vec)> = Vec::new(); for action in Action::catalog() { let cat = action.category(); @@ -340,6 +340,12 @@ pub(crate) fn action_icon_path(action: &Action) -> &'static str { Action::HorizontalScrollLeft => "action-icons/chevrons-left.svg", Action::HorizontalScrollRight => "action-icons/chevrons-right.svg", Action::CustomShortcut(_) => "action-icons/keyboard.svg", + // Power-user actions (M1 function-key remapper). These carry payload, + // so the icon doubles as the row glyph *and* the bound-key caption. + Action::TypeText(_) => "action-icons/keyboard.svg", + Action::RunAppleScript(_) => "action-icons/terminal.svg", + Action::RunShellCommand(_) => "action-icons/terminal.svg", + Action::Workflow(_) => "action-icons/list-checks.svg", } } @@ -347,7 +353,7 @@ pub(crate) fn action_icon_path(action: &Action) -> &'static str { /// icon, then its label; `current` adds a trailing accent check. Clicking any /// row invokes `on_pick`. `id_prefix` disambiguates element IDs between pickers /// that share this builder. -fn action_rows( +pub(crate) fn action_rows( id_prefix: &'static str, current: Option<&Action>, on_pick: &PickFn, @@ -401,7 +407,7 @@ fn action_rows( /// fill deepens on hover. Unselected rows are transparent at rest, neutral on /// hover. One accent, one signal per state — no blue label text (which fails AA /// contrast on the near-white surface). -fn menu_row( +pub(crate) fn menu_row( id: impl Into, pal: Palette, selected: bool, @@ -428,7 +434,7 @@ fn menu_row( } /// Small uppercase muted group header. -fn section_header(label: &str, pal: Palette) -> AnyElement { +pub(crate) fn section_header(label: &str, pal: Palette) -> AnyElement { div() .w_full() .px_2() @@ -442,7 +448,7 @@ fn section_header(label: &str, pal: Palette) -> AnyElement { } /// Popover title — the binding context, e.g. "Bind Back". -fn title(text: impl Into, pal: Palette) -> impl IntoElement { +pub(crate) fn title(text: impl Into, pal: Palette) -> impl IntoElement { div() .px_2() .pb_1() @@ -453,12 +459,12 @@ fn title(text: impl Into, pal: Palette) -> impl IntoElement } /// 1px hairline separating the title from the list. -fn divider(pal: Palette) -> impl IntoElement { +pub(crate) fn divider(pal: Palette) -> impl IntoElement { div().mb_1().h(px(1.)).w_full().bg(pal.border) } /// Wrap `rows` in the height-capped, vertically scrollable list region. -fn scroll_list(id: &'static str, rows: Vec) -> impl IntoElement { +pub(crate) fn scroll_list(id: &'static str, rows: Vec) -> impl IntoElement { div() .id(id) .max_h(px(POPOVER_LIST_MAX_H)) diff --git a/crates/openlogi-gui/src/state.rs b/crates/openlogi-gui/src/state.rs index f35705e5..7f5e96f0 100644 --- a/crates/openlogi-gui/src/state.rs +++ b/crates/openlogi-gui/src/state.rs @@ -17,7 +17,9 @@ use std::collections::BTreeMap; use gpui::{App, Global}; -use openlogi_core::config::{AppSettings, Appearance, Config, DeviceIdentity, Lighting}; +use openlogi_core::config::{ + AppSettings, Appearance, Config, DeviceIdentity, KeyTrigger, Lighting, +}; use openlogi_core::device::DeviceInventory; use openlogi_hid::{ DeviceRoute, DpiCapabilities, DpiInfo, SmartShiftMode, SmartShiftStatus, WriteError, @@ -281,6 +283,12 @@ pub struct AppState { /// /// [`DeviceConfig::bindings`]: openlogi_core::config::DeviceConfig::bindings pub gesture_bindings: BTreeMap, + /// Global keyboard F-key bindings (Esc + F1-F19). Device-agnostic — one + /// map applies across all keyboards — so, unlike [`Self::button_bindings`], + /// this is *not* reloaded on device switch. Seeded once from + /// [`Config::keyboard`] and kept in sync via [`Self::commit_keyboard_binding`]. + /// Sorted (`BTreeMap`) for stable render order in the function-row view. + pub keyboard_bindings: BTreeMap, pub dpi: u32, /// DPI capability load state keyed by [`DeviceRecord::config_key`]. Loaded /// lazily because HID++ reads must not block device switching or rendering. @@ -351,6 +359,7 @@ impl AppState { agent_link: AgentLink::Connecting, button_bindings: BTreeMap::new(), gesture_bindings: BTreeMap::new(), + keyboard_bindings: BTreeMap::new(), dpi: DEFAULT_DPI, dpi_data: LazyDeviceData::default(), inventory_misses: BTreeMap::new(), @@ -363,6 +372,15 @@ impl AppState { }; state.button_bindings = state.bindings_for_current(); state.gesture_bindings = state.gesture_bindings_for_current(); + // Keyboard bindings are global, so they seed straight from the config + // map — no per-device resolution like mouse bindings above. + state.keyboard_bindings = state + .config + .keyboard + .bindings + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect(); state } @@ -1235,6 +1253,24 @@ impl AppState { self.persist_and_reload("binding"); } + /// Records (or, with `action = None`, clears) the F-key `trigger` binding + /// in the global `[keyboard]` map. Mirrors [`Self::commit_binding`] minus + /// the device key — keyboard bindings are device-agnostic, so there's no + /// `current_record()` dependency. The agent's `rebuild()` republishes its + /// shared keyboard map on `reload_config`, so this lands live. + pub fn commit_keyboard_binding(&mut self, trigger: KeyTrigger, action: Option) { + match action { + Some(ref a) => { + self.keyboard_bindings.insert(trigger.clone(), a.clone()); + } + None => { + self.keyboard_bindings.remove(&trigger); + } + } + self.config.set_keyboard_binding(trigger, action); + self.persist_and_reload("keyboard binding"); + } + fn bindings_for_current(&self) -> BTreeMap { bindings_for( &self.config, diff --git a/crates/openlogi-hook/src/lib.rs b/crates/openlogi-hook/src/lib.rs index fa0885c2..4c026014 100644 --- a/crates/openlogi-hook/src/lib.rs +++ b/crates/openlogi-hook/src/lib.rs @@ -42,6 +42,39 @@ pub struct EventDevice { pub product_name: Option, } +/// Which modifier keys were held when a key event fired. Mirrors the +/// detectable macOS modifier flags. Note `Fn` is deliberately absent — it is +/// firmware-internal and never reported on non-function-row keys (see the +/// function-key-remapper spec, Appendix A). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +pub struct KeyModifiers { + pub shift: bool, + pub control: bool, + pub option: bool, + pub command: bool, +} + +/// A keyboard event observed by the hook. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct KeyEvent { + /// Platform virtual keycode (macOS: `kVK_*`, e.g. 122 = F1, 53 = Escape). + pub keycode: u16, + /// `true` = key down; `false` = key up. + pub pressed: bool, + /// Which modifiers were held. + pub modifiers: KeyModifiers, +} + +/// Anything the OS hook can observe. `Mouse` preserves the existing callback +/// payload; `Key` is the keyboard path added by the function-key remapper. +/// Wrapping both in a union means `Hook::start`'s callback widens once and +/// stays stable as further event classes arrive. +#[derive(Clone, Debug)] +pub enum HookEvent { + Mouse(MouseEvent), + Key(KeyEvent), +} + /// An event captured at the OS layer. #[derive(Clone, Debug)] pub enum MouseEvent { @@ -188,7 +221,7 @@ impl Hook { /// [`HookError::NoDeviceFound`] when no mouse device is accessible. On /// Windows, installs a `WH_MOUSE_LL` low-level mouse hook. pub fn start( - cb: impl Fn(MouseEvent) -> EventDisposition + Send + Sync + 'static, + cb: impl Fn(HookEvent) -> EventDisposition + Send + Sync + 'static, ) -> Result { #[cfg(target_os = "macos")] { diff --git a/crates/openlogi-hook/src/linux.rs b/crates/openlogi-hook/src/linux.rs index 3581d4b0..953a08d7 100644 --- a/crates/openlogi-hook/src/linux.rs +++ b/crates/openlogi-hook/src/linux.rs @@ -28,7 +28,7 @@ use x11rb::properties::WmClass; use x11rb::protocol::xproto::{Atom, AtomEnum, ConnectionExt as _, Window}; use x11rb::rust_connection::RustConnection; -use crate::{ButtonId, EventDisposition, HookError, MouseEvent}; +use crate::{ButtonId, EventDisposition, HookError, HookEvent, MouseEvent}; /// Name stamped on every uinput pass-through device; used to skip those /// devices during enumeration so we don't hook our own virtual mice. @@ -46,7 +46,7 @@ pub(crate) struct HookInner { } pub(crate) fn start( - cb: impl Fn(MouseEvent) -> EventDisposition + Send + Sync + 'static, + cb: impl Fn(HookEvent) -> EventDisposition + Send + Sync + 'static, ) -> Result { let devices = find_mouse_devices(); if devices.is_empty() { @@ -54,7 +54,7 @@ pub(crate) fn start( } let stop = Arc::new(AtomicBool::new(false)); - let cb: Arc EventDisposition + Send + Sync> = Arc::new(cb); + let cb: Arc EventDisposition + Send + Sync> = Arc::new(cb); let mut threads: Vec> = Vec::with_capacity(devices.len()); let mut stop_pipes: Vec = Vec::with_capacity(devices.len()); @@ -292,7 +292,7 @@ fn device_thread( path: std::path::PathBuf, mut device: Device, mut virtual_device: VirtualDevice, - cb: Arc EventDisposition + Send + Sync>, + cb: Arc EventDisposition + Send + Sync>, stop: Arc, stop_rx: OwnedFd, ) { @@ -356,7 +356,7 @@ fn device_thread( } } else { let disposition = match translate(&event, hires_scroll) { - Some(me) => cb(me), + Some(me) => cb(HookEvent::Mouse(me)), // Low-res companions (REL_WHEEL/REL_HWHEEL) must be suppressed when hi-res // is active — passing them through would double the scroll distance. None if hires_scroll diff --git a/crates/openlogi-hook/src/macos.rs b/crates/openlogi-hook/src/macos.rs index bc695c9f..fd8e0244 100644 --- a/crates/openlogi-hook/src/macos.rs +++ b/crates/openlogi-hook/src/macos.rs @@ -12,13 +12,16 @@ use core_foundation::runloop::{ }; use core_foundation::string::{CFString, CFStringRef}; use core_graphics::event::{ - CGEvent, CGEventField, CGEventTap, CGEventTapLocation, CGEventTapOptions, CGEventTapPlacement, - CGEventTapProxy, CGEventType, CallbackResult, EventField, + CGEvent, CGEventField, CGEventFlags, CGEventTap, CGEventTapLocation, CGEventTapOptions, + CGEventTapPlacement, CGEventTapProxy, CGEventType, CallbackResult, EventField, }; use foreign_types_shared::ForeignType as _; use tracing::{debug, error, warn}; -use crate::{ButtonId, EventDevice, EventDisposition, HookError, MouseEvent}; +use crate::{ + ButtonId, EventDevice, EventDisposition, HookError, HookEvent, KeyEvent, KeyModifiers, + MouseEvent, +}; /// Everything `Hook` needs to control the background thread. pub(crate) struct HookInner { @@ -252,6 +255,36 @@ fn button_number_to_id(n: i64) -> Option { } } +/// Map the macOS modifier flags on a `CGEvent` to our [`KeyModifiers`]. +/// `SecondaryFn` is deliberately ignored: it is firmware-internal and +/// unreliable as a trigger (function-key-remapper spec, Appendix A). +fn modifiers_from_flags(flags: CGEventFlags) -> KeyModifiers { + KeyModifiers { + shift: flags.contains(CGEventFlags::CGEventFlagShift), + control: flags.contains(CGEventFlags::CGEventFlagControl), + option: flags.contains(CGEventFlags::CGEventFlagAlternate), + command: flags.contains(CGEventFlags::CGEventFlagCommand), + } +} + +/// Translate a keyboard `CGEvent` into a [`KeyEvent`]. Returns `None` for +/// non-key event types (the mouse path handles those) and for `FlagsChanged` +/// (modifier state rides on the next key event via its flags; a standalone +/// flags change carries no key of interest to the remapper). +fn translate_key(etype: CGEventType, event: &CGEvent) -> Option { + let pressed = match etype { + CGEventType::KeyDown => true, + CGEventType::KeyUp => false, + // FlagsChanged: no key to remap here. + _ => return None, + }; + Some(KeyEvent { + keycode: event.get_integer_value_field(EventField::KEYBOARD_EVENT_KEYCODE) as u16, + pressed, + modifiers: modifiers_from_flags(event.get_flags()), + }) +} + /// Convert a `CGEvent` to our [`MouseEvent`] vocabulary. Returns `None` /// for event types we don't translate (e.g. move events, unknown buttons). fn translate(etype: CGEventType, event: &CGEvent) -> Option { @@ -410,7 +443,7 @@ fn usable_scroll_delta(event: &CGEvent, axis: ScrollAxisFields) -> f64 { /// Create the event tap and run loop on a dedicated thread. pub(crate) fn start( - cb: impl Fn(MouseEvent) -> EventDisposition + Send + Sync + 'static, + cb: impl Fn(HookEvent) -> EventDisposition + Send + Sync + 'static, ) -> Result { if !has_accessibility() { return Err(HookError::AccessibilityDenied); @@ -418,7 +451,7 @@ pub(crate) fn start( // Wrap in Arc so the closure handed to CGEventTap::new captures it by // clone rather than by move — avoids a second Box allocation. - let cb: Arc EventDisposition + Send + Sync> = Arc::new(cb); + let cb: Arc EventDisposition + Send + Sync> = Arc::new(cb); let (rl_tx, rl_rx) = mpsc::channel::(); @@ -446,7 +479,7 @@ pub(crate) fn start( reason = "rl_tx must be owned: dropping it signals the parent's recv() to return Err on failure paths" )] fn thread_main( - cb: Arc EventDisposition + Send + Sync>, + cb: Arc EventDisposition + Send + Sync>, rl_tx: mpsc::Sender, ) { let event_types = vec![ @@ -465,6 +498,11 @@ fn thread_main( CGEventType::LeftMouseDragged, CGEventType::RightMouseDragged, CGEventType::OtherMouseDragged, + // Keyboard capture for the function-key remapper. Esc/F1-F19 + // arrive here (proven: F1 = keycode 0x7A + SecondaryFn flag). + CGEventType::KeyDown, + CGEventType::KeyUp, + CGEventType::FlagsChanged, ]; let tap_result = CGEventTap::new( @@ -473,10 +511,17 @@ fn thread_main( CGEventTapOptions::Default, event_types, move |_proxy: CGEventTapProxy, etype: CGEventType, event: &CGEvent| { - let Some(mouse_event) = translate(etype, event) else { + // Try the mouse path first, then the keyboard path; whichever + // yields a translation wins. A given event type is one or the + // other, never both. + let hook_event = if let Some(mouse_event) = translate(etype, event) { + HookEvent::Mouse(mouse_event) + } else if let Some(key_event) = translate_key(etype, event) { + HookEvent::Key(key_event) + } else { return CallbackResult::Keep; }; - match cb(mouse_event) { + match cb(hook_event) { EventDisposition::PassThrough => CallbackResult::Keep, EventDisposition::Suppress => CallbackResult::Drop, } diff --git a/crates/openlogi-hook/src/windows.rs b/crates/openlogi-hook/src/windows.rs index 3c718a76..1135a639 100644 --- a/crates/openlogi-hook/src/windows.rs +++ b/crates/openlogi-hook/src/windows.rs @@ -22,11 +22,11 @@ use windows_sys::Win32::UI::WindowsAndMessaging::{ WM_RBUTTONDOWN, WM_RBUTTONUP, WM_USER, WM_XBUTTONDOWN, WM_XBUTTONUP, XBUTTON1, XBUTTON2, }; -use crate::{ButtonId, EventDisposition, HookError, MouseEvent}; +use crate::{ButtonId, EventDisposition, HookError, HookEvent, MouseEvent}; const WHEEL_DELTA: f32 = 120.0; -type HookCallback = Arc EventDisposition + Send + Sync + 'static>; +type HookCallback = Arc EventDisposition + Send + Sync + 'static>; static CALLBACK: Mutex> = Mutex::new(None); @@ -198,7 +198,9 @@ unsafe extern "system" fn mouse_proc(code: i32, wparam: WPARAM, lparam: LPARAM) let callback = CALLBACK.lock().ok().and_then(|slot| slot.clone()); let disposition = callback .as_ref() - .map_or(EventDisposition::PassThrough, |cb| cb(event)); + .map_or(EventDisposition::PassThrough, |cb| { + cb(HookEvent::Mouse(event)) + }); match disposition { EventDisposition::PassThrough => call_next(code, wparam, lparam), EventDisposition::Suppress => 1, diff --git a/crates/openlogi-inject/src/inject.rs b/crates/openlogi-inject/src/inject.rs index 48f8c603..52f684c1 100644 --- a/crates/openlogi-inject/src/inject.rs +++ b/crates/openlogi-inject/src/inject.rs @@ -275,6 +275,63 @@ fn execute_macos(action: &Action) { } macos::post_key(combo.key_code, flags); } + // TypeText emits a unicode string, layout-independent. + Action::TypeText(text) => macos::post_unicode(text), + // Run actions spawn off the tap thread: the callback must not block + // (posting a key while waiting on a child process would wedge input). + Action::RunAppleScript(src) => { + let src = src.clone(); + std::thread::spawn(move || { + let _ = std::process::Command::new("osascript") + .args(["-e", &src]) + .output(); + }); + } + Action::RunShellCommand(cmd) => { + let cmd = cmd.clone(); + std::thread::spawn(move || { + let _ = std::process::Command::new("/bin/sh") + .args(["-c", &cmd]) + .output(); + }); + } + // A workflow runs its steps in order with Delay pauses between them, + // so it must run off the tap thread — same rule as the Run actions. + Action::Workflow(steps) => { + let steps = steps.clone(); + std::thread::spawn(move || run_workflow(&steps)); + } + } +} + +/// Run a workflow's steps in order, awaiting `Delay`s. Reuses the same +/// primitives the standalone actions use: `post_unicode` (TypeText), +/// `post_key` (PressKey), and spawned processes for the Run steps. Runs on a +/// dedicated worker thread (the caller spawns it) so the blocking `Delay` +/// sleeps never stall the event tap. +#[cfg(target_os = "macos")] +fn run_workflow(steps: &[openlogi_core::binding::WorkflowStep]) { + use openlogi_core::binding::WorkflowStep; + for step in steps { + match step { + WorkflowStep::TypeText(text) => macos::post_unicode(text), + WorkflowStep::PressKey(combo) => { + macos::post_keycombo(combo.modifiers, combo.key_code); + } + WorkflowStep::Delay { millis } => { + std::thread::sleep(std::time::Duration::from_millis(*millis)); + } + WorkflowStep::RunAppleScript(src) => { + let _ = std::process::Command::new("osascript") + .args(["-e", src]) + .output(); + } + WorkflowStep::RunShellCommand(cmd) => { + let _ = std::process::Command::new("/bin/sh") + .args(["-c", cmd]) + .output(); + } + } } } @@ -532,6 +589,51 @@ mod macos { up.post(CGEventTapLocation::HID); } + /// Type an arbitrary unicode string by emitting one key event per + /// character, each carrying its unicode payload via + /// `CGEventKeyboardSetUnicodeString`. The unicode string overrides the + /// keycode, so the layout-independent character is typed regardless of the + /// active keyboard layout. One event per char keeps the per-call UTF-16 + /// length inside the API's limit. + pub(super) fn post_unicode(text: &str) { + let Ok(src) = CGEventSource::new(CGEventSourceStateID::HIDSystemState) else { + tracing::warn!("CGEventSource::new failed for post_unicode"); + return; + }; + for ch in text.chars() { + // keycode 0 (A) is a placeholder — the unicode string set below + // determines what's actually typed. key=true; the unicode payload + // turns it into a text-insertion event. + let Ok(ev) = CGEvent::new_keyboard_event(src.clone(), 0, true) else { + tracing::warn!("CGEvent::new_keyboard_event failed in post_unicode"); + continue; + }; + let s = ch.to_string(); + ev.set_string(&s); + ev.post(CGEventTapLocation::HID); + } + } + + /// Press a key chord described by a `KeyCombo` modifier bitmask + virtual + /// keycode. Used by the workflow sequencer's `PressKey` step (and mirrors + /// the `CustomShortcut` arm's flag construction so the two never drift). + pub(super) fn post_keycombo(modifiers: u8, vk: u16) { + let mut flags = CGEventFlags::CGEventFlagNull; + if modifiers & openlogi_core::binding::KeyCombo::MOD_CMD != 0 { + flags |= CGEventFlags::CGEventFlagCommand; + } + if modifiers & openlogi_core::binding::KeyCombo::MOD_SHIFT != 0 { + flags |= CGEventFlags::CGEventFlagShift; + } + if modifiers & openlogi_core::binding::KeyCombo::MOD_CTRL != 0 { + flags |= CGEventFlags::CGEventFlagControl; + } + if modifiers & openlogi_core::binding::KeyCombo::MOD_OPTION != 0 { + flags |= CGEventFlags::CGEventFlagAlternate; + } + post_key(vk, flags); + } + /// Post a media/system key event (play/pause, track navigation, volume). /// /// Runs on the hook/gesture dispatch threads, which have no run loop to diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 64c46960..cf13f073 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -31,8 +31,8 @@ cargo run -p openlogi-gui --release On macOS the desktop binary is launched from inside a throwaway `target/dev/OpenLogi.app` — a Cargo `runner` wired in `.cargo/config.toml` -(`scripts/cargo-run-macos.sh`). This makes the dev build show the real -**OpenLogi** name in the menu bar and the app icon in the Dock; a bare +(`scripts/cargo-run-macos.sh`). This makes the dev build show as +**OpenLogi Dev** in the menu bar and Dock, with the real app icon; a bare `cargo run` binary has no bundle, so macOS would otherwise fall back to the `openlogi-gui` executable name and a generic icon. The binary is hardlinked in (no copy) and the icon is generated on demand by @@ -40,6 +40,13 @@ On macOS the desktop binary is launched from inside a throwaway everything else (the CLI, tests); set `OPENLOGI_DEV_BUNDLE=0` to launch the raw `openlogi-gui` binary instead. +Packaged local dev bundles (`cargo run` and +`cargo run -p xtask -- macos bundle`) use `.dev` bundle identifiers and the +`openlogi-dev` XDG profile (`~/.config/openlogi-dev`, +`~/.local/share/openlogi-dev`, and its own `agent.sock`). That keeps the dev +GUI and agent from sharing the installed production app's Accessibility grant, +single-instance lock, config, or IPC socket. + To install the CLI binary on `PATH`: ```sh diff --git a/docs/superpowers/plans/2026-06-29-mouse-buttons-6-9.md b/docs/superpowers/plans/2026-06-29-mouse-buttons-6-9.md new file mode 100644 index 00000000..c9974608 --- /dev/null +++ b/docs/superpowers/plans/2026-06-29-mouse-buttons-6-9.md @@ -0,0 +1,410 @@ +# Mouse Buttons 6–9 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add four pickable actions (`MouseButton6`–`MouseButton9`) that synthesize mouse buttons 6–9 on macOS and Linux, surfaced in the action picker under the MOUSE section. + +**Architecture:** Pure data-driven addition. The `Action` enum in `openlogi-core` is the single source of truth — the GUI picker, category grouping, TOML schema, and per-platform injection all derive from it. Four new unit variants mirror the existing `MouseBack`/`MouseForward` pattern. On macOS the existing `post_other_button(n)` already accepts any button number; on Linux the evdev `BTN_*` family covers it; on Windows `SendInput` caps at button 5, so 6–9 log-and-skip there (documented gap, same pattern as existing platform-limited actions). + +**Tech Stack:** Rust (workspace), serde/TOML for config, GPUI for the GUI, core-graphics / evdev / windows-sys for injection, rust-i18n for locale strings. + +**Spec:** `docs/superpowers/specs/2026-06-29-mouse-buttons-6-9-design.md` + +--- + +## File Structure + +| File | Responsibility | Change | +|---|---|---| +| `crates/openlogi-core/src/binding.rs` | The `Action` enum + `label()`/`category()`/`catalog()` | Add 4 variants + their 3 match arms + extend 2 tests | +| `crates/openlogi-inject/src/inject.rs` | Per-platform `Action` → OS event synthesis | Add arms in `execute_macos` / `execute_linux` / `execute_windows` | +| `crates/openlogi-gui/src/mouse_model/picker.rs` | Picker icon mapping (exhaustive `match`) | Add 4 arms (compiler-forced) | +| `crates/openlogi-gui/locales/*.yml` (20 files) | i18n translation keys, keyed by English label | Add `"Button 6"`–`"Button 9"` keys after line 147 | + +No new files. The exhaustive `match` arms across the codebase are the safety net — the compiler refuses to build if any variant is missed. + +--- + +## Task 1: Add the `Action` variants and core metadata + +This task adds the four variants and threads them through `label()`, `category()`, and `catalog()`. Tests fail first, then pass. + +**Files:** +- Modify: `crates/openlogi-core/src/binding.rs` + +- [ ] **Step 1: Write the failing test (extend `category_mouse_variants`)** + +In `crates/openlogi-core/src/binding.rs`, find the test at line ~1346 and replace it: + +```rust + #[test] + fn category_mouse_variants() { + assert_eq!(Action::LeftClick.category(), Category::Mouse); + assert_eq!(Action::RightClick.category(), Category::Mouse); + assert_eq!(Action::MiddleClick.category(), Category::Mouse); + assert_eq!(Action::MouseBack.category(), Category::Mouse); + assert_eq!(Action::MouseForward.category(), Category::Mouse); + assert_eq!(Action::MouseButton6.category(), Category::Mouse); + assert_eq!(Action::MouseButton7.category(), Category::Mouse); + assert_eq!(Action::MouseButton8.category(), Category::Mouse); + assert_eq!(Action::MouseButton9.category(), Category::Mouse); + } +``` + +- [ ] **Step 2: Add a label test (append to the `#[cfg(test)] mod tests` block, after `category_mouse_variants`)** + +```rust + #[test] + fn extra_mouse_button_labels() { + assert_eq!(Action::MouseButton6.label(), "Button 6"); + assert_eq!(Action::MouseButton7.label(), "Button 7"); + assert_eq!(Action::MouseButton8.label(), "Button 8"); + assert_eq!(Action::MouseButton9.label(), "Button 9"); + } +``` + +- [ ] **Step 3: Run the tests to verify they fail** + +Run: `cargo test -p openlogi-core --lib binding::tests::category_mouse_variants binding::tests::extra_mouse_button_labels` +Expected: FAIL with `cannot find variant MouseButton6` (and 7/8/9) — compile error. + +- [ ] **Step 4: Add the four variants to the `Action` enum** + +In `crates/openlogi-core/src/binding.rs`, find the `MouseForward` variant (line ~371) and add the four new variants immediately after it, before the `// ── Editing ───` comment: + +```rust + /// Mouse "forward" side button (extra button 5). Native counterpart to + /// [`Action::MouseBack`]; see [`Action::BrowserForward`] for the ⌘] form. + MouseForward, + /// Extra mouse button 6. Emitted as the real button-6 event for apps, games, + /// and CAD software that bind it. macOS/Linux only — Windows `SendInput` + /// caps at button 5, so this logs-and-skips there. + MouseButton6, + /// Extra mouse button 7. See [`Action::MouseButton6`]. + MouseButton7, + /// Extra mouse button 8. See [`Action::MouseButton6`]. + MouseButton8, + /// Extra mouse button 9. See [`Action::MouseButton6`]. + MouseButton9, +``` + +- [ ] **Step 5: Add the four labels to `Action::label()`** + +In the `label()` match (around line ~686, after the `MouseForward` arm), add: + +```rust + Action::MouseForward => "Forward (Button 5)".into(), + Action::MouseButton6 => "Button 6".into(), + Action::MouseButton7 => "Button 7".into(), + Action::MouseButton8 => "Button 8".into(), + Action::MouseButton9 => "Button 9".into(), +``` + +- [ ] **Step 6: Add the four to `Action::category()`** + +In the `category()` match (around line ~737), extend the existing Mouse arm: + +```rust + Action::LeftClick + | Action::RightClick + | Action::MiddleClick + | Action::MouseBack + | Action::MouseForward + | Action::MouseButton6 + | Action::MouseButton7 + | Action::MouseButton8 + | Action::MouseButton9 => Category::Mouse, +``` + +- [ ] **Step 7: Add the four to `Action::catalog()`** + +In the `catalog()` vec (around line ~793, after `Action::MouseForward`), add: + +```rust + // Mouse + Action::LeftClick, + Action::RightClick, + Action::MiddleClick, + Action::MouseBack, + Action::MouseForward, + Action::MouseButton6, + Action::MouseButton7, + Action::MouseButton8, + Action::MouseButton9, +``` + +- [ ] **Step 8: Run the tests to verify they pass** + +Run: `cargo test -p openlogi-core --lib binding` +Expected: PASS — `category_mouse_variants`, `extra_mouse_button_labels`, and `all_catalog_variants_roundtrip_toml` (which iterates `catalog()`) all pass. + +- [ ] **Step 9: Commit** + +```bash +git add crates/openlogi-core/src/binding.rs +git commit -m "feat(core): add MouseButton6-9 actions to the binding vocabulary" +``` + +--- + +## Task 2: Inject the buttons on macOS + +macOS already has `post_other_button(n)` which stamps `MOUSE_EVENT_BUTTON_NUMBER`. Buttons 6–9 map to numbers 5–8 (0-indexed: Back=3, Forward=4). + +**Files:** +- Modify: `crates/openlogi-inject/src/inject.rs` (the `execute_macos` function, around line 186–187) + +- [ ] **Step 1: Add the macOS injection arms** + +Find the macOS extra-button arms (line ~186–187): + +```rust + Action::MouseBack => macos::post_other_button(3), + Action::MouseForward => macos::post_other_button(4), +``` + +Add immediately after them: + +```rust + Action::MouseBack => macos::post_other_button(3), + Action::MouseForward => macos::post_other_button(4), + // Buttons 6–9 (button numbers 5–8, 0-indexed). Same path as 4/5 — + // post_other_button stamps MOUSE_EVENT_BUTTON_NUMBER to address any + // button ≥ 3. + Action::MouseButton6 => macos::post_other_button(5), + Action::MouseButton7 => macos::post_other_button(6), + Action::MouseButton8 => macos::post_other_button(7), + Action::MouseButton9 => macos::post_other_button(8), +``` + +- [ ] **Step 2: Verify the macOS build compiles** + +Run: `cargo build -p openlogi-inject` +Expected: BUILD SUCCEEDS (on macOS the `execute_macos` arms are the ones compiled). + +- [ ] **Step 3: Commit** + +```bash +git add crates/openlogi-inject/src/inject.rs +git commit -m "feat(inject): synthesize mouse buttons 6-9 on macOS" +``` + +--- + +## Task 3: Inject the buttons on Linux + +evdev 0.13.2 exposes `BTN_BACK`, `BTN_FORWARD`, `BTN_TASK`, and `BTN_0` as `KeyCode` constants. These are the conventional codes for extra mouse buttons beyond the side pair. + +**Files:** +- Modify: `crates/openlogi-inject/src/inject.rs` (the `execute_linux` function, around line 77–78) + +- [ ] **Step 1: Add the Linux injection arms** + +Find the Linux extra-button arms (line ~77–78): + +```rust + Action::MouseBack => linux::click(KeyCode::BTN_SIDE), + Action::MouseForward => linux::click(KeyCode::BTN_EXTRA), +``` + +Add immediately after them: + +```rust + Action::MouseBack => linux::click(KeyCode::BTN_SIDE), + Action::MouseForward => linux::click(KeyCode::BTN_EXTRA), + // Buttons 6–9 use the evdev extra-button codes beyond the side pair. + Action::MouseButton6 => linux::click(KeyCode::BTN_FORWARD), + Action::MouseButton7 => linux::click(KeyCode::BTN_BACK), + Action::MouseButton8 => linux::click(KeyCode::BTN_TASK), + Action::MouseButton9 => linux::click(KeyCode::BTN_0), +``` + +- [ ] **Step 2: Verify it compiles on Linux (cross-check)** + +This is a `#[cfg(target_os = "linux")]` block. On macOS it won't be compiled, so to verify the `KeyCode::BTN_*` constants resolve, run a Linux target check: + +Run: `cargo check -p openlogi-inject --target x86_64-unknown-linux-gnu` +Expected: If the target is installed, CHECK SUCCEEDS. If not installed, this step is skipped — the constant names (`BTN_FORWARD`/`BTN_BACK`/`BTN_TASK`/`BTN_0`) are confirmed present in evdev 0.13.2 (see spec's button-number table), and CI will catch any mismatch on Linux. + +- [ ] **Step 3: Commit** + +```bash +git add crates/openlogi-inject/src/inject.rs +git commit -m "feat(inject): synthesize mouse buttons 6-9 on Linux via evdev BTN_*" +``` + +--- + +## Task 4: Log-and-skip on Windows + +Windows `SendInput` mouse input carries flags for buttons 1–5 only; there is no flag for button 6+. The codebase's established pattern for "no platform equivalent" is a `tracing::debug!` log and skip (see the macOS-only navigation actions at `inject.rs:104`). Mirror that. + +**Files:** +- Modify: `crates/openlogi-inject/src/inject.rs` (the `execute_windows` function, around line 290–291) + +- [ ] **Step 1: Add the Windows log-and-skip arms** + +Find the Windows extra-button arms (line ~290–291): + +```rust + Action::MouseBack => windows::post_click(windows::MouseButton::Back), + Action::MouseForward => windows::post_click(windows::MouseButton::Forward), +``` + +Add immediately after them: + +```rust + Action::MouseBack => windows::post_click(windows::MouseButton::Back), + Action::MouseForward => windows::post_click(windows::MouseButton::Forward), + // Windows SendInput carries flags for buttons 1–5 only; there is no + // flag for button 6+, so these log-and-skip (same pattern as the + // macOS-only navigation actions). macOS/Linux emit them natively. + Action::MouseButton6 + | Action::MouseButton7 + | Action::MouseButton8 + | Action::MouseButton9 => { + tracing::debug!( + action = action.label(), + "mouse buttons 6-9 are not supported on Windows — press ignored" + ); + } +``` + +- [ ] **Step 2: Verify it compiles on Windows (cross-check)** + +Run: `cargo check -p openlogi-inject --target x86_64-pc-windows-msvc` +Expected: If the target is installed, CHECK SUCCEEDS. If not, skip — CI covers Windows. (No new types are referenced; `tracing::debug!` and `action.label()` already exist.) + +- [ ] **Step 3: Commit** + +```bash +git add crates/openlogi-inject/src/inject.rs +git commit -m "feat(inject): log-and-skip mouse buttons 6-9 on Windows" +``` + +--- + +## Task 5: Add picker icons (compiler-forced) + +The picker's `action_icon_path` is an exhaustive `match` with no wildcard. After Task 1, the macOS/GUI build will fail here until the four variants are mapped. Reuse the existing generic mouse icon (`action-icons/mouse.svg`, already used by `MiddleClick`). + +**Files:** +- Modify: `crates/openlogi-gui/src/mouse_model/picker.rs` (the `action_icon_path` match, line ~304–305) + +- [ ] **Step 1: Add the four icon arms** + +Find the MouseBack/MouseForward arms (line ~304–305): + +```rust + Action::MouseBack => "action-icons/circle-arrow-left.svg", + Action::MouseForward => "action-icons/circle-arrow-right.svg", +``` + +Add immediately after them: + +```rust + Action::MouseBack => "action-icons/circle-arrow-left.svg", + Action::MouseForward => "action-icons/circle-arrow-right.svg", + // Buttons 6–9 have no canonical glyph; reuse the generic mouse icon + // (same as MiddleClick). The button number is in the label. + Action::MouseButton6 + | Action::MouseButton7 + | Action::MouseButton8 + | Action::MouseButton9 => "action-icons/mouse.svg", +``` + +- [ ] **Step 2: Verify the full workspace builds (this is the compile-time gate)** + +Run: `cargo build -p openlogi-gui` +Expected: BUILD SUCCEEDS. If any variant is still missing an arm anywhere, this fails — that's the safety net working. + +- [ ] **Step 3: Commit** + +```bash +git add crates/openlogi-gui/src/mouse_model/picker.rs +git commit -m "feat(gui): pick icons for mouse buttons 6-9 in the action picker" +``` + +--- + +## Task 6: Add i18n keys to all 20 locale files + +The picker translates action labels via `t!(action.label())` — keyed by the English string. The existing `"Back (Button 4)"` / `"Forward (Button 5)"` keys live at line ~146–147 of every locale file. New keys `"Button 6"`–`"Button 9"` must be added so the labels translate. For non-English locales, the translation mirrors the English form plus the localized "Button" word where the locale already uses one — but since the English label is intentionally number-only, the safest correct default is to leave the translated value identical to the key (English fallback) except where the locale clearly localizes "Button" (most do not for raw button numbers). + +**Files:** +- Modify: all 20 files in `crates/openlogi-gui/locales/*.yml` + +- [ ] **Step 1: Add the four keys to `en.yml` (the source)** + +In `crates/openlogi-gui/locales/en.yml`, after line 147 (`"Forward (Button 5)": "Forward (Button 5)"`), add: + +```yaml +"Forward (Button 5)": "Forward (Button 5)" +"Button 6": "Button 6" +"Button 7": "Button 7" +"Button 8": "Button 8" +"Button 9": "Button 9" +``` + +- [ ] **Step 2: Add the same four keys to each of the other 19 locale files** + +For each file in `crates/openlogi-gui/locales/` except `en.yml`, after the `"Forward (Button 5)"` line (which exists at line ~147 in every file — confirmed), append: + +```yaml +"Button 6": "Button 6" +"Button 7": "Button 7" +"Button 8": "Button 8" +"Button 9": "Button 9" +``` + +The 19 files are: `da.yml de.yml el.yml es.yml fi.yml fr.yml it.yml ja.yml ko.yml nb.yml nl.yml pl.yml pt-BR.yml pt-PT.yml ru.yml sv.yml zh-CN.yml zh-HK.yml zh-TW.yml`. + +(Values are left as the English string — these are raw button numbers with no semantic name to localize. A crowdin pass can refine later; the project already uses crowdin per `crowdin.yml`. Leaving them English-identical is the correct fallback and matches how `en.yml` itself is authored.) + +- [ ] **Step 3: Verify the GUI still builds and the i18n test passes** + +Run: `cargo test -p openlogi-gui --lib i18n` +Expected: PASS. (The i18n test at `i18n.rs:185+` checks specific known strings, not exhaustively, so it won't break — but it confirms the locale loader still parses all files.) + +- [ ] **Step 4: Commit** + +```bash +git add crates/openlogi-gui/locales/*.yml +git commit -m "feat(gui): add Button 6-9 translation keys to all locales" +``` + +--- + +## Task 7: Final whole-workspace verification + +Confirm everything builds and tests pass end-to-end. + +- [ ] **Step 1: Build the whole workspace** + +Run: `cargo build --workspace` +Expected: BUILD SUCCEEDS on macOS (the dev platform). This compiles `execute_macos`, the picker, the core — everything reachable on this host. + +- [ ] **Step 2: Run the whole test suite** + +Run: `cargo test --workspace` +Expected: ALL PASS. Key tests: `binding::tests::category_mouse_variants`, `binding::tests::extra_mouse_button_labels`, `binding::tests::all_catalog_variants_roundtrip_toml` (now exercises the 4 new variants' TOML roundtrip). + +- [ ] **Step 3: Manual smoke check (optional but recommended)** + +Build and run the GUI, open a device, click a rebindable button (e.g. Gesture Button), and confirm "Button 6"–"Button 9" appear in the MOUSE section of the action picker. Bind one and confirm it fires (e.g. an app that binds MB6). + +Run: `cargo run -p openlogi-gui` + +- [ ] **Step 4: Final commit if any fixups were needed** + +If steps 1–2 surfaced anything to fix, commit it. Otherwise this task produces no commit. + +--- + +## Self-Review Notes + +**Spec coverage:** Every layer in the spec's "Per-layer changes" table maps to a task — enum/label/category/catalog (Task 1), macOS inject (Task 2), Linux inject (Task 3), Windows log-and-skip (Task 4), picker icons (Task 5), i18n keys (Task 6). The spec's "Testing" section is covered by the test edits in Task 1 and the full-suite run in Task 7. Platform-coverage table matches (Windows gap explicit in Task 4). + +**No placeholders:** Every code step shows the exact code. The two `cargo check --target` steps for Linux/Windows explicitly document the "skip if target not installed" fallback rather than hiding it. + +**Type consistency:** Variant names `MouseButton6`–`MouseButton9` are identical across all tasks. macOS button numbers (5–8) are internally consistent with the existing 3/4 for Back/Forward. Linux `KeyCode` constants are confirmed in evdev 0.13.2. diff --git a/docs/superpowers/plans/2026-06-30-function-key-remapper-m1.md b/docs/superpowers/plans/2026-06-30-function-key-remapper-m1.md new file mode 100644 index 00000000..e1618a81 --- /dev/null +++ b/docs/superpowers/plans/2026-06-30-function-key-remapper-m1.md @@ -0,0 +1,728 @@ +# Function-Key Remapper — M1 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Capture F1–F12 + Esc key presses (and Shift/Ctrl/Opt/Cmd-qualified combos) and remap each to any action in the existing palette, plus three new execution actions (`TypeText`, `RunAppleScript`, `RunShellCommand`). + +**Architecture:** Three layers, each mirroring the mouse path. (1) `openlogi-hook` gains keyboard event types and a `KeyEvent` vocabulary alongside `MouseEvent`. (2) `openlogi-core` gains three `Action` variants and a `[keyboard.bindings]` config section keyed by keycode+modifiers. (3) `openlogi-inject` gains a `post_unicode` text-typing primitive and the new action arms. The hook callback routes keyboard events through the same `EventDisposition` (PassThrough/Suppress) the mouse side uses, so remapped keys are suppressed exactly as remapped mouse buttons are. + +**Tech Stack:** Rust workspace; `CGEventTap` (macOS) for capture; `CGEventKeyboardSetUnicodeString` for text typing; `std::process::Command` for AppleScript/shell; serde/TOML for config. + +**Spec:** `docs/superpowers/specs/2026-06-30-function-key-remapper-design.md` (M1 scope only; M2 Workflow and M3 media-key capture are separate plans). + +--- + +## File Structure + +| File | Responsibility | Change | +|---|---|---| +| `crates/openlogi-hook/src/lib.rs` | The event vocabulary (`MouseEvent`, `EventDisposition`) | Add `KeyEvent` + `HookEvent` union; widen the callback signature | +| `crates/openlogi-hook/src/macos.rs` | The `CGEventTap` capture | Add keyboard event types to the mask; `translate` keyboard events; macOS keycode table for F-keys | +| `crates/openlogi-core/src/binding.rs` | The `Action` enum + `label`/`category`/`catalog` | Add `TypeText`/`RunAppleScript`/`RunShellCommand` variants (excluded from catalog — power-user escape hatch) | +| `crates/openlogi-core/src/config.rs` | Config loading | Add `[keyboard]` section + `KeyTrigger` (keycode + modifiers) | +| `crates/openlogi-inject/src/inject.rs` | Action → OS event synthesis | Add `post_unicode` primitive; three new `Action` arms in `execute_macos` | +| `crates/openlogi-agent-core/src/hook_runtime.rs` | Dispatches hook events → actions | Route `KeyEvent` → look up keyboard binding → execute action → Suppress | + +No new files except where a table cell says "Add". The exhaustive `match` arms across the codebase are the safety net (the picker icon `match`, the inject `match`) — they fail to compile if a variant is missed, exactly as with mouse buttons 6–9. + +--- + +## Task 1: Add the `KeyEvent` vocabulary and widen the hook callback + +This is the foundational change: the hook must be able to report keyboard events, not just mouse. We add a `KeyEvent` type and a `HookEvent` union so the existing `Hook::start` callback can receive either, then update the (single) call site. + +**Files:** +- Modify: `crates/openlogi-hook/src/lib.rs:47` (add `KeyEvent`, `HookEvent` near `MouseEvent`) +- Modify: `crates/openlogi-hook/src/lib.rs` (the `Hook::start` signature — find it via `grep -n "pub fn start" crates/openlogi-hook/src/lib.rs`) +- Modify: `crates/openlogi-agent-core/src/hook_runtime.rs:115` (the single call site) + +- [ ] **Step 1: Read the current `MouseEvent` + `Hook::start` signature** + +Run: `sed -n '40,100p' crates/openlogi-hook/src/lib.rs && grep -n "pub fn start" crates/openlogi-hook/src/lib.rs` +Note the `MouseEvent` enum (around line 47), `EventDisposition` (line 95), and the `start` signature's callback type `impl Fn(MouseEvent) -> EventDisposition`. + +- [ ] **Step 2: Add `KeyEvent` + `KeyModifiers` + `HookEvent` to `lib.rs`** + +Immediately above `pub enum MouseEvent {` (line 47), add: + +```rust +/// Which modifier keys were held when a key event fired. Mirrors the +/// detectable macOS modifier flags (everything *except* Fn — see spec +/// Appendix A; Fn is firmware-internal and never reported on non-function-row +/// keys). +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +pub struct KeyModifiers { + pub shift: bool, + pub control: bool, + pub option: bool, + pub command: bool, +} + +/// A keyboard event observed by the hook. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct KeyEvent { + /// macOS virtual keycode (e.g. 122 = F1, 53 = Escape). + pub keycode: u16, + /// `true` = key down; `false` = key up. + pub pressed: bool, + /// Which modifiers were held. + pub modifiers: KeyModifiers, +} + +/// Anything the hook can observe. `Mouse` keeps the existing callback shape; +/// `Key` is the new keyboard path. Wrapping in a union means the callback +/// signature widens once (here) and stays stable as more event classes arrive. +#[derive(Debug, Clone, Copy)] +pub enum HookEvent { + Mouse(MouseEvent), + Key(KeyEvent), +} +``` + +- [ ] **Step 3: Widen the `Hook::start` callback to `HookEvent`** + +Find `pub fn start(` in `lib.rs`. Change every occurrence of the callback parameter type +`impl Fn(MouseEvent) -> EventDisposition + Send + Sync + 'static` +to +`impl Fn(HookEvent) -> EventDisposition + Send + Sync + 'static` +(on all platform stubs — macOS, Linux, Windows, and the `Unsupported` fallback). + +- [ ] **Step 4: Update the single call site in `hook_runtime.rs:115`** + +The callback currently matches `MouseEvent` variants directly. Wrap the existing body +to only act on `HookEvent::Mouse` and pass through keys for now: + +```rust +let result = Hook::start(move |event| match event { + HookEvent::Mouse(mouse_event) => match mouse_event { + MouseEvent::Button { id, pressed } => { + // ... existing body unchanged ... + } + MouseEvent::Moved { delta_x, delta_y } => { + // ... existing body unchanged ... + } + MouseEvent::CaptureInterrupted => { + // ... existing body unchanged ... + } + MouseEvent::Scroll { .. } => EventDisposition::PassThrough, + }, + HookEvent::Key(_) => EventDisposition::PassThrough, // wired up in Task 6 +}); +``` + +Add the import: `use openlogi_hook::{EventDisposition, Hook, HookEvent, MouseEvent};` + +- [ ] **Step 5: Build + run the full hook + agent-core tests** + +Run: `cargo test -p openlogi-hook -p openlogi-agent-core` +Expected: PASS. The keyboard path is inert (`PassThrough`), so behavior is unchanged; this just proves the widened signature compiles and nothing regresses. + +- [ ] **Step 6: Commit** + +```bash +git add crates/openlogi-hook/src/lib.rs crates/openlogi-agent-core/src/hook_runtime.rs +git commit -m "refactor(hook): widen hook callback to HookEvent (Mouse | Key) + +Adds KeyEvent + KeyModifiers + HookEvent vocabulary alongside MouseEvent. +Hook::start's callback now receives HookEvent; hook_runtime wraps its +existing MouseEvent body and passes keys through inertly. No behavior +change yet — keyboard capture lands in the next task." +``` + +--- + +## Task 2: Capture keyboard events in the macOS `CGEventTap` + +Extend the existing tap (currently mouse-only) to also subscribe to keyboard event types, and translate them into `KeyEvent`s. F-keys are proven to arrive here (F1 = keycode 122 + `SecondaryFn` flag); this task makes the tap see them. + +**Files:** +- Modify: `crates/openlogi-hook/src/macos.rs:452` (the `event_types` vec) +- Modify: `crates/openlogi-hook/src/macos.rs:257` (`translate` — add keyboard arms) and the callback closure at `:475` + +- [ ] **Step 1: Add keyboard event types to the tap mask** + +In `macos.rs`, the `event_types` vec (line 452) currently lists only mouse types. Append: + +```rust + let event_types = vec![ + CGEventType::LeftMouseDown, + CGEventType::LeftMouseUp, + // ... existing mouse types unchanged ... + CGEventType::OtherMouseDragged, + // NEW — keyboard capture for the function-key remapper (M1). + CGEventType::KeyDown, + CGEventType::KeyUp, + CGEventType::FlagsChanged, + ]; +``` + +- [ ] **Step 2: Add a keyboard-translation helper** + +Above the existing `fn translate(...)` (line 257), add: + +```rust +/// Map the macOS modifier flags on a `CGEvent` to our [`KeyModifiers`]. +/// `SecondaryFn` is deliberately ignored — it is firmware-internal and +/// unreliable as a trigger (see spec Appendix A). +fn modifiers_from_flags(flags: CGEventFlags) -> KeyModifiers { + KeyModifiers { + shift: flags.contains(CGEventFlags::MASK_SHIFT), + control: flags.contains(CGEventFlags::MASK_CONTROL), + option: flags.contains(CGEventFlags::MASK_ALTERNATE), + command: flags.contains(CGEventFlags::MASK_COMMAND), + } +} + +/// Translate a keyboard `CGEvent` into a [`KeyEvent`]. Returns `None` for +/// non-key event types (handled by the mouse path) or for `FlagsChanged` +/// alone (modifier state is reported on the subsequent key event). +fn translate_key(etype: CGEventType, event: &CGEvent) -> Option { + let (pressed, keycode) = match etype { + CGEventType::KeyDown => (true, event.get_integer_value_field(EventField::KEYBOARD_EVENT_KEYCODE) as u16), + CGEventType::KeyUp => (false, event.get_integer_value_field(EventField::KEYBOARD_EVENT_KEYCODE) as u16), + // FlagsChanged carries no keycode of interest here; modifiers ride on + // the next key event via its flags. Drop it. + _ => return None, + }; + Some(KeyEvent { + keycode, + pressed, + modifiers: modifiers_from_flags(event.get_flags()), + }) +} +``` + +(Add `use core_graphics::event::EventField;` to the imports if not already present — check with `grep -n "use core_graphics" crates/openlogi-hook/src/macos.rs | head`.) + +- [ ] **Step 3: Route keyboard events through the callback** + +The callback closure (line 475) currently does `let Some(mouse_event) = translate(etype, event)`. Replace it to build a `HookEvent` from either path: + +```rust + move |_proxy: CGEventTapProxy, etype: CGEventType, event: &CGEvent| { + let hook_event = if let Some(mouse_event) = translate(etype, event) { + HookEvent::Mouse(mouse_event) + } else if let Some(key_event) = translate_key(etype, event) { + HookEvent::Key(key_event) + } else { + return CallbackResult::Keep; + }; + match cb(hook_event) { + EventDisposition::PassThrough => CallbackResult::Keep, + EventDisposition::Suppress => CallbackResult::Drop, + } + }, +``` + +- [ ] **Step 4: Build the hook crate** + +Run: `cargo build -p openlogi-hook` +Expected: BUILD SUCCEEDS. The `CGEventFlags::MASK_*` constant names must match what `core-graphics` exposes; if any is named differently, the compiler error names the correct constant — fix and rebuild. + +- [ ] **Step 5: Commit** + +```bash +git add crates/openlogi-hook/src/macos.rs +git commit -m "feat(hook): capture keyboard events in the macOS CGEventTap + +Adds KeyDown/KeyUp/FlagsChanged to the tap mask, plus translate_key() +which maps a key CGEvent to our KeyEvent (keycode + press state + +detectable modifiers, ignoring SecondaryFn). The callback now builds a +HookEvent from either the mouse or key path. F1-F12/Esc are now observed; +nothing acts on them yet." +``` + +--- + +## Task 3: Add the three execution `Action` variants + +The action palette gains `TypeText`, `RunAppleScript`, `RunShellCommand`. These are power-user escape hatches (like `CustomShortcut`), so they are **excluded from the default catalog** — they must be hand-authored in config. This task only adds the variants + their `label`/`category`/TOML shape; injection lands in Task 5. + +**Files:** +- Modify: `crates/openlogi-core/src/binding.rs` — `Action` enum (near `CustomShortcut(KeyCombo)` at line 483), `label()` (:679), `category()` (:731), `catalog()` (:787) + +- [ ] **Step 1: Add the failing test for the new variants' category + label** + +In `binding.rs`, append to the `#[cfg(test)] mod tests` block: + +```rust + #[test] + fn power_user_action_labels_and_category() { + assert_eq!(Action::TypeText("hi".into()).label(), "Type \"hi\""); + assert_eq!(Action::RunAppleScript("osascript".into()).label(), "Run AppleScript"); + assert_eq!(Action::RunShellCommand("echo hi".into()).label(), "Run Command"); + // All three are power-user escape hatches: never in the default catalog, + // but classed as Editing so a hand-authored binding has a home group. + assert_eq!(Action::TypeText("x".into()).category(), Category::Editing); + assert_eq!(Action::RunAppleScript("x".into()).category(), Category::Editing); + assert_eq!(Action::RunShellCommand("x".into()).category(), Category::Editing); + } + + #[test] + fn power_user_actions_excluded_from_catalog() { + let cat = Action::catalog(); + assert!(cat.iter().all(|a| !matches!(a, + Action::TypeText(_) | Action::RunAppleScript(_) | Action::RunShellCommand(_)))); + } +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `cargo test -p openlogi-core --lib binding::tests::power_user` +Expected: FAIL with `cannot find variant TypeText` — compile error. + +- [ ] **Step 3: Add the three variants to the `Action` enum** + +After `CustomShortcut(KeyCombo),` (line 483), add: + +```rust + /// Type an arbitrary string by emitting unicode characters (macOS + /// `CGEventKeyboardSetUnicodeString`). Used for macro text. Power-user + /// escape hatch — excluded from the default catalog. + TypeText(String), + /// Run an AppleScript via `osascript -e `. Power-user escape hatch. + RunAppleScript(String), + /// Run a shell command via `/bin/sh -c `. Power-user escape hatch. + RunShellCommand(String), +``` + +- [ ] **Step 4: Add the three labels** + +In `label()` (:679), in the `match` (after the `CustomShortcut` arm), add: + +```rust + Action::TypeText(s) => format!("Type \"{s}\"").into(), + Action::RunAppleScript(_) => "Run AppleScript".into(), + Action::RunShellCommand(_) => "Run Command".into(), +``` + +- [ ] **Step 5: Add the three category arms** + +In `category()` (:731), extend the existing `Editing` arm that already holds +`CustomShortcut`: + +```rust + | Action::CustomShortcut(_) + | Action::TypeText(_) + | Action::RunAppleScript(_) + | Action::RunShellCommand(_) => Category::Editing, +``` + +- [ ] **Step 6: Confirm `catalog()` excludes them** + +`catalog()` (:787) is an explicit list — by NOT adding the three variants to it, +they are excluded. Verify the existing `catalog_excludes_custom_shortcut` test +pattern and confirm no test forces them in. No code change needed here; the +test in Step 1 asserts exclusion. + +- [ ] **Step 7: Run the tests to verify they pass + the TOML roundtrip still works** + +Run: `cargo test -p openlogi-core --lib binding` +Expected: PASS — including the new tests and the existing +`all_catalog_variants_roundtrip_toml` (the new variants aren't in the catalog, +but add a manual roundtrip assertion for `TypeText` in the test block): + +```rust + #[test] + fn power_user_actions_roundtrip_toml() { + for action in [ + Action::TypeText("hello".into()), + Action::RunAppleScript("beep".into()), + Action::RunShellCommand("date".into()), + ] { + let toml = toml::to_string(&action).unwrap(); + let back: Action = toml::from_str(&toml).unwrap(); + assert_eq!(action, back); + } + } +``` + +- [ ] **Step 8: Commit** + +```bash +git add crates/openlogi-core/src/binding.rs +git commit -m "feat(core): add TypeText / RunAppleScript / RunShellCommand actions + +Three power-user escape-hatch actions (excluded from the default catalog, +classed as Editing). TypeText emits a unicode string; the two Run actions +spawn osascript / sh. Injection arms land in the inject task." +``` + +--- + +## Task 4: Add the `[keyboard]` config section + `KeyTrigger` + +The config gains a new top-level `[keyboard]` table mapping trigger strings (`"f1"`, `"shift+f1"`) to actions. This is independent of the per-device `[devices]` bindings. + +**Files:** +- Modify: `crates/openlogi-core/src/config.rs` — the `Config` struct (find via `grep -n "pub struct Config" crates/openlogi-core/src/config.rs`) +- Create: the `KeyTrigger` type + trigger-string parser lives in `config.rs` (single file, follow existing patterns) + +- [ ] **Step 1: Read the current `Config` struct + a device-binding sample** + +Run: `sed -n '/pub struct Config/,/^}/p' crates/openlogi-core/src/config.rs` +Note the existing fields (`devices`, `app_settings`, `schema_version`) and how +bindings are typed. + +- [ ] **Step 2: Write the failing test for the trigger-string parser + config load** + +Append to `config.rs`'s test module: + +```rust + #[test] + fn key_trigger_parses_bare_and_modified() { + // Bare function key. + let t: KeyTrigger = "f1".parse().unwrap(); + assert_eq!(t.keycode, 122); + assert!(t.modifiers.is_empty()); + // Modifier-qualified. + let t: KeyTrigger = "shift+cmd+f5".parse().unwrap(); + assert_eq!(t.keycode, 96); // F5 + assert!(t.modifiers.shift && t.modifiers.command); + assert!(!t.modifiers.control && !t.modifiers.option); + } + + #[test] + fn keyboard_section_loads_from_toml() { + let toml = r#" +[keyboard.bindings] +"f1" = { TypeText = "hi" } +"shift+f2" = "VolumeUp" +"#; + let cfg: Config = toml::from_str(toml).unwrap(); + assert_eq!(cfg.keyboard.bindings.len(), 2); + assert!(cfg.keyboard.bindings.contains_key(&"f1".parse::().unwrap())); + } +``` + +- [ ] **Step 3: Run the test to verify it fails** + +Run: `cargo test -p openlogi-core --lib config::` +Expected: FAIL — `KeyTrigger` and `keyboard` field don't exist. + +- [ ] **Step 4: Add `KeyTrigger` + the parser + `KeyboardConfig`** + +In `config.rs`, add (types first, then impls): + +```rust +use std::str::FromStr; + +/// A keyboard trigger: a keycode plus an optional modifier mask. The parse +/// format is `[mod+]+key`, e.g. `"f1"`, `"shift+cmd+f5"`. Modifier names are +/// `shift`, `control` (alias `ctrl`), `option` (alias `alt`), `command` +/// (alias `cmd`). Key names: `esc`, `f1`..`f12`. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct KeyTrigger { + pub keycode: u16, + pub modifiers: KeyModifiers, +} + +impl KeyModifiers { + pub fn is_empty(&self) -> bool { + !self.shift && !self.control && !self.option && !self.command + } +} + +#[derive(Debug, Default)] +pub struct ParseTriggerError(String); +impl std::fmt::Display for ParseTriggerError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "invalid key trigger: {}", self.0) + } +} +impl std::error::Error for ParseTriggerError {} + +impl FromStr for KeyTrigger { + type Err = ParseTriggerError; + fn from_str(s: &str) -> Result { + let mut mods = KeyModifiers::default(); + let mut parts = s.split('+').map(str::trim); + let mut last: Option<&str> = None; + for part in parts.by_ref() { + match part.to_ascii_lowercase().as_str() { + "shift" => mods.shift = true, + "control" | "ctrl" => mods.control = true, + "option" | "alt" => mods.option = true, + "command" | "cmd" => mods.command = true, + _ => { last = Some(part); break; } + } + } + let key = last.or_else(|| parts.next()).ok_or_else(|| ParseTriggerError("no key".into()))?; + let keycode = match key.to_ascii_lowercase().as_str() { + "esc" => 53, + "f1" => 122, "f2" => 120, "f3" => 99, "f4" => 118, + "f5" => 96, "f6" => 97, "f7" => 98, "f8" => 100, + "f9" => 101, "f10" => 109, "f11" => 103, "f12" => 111, + other => return Err(ParseTriggerError(format!("unknown key '{other}'"))), + }; + Ok(KeyTrigger { keycode, modifiers: mods }) + } +} + +/// The top-level `[keyboard]` table. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct KeyboardConfig { + /// Maps a trigger string (parsed into [`KeyTrigger`]) to its action. + /// Keyed by a `KeyTrigger`-rendered string for stable TOML. + #[serde(default)] + pub bindings: std::collections::HashMap, +} +``` + +(`KeyModifiers` lives in `openlogi-hook`; re-export or duplicate the four bools +in `openlogi-core` to avoid a core→hook dependency. Prefer duplicating — core +must stay leaf-level. Use a `core::KeyModifiers` with the same shape and +convert at the boundary in Task 6.) + +Add the field to `Config`: + +```rust + #[serde(default)] + pub keyboard: KeyboardConfig, +``` + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `cargo test -p openlogi-core --lib config::` +Expected: PASS — parser + TOML load both green. + +- [ ] **Step 6: Commit** + +```bash +git add crates/openlogi-core/src/config.rs +git commit -m "feat(core): add [keyboard] config section + KeyTrigger parser + +KeyTrigger parses '[mod+]+key' strings (f1, shift+cmd+f5, esc) into a +keycode + modifier mask, using the macOS F-key virtual keycodes. The +[keyboard.bindings] table maps triggers to Actions, independent of the +per-device bindings." +``` + +--- + +## Task 5: Add the `post_unicode` primitive + inject the three new actions + +The execution layer. `post_unicode` types a string via `CGEventKeyboardSetUnicodeString`; the three new `Action` arms call it (for `TypeText`) or spawn a process (for the two Run actions). + +**Files:** +- Modify: `crates/openlogi-inject/src/inject.rs:516` (add `post_unicode` next to `post_key`) and the `execute_macos` match arms + +- [ ] **Step 1: Add the `post_unicode` primitive to the macOS mod** + +In the `mod macos {` block (after `post_media_key`, line 541), add: + +```rust + /// Type an arbitrary unicode string by emitting a single key event per + /// character whose payload is set via `CGEventKeyboardSetUnicodeString`. + /// This sidesteps the keyboard layout entirely — characters are injected + /// as unicode, so "bite me" types verbatim regardless of layout. + pub(super) fn post_unicode(text: &str) { + for ch in text.chars() { + let mut buf = [0u16; 2]; + let s: Cow = Cow::Owned(ch.to_string()); + let _ = s; // unused; the unicode string is set on the event below. + let event = CGEvent::new(None); + event.set_flags(CGEventFlags::empty()); + // CGEventKeyboardSetUnicodeString: max 20 UTF-16 units per call; + // one char at a time is simplest and always in-bounds. + let units: Vec = ch.encode_utf16(&mut buf).to_vec(); + unsafe { + core_foundation::string::CFString::from(&*units.iter() + .filter_map(|&u| char::from_u32(u as u32)) + .collect::()); + } + // Use the core-graphics binding's keyboard-set-unicode path: + event.set_string_from_utf16(&units); + event.post(CGEventTapLocation::HID); + } + } +``` + +NOTE: the exact `core-graphics` API for setting a unicode string on a `CGEvent` +varies by crate version — `set_string_from_utf16` is the typical name. If the +compiler rejects it, run `grep -rn "KeyboardSetUnicodeString\|set_string\|unicode" ~/.cargo/registry/src/*/core-graphics-*/src/event.rs` to find the exact method +name in the pinned version, and use that. The contract is: one `CGEvent` per +character, unicode payload set, posted to HID. + +- [ ] **Step 2: Add the three `execute_macos` arms** + +In `execute_macos` (find the `match action {` and the `CustomShortcut` arm near line 142), add: + +```rust + Action::TypeText(text) => macos::post_unicode(text), + Action::RunAppleScript(src) => { + // Fire-and-forget; the agent must not block the event tap thread. + let src = src.clone(); + std::thread::spawn(move || { + let _ = std::process::Command::new("osascript") + .args(["-e", &src]) + .output(); + }); + } + Action::RunShellCommand(cmd) => { + let cmd = cmd.clone(); + std::thread::spawn(move || { + let _ = std::process::Command::new("/bin/sh") + .args(["-c", &cmd]) + .output(); + }); + } +``` + +(The Run actions spawn off the tap thread because the tap callback must not +block — posting a key while the tap is waiting on a child process wedges input. +Same discipline the existing mouse actions follow.) + +- [ ] **Step 3: Build the inject crate** + +Run: `cargo build -p openlogi-inject` +Expected: BUILD SUCCEEDS once the `post_unicode` API name matches the pinned +core-graphics version (resolve per the NOTE in Step 1 if needed). + +- [ ] **Step 4: Commit** + +```bash +git add crates/openlogi-inject/src/inject.rs +git commit -m "feat(inject): post_unicode primitive + TypeText/Run* execution + +post_unicode types a string one char at a time via +CGEventKeyboardSetUnicodeString (layout-independent). TypeText uses it; +RunAppleScript spawns osascript, RunShellCommand spawns /bin/sh, both +off the tap thread so a slow script can't wedge input." +``` + +--- + +## Task 6: Wire keyboard events → bindings → actions in `hook_runtime` + +The integration task. A `KeyEvent` arrives; look it up in the `[keyboard.bindings]` table (by keycode + modifiers); if matched, execute the action and `Suppress` the original key; else `PassThrough`. + +**Files:** +- Modify: `crates/openlogi-agent-core/src/hook_runtime.rs` (the `HookEvent::Key(_)` arm from Task 1, Step 4) + +- [ ] **Step 1: Read how mouse bindings are looked up + executed** + +Run: `grep -n "bindings\|MouseEvent::Button\|inject\|execute" crates/openlogi-agent-core/src/hook_runtime.rs | head -20` +Note how `MouseEvent::Button { id, pressed }` finds its action and calls into +`openlogi-inject`. Mirror that for keys. + +- [ ] **Step 2: Replace the inert `HookEvent::Key(_)` arm with real lookup** + +The binding state needs access to the loaded `Config`'s `keyboard.bindings`. +Capture an `Arc>` into the hook closure (same way +the mouse bindings are captured — find the existing `Arc` capture pattern in +`hook_runtime.rs` and mirror it). Then: + +```rust + HookEvent::Key(KeyEvent { keycode, pressed: true, modifiers }) => { + // Only act on key-down (avoid double-fire on key-up). + let trigger = KeyTrigger { keycode, modifiers: convert_modifiers(modifiers) }; + match keyboard_bindings.get(&trigger) { + Some(action) => { + execute_action(action); // reuse the existing mouse-action executor + EventDisposition::Suppress // eat the original key + } + None => EventDisposition::PassThrough, + } + } + HookEvent::Key(_) => EventDisposition::PassThrough, // key-up, ignore +``` + +`convert_modifiers` maps `hook::KeyModifiers` → `config::KeyModifiers` (the +duplicate-type boundary noted in Task 4, Step 4). Add it as a small `fn` in +`hook_runtime.rs`. + +- [ ] **Step 3: Build + run agent-core tests** + +Run: `cargo test -p openlogi-agent-core` +Expected: PASS. No new test here — the integration is exercised manually in +Task 7 (the unit-testable seams are the parser and the action arms, both +already covered). + +- [ ] **Step 4: Commit** + +```bash +git add crates/openlogi-agent-core/src/hook_runtime.rs +git commit -m "feat(agent): dispatch keyboard events to [keyboard] bindings + +A key-down whose keycode+modifiers match a [keyboard.bindings] entry +executes its action and suppresses the original key; unmatched keys pass +through. Reuses the existing action executor; key-up is ignored." +``` + +--- + +## Task 7: Manual end-to-end verification on hardware + +M1 is complete at this point. This task verifies it on real hardware — the +critical check, per the spec's "test incrementally on hardware" note. + +- [ ] **Step 1: Build the dev agent** + +Run: `cargo build -p openlogi-agent` + +- [ ] **Step 2: Add a test binding to config** + +Append to `~/.config/openlogi/config.toml`: + +```toml +[keyboard.bindings] +"f1" = { TypeText = "hello from F1" } +``` + +- [ ] **Step 3: Stop the installed agent and run the dev agent foreground** + +```sh +launchctl bootout gui/$(id -u)/org.openlogi.agent +# also quit the GUI so it doesn't respawn the agent +osascript -e 'tell application "OpenLogi" to quit' +sleep 2 +OPENLOGI_LOG=debug target/debug/openlogi-agent +``` + +- [ ] **Step 4: Press F1 in a text field** + +Expected: the text "hello from F1" is typed. The original F1 is suppressed (no +brightness/media action fires). + +- [ ] **Step 5: Verify the failure modes don't wedge input** + +- Press an **unbound** key (e.g. `a`) — it must type normally (PassThrough works). +- Hold the agent running for 30s of mixed typing — input must not freeze. If it + does, the tap is wedging; revisit Task 2 (the documented HID-tap failure mode). + +- [ ] **Step 6: Restore the installed agent** + +```sh +launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/org.openlogi.agent.plist +``` + +- [ ] **Step 7: Commit any fixups + tag the milestone** + +If Steps 4–5 surfaced anything, fix and commit. Then: + +```bash +git commit --allow-empty -m "chore: M1 complete — F-key capture + action palette verified on hardware" +``` + +--- + +## Self-Review Notes + +**Spec coverage (M1 scope):** Execution actions (TypeText/RunAppleScript/RunShellCommand) → Task 3 + 5. `[keyboard.bindings]` config + KeyTrigger → Task 4. F-key capture → Task 2. Modifier-qualified combos → Task 4 (parser) + Task 6 (dispatch). Press-to-bind is **deferred to a later M1.x** — it's a UI/UX flow, not a correctness gap, and adding it here would balloon the plan; flagged honestly rather than hidden. Fixed F-key list → covered by the parser's `f1..f12`/`esc` table (Task 4). Suppress-on-remap → Task 6 + verified in Task 7 Step 5. Media-key reassignment (existing `post_media_key`) is already wired and reachable as a binding target (Task 4's `Action` is the full enum). + +**Placeholder scan:** Task 5 Step 1 has an explicit NOTE about resolving the exact `core-graphics` unicode API name against the pinned version — this is a *known unknown with a resolution path*, not a placeholder; the grep command finds the real method name. No "TBD"/"implement later"/"add error handling" anywhere. + +**Type consistency:** `KeyEvent` (hook) ↔ `KeyModifiers` (hook) ↔ `KeyTrigger` (config) ↔ `KeyModifiers` (config, duplicate) — the `convert_modifiers` boundary fn in Task 6 bridges the intentional duplicate (core stays leaf-level, no core→hook dep). `Action::TypeText(String)` etc. consistent across Tasks 3/5/6. + +**Out of scope (separate plans):** M2 `Workflow` sequencer, M3 media-key capture, press-to-bind UI, per-app keyboard profiles, Windows/Linux capture. + +## Execution Handoff + +Plan complete and saved to `docs/superpowers/plans/2026-06-30-function-key-remapper-m1.md`. Two execution options: + +**1. Subagent-Driven (recommended)** — fresh subagent per task, review between tasks, fast iteration. Best for a multi-task plan touching the input hook (where each task changes observable behavior). + +**2. Inline Execution** — execute tasks in this session with checkpoints for review. + +Which approach? diff --git a/docs/superpowers/specs/2026-06-29-mouse-buttons-6-9-design.md b/docs/superpowers/specs/2026-06-29-mouse-buttons-6-9-design.md new file mode 100644 index 00000000..b88a4561 --- /dev/null +++ b/docs/superpowers/specs/2026-06-29-mouse-buttons-6-9-design.md @@ -0,0 +1,153 @@ +# Design: Mouse buttons 6–9 + +**Status:** Approved +**Date:** 2026-06-29 +**Scope:** Add four pickable actions that synthesize mouse buttons 6–9, so apps +that bind those buttons (CAD, games, Blender, MMO-mouse emulators) receive them. + +## Problem + +OpenLogi's `Action` vocabulary caps mouse output at "button 5" +(`MouseForward`). There is no way to emit mouse buttons 6–9, even though the +underlying injection layer on macOS and Linux already supports arbitrary button +numbers. Users who want a physical button to produce a button-6-or-higher event +have no path today. + +## Goal / non-goals + +**Goal:** Let any rebindable `ButtonId` be mapped to an emitted mouse button +6, 7, 8, or 9, selectable from the action picker like any existing action. + +**Non-goals:** + +- No new physical button capture — these are *output* actions bound to existing + physical buttons (e.g. Gesture Button → button 6). +- No new picker UI. The catalog auto-surfaces new variants under the MOUSE + section. +- No Windows support for buttons 6–9. `SendInput`'s mouse path carries flags + for buttons 1–5 only; 6–9 are a documented macOS/Linux-only gap (Windows + remains an "untested preview" per the README). See + [Platform coverage](#platform-coverage). + +## Background: why this is small + +Every layer of the action stack is data-driven from the `Action` enum: + +- The GUI picker (`crates/openlogi-gui/src/mouse_model/picker.rs`) builds its + rows by calling `Action::catalog()` and grouping by `Action::category()`. +- The picker's icon mapping (`picker.rs:298`) is an exhaustive `match` with + **no wildcard arm**, so the compiler refuses to build if a new variant lacks + an icon entry. +- `Action::label()` / `category()` / `catalog()` drive the picker text, + grouping, and TOML roundtrip tests. +- The injection layer on macOS (`post_other_button(n)`) already accepts any + button number via the `MOUSE_EVENT_BUTTON_NUMBER` field; Linux `evdev` + exposes `BTN_BACK`/`BTN_FORWARD`/`BTN_TASK`/`BTN_0`… + +So the work is: four enum variants, each threaded through the data-driven +machinery that already exists for `MouseBack`/`MouseForward`. + +## Design + +### New variants + +Append four unit variants to `Action` in `crates/openlogi-core/src/binding.rs`, +directly after `MouseForward`, mirroring that pattern exactly: + +```rust +/// Extra mouse button 6. Emitted as the real button-6 event for apps/games/CAD +/// that bind it. macOS/Linux only — Windows SendInput caps at button 5. +MouseButton6, +MouseButton7, +MouseButton8, +MouseButton9, +``` + +**Naming:** variant identifiers `MouseButton6`..`MouseButton9`; display labels +`"Button 6"`..`"Button 9"`. Unlike `Back`/`Forward`, these numbers have no +universal semantic meaning, so they carry no semantic name. + +### Per-layer changes + +| Layer | File | Change | +|---|---|---| +| Enum | `openlogi-core/src/binding.rs` | +4 variants after `MouseForward` | +| `Action::label()` | same | `"Button 6"` … `"Button 9"` | +| `Action::category()` | same | all 4 → `Category::Mouse` | +| `Action::catalog()` | same | append all 4 to the catalog (Mouse group) | +| Picker icon map | `openlogi-gui/src/mouse_model/picker.rs:298` | map all 4 to an existing generic icon (`action-icons/mouse.svg`) — the existing exhaustive `match` forces this | +| Inject — macOS | `openlogi-inject/src/inject.rs` (`execute_macos`) | `MouseButton6..9` → `macos::post_other_button(5..=8)` | +| Inject — Linux | `openlogi-inject/src/inject.rs` (`execute_linux`) | → `BTN_FORWARD` / `BTN_BACK` / `BTN_TASK` / `BTN_0` | +| Inject — Windows | `openlogi-inject/src/inject.rs` (`execute_windows`) | log-and-skip (`tracing::debug!`, same pattern as the macOS-only navigation actions at `inject.rs:104`) | + +### Button-number mapping + +The macOS convention (0-indexed, from existing `MouseBack`=3, `MouseForward`=4) +extends naturally: + +| Action | macOS `post_other_button` arg | Linux evdev `KeyCode` | +|---|---|---| +| `MouseButton6` | 5 | `BTN_FORWARD` | +| `MouseButton7` | 6 | `BTN_BACK` | +| `MouseButton8` | 7 | `BTN_TASK` | +| `MouseButton9` | 8 | `BTN_0` | + +> **Open question for implementation:** the Linux `BTN_*` assignment above is a +> reasonable convention (the evdev `BTN_BACK/FORWARD/TASK/0..9` family is how +> multi-button mice report extras), but exact code choice is a convention call +> that should be confirmed against how target apps read buttons on Linux. +> macOS numbers are unambiguous. + +### TOML / config schema + +Unit variants serialize as bare strings via serde's default external tagging — +identical to `MouseBack`/`MouseForward`: + +```toml +[devices."".bindings] +GestureButton = "MouseButton6" +# In gesture form: +Back = { Click = "MouseButton7" } +``` + +**Stability contract preserved:** existing variant names are frozen; these are +purely additive new names. **No `schema_version` bump, no migration.** Older +OpenLogi builds reading a config containing `MouseButton6` will error on the +unknown variant (acceptable — same as any newer-schema config on older code). + +### Platform coverage + +| Platform | Buttons 6–9 | Notes | +|---|---|---| +| macOS | ✅ Full | `post_other_button` already takes any number | +| Linux | ✅ Full | evdev `BTN_*` family | +| Windows | ❌ Log-and-skip | `SendInput` mouse path (`inject.rs:1416`) has flags for buttons 1–5 only; no flag exists for 6+. Documented gap, matches the codebase's existing "no platform equivalent → debug log + skip" pattern. | + +Windows users who bind these actions see nothing on press and a debug log line; +no crash, no misfire. Given Windows is an untested preview and the requester is +on macOS, this boundary is acceptable and explicitly out of scope to fix here. + +## Testing + +- **TOML roundtrip:** `all_catalog_variants_roundtrip_toml` already iterates + `catalog()`, so the four new entries are covered automatically once in the + catalog. +- **Category:** extend `category_mouse_variants` to assert all four map to + `Category::Mouse`. +- **Compile-time guarantee:** the exhaustive picker icon `match` (no wildcard) + fails to build if any variant is missed — this is the primary safety net. + +## Risks + +- **Linux `BTN_*` choice** — convention rather than correctness; see open + question above. Low impact (target apps are the test). +- **Pickup-row clutter** — four more entries in the MOUSE group. Acceptable; + matches user intent for a "full set". +- None to the input-capture path — these are pure output/synthesis actions. + +## Out of scope + +- Windows support for buttons 6–9. +- A parameterized `MouseButton(n)` variant (Approach B) — rejected as + disproportionate picker UI for a fixed set of four. +- Capturing buttons 6–9 as *input* from exotic hardware. diff --git a/docs/superpowers/specs/2026-06-30-function-key-remapper-design.md b/docs/superpowers/specs/2026-06-30-function-key-remapper-design.md new file mode 100644 index 00000000..5a5907aa --- /dev/null +++ b/docs/superpowers/specs/2026-06-30-function-key-remapper-design.md @@ -0,0 +1,243 @@ +# Design: Function-Key Remapper + +**Status:** Draft (pending user review) +**Date:** 2026-06-30 +**Scope:** Turn every capturable function-row key (and, in a later milestone, the +system media keys) into a fully programmable trigger that can reassign media +keys, type macro strings, run AppleScript, run shell commands, or execute a +timed multi-step workflow. + +## Motivation + +OpenLogi today remaps **mouse** buttons only. Its event hook captures no +keyboard events at all, despite a rich output `Action` palette (media-key +emission, `CustomShortcut` chords, browser/app navigation). Users get no value +out of the function row beyond what the firmware already does — and the +firmware's defaults frequently don't fit (e.g. volume keys are useless when an +external amp manages audio; the emoji/Globe key is unwanted). + +The function row is a captive, always-there set of physical triggers that +*can* be observed (proven empirically: F1 arrives at a `CGEventTap` as keycode +122 with the `SecondaryFn`/`0x80000000` flag), and there is no reason a device- +remapping app should leave it unconfigurable. This design makes every +capturable key a fully programmable one. + +## Goals / non-goals + +**Goals** +- Remap F1–F12 + Esc (the literal function-row keys) to arbitrary actions. +- A powerful action palette: reassign to any media key, type a macro string, + run AppleScript, run a shell command, or run a timed multi-step workflow. +- Modifier-qualified combos (Shift/Ctrl/Opt/Cmd + function key) so one physical + key hosts multiple actions. +- A press-to-bind capture flow so any capturable key can be bound without + picking from a fixed list. + +**Non-goals (for this design)** +- **Capturing the `Fn` modifier itself** as a trigger. Proven infeasible: the Fn + flag attaches only to function-row keys, never to letters, numbers, or other + modifiers. `Fn+Q` is byte-identical to plain `Q` at the event tap. Fn is + firmware-internal unless the key has a dual function-row meaning. See + Appendix A. +- **Windows / Linux** capture in the initial milestones. macOS first; the + execution actions cross-platform where they already are; capture ported later. +- **Per-application profiles** for keyboard bindings in M1 (the mouse side has + these; the keyboard side inherits them in a later milestone once the base + works). + +## Background: what exists today + +- **Hook is mouse-only** (`crates/openlogi-hook/src/macos.rs::translate`): + handles `LeftMouseDown`/`RightMouseDown`/scroll/move only. Zero keyboard + events. This is the central new ground. +- **Rich action palette** (`crates/openlogi-core/src/binding.rs::Action`): + `VolumeUp/Down`, `MuteVolume`, `PlayPause`, `NextTrack`, `PrevTrack`, + `BrightnessUp/Down`, `BrowserBack/Forward`, `MissionControl`, `LaunchpadShow`, + `Paste/Copy/Cut/Undo/Redo`, `CustomShortcut(KeyCombo)`, `SetDpiPreset`, and + (via the mouse-buttons-6-9 PR) `MouseButton6..9`. +- **Media-key emission exists** (`macos::post_media_key(NX_KEYTYPE_*)`) — so + "reassign to a media key" is already an execution primitive, not new work. +- **Key-chord emission exists** (`CustomShortcut` → `macos::post_key` + + modifiers) — so emitting key sequences is partially there, but there is **no + text-typing / unicode-string primitive** (`CGEventKeyboardSetUnicodeString`). +- **Config is TOML**, keyed per-device, with a frozen variant-name contract and + `schema_version` for migrations. + +## Architecture + +The feature splits into two halves with very different risk profiles. + +### Half 1 — Execution (what a key does): all buildable + +The action palette gains three new `Action` variants, all reusing the existing +enum → picker → injection pipeline (the same one extended for mouse buttons +6–9). No new mechanism, only new variants + two new emission primitives. + +| Action variant | Mechanism | New work | +|---|---|---| +| `RunAppleScript(String)` | spawn `osascript -e ""` | new variant, trivial | +| `RunShellCommand(String)` | spawn shell, capture nothing | new variant, trivial | +| `TypeText(String)` | new `macos::post_unicode(&str)` via `CGEventKeyboardSetUnicodeString` | new variant **+ new emitter** | +| `Workflow(Vec)` | a sequencer that runs steps with `Delay` timing | new variant **+ new sequencer subsystem** | +| (media reassignment) | existing `post_media_key` | **already exists** | + +A `WorkflowStep` is a small enum: + +```rust +enum WorkflowStep { + TypeText(String), + PressKey(KeyCode), // reuse the key-emitter from CustomShortcut + Delay(Duration), + RunAppleScript(String), + RunShellCommand(String), +} +``` + +The sequencer runs steps in order, awaiting `Delay`s. This is the native, +no-code version of the "type 'bite me', wait 5s, Enter, wait 5s, type more, +Escape" example. Power users can equivalently express the same thing in a +single `RunAppleScript` or `RunShellCommand`. + +### Half 2 — Capture (which key triggers it): split by risk + +Extending the mouse-only hook to also subscribe to keyboard `CGEvent` types is +the central new capture work. It splits by key class: + +| Key class | Capture mechanism | Risk | +|---|---|---| +| **F1–F12, Esc** (function mode) | Extend the existing `CGEventTap` mask to include `keyDown`/`keyUp`/`flagsChanged`; new `KeyEvent` vocabulary analogous to `MouseEvent` | **Low** — same tap, new event types. F1 proven empirically (keycode 122 + `0x80000000`). | +| **Media keys** (volume / brightness / emoji / play / etc.) | New `NX_SYSDEFINED` system-event tap (`CGSSetSystemDefinedMediaTap`) — a **separate event stream** OpenLogi has none of today | **High / unproven** — gated milestone; see M3. | + +This split is why the milestones order F-key capture before media-key capture: +F-key capture is an extension of the proven existing tap; media-key capture is a +new subsystem whose feasibility must be empirically confirmed before design +commits to it. + +## Trigger specification + +Three complementary ways to specify a trigger, all producing the same +`KeyTrigger`: + +```rust +/// A keyboard trigger: a keycode plus an optional modifier mask. +/// Stored under `[keyboard.bindings]` keyed by a stable string. +struct KeyTrigger { + keycode: u16, // macOS kVK_* code (e.g. 122 = F1) + modifiers: Modifiers, // Shift/Control/Option/Command mask; empty for bare +} +``` + +1. **Fixed F-key list** — the picker offers F1–F12 + Esc (the keys proven + capturable). Matches the mouse-button picker UX. +2. **Modifier-qualified combos** — Shift/Ctrl/Opt/Cmd + F-key, so one physical + key hosts several actions. These modifiers ARE detectable (unlike Fn). +3. **Press-to-bind capture** — a "press a key to bind" flow: OpenLogi records + the next `keyDown`'s keycode (+modifiers) and binds it. Generalizes beyond + the fixed list to any capturable key. + +## Config schema (additive) + +A new top-level `[keyboard]` section, keyed by a stable trigger string. New +`Action` variants are tagged unions (serde external tagging), consistent with +`CustomShortcut(KeyCombo)`: + +```toml +# Existing device bindings unchanged: +[devices."".bindings] +GestureButton = "MissionControl" + +# NEW — keyboard bindings, independent of device: +[keyboard.bindings] +"f1" = { TypeText = "bite me" } +"shift+f1" = { RunAppleScript = "tell application \"Terminal\" to activate" } +"cmd+f1" = { RunShellCommand = "open -a 'Safari' https://example.com" } +"f2" = "VolumeUp" # reassign a function key to a media key +"f3" = { Workflow = [ + { TypeText = "bite me" }, + { Delay = "5s" }, + { PressKey = "Return" }, + { Delay = "5s" }, + { TypeText = "bite me bad" }, + { PressKey = "Return" }, + { PressKey = "Escape" }, +]} +``` + +**Stability contract:** existing variant names are frozen; these are purely +additive. A `[keyboard]` section is new, but unknown top-level sections are +ignored by older loaders, so no `schema_version` bump is *required* for +back-compat. Bump it anyway (cheap, conventional) so the GUI can show a clean +"what changed" diff and refuse to silently drop bindings a newer build wrote. + +## Milestones + +**M1 — F-key capture + powerful action palette (shippable, low-risk)** +- Extend `openlogi-hook` to capture keyboard `CGEvent`s; new `KeyEvent` vocab. +- New `Action` variants: `RunAppleScript`, `RunShellCommand`, `TypeText` + (+ new `macos::post_unicode` emitter). +- `[keyboard.bindings]` config + loader; fixed F-key picker UI. +- Modifier-qualified combos (Shift/Ctrl/Opt/Cmd + F-key). +- Deliverable: any F1–F12/Esc (and combo) runs AppleScript / shell / types a + string / fires a media key. + +**M2 — Native Workflow sequencer** +- `Workflow(Vec)` action + sequencer with `Delay` timing. +- `WorkflowStep`: `TypeText`, `PressKey`, `Delay`, `RunAppleScript`, `RunShellCommand`. +- Deliverable: the timed multi-step "type, wait, Enter, wait, type, Esc" flows + authorable in TOML without scripting. + +**M3 — Media-key capture (gated on feasibility test)** +- *Before any design commitment:* empirically test whether volume/brightness/ + emoji keys are interceptable via an `NX_SYSDEFINED` system-event tap. If the + OS grabs them below the tap (as the Fn investigation warned), this milestone + is descoped or killed — do not assume. +- If feasible: new system-event tap subsystem; extend trigger list to media + keys; deliverable: remap volume/brightness/emoji to any action. + +## Risks (honest) + +1. **Media-key capture (M3) is unproven.** macOS routes system media keys + through `NX_SYSDEFINED`, a separate stream from `CGEventTap`. OpenLogi has + zero of this today. The feasibility test gates M3; M1/M2 do not depend on it. +2. **Security surface.** `RunShellCommand` / `RunAppleScript` execute arbitrary + code from config. This is a real escalation vs. today's action set. Mitigation: + these variants are **never** in the default catalog; they must be hand-authored + in config, and the loader warns on first use. (Matches how `CustomShortcut` is + already a deliberate escape hatch.) +3. **Key-suppression correctness.** Remapping requires *consuming* the original + key event (returning "drop this") so it doesn't also type. The mouse hook + already does this via `EventDisposition`; the keyboard path must too, with + care to avoid wedging input (the documented HID-tap-wedge failure mode). +4. **Capture vs. the existing mouse tap.** Adding keyboard event types to the + existing tap broadens what it intercepts; the HID-location tap that outlives + its permission wedges **all** input (mouse + keyboard). Extra care + testing + needed here, given that documented failure mode. + +## Out of scope + +- Capturing the `Fn` modifier as a trigger (proven infeasible — Appendix A). +- Per-application keyboard profiles in M1 (mouse side has these; keyboard + inherits later). +- Windows/Linux keyboard capture in M1 (port after macOS works). + +--- + +## Appendix A: Why Fn is not a trigger (proven, not assumed) + +Investigated empirically this session with an instrumented `CGEventTap`: + +- **F1** arrives as keycode 122 **with** the `SecondaryFn`/`0x80000000` flag. +- **plain Q** and **Fn+Q** are byte-for-byte identical (keycode 12, `raw=0x100`, + no Fn flag). Same for A. +- **plain Shift** and **Fn+Shift** are byte-for-byte identical (`raw=0x20102`, + no Fn flag). +- Pressing **Fn alone** produces no event of any kind (no `FlagsChanged`). + +**Conclusion:** the Fn flag attaches **only to function-row keys** (F1–F12), +never to letters, numbers, or other modifiers. The keyboard firmware holds Fn +internal unless the key has a dual function-row meaning. `Fn+` +is indistinguishable from `` at the `CGEventTap`. This is +firmware behavior, not a limitation OpenLogi can code around at this layer. +The only theoretical path to sensing Fn+letters is raw-HID reading below the +OS event system (Karabiner/driver-kit territory) — a large subsystem with no +guarantee the MX Keys S exposes Fn there. Not pursued. diff --git a/scripts/cargo-run-macos.sh b/scripts/cargo-run-macos.sh index f622179d..34e9b442 100755 --- a/scripts/cargo-run-macos.sh +++ b/scripts/cargo-run-macos.sh @@ -7,7 +7,7 @@ # the desktop binary it's a transparent passthrough (`exec "$@"`). # # For `openlogi-gui` it launches the build from inside a throwaway -# `OpenLogi.app` so macOS shows the real app name (the bold menu-bar title) +# `OpenLogi.app` so macOS shows the dev app name (the bold menu-bar title) # and the Dock icon during development. Both are read from the bundle's # `Info.plist` / `Resources` — a bare `target/debug/openlogi-gui` has neither, # so macOS falls back to the executable name and a generic icon. @@ -94,8 +94,9 @@ if [ "$ICON_SRC" -nt "$RES/AppIcon.icns" ]; then cp -f "$ICON_SRC" "$RES/AppIcon.icns" fi -# Info.plist — minimal, dev-only. A distinct `.dev` identifier keeps this -# target artifact from registering as the production app in LaunchServices. +# Info.plist — minimal, dev-only. A distinct `.dev` identifier and display name +# keep this target artifact from registering as the production app in +# LaunchServices or macOS Privacy & Security. PLIST="$APP/Contents/Info.plist" if [ "$PLIST_SRC" -nt "$PLIST" ]; then cp -f "$PLIST_SRC" "$PLIST" diff --git a/xtask/README.md b/xtask/README.md index 8b279cae..945bfa7e 100644 --- a/xtask/README.md +++ b/xtask/README.md @@ -18,6 +18,17 @@ devenv shell -- cargo run -p xtask -- - `linux package` — build release binaries and package `.deb` / `.rpm` artifacts with nfpm. - `release latest-json` — generate the static updater manifest for the stable channel. +For local testing, `macos bundle` stamps the built app/helper as +`org.openlogi.openlogi.dev` / `org.openlogi.agent.dev`, then signs the final +layout with `OPENLOGI_LOCAL_CODESIGN_IDENTITY` or the first Apple Development +identity it finds. This keeps local Accessibility/TCC grants isolated from the +installed production `org.openlogi.agent` grant. Set `OPENLOGI_LOCAL_CODESIGN=0` +only when you explicitly want an unsigned local bundle. `macos package` keeps +the production bundle IDs and signs with `OPENLOGI_SIGN_IDENTITY` / `--sign-identity`. +The raw DMG emitted by `cargo-bundle` is deleted during `macos bundle` because it +is created before xtask embeds and signs the helper; use `macos package` when +you need a DMG. + The Cargo runner in `../scripts/cargo-run-macos.sh` stays outside xtask because Cargo must execute it while running arbitrary binaries, including this crate. The release-notes generator stays in `../scripts/release-notes` because it is a diff --git a/xtask/src/commands/macos.rs b/xtask/src/commands/macos.rs index b1a7a379..56612f9a 100644 --- a/xtask/src/commands/macos.rs +++ b/xtask/src/commands/macos.rs @@ -22,10 +22,8 @@ pub(crate) fn run(command: Command) -> Result<()> { Command::Bundle => bundle::run(), Command::Dmg(args) => dmg::run(&args), Command::Package(args) => { - bundle::run()?; - if let Some(identity) = &args.sign_identity { - bundle::sign_app(identity)?; - } else { + bundle::run_for_distribution(args.sign_identity.as_deref())?; + if args.sign_identity.is_none() { println!("==> codesign: skipped (unsigned — set OPENLOGI_SIGN_IDENTITY to sign)"); } dmg::run(&args) diff --git a/xtask/src/commands/macos/bundle.rs b/xtask/src/commands/macos/bundle.rs index 8c5529a9..f65768d6 100644 --- a/xtask/src/commands/macos/bundle.rs +++ b/xtask/src/commands/macos/bundle.rs @@ -57,6 +57,14 @@ fn write_icns(master: &Path, output: &Path) -> Result<()> { } pub(crate) fn run() -> Result<()> { + run_with_profile(BundleProfile::Local) +} + +pub(crate) fn run_for_distribution(sign_identity: Option<&str>) -> Result<()> { + run_with_profile(BundleProfile::Distribution { sign_identity }) +} + +fn run_with_profile(profile: BundleProfile<'_>) -> Result<()> { let root = repo_root()?; let sh = Shell::new()?; let _repo = sh.push_dir(&root); @@ -95,15 +103,44 @@ pub(crate) fn run() -> Result<()> { .envs(xcode_env.iter().map(|(key, value)| (key, value))) .run()?; } + remove_cargo_bundle_dmg(&root)?; let app = root.join("target/release/bundle/osx/OpenLogi.app"); ensure_dir(&app)?; embed_agent_helper(&root, &app, &xcode_env)?; + match profile { + BundleProfile::Local => { + stamp_local_bundle_identity(&app)?; + local_sign_app_if_available()?; + } + BundleProfile::Distribution { sign_identity } => { + if let Some(identity) = sign_identity { + sign_app_with_timestamp(identity, TimestampMode::Secure)?; + } + } + } println!(); println!("Bundle ready: {}", app.display()); Ok(()) } +enum BundleProfile<'a> { + Local, + Distribution { sign_identity: Option<&'a str> }, +} + +fn remove_cargo_bundle_dmg(root: &Path) -> Result<()> { + let dmg = root.join("target/release/bundle/dmg/OpenLogi.dmg"); + if dmg.exists() { + fs_err::remove_file(&dmg) + .with_context(|| format!("could not remove stale {}", dmg.display()))?; + println!( + " removed cargo-bundle DMG before helper embedding; use `macos package` for a DMG" + ); + } + Ok(()) +} + /// Build the headless agent and embed it as a nested login-item helper at /// `OpenLogi.app/Contents/Library/LoginItems/OpenLogiAgent.app`. The agent is /// the always-on process (hook + device I/O + menu bar); shipping it inside the @@ -177,7 +214,81 @@ fn xcode_env() -> Result> { ]) } -pub(crate) fn sign_app(identity: &str) -> Result<()> { +fn stamp_local_bundle_identity(app: &Path) -> Result<()> { + println!("==> local bundle identity"); + let app_info = app.join("Contents/Info.plist"); + stamp_plist_strings( + &app_info, + &[ + ("CFBundleDisplayName", "OpenLogi Dev"), + ("CFBundleIdentifier", "org.openlogi.openlogi.dev"), + ("CFBundleName", "OpenLogi Dev"), + ], + )?; + + let helper_info = app.join("Contents/Library/LoginItems/OpenLogiAgent.app/Contents/Info.plist"); + if helper_info.exists() { + stamp_plist_strings( + &helper_info, + &[ + ("CFBundleDisplayName", "OpenLogi Agent Dev"), + ("CFBundleIdentifier", "org.openlogi.agent.dev"), + ("CFBundleName", "OpenLogi Agent Dev"), + ], + )?; + } + + println!(" stamped local IDs: org.openlogi.openlogi.dev / org.openlogi.agent.dev"); + Ok(()) +} + +fn stamp_plist_strings(info_plist: &Path, entries: &[(&str, &str)]) -> Result<()> { + let mut plist = Value::from_file(info_plist) + .with_context(|| format!("could not read {}", info_plist.display()))?; + let dict = plist + .as_dictionary_mut() + .with_context(|| format!("{} is not a plist dictionary", info_plist.display()))?; + for (key, value) in entries { + dict.insert((*key).into(), Value::String((*value).to_string())); + } + plist + .to_file_xml(info_plist) + .with_context(|| format!("could not write {}", info_plist.display())) +} + +fn local_sign_app_if_available() -> Result<()> { + if env::var("OPENLOGI_LOCAL_CODESIGN").as_deref() == Ok("0") { + println!("==> local codesign: skipped (OPENLOGI_LOCAL_CODESIGN=0)"); + return Ok(()); + } + + if let Some(identity) = env_nonempty("OPENLOGI_SIGN_IDENTITY") { + sign_app_with_timestamp(&identity, TimestampMode::Secure)?; + return Ok(()); + } + + if let Some(identity) = env_nonempty("OPENLOGI_LOCAL_CODESIGN_IDENTITY") { + sign_app_with_timestamp(&identity, TimestampMode::None)?; + return Ok(()); + } + + if let Some(identity) = first_apple_development_identity()? { + sign_app_with_timestamp(&identity, TimestampMode::None)?; + return Ok(()); + } + + println!( + "==> local codesign: skipped (no Apple Development identity found; \ + set OPENLOGI_LOCAL_CODESIGN_IDENTITY or OPENLOGI_SIGN_IDENTITY to sign)" + ); + println!( + " warning: unsigned/ad-hoc local bundles with production bundle IDs can \ + make macOS Accessibility grants appear stale or missing" + ); + Ok(()) +} + +fn sign_app_with_timestamp(identity: &str, timestamp: TimestampMode) -> Result<()> { let sh = Shell::new()?; let app = repo_root()?.join("target/release/bundle/osx/OpenLogi.app"); let helper = app.join("Contents/Library/LoginItems/OpenLogiAgent.app"); @@ -188,9 +299,9 @@ pub(crate) fn sign_app(identity: &str) -> Result<()> { // stable, separately-signed helper identity is exactly what lets the agent's // Accessibility (TCC) grant persist across updates. So sign each explicitly. if helper.exists() { - codesign_runtime(identity, &helper)?; + codesign_runtime(identity, &helper, timestamp)?; } - codesign_runtime(identity, &app)?; + codesign_runtime(identity, &app, timestamp)?; cmd!(sh, "codesign --verify --strict {app}").run()?; if helper.exists() { cmd!(sh, "codesign --verify --strict {helper}").run()?; @@ -198,13 +309,52 @@ pub(crate) fn sign_app(identity: &str) -> Result<()> { Ok(()) } -/// Sign one bundle with the hardened runtime + a secure timestamp. -fn codesign_runtime(identity: &str, target: &Path) -> Result<()> { +/// Sign one bundle with the hardened runtime and the requested timestamp mode. +fn codesign_runtime(identity: &str, target: &Path, timestamp: TimestampMode) -> Result<()> { let sh = Shell::new()?; - cmd!( - sh, - "codesign --force --options runtime --timestamp --sign {identity} {target}" - ) - .run()?; + match timestamp { + TimestampMode::Secure => { + cmd!( + sh, + "codesign --force --options runtime --timestamp --sign {identity} {target}" + ) + .run()?; + } + TimestampMode::None => { + cmd!( + sh, + "codesign --force --options runtime --timestamp=none --sign {identity} {target}" + ) + .run()?; + } + } Ok(()) } + +#[derive(Clone, Copy)] +enum TimestampMode { + Secure, + None, +} + +fn env_nonempty(name: &str) -> Option { + env::var(name).ok().filter(|value| !value.trim().is_empty()) +} + +fn first_apple_development_identity() -> Result> { + let sh = Shell::new()?; + let output = match cmd!(sh, "security find-identity -v -p codesigning").read() { + Ok(output) => output, + Err(_) => return Ok(None), + }; + Ok(output + .lines() + .filter_map(quoted_identity) + .find(|identity| identity.starts_with("Apple Development:"))) +} + +fn quoted_identity(line: &str) -> Option { + let start = line.find('"')? + 1; + let end = line[start..].find('"')?; + Some(line[start..start + end].to_string()) +}