diff --git a/crates/openlogi-agent-core/src/runtime/hook.rs b/crates/openlogi-agent-core/src/runtime/hook.rs index 83aa911fa..9df85f56b 100644 --- a/crates/openlogi-agent-core/src/runtime/hook.rs +++ b/crates/openlogi-agent-core/src/runtime/hook.rs @@ -11,7 +11,7 @@ use std::thread; use std::time::{Duration, Instant}; use openlogi_core::binding::{ - Action, Binding, ButtonId, GestureDirection, SwipeAccumulator, default_binding, + Action, Binding, ButtonId, GestureDirection, SwipeAccumulator, SwipeStep, default_binding, }; use openlogi_core::config::{KeyModifiers, KeyTrigger}; use openlogi_hook::{ @@ -138,12 +138,12 @@ impl HoldState { } /// Feed a pointer-move delta into the active hold, tagging a committed swipe - /// with its exact press token and held button. Returns one commit per hold, - /// or `None` while still too short, already fired, or not holding. - fn accumulate(&mut self, dx: i32, dy: i32) -> Option<(PressToken, ButtonId, GestureDirection)> { + /// with its exact press token and held button. Subsequent movement is tagged + /// separately so ordinary bindings remain one-shot. + fn accumulate(&mut self, dx: i32, dy: i32) -> Option<(PressToken, ButtonId, SwipeStep)> { let held = self.current.as_ref()?; self.swipe - .accumulate(dx, dy) + .accumulate_repeating(dx, dy) .map(|dir| (held.press.clone(), held.button, dir)) } @@ -364,14 +364,15 @@ fn handle_moved( dispatcher: &ActionDispatcher, ) -> EventDisposition { let commit = HOLD.with_borrow_mut(|h| h.accumulate(delta_x, delta_y)); - if let Some((press, button, dir)) = commit { + if let Some((press, button, step)) = commit { + let dir = step.direction(); let action = hooks.try_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 { + if let Some(action) = action.filter(|action| step.accepts(action)) { info!(button = %button, ?dir, action = %action.label(), "gesture swipe → executing bound action"); dispatcher.try_dispatch_while_pressed(&press, &action); } diff --git a/crates/openlogi-agent-core/src/runtime/hook/tests.rs b/crates/openlogi-agent-core/src/runtime/hook/tests.rs index 819466b8a..698cf8582 100644 --- a/crates/openlogi-agent-core/src/runtime/hook/tests.rs +++ b/crates/openlogi-agent-core/src/runtime/hook/tests.rs @@ -40,12 +40,20 @@ fn accumulate_tags_a_committed_swipe_with_the_held_press() { assert_eq!( hold.accumulate(GESTURE_SWIPE_THRESHOLD + 10, 0), - Some((press.clone(), ButtonId::Back, GestureDirection::Right)) + Some(( + press.clone(), + ButtonId::Back, + SwipeStep::First(GestureDirection::Right) + )) ); assert_eq!( hold.accumulate(50, 0), - None, - "commits at most once per hold" + Some(( + press.clone(), + ButtonId::Back, + SwipeStep::Repeat(GestureDirection::Right) + )), + "subsequent travel retains the exact press and is tagged as a repeat" ); assert_eq!(hold.end(ButtonId::Back), Some((press, false))); } @@ -69,7 +77,11 @@ fn a_same_button_repress_restarts_the_stale_hold() { hold.swipe.backdate_hold_for_test(); assert_eq!( hold.accumulate(GESTURE_SWIPE_THRESHOLD + 10, 0), - Some((replacement, ButtonId::Back, GestureDirection::Right)) + Some(( + replacement, + ButtonId::Back, + SwipeStep::First(GestureDirection::Right) + )) ); } @@ -88,7 +100,11 @@ fn an_aged_hold_yields_to_a_new_buttons_press() { hold.swipe.backdate_hold_for_test(); assert_eq!( hold.accumulate(GESTURE_SWIPE_THRESHOLD + 10, 0), - Some((replacement, ButtonId::Forward, GestureDirection::Right)) + Some(( + replacement, + ButtonId::Forward, + SwipeStep::First(GestureDirection::Right) + )) ); } @@ -105,7 +121,11 @@ fn begin_is_first_wins_while_a_hold_is_active() { assert_eq!( hold.accumulate(GESTURE_SWIPE_THRESHOLD + 10, 0), - Some((first.clone(), ButtonId::Back, GestureDirection::Right)) + Some(( + first.clone(), + ButtonId::Back, + SwipeStep::First(GestureDirection::Right) + )) ); assert_eq!(hold.end(ButtonId::Forward), None); assert_eq!(hold.end(ButtonId::Back), Some((first, false))); diff --git a/crates/openlogi-agent-core/src/watchers/gesture/dispatch.rs b/crates/openlogi-agent-core/src/watchers/gesture/dispatch.rs index 899a1612d..5db44b661 100644 --- a/crates/openlogi-agent-core/src/watchers/gesture/dispatch.rs +++ b/crates/openlogi-agent-core/src/watchers/gesture/dispatch.rs @@ -147,7 +147,8 @@ impl InputDispatcher { return; } match input { - CapturedInput::Gesture(button, direction) => { + CapturedInput::Gesture(button, direction) + | CapturedInput::GestureRepeat(button, direction) => { let Some(press) = self.gesture_presses.get(session, button) else { debug!(key, %button, ?direction, "gesture from a canceled button lifecycle — ignored"); return; @@ -157,6 +158,10 @@ impl InputDispatcher { .get(&button) .or_else(|| plan.side_gesture_bindings.get(&button)) .and_then(|map| map.get(&direction)) + .filter(|action| { + !matches!(input, CapturedInput::GestureRepeat(..)) + || action.repeats_on_motion() + }) { debug!(key, %button, ?direction, action = %action.label(), "gesture → action"); if !self diff --git a/crates/openlogi-agent-core/src/watchers/keyboard.rs b/crates/openlogi-agent-core/src/watchers/keyboard.rs index 077a9e36c..b22eabee9 100644 --- a/crates/openlogi-agent-core/src/watchers/keyboard.rs +++ b/crates/openlogi-agent-core/src/watchers/keyboard.rs @@ -167,6 +167,7 @@ fn dispatch_input( dispatcher.dispatch_hidpp_button_pulse(session, button, bindings.bindings.get(&button)); } CapturedInput::Gesture(..) + | CapturedInput::GestureRepeat(..) | CapturedInput::Scroll { .. } | CapturedInput::ThumbwheelDirection { .. } => {} } diff --git a/crates/openlogi-core/src/binding.rs b/crates/openlogi-core/src/binding.rs index 23fc92fe5..85a88ce28 100644 --- a/crates/openlogi-core/src/binding.rs +++ b/crates/openlogi-core/src/binding.rs @@ -19,6 +19,7 @@ mod effect; mod gesture; mod key_combo; mod swipe; +pub use swipe::SwipeStep; mod value; #[cfg(test)] diff --git a/crates/openlogi-core/src/binding/action.rs b/crates/openlogi-core/src/binding/action.rs index 884380e29..c0b611a27 100644 --- a/crates/openlogi-core/src/binding/action.rs +++ b/crates/openlogi-core/src/binding/action.rs @@ -187,6 +187,14 @@ pub enum Action { /// cancellation and shutdown. Dispatchers without a release context must /// degrade this action to a balanced tap rather than leave keys held. HoldShortcut(KeyCombo), + /// Zoom in one step in the active application. + ZoomIn, + /// Zoom out one step in the active application. + ZoomOut, + /// Zoom in repeatedly as a held gesture continues moving. + ZoomInContinuous, + /// Zoom out repeatedly as a held gesture continues moving. + ZoomOutContinuous, } /// One step in a [`Action::Workflow`]. A workflow is a `Vec` @@ -256,6 +264,10 @@ macro_rules! for_each_unit_action { NextTab "Next Tab" "actions.next_tab" Browser NextTab, PrevTab "Previous Tab" "actions.previous_tab" Browser PreviousTab, ReloadPage "Reload Page" "actions.reload_page" Browser Reload, + ZoomIn "Zoom In (one step)" "actions.zoom_in" Browser Search, + ZoomOut "Zoom Out (one step)" "actions.zoom_out" Browser Search, + ZoomInContinuous "Zoom In (continuous gesture)" "actions.zoom_in_continuous" Browser Search, + ZoomOutContinuous "Zoom Out (continuous gesture)" "actions.zoom_out_continuous" Browser Search, // Navigation MissionControl "Mission Control" "actions.mission_control" Navigation Grid, AppExpose "App Exposé" "actions.app_expose" Navigation Layers, @@ -393,6 +405,12 @@ macro_rules! derive_action_core { for_each_unit_action!(derive_action_core); impl Action { + /// Whether additional travel in the same gesture may fire this action. + #[must_use] + pub fn repeats_on_motion(&self) -> bool { + matches!(self, Self::ZoomInContinuous | Self::ZoomOutContinuous) + } + /// The chord whose output must remain down until the originating press /// ends, or `None` for an instantaneous action. #[must_use] diff --git a/crates/openlogi-core/src/binding/effect.rs b/crates/openlogi-core/src/binding/effect.rs index 8010cb6a1..47c62ea71 100644 --- a/crates/openlogi-core/src/binding/effect.rs +++ b/crates/openlogi-core/src/binding/effect.rs @@ -129,6 +129,10 @@ pub enum Shortcut { PrevTab, /// Reload the current page. ReloadPage, + /// Increase application zoom by one step. + ZoomIn, + /// Decrease application zoom by one step. + ZoomOut, } impl Shortcut { @@ -269,6 +273,8 @@ impl Action { | Action::OpenApplication(_) => Effect::AgentSide, Action::ScrollUp => Effect::Scroll { dx: 0, dy: 1 }, + Action::ZoomIn | Action::ZoomInContinuous => Effect::Shortcut(Shortcut::ZoomIn), + Action::ZoomOut | Action::ZoomOutContinuous => Effect::Shortcut(Shortcut::ZoomOut), Action::ScrollDown => Effect::Scroll { dx: 0, dy: -1 }, Action::HorizontalScrollLeft => Effect::Scroll { dx: -1, dy: 0 }, Action::HorizontalScrollRight => Effect::Scroll { dx: 1, dy: 0 }, diff --git a/crates/openlogi-core/src/binding/swipe.rs b/crates/openlogi-core/src/binding/swipe.rs index 43fe1eaa8..ebac5e398 100644 --- a/crates/openlogi-core/src/binding/swipe.rs +++ b/crates/openlogi-core/src/binding/swipe.rs @@ -5,7 +5,32 @@ use std::time::Instant; -use super::GestureDirection; +use super::{Action, GestureDirection}; + +/// A gesture's initial commitment or subsequent movement while still held. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SwipeStep { + /// The first committed direction; all bound actions may fire. + First(GestureDirection), + /// Further travel; only continuous gesture actions may fire. + Repeat(GestureDirection), +} + +impl SwipeStep { + /// Direction of this movement step. + #[must_use] + pub fn direction(self) -> GestureDirection { + match self { + Self::First(direction) | Self::Repeat(direction) => direction, + } + } + + /// Preserve one-shot bindings while allowing movement-driven zoom. + #[must_use] + pub fn accepts(self, action: &Action) -> bool { + matches!(self, Self::First(_)) || action.repeats_on_motion() + } +} /// Minimum dominant-axis travel (raw-XY units) before a held gesture commits to /// a direction. Tuned to match Logitech Options+'s responsiveness. @@ -129,6 +154,31 @@ impl SwipeAccumulator { None } + /// Emit the initial swipe and additional distance-based steps. Reversing + /// direction discards leftover travel, so reversing a zoom responds promptly. + /// No timer generates repeats: holding still produces no output. + pub fn accumulate_repeating(&mut self, dx: i32, dy: i32) -> Option { + if !self.fired { + let direction = self.accumulate(dx, dy)?; + self.dx = 0; + self.dy = 0; + return Some(SwipeStep::First(direction)); + } + self.held_since?; + if dx != 0 && self.dx.signum() != dx.signum() { + self.dx = 0; + } + if dy != 0 && self.dy.signum() != dy.signum() { + self.dy = 0; + } + self.dx = self.dx.saturating_add(dx); + self.dy = self.dy.saturating_add(dy); + let direction = detect_swipe(self.dx, self.dy)?; + self.dx = 0; + self.dy = 0; + Some(SwipeStep::Repeat(direction)) + } + /// End the current hold. Returns `true` when an in-progress hold ended /// without committing a swipe — the caller should fire the plain `Click` /// action — and `false` when a swipe already fired mid-motion, or when there @@ -155,6 +205,33 @@ impl SwipeAccumulator { mod tests { use super::*; + #[test] + fn continuous_zoom_reverses_and_stops_at_release() { + let mut swipe = SwipeAccumulator::default(); + assert_eq!(swipe.accumulate_repeating(0, -100), None); + swipe.begin(); + assert_eq!(swipe.accumulate_repeating(0, -100), None); + swipe.backdate_hold_for_test(); + let first = swipe.accumulate_repeating(0, -1).unwrap(); + assert_eq!(first, SwipeStep::First(GestureDirection::Up)); + assert!(first.accepts(&Action::ZoomIn)); + assert_eq!(swipe.accumulate_repeating(0, 0), None); + assert_eq!(swipe.accumulate_repeating(0, -49), None); + let repeat = swipe.accumulate_repeating(0, -1).unwrap(); + assert!(repeat.accepts(&Action::ZoomInContinuous)); + assert!(!repeat.accepts(&Action::ZoomIn)); + assert!(!repeat.accepts(&Action::MissionControl)); + assert_eq!(swipe.accumulate_repeating(0, -30), None); + assert_eq!( + swipe.accumulate_repeating(0, 50), + Some(SwipeStep::Repeat(GestureDirection::Down)) + ); + assert!(!swipe.end(), "zooming must not fire the click binding"); + assert_eq!(swipe.accumulate_repeating(0, 100), None); + swipe.begin(); + assert!(swipe.end(), "a fresh stationary press remains a click"); + } + // ── Gesture classification ──────────────────────────────────────────────── #[test] diff --git a/crates/openlogi-core/src/binding/tests.rs b/crates/openlogi-core/src/binding/tests.rs index 85d0b293b..99ae4cbd1 100644 --- a/crates/openlogi-core/src/binding/tests.rs +++ b/crates/openlogi-core/src/binding/tests.rs @@ -387,6 +387,10 @@ fn persisted_action_variant_names_are_stable() { "VolumeDown", "VolumeUp", "Workflow", + "ZoomIn", + "ZoomInContinuous", + "ZoomOut", + "ZoomOutContinuous", ]; expected.sort_unstable(); assert_eq!(actual, expected); diff --git a/crates/openlogi-desktop/src/features/mouse/picker.rs b/crates/openlogi-desktop/src/features/mouse/picker.rs index f932647bd..fe0f34cbc 100644 --- a/crates/openlogi-desktop/src/features/mouse/picker.rs +++ b/crates/openlogi-desktop/src/features/mouse/picker.rs @@ -70,7 +70,11 @@ pub(crate) fn action_icon_path(action: &Action) -> &'static str { Action::Undo => "action-icons/undo-2.svg", Action::Redo => "action-icons/redo-2.svg", Action::SelectAll | Action::Workflow(_) => "action-icons/list-checks.svg", - Action::Find => "action-icons/search.svg", + Action::Find + | Action::ZoomIn + | Action::ZoomOut + | Action::ZoomInContinuous + | Action::ZoomOutContinuous => "action-icons/search.svg", Action::Save => "action-icons/save.svg", Action::BrowserBack => "action-icons/arrow-left.svg", Action::BrowserForward => "action-icons/arrow-right.svg", diff --git a/crates/openlogi-device/src/session/gesture.rs b/crates/openlogi-device/src/session/gesture.rs index 50d3c8105..d8d989b39 100644 --- a/crates/openlogi-device/src/session/gesture.rs +++ b/crates/openlogi-device/src/session/gesture.rs @@ -31,7 +31,7 @@ use hidpp::{ }, protocol::v20, }; -use openlogi_core::binding::{ButtonId, GestureDirection, SwipeAccumulator}; +use openlogi_core::binding::{ButtonId, GestureDirection, SwipeAccumulator, SwipeStep}; use tokio::sync::{mpsc, oneshot}; use tracing::{debug, info, warn}; @@ -62,6 +62,8 @@ pub enum CapturedInput { /// tagged with the source control so dispatch resolves it against that /// button's own direction map. Gesture(ButtonId, GestureDirection), + /// Additional travel during a committed hold, for continuous actions only. + GestureRepeat(ButtonId, GestureDirection), /// A diverted button's physical down edge. ButtonDown(ButtonId), /// Thumb-wheel rotation to re-synthesise on the configured scroll axis. @@ -1050,12 +1052,12 @@ fn handle_raw_xy( *skip_first_raw_xy = false; return; } - // Commit the instant a clean direction emerges (mid-swipe, once per hold); - // the accumulator gates on hold duration internally and drops travel that - // arrives outside a hold. - if let Some(direction) = swipe.accumulate(i32::from(dx), i32::from(dy)) { - debug!(?direction, %button, "gesture committed"); - let _ = sink.send(CapturedInput::Gesture(*button, direction)); + if let Some(step) = swipe.accumulate_repeating(i32::from(dx), i32::from(dy)) { + let input = match step { + SwipeStep::First(direction) => CapturedInput::Gesture(*button, direction), + SwipeStep::Repeat(direction) => CapturedInput::GestureRepeat(*button, direction), + }; + let _ = sink.send(input); } } diff --git a/crates/openlogi-device/src/session/gesture/tests.rs b/crates/openlogi-device/src/session/gesture/tests.rs index a7b75f6ca..6904dffe5 100644 --- a/crates/openlogi-device/src/session/gesture/tests.rs +++ b/crates/openlogi-device/src/session/gesture/tests.rs @@ -298,6 +298,33 @@ fn release() -> RawControlEvent { RawControlEvent::DivertedButtons([0, 0, 0, 0]) } +#[test] +fn zoom_motion_repeats_reverse_and_cannot_outlive_the_hid_hold() { + let (tx, mut rx) = mpsc::unbounded_channel(); + let mut acc = CaptureAccum::default(); + handle_reprog(&mut acc, press(), BOTH, &[], &[], &tx); + acc.backdate_hold_for_test(); + for dy in [-50, -50, 50] { + handle_raw_xy(&mut acc, 0, dy, &tx); + } + handle_reprog(&mut acc, release(), BOTH, &[], &[], &tx); + handle_raw_xy(&mut acc, 0, -100, &tx); + let mut captured = Vec::new(); + while let Ok(input) = rx.try_recv() { + captured.push(input); + } + assert_eq!( + captured, + vec![ + CapturedInput::ButtonDown(ButtonId::GestureButton), + CapturedInput::Gesture(ButtonId::GestureButton, GestureDirection::Up), + CapturedInput::GestureRepeat(ButtonId::GestureButton, GestureDirection::Up), + CapturedInput::GestureRepeat(ButtonId::GestureButton, GestureDirection::Down), + CapturedInput::ButtonUp(ButtonId::GestureButton), + ] + ); +} + /// Read the next completed gesture while leaving lifecycle assertions to the /// dedicated edge tests below. fn next_gesture( diff --git a/crates/openlogi-inject/src/inject/linux.rs b/crates/openlogi-inject/src/inject/linux.rs index b5fe8d196..e086bba54 100644 --- a/crates/openlogi-inject/src/inject/linux.rs +++ b/crates/openlogi-inject/src/inject/linux.rs @@ -95,6 +95,8 @@ fn combo(shortcut: Shortcut) -> KeyCombo { Shortcut::NextTab => "Ctrl+Tab", Shortcut::PrevTab => "Ctrl+Shift+Tab", Shortcut::ReloadPage => "Ctrl+R", + Shortcut::ZoomIn => "Ctrl+=", + Shortcut::ZoomOut => "Ctrl+-", }; parse_shortcut(text) } diff --git a/crates/openlogi-inject/src/inject/macos.rs b/crates/openlogi-inject/src/inject/macos.rs index fd3718a90..e7a2c93bd 100644 --- a/crates/openlogi-inject/src/inject/macos.rs +++ b/crates/openlogi-inject/src/inject/macos.rs @@ -109,6 +109,8 @@ fn combo(shortcut: Shortcut) -> KeyCombo { Shortcut::NextTab => "Ctrl+Tab", Shortcut::PrevTab => "Ctrl+Shift+Tab", Shortcut::ReloadPage => "Cmd+R", + Shortcut::ZoomIn => "Cmd+=", + Shortcut::ZoomOut => "Cmd+-", }; parse_shortcut(text) } diff --git a/crates/openlogi-inject/src/inject/windows.rs b/crates/openlogi-inject/src/inject/windows.rs index 8407fc3c1..4b71798de 100644 --- a/crates/openlogi-inject/src/inject/windows.rs +++ b/crates/openlogi-inject/src/inject/windows.rs @@ -5,10 +5,10 @@ use std::mem::size_of; use std::sync::{LazyLock, Mutex}; use windows_sys::Win32::UI::Input::KeyboardAndMouse::{ - INPUT, INPUT_0, INPUT_KEYBOARD, INPUT_MOUSE, KEYBDINPUT, KEYEVENTF_KEYUP, MOUSEEVENTF_HWHEEL, - MOUSEEVENTF_LEFTDOWN, MOUSEEVENTF_LEFTUP, MOUSEEVENTF_MIDDLEDOWN, MOUSEEVENTF_MIDDLEUP, - MOUSEEVENTF_RIGHTDOWN, MOUSEEVENTF_RIGHTUP, MOUSEEVENTF_WHEEL, MOUSEEVENTF_XDOWN, - MOUSEEVENTF_XUP, MOUSEINPUT, SendInput, + GetAsyncKeyState, INPUT, INPUT_0, INPUT_KEYBOARD, INPUT_MOUSE, KEYBDINPUT, KEYEVENTF_KEYUP, + MOUSEEVENTF_HWHEEL, MOUSEEVENTF_LEFTDOWN, MOUSEEVENTF_LEFTUP, MOUSEEVENTF_MIDDLEDOWN, + MOUSEEVENTF_MIDDLEUP, MOUSEEVENTF_RIGHTDOWN, MOUSEEVENTF_RIGHTUP, MOUSEEVENTF_WHEEL, + MOUSEEVENTF_XDOWN, MOUSEEVENTF_XUP, MOUSEINPUT, SendInput, }; use openlogi_core::binding::{ @@ -108,6 +108,8 @@ fn combo(shortcut: Shortcut) -> Result { Shortcut::NextTab => "Ctrl+Tab", Shortcut::PrevTab => "Ctrl+Shift+Tab", Shortcut::ReloadPage => "Ctrl+R", + Shortcut::ZoomIn => "Ctrl+=", + Shortcut::ZoomOut => "Ctrl+-", }; Ok(parse_shortcut(text)) } @@ -118,12 +120,36 @@ fn parse_shortcut(text: &str) -> KeyCombo { } fn press_shortcut(shortcut: Shortcut) { + if matches!(shortcut, Shortcut::ZoomIn | Shortcut::ZoomOut) { + // Preserve a physically held Ctrl key: only release a modifier we added. + // SAFETY: GetAsyncKeyState accepts the documented virtual-key code. + let control_down = unsafe { GetAsyncKeyState(i32::from(VK_CONTROL)) } < 0; + let delta = if shortcut == Shortcut::ZoomIn { + WHEEL_DELTA + } else { + -WHEEL_DELTA + }; + send_inputs(&zoom_inputs(delta, control_down)); + return; + } match combo(shortcut) { Ok(combo) => post_custom_shortcut(&combo), Err(vk) => post_key(vk, &[]), } } +fn zoom_inputs(delta: i32, control_down: bool) -> Vec { + if control_down { + vec![mouse_input(MOUSEEVENTF_WHEEL, delta)] + } else { + vec![ + key_input(VK_CONTROL, false), + mouse_input(MOUSEEVENTF_WHEEL, delta), + key_input(VK_CONTROL, true), + ] + } +} + /// Dispatch a window-manager or power [`NativeAction`]. macOS window-manager /// concepts map to their nearest Windows shortcut; `Sleep` has no clean /// synthesis (see the comment below) and is skipped. @@ -390,10 +416,37 @@ fn mouse_input(flags: u32, data: i32) -> INPUT { #[cfg(test)] mod tests { + use super::{ + INPUT_KEYBOARD, INPUT_MOUSE, KEYEVENTF_KEYUP, MOUSEEVENTF_WHEEL, VK_CONTROL, WHEEL_DELTA, + zoom_inputs, + }; use openlogi_core::binding::Shortcut; use super::{VK_BROWSER_BACK, VK_BROWSER_FORWARD, combo}; + #[test] + fn zoom_balances_ctrl_and_preserves_an_existing_hold() { + for delta in [WHEEL_DELTA, -WHEEL_DELTA] { + let inputs = zoom_inputs(delta, false); + assert_eq!(inputs.len(), 3); + assert_eq!(inputs[0].r#type, INPUT_KEYBOARD); + assert_eq!(inputs[1].r#type, INPUT_MOUSE); + assert_eq!(inputs[2].r#type, INPUT_KEYBOARD); + // SAFETY: the discriminants above identify the initialized union fields. + unsafe { + assert_eq!(inputs[0].Anonymous.ki.wVk, VK_CONTROL); + assert_eq!(inputs[0].Anonymous.ki.dwFlags, 0); + assert_eq!(inputs[1].Anonymous.mi.dwFlags, MOUSEEVENTF_WHEEL); + assert_eq!(inputs[1].Anonymous.mi.mouseData.cast_signed(), delta); + assert_eq!(inputs[2].Anonymous.ki.wVk, VK_CONTROL); + assert_eq!(inputs[2].Anonymous.ki.dwFlags, KEYEVENTF_KEYUP); + } + let held = zoom_inputs(delta, true); + assert_eq!(held.len(), 1, "an existing Ctrl hold must not be released"); + assert_eq!(held[0].r#type, INPUT_MOUSE); + } + } + /// Pin a handful of representative `Shortcut -> KeyCombo` rows so an /// edit to the table can't silently change what Ctrl+C sends. /// `Redo` differs from macOS/Linux by design (see the module doc on diff --git a/crates/openlogi-ipc/src/ipc.rs b/crates/openlogi-ipc/src/ipc.rs index dd99d2fe9..8f1382d02 100644 --- a/crates/openlogi-ipc/src/ipc.rs +++ b/crates/openlogi-ipc/src/ipc.rs @@ -61,7 +61,8 @@ pub use succession::Identity; /// v28: `Action::HoldShortcut` appended for lifecycle-held keyboard output. /// v29: `Agent::declare_client` + [`ClientKind`] appended — typed demand for /// the macOS dormancy gate. -pub const PROTOCOL_VERSION: u32 = 29; +/// v30: one-step and continuous gesture zoom actions appended. +pub const PROTOCOL_VERSION: u32 = 30; /// Environment variable through which the agent hands a supervised helper the /// run token it will serve, so the helper knows which agent it belongs to diff --git a/crates/openlogi-ipc/tests/wire_format.rs b/crates/openlogi-ipc/tests/wire_format.rs index 3a8510816..e06200762 100644 --- a/crates/openlogi-ipc/tests/wire_format.rs +++ b/crates/openlogi-ipc/tests/wire_format.rs @@ -31,7 +31,7 @@ use std::fmt::Write; use bincode::Options; use openlogi_core::app::ForegroundApp; -use openlogi_core::binding::{ActionRingIcon, ActionRingSlot}; +use openlogi_core::binding::{Action, ActionRingIcon, ActionRingSlot}; use openlogi_core::config::Lighting; use openlogi_core::device::{ BatteryInfo, BatteryLevel, BatteryStatus, Capabilities, DeviceInventory, DeviceKind, @@ -101,7 +101,15 @@ fn representative_smartshift_status() -> SmartShiftStatus { /// that makes that visible in the same diff. #[test] fn protocol_version_is_pinned() { - assert_eq!(PROTOCOL_VERSION, 29); + assert_eq!(PROTOCOL_VERSION, 30); +} + +#[test] +fn zoom_actions_are_appended_to_the_wire_vocabulary() { + assert_wire(&Action::ZoomIn, "35"); + assert_wire(&Action::ZoomOut, "36"); + assert_wire(&Action::ZoomInContinuous, "37"); + assert_wire(&Action::ZoomOutContinuous, "38"); } #[test] diff --git a/crates/openlogi-permissions/src/lib.rs b/crates/openlogi-permissions/src/lib.rs index 311fc4e2b..161ee266f 100644 --- a/crates/openlogi-permissions/src/lib.rs +++ b/crates/openlogi-permissions/src/lib.rs @@ -14,7 +14,7 @@ //! Two permissions matter: **Accessibility** (the hook's event tap) and **Input //! Monitoring** (opening HID devices via `IOHIDManager`). **Bluetooth** is //! surfaced for completeness — OpenLogi reaches BLE mice through `IOHIDManager`, -//! so it usually reads [`PermissionStatus::Unknown`]. +//! so it usually reads `PermissionStatus::Unknown`. //! //! Accessibility status is not read here: the agent owns the tap, so //! `openlogi_hook::has_accessibility` is the source of truth. diff --git a/crates/openlogi-ui/locales/be.toml b/crates/openlogi-ui/locales/be.toml index 3e4de70a4..d6c039331 100644 --- a/crates/openlogi-ui/locales/be.toml +++ b/crates/openlogi-ui/locales/be.toml @@ -270,6 +270,10 @@ reopen_tab = "Аднавіць укладку" next_tab = "Наступная ўкладка" previous_tab = "Папярэдняя ўкладка" reload_page = "Перазагрузіць старонку" +zoom_in = "Zoom In (one step)" +zoom_out = "Zoom Out (one step)" +zoom_in_continuous = "Zoom In (continuous gesture)" +zoom_out_continuous = "Zoom Out (continuous gesture)" mission_control = "Mission Control" app_expose = "App Exposé" previous_desktop = "Папярэдні працоўны стол" diff --git a/crates/openlogi-ui/locales/da.toml b/crates/openlogi-ui/locales/da.toml index 470179440..0289287e3 100644 --- a/crates/openlogi-ui/locales/da.toml +++ b/crates/openlogi-ui/locales/da.toml @@ -270,6 +270,10 @@ reopen_tab = "Genåbn faneblad" next_tab = "Næste faneblad" previous_tab = "Forrige faneblad" reload_page = "Genindlæs side" +zoom_in = "Zoom In (one step)" +zoom_out = "Zoom Out (one step)" +zoom_in_continuous = "Zoom In (continuous gesture)" +zoom_out_continuous = "Zoom Out (continuous gesture)" mission_control = "Mission Control" app_expose = "App Exposé" previous_desktop = "Forrige skrivebord" diff --git a/crates/openlogi-ui/locales/de.toml b/crates/openlogi-ui/locales/de.toml index bfbfb67a5..ef88dd53c 100644 --- a/crates/openlogi-ui/locales/de.toml +++ b/crates/openlogi-ui/locales/de.toml @@ -270,6 +270,10 @@ reopen_tab = "Tab erneut öffnen" next_tab = "Nächster Tab" previous_tab = "Vorheriger Tab" reload_page = "Seite neu laden" +zoom_in = "Vergrößern (ein Schritt)" +zoom_out = "Verkleinern (ein Schritt)" +zoom_in_continuous = "Vergrößern (kontinuierliche Geste)" +zoom_out_continuous = "Verkleinern (kontinuierliche Geste)" mission_control = "Mission Control" app_expose = "App Exposé" previous_desktop = "Vorheriger Schreibtisch" diff --git a/crates/openlogi-ui/locales/el.toml b/crates/openlogi-ui/locales/el.toml index efe3fd140..64f862f0b 100644 --- a/crates/openlogi-ui/locales/el.toml +++ b/crates/openlogi-ui/locales/el.toml @@ -270,6 +270,10 @@ reopen_tab = "Άνοιγμα καρτέλας ξανά" next_tab = "Επόμενη καρτέλα" previous_tab = "Προηγούμενη καρτέλα" reload_page = "Επαναφόρτωση σελίδας" +zoom_in = "Zoom In (one step)" +zoom_out = "Zoom Out (one step)" +zoom_in_continuous = "Zoom In (continuous gesture)" +zoom_out_continuous = "Zoom Out (continuous gesture)" mission_control = "Mission Control" app_expose = "App Exposé" previous_desktop = "Προηγούμενο γραφείο εργασίας" diff --git a/crates/openlogi-ui/locales/en.toml b/crates/openlogi-ui/locales/en.toml index f1098e7e2..e0f58873c 100644 --- a/crates/openlogi-ui/locales/en.toml +++ b/crates/openlogi-ui/locales/en.toml @@ -270,6 +270,10 @@ reopen_tab = "Reopen Tab" next_tab = "Next Tab" previous_tab = "Previous Tab" reload_page = "Reload Page" +zoom_in = "Zoom In (one step)" +zoom_out = "Zoom Out (one step)" +zoom_in_continuous = "Zoom In (continuous gesture)" +zoom_out_continuous = "Zoom Out (continuous gesture)" mission_control = "Mission Control" app_expose = "App Exposé" previous_desktop = "Previous Desktop" diff --git a/crates/openlogi-ui/locales/es.toml b/crates/openlogi-ui/locales/es.toml index e48ff802d..73669ac4a 100644 --- a/crates/openlogi-ui/locales/es.toml +++ b/crates/openlogi-ui/locales/es.toml @@ -270,6 +270,10 @@ reopen_tab = "Reabrir pestaña" next_tab = "Pestaña siguiente" previous_tab = "Pestaña anterior" reload_page = "Recargar página" +zoom_in = "Zoom In (one step)" +zoom_out = "Zoom Out (one step)" +zoom_in_continuous = "Zoom In (continuous gesture)" +zoom_out_continuous = "Zoom Out (continuous gesture)" mission_control = "Mission Control" app_expose = "App Exposé" previous_desktop = "Escritorio anterior" diff --git a/crates/openlogi-ui/locales/fi.toml b/crates/openlogi-ui/locales/fi.toml index d779f385b..6f9832580 100644 --- a/crates/openlogi-ui/locales/fi.toml +++ b/crates/openlogi-ui/locales/fi.toml @@ -270,6 +270,10 @@ reopen_tab = "Avaa välilehti uudelleen" next_tab = "Seuraava välilehti" previous_tab = "Edellinen välilehti" reload_page = "Lataa sivu uudelleen" +zoom_in = "Zoom In (one step)" +zoom_out = "Zoom Out (one step)" +zoom_in_continuous = "Zoom In (continuous gesture)" +zoom_out_continuous = "Zoom Out (continuous gesture)" mission_control = "Mission Control" app_expose = "App Exposé" previous_desktop = "Edellinen työpöytä" diff --git a/crates/openlogi-ui/locales/fr.toml b/crates/openlogi-ui/locales/fr.toml index 2f1c5d65b..66c730224 100644 --- a/crates/openlogi-ui/locales/fr.toml +++ b/crates/openlogi-ui/locales/fr.toml @@ -270,6 +270,10 @@ reopen_tab = "Rouvrir l’onglet" next_tab = "Onglet suivant" previous_tab = "Onglet précédent" reload_page = "Recharger la page" +zoom_in = "Zoom In (one step)" +zoom_out = "Zoom Out (one step)" +zoom_in_continuous = "Zoom In (continuous gesture)" +zoom_out_continuous = "Zoom Out (continuous gesture)" mission_control = "Mission Control" app_expose = "App Exposé" previous_desktop = "Bureau précédent" diff --git a/crates/openlogi-ui/locales/it.toml b/crates/openlogi-ui/locales/it.toml index 838a5172e..aec2c0350 100644 --- a/crates/openlogi-ui/locales/it.toml +++ b/crates/openlogi-ui/locales/it.toml @@ -270,6 +270,10 @@ reopen_tab = "Riapri scheda" next_tab = "Scheda successiva" previous_tab = "Scheda precedente" reload_page = "Ricarica pagina" +zoom_in = "Zoom In (one step)" +zoom_out = "Zoom Out (one step)" +zoom_in_continuous = "Zoom In (continuous gesture)" +zoom_out_continuous = "Zoom Out (continuous gesture)" mission_control = "Mission Control" app_expose = "App Exposé" previous_desktop = "Scrivania precedente" diff --git a/crates/openlogi-ui/locales/ja.toml b/crates/openlogi-ui/locales/ja.toml index 8c7f1560e..5d2609a23 100644 --- a/crates/openlogi-ui/locales/ja.toml +++ b/crates/openlogi-ui/locales/ja.toml @@ -270,6 +270,10 @@ reopen_tab = "タブを再度開く" next_tab = "次のタブ" previous_tab = "前のタブ" reload_page = "ページを再読み込み" +zoom_in = "Zoom In (one step)" +zoom_out = "Zoom Out (one step)" +zoom_in_continuous = "Zoom In (continuous gesture)" +zoom_out_continuous = "Zoom Out (continuous gesture)" mission_control = "Mission Control" app_expose = "App Exposé" previous_desktop = "前のデスクトップ" diff --git a/crates/openlogi-ui/locales/ko.toml b/crates/openlogi-ui/locales/ko.toml index 547db22eb..71b318ecc 100644 --- a/crates/openlogi-ui/locales/ko.toml +++ b/crates/openlogi-ui/locales/ko.toml @@ -270,6 +270,10 @@ reopen_tab = "탭 다시 열기" next_tab = "다음 탭" previous_tab = "이전 탭" reload_page = "페이지 새로고침" +zoom_in = "Zoom In (one step)" +zoom_out = "Zoom Out (one step)" +zoom_in_continuous = "Zoom In (continuous gesture)" +zoom_out_continuous = "Zoom Out (continuous gesture)" mission_control = "미션 컨트롤" app_expose = "앱 Exposé" previous_desktop = "이전 데스크톱" diff --git a/crates/openlogi-ui/locales/nb.toml b/crates/openlogi-ui/locales/nb.toml index df8326732..9ef8601ab 100644 --- a/crates/openlogi-ui/locales/nb.toml +++ b/crates/openlogi-ui/locales/nb.toml @@ -270,6 +270,10 @@ reopen_tab = "Gjenåpne fane" next_tab = "Neste fane" previous_tab = "Forrige fane" reload_page = "Last inn siden på nytt" +zoom_in = "Zoom In (one step)" +zoom_out = "Zoom Out (one step)" +zoom_in_continuous = "Zoom In (continuous gesture)" +zoom_out_continuous = "Zoom Out (continuous gesture)" mission_control = "Mission Control" app_expose = "App Exposé" previous_desktop = "Forrige skrivebord" diff --git a/crates/openlogi-ui/locales/nl.toml b/crates/openlogi-ui/locales/nl.toml index f9aba54e5..9e89bfb96 100644 --- a/crates/openlogi-ui/locales/nl.toml +++ b/crates/openlogi-ui/locales/nl.toml @@ -270,6 +270,10 @@ reopen_tab = "Tabblad heropenen" next_tab = "Volgend tabblad" previous_tab = "Vorig tabblad" reload_page = "Pagina herladen" +zoom_in = "Zoom In (one step)" +zoom_out = "Zoom Out (one step)" +zoom_in_continuous = "Zoom In (continuous gesture)" +zoom_out_continuous = "Zoom Out (continuous gesture)" mission_control = "Mission Control" app_expose = "App Exposé" previous_desktop = "Vorig bureaublad" diff --git a/crates/openlogi-ui/locales/pl.toml b/crates/openlogi-ui/locales/pl.toml index 83ef5fa44..14b094a6c 100644 --- a/crates/openlogi-ui/locales/pl.toml +++ b/crates/openlogi-ui/locales/pl.toml @@ -270,6 +270,10 @@ reopen_tab = "Otwórz ponownie kartę" next_tab = "Następna karta" previous_tab = "Poprzednia karta" reload_page = "Załaduj stronę ponownie" +zoom_in = "Zoom In (one step)" +zoom_out = "Zoom Out (one step)" +zoom_in_continuous = "Zoom In (continuous gesture)" +zoom_out_continuous = "Zoom Out (continuous gesture)" mission_control = "Mission Control" app_expose = "App Exposé" previous_desktop = "Poprzedni pulpit" diff --git a/crates/openlogi-ui/locales/pt-BR.toml b/crates/openlogi-ui/locales/pt-BR.toml index 63713bdd1..046d8d502 100644 --- a/crates/openlogi-ui/locales/pt-BR.toml +++ b/crates/openlogi-ui/locales/pt-BR.toml @@ -270,6 +270,10 @@ reopen_tab = "Reabrir Aba" next_tab = "Próxima Aba" previous_tab = "Aba Anterior" reload_page = "Recarregar Página" +zoom_in = "Zoom In (one step)" +zoom_out = "Zoom Out (one step)" +zoom_in_continuous = "Zoom In (continuous gesture)" +zoom_out_continuous = "Zoom Out (continuous gesture)" mission_control = "Mission Control" app_expose = "Exposé do App" previous_desktop = "Mesa Anterior" diff --git a/crates/openlogi-ui/locales/pt-PT.toml b/crates/openlogi-ui/locales/pt-PT.toml index 44df27c15..678e22495 100644 --- a/crates/openlogi-ui/locales/pt-PT.toml +++ b/crates/openlogi-ui/locales/pt-PT.toml @@ -270,6 +270,10 @@ reopen_tab = "Reabrir separador" next_tab = "Separador seguinte" previous_tab = "Separador anterior" reload_page = "Recarregar página" +zoom_in = "Zoom In (one step)" +zoom_out = "Zoom Out (one step)" +zoom_in_continuous = "Zoom In (continuous gesture)" +zoom_out_continuous = "Zoom Out (continuous gesture)" mission_control = "Mission Control" app_expose = "App Exposé" previous_desktop = "Secretária anterior" diff --git a/crates/openlogi-ui/locales/ru.toml b/crates/openlogi-ui/locales/ru.toml index 6e8b3dbc3..3b776381a 100644 --- a/crates/openlogi-ui/locales/ru.toml +++ b/crates/openlogi-ui/locales/ru.toml @@ -270,6 +270,10 @@ reopen_tab = "Открыть закрытую вкладку" next_tab = "Следующая вкладка" previous_tab = "Предыдущая вкладка" reload_page = "Перезагрузить страницу" +zoom_in = "Zoom In (one step)" +zoom_out = "Zoom Out (one step)" +zoom_in_continuous = "Zoom In (continuous gesture)" +zoom_out_continuous = "Zoom Out (continuous gesture)" mission_control = "Mission Control" app_expose = "App Exposé" previous_desktop = "Предыдущий рабочий стол" diff --git a/crates/openlogi-ui/locales/sv.toml b/crates/openlogi-ui/locales/sv.toml index a5bac2a28..8fe0f617a 100644 --- a/crates/openlogi-ui/locales/sv.toml +++ b/crates/openlogi-ui/locales/sv.toml @@ -270,6 +270,10 @@ reopen_tab = "Återöppna flik" next_tab = "Nästa flik" previous_tab = "Föregående flik" reload_page = "Läs in sidan på nytt" +zoom_in = "Zoom In (one step)" +zoom_out = "Zoom Out (one step)" +zoom_in_continuous = "Zoom In (continuous gesture)" +zoom_out_continuous = "Zoom Out (continuous gesture)" mission_control = "Mission Control" app_expose = "App Exposé" previous_desktop = "Föregående skrivbord" diff --git a/crates/openlogi-ui/locales/tr.toml b/crates/openlogi-ui/locales/tr.toml index 463a5f2a1..2c0cd9ed9 100644 --- a/crates/openlogi-ui/locales/tr.toml +++ b/crates/openlogi-ui/locales/tr.toml @@ -270,6 +270,10 @@ reopen_tab = "Sekmeyi Yeniden Aç" next_tab = "Sonraki Sekme" previous_tab = "Önceki Sekme" reload_page = "Sayfayı Yenile" +zoom_in = "Zoom In (one step)" +zoom_out = "Zoom Out (one step)" +zoom_in_continuous = "Zoom In (continuous gesture)" +zoom_out_continuous = "Zoom Out (continuous gesture)" mission_control = "Mission Control" app_expose = "App Exposé" previous_desktop = "Önceki Masaüstü" diff --git a/crates/openlogi-ui/locales/uk.toml b/crates/openlogi-ui/locales/uk.toml index 9adb99fc8..413584451 100644 --- a/crates/openlogi-ui/locales/uk.toml +++ b/crates/openlogi-ui/locales/uk.toml @@ -270,6 +270,10 @@ reopen_tab = "Відновити вкладку" next_tab = "Наступна вкладка" previous_tab = "Попередня вкладка" reload_page = "Перезавантажити сторінку" +zoom_in = "Zoom In (one step)" +zoom_out = "Zoom Out (one step)" +zoom_in_continuous = "Zoom In (continuous gesture)" +zoom_out_continuous = "Zoom Out (continuous gesture)" mission_control = "Mission Control" app_expose = "App Exposé" previous_desktop = "Попередній робочий стіл" diff --git a/crates/openlogi-ui/locales/zh-CN.toml b/crates/openlogi-ui/locales/zh-CN.toml index a13e6a945..f93e12e07 100644 --- a/crates/openlogi-ui/locales/zh-CN.toml +++ b/crates/openlogi-ui/locales/zh-CN.toml @@ -270,6 +270,10 @@ reopen_tab = "重开标签页" next_tab = "下一个标签页" previous_tab = "上一个标签页" reload_page = "刷新页面" +zoom_in = "Zoom In (one step)" +zoom_out = "Zoom Out (one step)" +zoom_in_continuous = "Zoom In (continuous gesture)" +zoom_out_continuous = "Zoom Out (continuous gesture)" mission_control = "调度中心" app_expose = "应用程序窗口" previous_desktop = "上一个桌面" diff --git a/crates/openlogi-ui/locales/zh-HK.toml b/crates/openlogi-ui/locales/zh-HK.toml index 48ed37f3f..989c324ed 100644 --- a/crates/openlogi-ui/locales/zh-HK.toml +++ b/crates/openlogi-ui/locales/zh-HK.toml @@ -270,6 +270,10 @@ reopen_tab = "重新開啟分頁" next_tab = "下一個分頁" previous_tab = "上一個分頁" reload_page = "重新載入頁面" +zoom_in = "Zoom In (one step)" +zoom_out = "Zoom Out (one step)" +zoom_in_continuous = "Zoom In (continuous gesture)" +zoom_out_continuous = "Zoom Out (continuous gesture)" mission_control = "調度中心" app_expose = "應用程式視窗" previous_desktop = "上一個桌面" diff --git a/crates/openlogi-ui/locales/zh-TW.toml b/crates/openlogi-ui/locales/zh-TW.toml index 51731224e..437df3466 100644 --- a/crates/openlogi-ui/locales/zh-TW.toml +++ b/crates/openlogi-ui/locales/zh-TW.toml @@ -270,6 +270,10 @@ reopen_tab = "重新開啟分頁" next_tab = "下一個分頁" previous_tab = "上一個分頁" reload_page = "重新載入頁面" +zoom_in = "Zoom In (one step)" +zoom_out = "Zoom Out (one step)" +zoom_in_continuous = "Zoom In (continuous gesture)" +zoom_out_continuous = "Zoom Out (continuous gesture)" mission_control = "Mission Control" app_expose = "App Exposé" previous_desktop = "上一個桌面" diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index d83b78c56..f967366ee 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -87,6 +87,31 @@ Common device fields are: ## Actions +### Gesture zoom + +In Buttons, select a gesture button, choose Up or Down, and pick a zoom action +from the Browser category. `ZoomIn` and `ZoomOut` fire one step per hold/swipe. +`ZoomInContinuous` and `ZoomOutContinuous` repeat as the held mouse moves and +allow reversing direction without releasing. Holding still does not repeat; +releasing after a swipe does not also fire the click action. + +```toml +[devices."".bindings.GestureButton] +Up = "ZoomInContinuous" +Down = "ZoomOutContinuous" +Left = "PreviousDesktop" +Right = "NextDesktop" +Click = "MissionControl" +``` + +Windows uses Ctrl+mouse-wheel zoom; macOS uses Cmd+= / Cmd+- and Linux uses +Ctrl+= / Ctrl+-. The focused application must support these inputs. Continuous +mode emits repeated zoom steps, not pinch gestures, and uses the existing +160 ms hold gate and 50-unit swipe threshold. Other gesture actions remain +one-shot. On a plain button, either zoom mode emits a single step. + +### Action values + Action names are the serialized Rust variant names, including `Copy`, `BrowserBack`, `PlayPause`, `CycleDpiPresets`, and `ShowActionsRing`. Payload actions use a one-key inline table: