From 6a1dce090ddeca736875a799516a4338747ee5cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ak=C4=B1n=20K=C4=B1l=C4=B1=C3=A7?= Date: Mon, 31 Aug 2026 01:41:33 +0300 Subject: [PATCH] feat: add hold-mode Pan and Zoom actions for HID++ mice Hold a bound button and HID++ feature 0x1b04 diverts the control with the raw_xy flag, so the firmware streams sensor deltas over HID++ instead of moving the pointer. Pan turns that travel into a pixel-precise two-axis scroll with real gesture phases; Zoom turns vertical travel into a continuous pinch magnify. A Zoom-bound button clicked without dragging fires the discrete native smart zoom instead, so both zoom gestures live on one button and neither can fire on button-down. Deltas are normalised against a live 0x2201 getSensorDpi read, so the feel does not change when the sensor DPI is cycled. Two settings scale it: Zoom Sensitivity on the same 1..=100 scale as the existing sliders, and Invert pan direction. Both are snapshotted when a hold opens, so saving settings mid-gesture cannot change the scale under the user's hand. Three things the hardware forced, each measured on an MX Master 3S at 950 DPI over Bluetooth: - The first raw-XY report of a hold is the sensor's pre-press bank, not travel, because the firmware only streams while the control is down. The worst case measured 1694 x 1619 counts 10 ms after button-down, which is 63 mm, against a 1306 mm/s ceiling for real motion in the same session. It is dropped at the device layer, which also keeps it out of the click/drag deadzone that smart zoom depends on. - A pinch is CGEventType 29 (NSEventTypeGesture) with field 110 = 8 at the HID layer, not CGEventType 30. AppKit is what promotes it into the NSEventTypeMagnify applications see, so posting 30 is inert. - CoreGraphics recomputes sibling scroll-delta fields on every write, and a line delta of 0, which is any pixel delta under 10, zeroes pointDelta. The line field has to be written before the pixel field. Hold-mode arming also needed a real sensor read: live_sensor_dpi() was reading the configured DPI-preset cycle, which is empty for anyone who never set one, so hold mode failed closed and logged nothing. Injection availability is the only hard gate now, since arming a raw-XY divert without an injector freezes the cursor with nothing to deliver the motion. The smart-zoom event synthesis, the ZoomToggle subtype and the two private payload markers, is Kyle Foley's work from PR #1119, reused with thanks and credited at the call site. Closes #360 --- Cargo.lock | 1 + .../openlogi-agent-core/src/capture_plan.rs | 389 +++++++++- .../src/capture_plan/hold.rs | 199 +++++ .../openlogi-agent-core/src/orchestrator.rs | 117 ++- .../src/orchestrator/tests.rs | 111 +++ crates/openlogi-agent-core/src/runtime.rs | 4 + .../src/watchers/gesture.rs | 46 +- .../src/watchers/gesture/dispatch.rs | 184 ++++- .../src/watchers/gesture/dispatch/hold.rs | 301 ++++++++ .../watchers/gesture/dispatch/hold/tests.rs | 448 +++++++++++ .../src/watchers/gesture/dispatch/tests.rs | 61 +- .../src/watchers/keyboard.rs | 8 +- crates/openlogi-agent/Cargo.toml | 1 + .../src/binary_watch/relaunch.rs | 15 +- crates/openlogi-agent/src/shutdown.rs | 125 +++- crates/openlogi-agent/src/tray.rs | 18 +- crates/openlogi-agent/src/tray_windows.rs | 6 +- crates/openlogi-core/src/binding.rs | 4 +- crates/openlogi-core/src/binding/action.rs | 21 + .../openlogi-core/src/binding/action_ring.rs | 12 + crates/openlogi-core/src/binding/effect.rs | 10 +- crates/openlogi-core/src/binding/swipe.rs | 256 ++++++- crates/openlogi-core/src/binding/tests.rs | 27 + crates/openlogi-core/src/binding/value.rs | 12 + crates/openlogi-core/src/bindings.rs | 39 + crates/openlogi-core/src/config.rs | 1 + crates/openlogi-core/src/config/settings.rs | 81 ++ .../src/features/action_ring/editor.rs | 9 + .../src/features/keyboard/function_row.rs | 13 +- .../src/features/mouse/inspector.rs | 10 +- .../src/features/mouse/picker.rs | 150 +++- crates/openlogi-desktop/src/services/i18n.rs | 18 +- crates/openlogi-desktop/src/state/settings.rs | 24 +- .../openlogi-desktop/src/windows/settings.rs | 74 +- .../src/windows/settings/general.rs | 47 +- crates/openlogi-device/src/lib.rs | 24 +- crates/openlogi-device/src/session/gesture.rs | 696 +++++++++++++++--- .../src/session/gesture/tests.rs | 387 ++++++++++ crates/openlogi-device/src/write.rs | 5 +- crates/openlogi-device/src/write/dpi.rs | 41 +- .../openlogi-device/src/write/sensor_dpi.rs | 97 +++ crates/openlogi-device/src/write/tests.rs | 19 + crates/openlogi-inject/Cargo.toml | 6 +- .../openlogi-inject/examples/inject_action.rs | 107 ++- crates/openlogi-inject/src/inject.rs | 177 ++++- crates/openlogi-inject/src/inject/gesture.rs | 470 ++++++++++++ crates/openlogi-inject/src/inject/linux.rs | 95 +++ crates/openlogi-inject/src/inject/macos.rs | 431 +++++++++++ crates/openlogi-inject/src/inject/windows.rs | 74 ++ crates/openlogi-inject/src/lib.rs | 3 +- crates/openlogi-ipc/src/ipc.rs | 6 +- crates/openlogi-ipc/tests/wire_format.rs | 14 +- crates/openlogi-ui/locales/be.yml | 10 + crates/openlogi-ui/locales/da.yml | 10 + crates/openlogi-ui/locales/de.yml | 10 + crates/openlogi-ui/locales/el.yml | 10 + crates/openlogi-ui/locales/en.yml | 10 + crates/openlogi-ui/locales/es.yml | 10 + crates/openlogi-ui/locales/fi.yml | 10 + crates/openlogi-ui/locales/fr.yml | 10 + crates/openlogi-ui/locales/it.yml | 10 + crates/openlogi-ui/locales/ja.yml | 10 + crates/openlogi-ui/locales/ko.yml | 10 + crates/openlogi-ui/locales/nb.yml | 10 + crates/openlogi-ui/locales/nl.yml | 10 + crates/openlogi-ui/locales/pl.yml | 10 + crates/openlogi-ui/locales/pt-BR.yml | 10 + crates/openlogi-ui/locales/pt-PT.yml | 10 + crates/openlogi-ui/locales/ru.yml | 10 + crates/openlogi-ui/locales/sv.yml | 10 + crates/openlogi-ui/locales/tr.yml | 10 + crates/openlogi-ui/locales/uk.yml | 10 + crates/openlogi-ui/locales/zh-CN.yml | 10 + crates/openlogi-ui/locales/zh-HK.yml | 10 + crates/openlogi-ui/locales/zh-TW.yml | 10 + crates/openlogi-ui/src/action_icons.rs | 14 + 76 files changed, 5413 insertions(+), 325 deletions(-) create mode 100644 crates/openlogi-agent-core/src/capture_plan/hold.rs create mode 100644 crates/openlogi-agent-core/src/watchers/gesture/dispatch/hold.rs create mode 100644 crates/openlogi-agent-core/src/watchers/gesture/dispatch/hold/tests.rs create mode 100644 crates/openlogi-device/src/write/sensor_dpi.rs create mode 100644 crates/openlogi-inject/src/inject/gesture.rs diff --git a/Cargo.lock b/Cargo.lock index 1348e6f02..510e9288a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5396,6 +5396,7 @@ dependencies = [ "openlogi-core", "openlogi-hid", "openlogi-hook", + "openlogi-inject", "openlogi-ipc", "rust-i18n", "succession", diff --git a/crates/openlogi-agent-core/src/capture_plan.rs b/crates/openlogi-agent-core/src/capture_plan.rs index c1b4e8bd3..ee2e1e35c 100644 --- a/crates/openlogi-agent-core/src/capture_plan.rs +++ b/crates/openlogi-agent-core/src/capture_plan.rs @@ -8,13 +8,21 @@ //! plan of the session it arrived on, never against a global selected-device //! map. +mod hold; + +#[cfg(test)] +pub(crate) use hold::FALLBACK_HOLD_SENSOR_DPI; + use std::collections::BTreeMap; use std::sync::Arc; use openlogi_core::binding::{Action, Binding, ButtonId, GestureDirection, default_binding}; -use openlogi_core::bindings::{button_bindings_for, hidpp_gesture_maps_for, oshook_gestures_for}; -use openlogi_core::config::{Config, ThumbwheelSensitivity}; +use openlogi_core::bindings::{ + button_bindings_for, hidpp_gesture_maps_for, hold_mode_bindings_for, oshook_gestures_for, +}; +use openlogi_core::config::{Config, ThumbwheelSensitivity, ZoomSensitivity}; use openlogi_core::device_order::PhysicalDeviceKey; +use openlogi_core::hid::Dpi; use openlogi_hid::DeviceRoute; use openlogi_hid::session::gesture::{ CaptureSpec, DIVERTABLE_STANDARD_BUTTONS, GESTURE_SOURCE_BUTTONS, @@ -63,6 +71,48 @@ pub struct DispatchPlan { /// This device's effective thumb-wheel sensitivity (device override or the /// app-wide default). pub thumbwheel_sensitivity: ThumbwheelSensitivity, + /// Hold-mode (`Pan` / `Zoom`) button bindings. The OS-hook map must omit + /// these keys so HID++ is the only dispatch path. + pub hold_bindings: BTreeMap, + /// Live sensor DPI, for converting raw counts to millimetres. Unlike + /// [`CaptureSpec::sensor_dpi`] this tracks the real reading, so the felt + /// speed of a pan is right on a device whose sensor is not at the + /// fallback. + pub sensor_dpi: Option, + /// App-wide hold-mode zoom responsiveness. + pub zoom_sensitivity: ZoomSensitivity, + /// App-wide hold-mode pan direction. `false` is content-follows-hand. + pub invert_pan: bool, +} + +/// Host facts that decide whether hold-mode raw-XY may be armed. +/// +/// [`plan_for_device`] fail-closes on injection only: it stays unavailable +/// until the orchestrator calls [`plan_for_device_with`]. A missing DPI no +/// longer disables hold-mode — see [`hold::FALLBACK_HOLD_SENSOR_DPI`]. +#[derive(Clone, Copy, Debug)] +pub struct CaptureHostAbility { + /// Whether the OS movement hook is currently usable. + pub os_mouse_hook_available: bool, + /// Whether synthesised events can be delivered (macOS Accessibility). + /// Arming a raw-XY divert without this freezes the cursor for a gesture + /// that can never happen. + pub injection_available: bool, + /// Live sensor DPI. Falls back to the committed config DPI, then to a + /// named factory default, so a missing reading never disables hold-mode. + pub sensor_dpi: Option, +} + +impl CaptureHostAbility { + /// Hook availability only — hold-mode stays unarmed. + #[must_use] + pub const fn hook_only(os_mouse_hook_available: bool) -> Self { + Self { + os_mouse_hook_available, + injection_available: false, + sensor_dpi: None, + } + } } /// One device's independently versioned hardware target and dispatch plan. @@ -105,6 +155,45 @@ pub fn plan_for_device( rearm_generation: u64, os_mouse_hook_available: bool, ) -> DeviceCapturePlan { + plan_for_device_with( + config, + physical_key, + config_key, + route, + app, + rearm_generation, + CaptureHostAbility::hook_only(os_mouse_hook_available), + ) +} + +/// Whether any thumb-wheel control carries a non-default binding. That alone +/// is reason to capture the wheel, independent of its sensitivity. +fn thumbwheel_bindings_customized(bindings: &BTreeMap) -> bool { + [ + ButtonId::Thumbwheel, + ButtonId::ThumbwheelScrollUp, + ButtonId::ThumbwheelScrollDown, + ] + .iter() + .any(|button| { + bindings + .get(button) + .is_some_and(|binding| binding.click_action() != default_binding(*button)) + }) +} + +/// Build one device's plan with explicit injection and DPI facts. +#[must_use] +pub fn plan_for_device_with( + config: &Config, + physical_key: PhysicalDeviceKey, + config_key: &str, + route: DeviceRoute, + app: Option<&str>, + rearm_generation: u64, + ability: CaptureHostAbility, +) -> DeviceCapturePlan { + let os_mouse_hook_available = ability.os_mouse_hook_available; let bindings = button_bindings_for(config, Some(config_key), app); // Gesture-mode OS-hook controls normally stay native so the hook sees the // press. macOS Back/Forward are the exception below: HID++ owns their @@ -116,10 +205,34 @@ pub fn plan_for_device( // gesture at once, each armed with its own raw-XY divert (the capture // target below derives the CIDs to divert from this map's keys). let gesture_bindings = hidpp_gesture_maps_for(config, Some(config_key)); + let hold_bindings = hold_mode_bindings_for(config, Some(config_key), app); + let resolved_dpi = hold::resolve_hold_sensor_dpi(ability.sensor_dpi, config.dpi(config_key)); + hold::warn_if_hold_dpi_is_approximate( + config_key, + !hold_bindings.is_empty(), + ability.injection_available, + &resolved_dpi, + ); + let sensor_dpi = Some(resolved_dpi.dpi); + // The armed spec carries the fallback only, never a live sensor reading. + // `CaptureSpec` is part of `CaptureTarget`'s identity, so folding the live + // value in would retire and re-arm the session the moment the first + // `getSensorDpi` lands, seconds after connect, tearing down any hold the + // user had already started. The device layer prefers the process-wide + // sensor cache at press time anyway, so this value only ever matters on a + // device whose DPI can never be read. + let armed_dpi = Some(hold::resolve_hold_sensor_dpi(None, config.dpi(config_key)).dpi); + let divert_hold_buttons = hold::raw_xy_hold_diverts( + &hold_bindings, + &gesture_bindings, + &oshook, + ability.injection_available, + ); let divert_gesture_buttons = if os_mouse_hook_available { DIVERTABLE_STANDARD_BUTTONS .into_iter() .filter(|(_, button)| side_gesture_bindings.contains_key(button)) + .filter(|(_, button)| !hold_bindings.contains_key(button)) .collect::>() } else { Vec::new() @@ -141,6 +254,12 @@ pub fn plan_for_device( config.app_settings.capture_mouse_events || !button.is_os_hook_button() }) .filter(|(_, button)| !oshook.contains_key(button)) + .filter(|(_, button)| { + // Raw-XY hold owns these CIDs when injection can deliver. When it + // cannot, keep them on the plain-divert list so the button still + // runs its binding as a click instead of its native action. + !hold_bindings.contains_key(button) || !ability.injection_available + }) .filter(|(_, button)| { bindings.get(button).is_some_and(|binding| { if matches!(binding, Binding::LongPress(_)) { @@ -158,17 +277,7 @@ pub fn plan_for_device( }) }) .collect(); - let thumbwheel_bindings_nondefault = [ - ButtonId::Thumbwheel, - ButtonId::ThumbwheelScrollUp, - ButtonId::ThumbwheelScrollDown, - ] - .iter() - .any(|button| { - bindings - .get(button) - .is_some_and(|binding| binding.click_action() != default_binding(*button)) - }); + let thumbwheel_bindings_nondefault = thumbwheel_bindings_customized(&bindings); let thumbwheel_sensitivity = config.thumbwheel_sensitivity(config_key); DeviceCapturePlan { target: CaptureTarget { @@ -183,7 +292,11 @@ pub fn plan_for_device( .map(|(cid, _)| cid) .collect(), divert_gesture_buttons, + divert_hold_buttons, divert_buttons, + sensor_dpi: armed_dpi, + hold_requested: hold_bindings.len(), + injection_available: ability.injection_available, }, rearm_generation, }, @@ -193,6 +306,10 @@ pub fn plan_for_device( gesture_bindings, side_gesture_bindings, thumbwheel_sensitivity, + hold_bindings, + sensor_dpi, + zoom_sensitivity: config.app_settings.zoom_sensitivity, + invert_pan: config.app_settings.invert_pan, }, } } @@ -200,6 +317,7 @@ pub fn plan_for_device( #[cfg(test)] mod tests { use openlogi_core::binding::{Binding, LongPressBinding}; + use openlogi_core::hid::Dpi; use openlogi_hid::reprog_controls::{GESTURE_BUTTON_CID, HAPTIC_PANEL_CID}; use super::*; @@ -583,4 +701,249 @@ mod tests { assert!(plan.dispatch.side_gesture_bindings.is_empty()); } } + + fn plan_with( + config: &Config, + config_key: &str, + ability: CaptureHostAbility, + ) -> DeviceCapturePlan { + super::plan_for_device_with( + config, + PhysicalDeviceKey::parse("receiver:cafe:slot:2") + .expect("fixture should be a physical key"), + config_key, + route(), + None, + 0, + ability, + ) + } + + fn hold_ready(dpi: Dpi) -> CaptureHostAbility { + CaptureHostAbility { + os_mouse_hook_available: true, + injection_available: true, + sensor_dpi: Some(dpi), + } + } + + #[test] + fn hold_mode_button_is_raw_xy_diverted_and_not_plain_or_os_hook() { + let mut cfg = Config::default(); + cfg.set_binding("2b042", ButtonId::Back, Binding::Single(Action::Pan)); + + let plan = plan_with(&cfg, "2b042", hold_ready(Dpi::new(1000))); + assert_eq!( + plan.dispatch.hold_bindings.get(&ButtonId::Back), + Some(&Action::Pan) + ); + assert!( + plan.target + .spec + .divert_hold_buttons + .iter() + .any(|&(_, button)| button == ButtonId::Back), + "Pan must be a raw-XY hold divert: {:?}", + plan.target.spec.divert_hold_buttons + ); + assert!( + !plan + .target + .spec + .divert_buttons + .iter() + .any(|&(_, button)| button == ButtonId::Back), + "a hold-mode button must not also be a plain divert" + ); + assert!( + !plan + .target + .spec + .divert_gesture_buttons + .iter() + .any(|&(_, button)| button == ButtonId::Back), + "a hold-mode button must not stay on the swipe-gesture divert list" + ); + } + + #[test] + fn hold_mode_is_not_armed_without_injection() { + let mut cfg = Config::default(); + cfg.set_binding("2b042", ButtonId::Back, Binding::Single(Action::Zoom)); + + let plan = plan_with( + &cfg, + "2b042", + CaptureHostAbility { + os_mouse_hook_available: true, + injection_available: false, + sensor_dpi: Some(Dpi::new(1000)), + }, + ); + assert!( + plan.dispatch.hold_bindings.contains_key(&ButtonId::Back), + "dispatch still names the hold so the hook map can strip it" + ); + assert!( + plan.target.spec.divert_hold_buttons.is_empty(), + "arming without injection would freeze the cursor for a dropped gesture" + ); + } + + #[test] + fn hold_mode_arms_on_a_fallback_dpi_when_the_sensor_read_is_missing() { + let mut cfg = Config::default(); + cfg.set_binding("2b042", ButtonId::Back, Binding::Single(Action::Pan)); + + let plan = plan_with( + &cfg, + "2b042", + CaptureHostAbility { + os_mouse_hook_available: true, + injection_available: true, + sensor_dpi: None, + }, + ); + assert_eq!( + plan.target.spec.sensor_dpi, + Some(hold::FALLBACK_HOLD_SENSOR_DPI) + ); + assert!( + plan.target + .spec + .divert_hold_buttons + .iter() + .any(|&(_, button)| button == ButtonId::Back), + "a missing DPI must not disable the feature" + ); + } + + #[test] + fn hold_mode_scale_dpi_is_the_live_sensor_reading_not_a_constant() { + let mut cfg = Config::default(); + cfg.set_binding("2b042", ButtonId::Back, Binding::Single(Action::Pan)); + cfg.set_dpi("2b042", Dpi::new(400)); + + let low = plan_with(&cfg, "2b042", hold_ready(Dpi::new(400))); + let high = plan_with(&cfg, "2b042", hold_ready(Dpi::new(1600))); + assert_eq!(low.dispatch.sensor_dpi, Some(Dpi::new(400))); + assert_eq!(high.dispatch.sensor_dpi, Some(Dpi::new(1600))); + assert_ne!( + low.dispatch.sensor_dpi, high.dispatch.sensor_dpi, + "if this were the DPI-blind path both plans would carry the same scale" + ); + } + + #[test] + fn a_live_dpi_reading_never_changes_the_armed_capture_target() { + // `CaptureSpec` is part of `CaptureTarget`'s identity. Folding the + // live reading in retired and re-armed the session the moment the + // first `getSensorDpi` landed, which tore down a hold the user had + // already started and, on a re-armed accumulator, turned the eventual + // release into a fresh click. + let mut cfg = Config::default(); + cfg.set_binding("2b042", ButtonId::Back, Binding::Single(Action::Pan)); + + let unread = plan_with( + &cfg, + "2b042", + CaptureHostAbility { + os_mouse_hook_available: true, + injection_available: true, + sensor_dpi: None, + }, + ); + let read = plan_with(&cfg, "2b042", hold_ready(Dpi::new(950))); + + assert_eq!( + unread.target, read.target, + "a completed sensor read must not cycle the firmware diverts" + ); + assert_ne!( + unread.dispatch.sensor_dpi, read.dispatch.sensor_dpi, + "the reading still has to reach the millimetre conversion" + ); + } + + #[test] + fn gesture_mode_takes_precedence_over_a_hold_mode_single() { + let mut cfg = Config::default(); + cfg.set_gesture_mode("2b042", ButtonId::GestureButton, true); + cfg.set_per_app_binding( + "2b042", + "com.apple.Safari", + ButtonId::GestureButton, + Some(Action::Pan), + ); + + let plan = super::plan_for_device_with( + &cfg, + PhysicalDeviceKey::parse("receiver:cafe:slot:2") + .expect("fixture should be a physical key"), + "2b042", + route(), + Some("com.apple.Safari"), + 0, + hold_ready(Dpi::new(1000)), + ); + assert_eq!( + plan.dispatch.hold_bindings.get(&ButtonId::GestureButton), + Some(&Action::Pan), + "the per-app overlay is a hold-mode Single" + ); + assert!( + plan.dispatch + .gesture_bindings + .contains_key(&ButtonId::GestureButton), + "device-level gesture mode still owns the HID++ source" + ); + assert!( + !plan + .target + .spec + .divert_hold_buttons + .iter() + .any(|&(_, button)| button == ButtonId::GestureButton), + "a gesturing HID++ source must keep the swipe divert, not a hold-mode stream" + ); + } + + #[test] + fn hold_mode_uses_committed_config_dpi_when_the_sensor_is_unread() { + let mut cfg = Config::default(); + cfg.set_binding("2b042", ButtonId::Back, Binding::Single(Action::Pan)); + cfg.set_dpi("2b042", Dpi::new(800)); + + let plan = plan_with( + &cfg, + "2b042", + CaptureHostAbility { + os_mouse_hook_available: true, + injection_available: true, + sensor_dpi: None, + }, + ); + assert_eq!(plan.target.spec.sensor_dpi, Some(Dpi::new(800))); + assert!( + plan.target + .spec + .divert_hold_buttons + .iter() + .any(|&(_, button)| button == ButtonId::Back), + "config DPI must be enough to arm; a missing live reading is not a count-blind fallback" + ); + } + + #[test] + fn plan_for_device_fail_closes_hold_mode_until_the_host_opts_in() { + let mut cfg = Config::default(); + cfg.set_binding("2b042", ButtonId::Back, Binding::Single(Action::Pan)); + cfg.set_dpi("2b042", Dpi::new(1000)); + + let plan = plan_for_device(&cfg, "2b042", route(), None, 0, true); + assert!( + plan.target.spec.divert_hold_buttons.is_empty(), + "the compatibility entry point must not arm hold-mode without injection" + ); + } } diff --git a/crates/openlogi-agent-core/src/capture_plan/hold.rs b/crates/openlogi-agent-core/src/capture_plan/hold.rs new file mode 100644 index 000000000..358c85707 --- /dev/null +++ b/crates/openlogi-agent-core/src/capture_plan/hold.rs @@ -0,0 +1,199 @@ +//! Hold-mode DPI resolution and arming policy for a capture plan. + +use std::collections::BTreeMap; + +use openlogi_core::binding::{Action, ButtonId, GestureDirection}; +use openlogi_core::hid::Dpi; +use openlogi_hid::session::gesture::{DIVERTABLE_STANDARD_BUTTONS, GESTURE_SOURCE_BUTTONS}; +use tracing::warn; + +/// MX-class factory default (1000 CPI). Used only when HID++ `getSensorDpi`, +/// the configured cycle preset, and `config.dpi` are all missing. Hold-mode +/// still arms; the 2.5 mm deadzone is then approximate. +pub(crate) const FALLBACK_HOLD_SENSOR_DPI: Dpi = Dpi::new(1000); + +/// Sensor DPI used to size the hold-mode deadzone, plus whether it is the +/// last-resort factory default rather than a live or configured reading. +pub(super) struct ResolvedHoldDpi { + /// Counts-per-inch applied to the 2.5 mm deadzone. + pub dpi: Dpi, + /// True when both the live sensor and `config.dpi` were missing. + pub used_fallback: bool, +} + +/// Live sensor, then committed config DPI, then [`FALLBACK_HOLD_SENSOR_DPI`]. +/// A missing reading must not disable hold-mode — it only degrades deadzone +/// accuracy. +pub(super) fn resolve_hold_sensor_dpi( + live: Option, + configured: Option, +) -> ResolvedHoldDpi { + if let Some(dpi) = live.or(configured) { + return ResolvedHoldDpi { + dpi, + used_fallback: false, + }; + } + ResolvedHoldDpi { + dpi: FALLBACK_HOLD_SENSOR_DPI, + used_fallback: true, + } +} + +/// Raw-XY hold CIDs when injection can deliver. Empty otherwise — the +/// caller then plain-diverts those buttons so firmware cannot keep scrolling. +pub(super) fn raw_xy_hold_diverts( + hold_bindings: &BTreeMap, + gesture_bindings: &BTreeMap>, + oshook: &BTreeMap>, + injection_available: bool, +) -> Vec<(u16, ButtonId)> { + if !injection_available { + return Vec::new(); + } + DIVERTABLE_STANDARD_BUTTONS + .into_iter() + .chain(GESTURE_SOURCE_BUTTONS) + .filter(|(_, button)| hold_bindings.contains_key(button)) + .filter(|(_, button)| !gesture_bindings.contains_key(button)) + .filter(|(_, button)| !oshook.contains_key(button)) + .collect() +} + +/// Log when hold-mode is arming with [`FALLBACK_HOLD_SENSOR_DPI`]. +pub(super) fn warn_if_hold_dpi_is_approximate( + config_key: &str, + hold_bound: bool, + injection_available: bool, + resolved: &ResolvedHoldDpi, +) { + if resolved.used_fallback && hold_bound && injection_available { + warn!( + config_key, + dpi = %resolved.dpi, + "hold-mode sizing is approximate; sensor DPI unread" + ); + } +} + +#[cfg(test)] +mod tests { + use openlogi_core::binding::{Action, Binding, ButtonId}; + use openlogi_core::config::Config; + use openlogi_core::device_order::PhysicalDeviceKey; + use openlogi_core::hid::Dpi; + use openlogi_hid::DeviceRoute; + + use super::*; + use crate::capture_plan::{CaptureHostAbility, plan_for_device_with}; + + fn route() -> DeviceRoute { + DeviceRoute::Bolt { + receiver_uid: "cafe".into(), + slot: 2, + } + } + + fn plan_with( + config: &Config, + config_key: &str, + ability: CaptureHostAbility, + ) -> crate::capture_plan::DeviceCapturePlan { + plan_for_device_with( + config, + PhysicalDeviceKey::parse("receiver:cafe:slot:2") + .expect("fixture should be a physical key"), + config_key, + route(), + None, + 0, + ability, + ) + } + + #[test] + fn live_reading_wins_over_configured_and_fallback() { + let resolved = resolve_hold_sensor_dpi(Some(Dpi::new(400)), Some(Dpi::new(1600))); + assert_eq!(resolved.dpi, Dpi::new(400)); + assert!(!resolved.used_fallback); + } + + #[test] + fn configured_dpi_wins_over_fallback() { + let resolved = resolve_hold_sensor_dpi(None, Some(Dpi::new(800))); + assert_eq!(resolved.dpi, Dpi::new(800)); + assert!(!resolved.used_fallback); + } + + #[test] + fn missing_every_read_uses_the_named_fallback() { + let resolved = resolve_hold_sensor_dpi(None, None); + assert_eq!(resolved.dpi, FALLBACK_HOLD_SENSOR_DPI); + assert!(resolved.used_fallback); + } + + #[test] + fn hold_mode_arms_with_fallback_dpi_when_every_read_is_missing() { + let mut cfg = Config::default(); + cfg.set_binding("2b042", ButtonId::Back, Binding::Single(Action::Pan)); + + let plan = plan_with( + &cfg, + "2b042", + CaptureHostAbility { + os_mouse_hook_available: true, + injection_available: true, + sensor_dpi: None, + }, + ); + assert_eq!( + plan.target.spec.sensor_dpi, + Some(FALLBACK_HOLD_SENSOR_DPI), + "a missing DPI must degrade the deadzone, not refuse to arm" + ); + assert!( + plan.target + .spec + .divert_hold_buttons + .iter() + .any(|&(_, button)| button == ButtonId::Back), + "hold-mode must arm when injection can deliver, even with no sensor reading" + ); + } + + #[test] + fn unarmed_hold_is_plain_diverted_so_firmware_cannot_scroll() { + let mut cfg = Config::default(); + cfg.set_binding("2b042", ButtonId::Forward, Binding::Single(Action::Pan)); + cfg.set_binding("2b042", ButtonId::Back, Binding::Single(Action::Zoom)); + + let plan = plan_with( + &cfg, + "2b042", + CaptureHostAbility { + os_mouse_hook_available: true, + injection_available: false, + sensor_dpi: Some(Dpi::new(1000)), + }, + ); + assert!( + plan.target.spec.divert_hold_buttons.is_empty(), + "raw-XY must stay off when injection cannot deliver" + ); + assert!( + plan.target + .spec + .divert_buttons + .iter() + .any(|&(_, button)| button == ButtonId::Forward) + && plan + .target + .spec + .divert_buttons + .iter() + .any(|&(_, button)| button == ButtonId::Back), + "an undeliverable hold must be swallowed over HID++, not left native for firmware scroll: {:?}", + plan.target.spec.divert_buttons + ); + } +} diff --git a/crates/openlogi-agent-core/src/orchestrator.rs b/crates/openlogi-agent-core/src/orchestrator.rs index 0c110fc82..ad77b5242 100644 --- a/crates/openlogi-agent-core/src/orchestrator.rs +++ b/crates/openlogi-agent-core/src/orchestrator.rs @@ -16,7 +16,7 @@ use std::sync::{Arc, RwLock}; use openlogi_core::app::ForegroundApp; use openlogi_core::binding::{Action, Binding}; -use openlogi_core::bindings::{button_bindings_for, oshook_gestures_for}; +use openlogi_core::bindings::{button_bindings_for, hold_mode_bindings_for, oshook_gestures_for}; use openlogi_core::config::{Config, LightSettings, ScrollResolution, canonical_device_key}; use openlogi_core::device::{ Capabilities, DeviceInventory, DeviceKind, LightCapabilities, StandaloneDevice, @@ -24,15 +24,17 @@ use openlogi_core::device::{ use openlogi_core::device_order::{DeviceIdentity, DeviceStableId, PhysicalDeviceKey}; use openlogi_hid::{ CaptureChannel, ChannelPool, ChannelRegistry, DIRECT_DEVICE_INDEX, DeviceIoGate, DeviceRoute, - KEYBOARD_KEY_CIDS, + KEYBOARD_KEY_CIDS, cached_sensor_dpi, get_dpi_on, }; +use openlogi_hook::Hook; use openlogi_ipc::InventoryHealth; use tokio::sync::watch; use tracing::{debug, info, warn}; use crate::action_ring::ActionRingSessionSpec; use crate::capture_plan::{ - DeviceCapturePlan, SharedCapturePlans, hidpp_side_gesture_maps_for, plan_for_device, + CaptureHostAbility, DeviceCapturePlan, SharedCapturePlans, hidpp_side_gesture_maps_for, + plan_for_device_with, }; use crate::hardware::DeviceOp; use crate::observable::ObservableState; @@ -171,11 +173,9 @@ pub struct Orchestrator { /// Transient manual power choices for camera-linked lights. A camera-use /// transition clears them; they are never written to the config. manual_light_overrides: BTreeMap, - /// Whether the OS mouse hook is currently installed. Back/Forward gesture - /// motion comes from HID++, but diversion is published only while the - /// broader mouse-remapping path is available so losing the hook leaves the - /// side buttons native. - os_mouse_hook_available: bool, + /// OS-hook and inject facts that decide HID++ diversion and hold-mode + /// arming. Grouped so a grant without a hook still republishes plans. + host_ability: HostAbility, /// Private producer halves for the read-only runtime projections in /// `shared`, keeping the orchestrator's single-writer contract structural. capture_plans_tx: watch::Sender>>, @@ -188,6 +188,19 @@ pub struct Orchestrator { observable: Arc, } +/// Live host facts the orchestrator feeds into [`CaptureHostAbility`]. +struct HostAbility { + /// Whether the OS mouse hook is currently installed. Back/Forward gesture + /// motion comes from HID++, but diversion is published only while the + /// broader mouse-remapping path is available so losing the hook leaves the + /// side buttons native. + os_mouse_hook: bool, + /// Whether synthesised pan/zoom can be delivered. Seeded from + /// [`Hook::has_accessibility`] and refreshed when the hook availability + /// changes so a grant without a hook still republishes capture plans. + injection: bool, +} + /// See [`Orchestrator::inventory`] (the field) — the agent-side superset of /// the wire-level [`InventoryHealth`], carrying the snapshot itself. enum InventoryState { @@ -244,7 +257,10 @@ impl Orchestrator { reapply_followup: HashMap::new(), camera_active: None, manual_light_overrides: BTreeMap::new(), - os_mouse_hook_available: false, + host_ability: HostAbility { + os_mouse_hook: false, + injection: Hook::has_accessibility(), + }, capture_plans_tx, keyboard_spec_tx, host_switch_links_tx, @@ -291,6 +307,12 @@ impl Orchestrator { bindings.remove(button); gestures.remove(button); } + for button in hold_mode_bindings_for(&self.config, Some(key), app).keys() { + // HID++ raw-XY owns the hold. Leaving Pan/Zoom on the OS hook + // starts a second dispatch path with no matching button-up. + bindings.remove(button); + gestures.remove(button); + } } HookMaps { bindings, @@ -372,8 +394,12 @@ impl Orchestrator { /// forget the other — a waking device needs both its capture session and /// its DPI-cycle slot. fn publish_device_runtime(&self) { - self.publish_capture_plans(); + // Rebuild the cycle map first so `live_sensor_dpi` can see this + // tick's presets. Then kick HID++ reads for cache misses; those + // complete asynchronously and land on a later publish. self.rebuild_dpi_cycles(self.current_key()); + self.refresh_missing_sensor_dpi(); + self.publish_capture_plans(); // 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 @@ -442,14 +468,18 @@ impl Orchestrator { let identity = DeviceIdentity::from_parts(dev.serial.as_deref(), dev.unit_id); let physical_key = canonical_device_key(&stable_id(dev), Some(&identity)) .or_else(|| PhysicalDeviceKey::parse(&dev.config_key))?; - Some(plan_for_device( + Some(plan_for_device_with( &self.config, physical_key, &dev.config_key, route, self.current_app.as_deref(), rearm_generation, - self.os_mouse_hook_available, + CaptureHostAbility { + os_mouse_hook_available: self.host_ability.os_mouse_hook, + injection_available: self.host_ability.injection, + sensor_dpi: self.live_sensor_dpi(&dev.config_key), + }, )) }) .collect() @@ -461,13 +491,72 @@ impl Orchestrator { /// if the mouse-remapping hook is unavailable, side buttons remain native. /// Other HID++-only controls remain captured independently. pub fn set_os_mouse_hook_available(&mut self, available: bool) { - if self.os_mouse_hook_available == available { + let injection = Hook::has_accessibility(); + if self.host_ability.os_mouse_hook == available && self.host_ability.injection == injection + { + return; + } + self.host_ability.os_mouse_hook = available; + self.host_ability.injection = injection; + self.publish_capture_plans(); + } + + /// Publish whether synthesised pan/zoom can be delivered. + /// + /// The accessibility watcher already calls [`Self::set_os_mouse_hook_available`] + /// on every grant change; this setter is for tests and for a grant that + /// does not also change hook installation. + pub fn set_injection_available(&mut self, available: bool) { + if self.host_ability.injection == available { return; } - self.os_mouse_hook_available = available; + self.host_ability.injection = available; self.publish_capture_plans(); } + /// Sensor DPI for `config_key`: the cached HID++ `getSensorDpi` reading + /// first, then the selected cycle preset. [`plan_for_device_with`] then + /// falls back to committed `config.dpi` and a named factory default. + fn live_sensor_dpi(&self, config_key: &str) -> Option { + if let Some(route) = self + .devices + .iter() + .find(|dev| dev.config_key == config_key) + .and_then(|dev| dev.route.as_ref()) + && let Some(dpi) = cached_sensor_dpi(route) + { + return Some(dpi); + } + let Ok(guard) = self.shared.dpi_cycle.read() else { + return None; + }; + let state = guard.by_key.get(config_key)?; + state.presets.get(state.index).copied() + } + + /// Read `getSensorDpi` for online devices whose cache is empty. The + /// write path (`set_dpi_on`) already refreshes the cache, so a cycle + /// cannot leave hold-mode sizing on a stale reading. + fn refresh_missing_sensor_dpi(&self) { + for dev in self + .devices + .iter() + .filter(|dev| dev.online && self.config.device_enabled(&dev.config_key)) + { + let Some(route) = dev.route.clone() else { + continue; + }; + if cached_sensor_dpi(&route).is_some() { + continue; + } + self.shared + .device(&route) + .detach("sensor DPI read", |channel| async move { + get_dpi_on(&channel).await.map(|_| ()) + }); + } + } + /// Apply a fresh inventory snapshot. Always refreshes the snapshot the IPC /// `inventory()` poll serves (battery / online state changes without /// altering the device *set*), but only re-picks the selection and rebuilds diff --git a/crates/openlogi-agent-core/src/orchestrator/tests.rs b/crates/openlogi-agent-core/src/orchestrator/tests.rs index 448614595..3a418b7e8 100644 --- a/crates/openlogi-agent-core/src/orchestrator/tests.rs +++ b/crates/openlogi-agent-core/src/orchestrator/tests.rs @@ -1036,3 +1036,114 @@ fn equal_runtime_projection_does_not_wake_managers() { .expect("publication remains open") ); } + +#[test] +fn hook_maps_omit_hold_mode_buttons() { + let mut config = Config::default(); + config.set_binding("a", ButtonId::Back, Binding::Single(Action::Pan)); + let mut orch = orchestrator(config); + orch.devices = vec![dev("a", 1, true)]; + orch.rebuild(); + + let maps = orch.hook_maps_for(Some("a"), None); + assert!( + !maps.bindings.contains_key(&ButtonId::Back), + "a hold-mode button must not stay on the OS-hook binding map" + ); + assert!( + !maps.gestures.contains_key(&ButtonId::Back), + "a hold-mode button must not stay on the OS-hook gesture map" + ); + assert!( + maps.bindings.contains_key(&ButtonId::MiddleClick), + "stripping hold-mode must not empty the rest of the hook map" + ); +} + +#[test] +fn injection_is_the_only_gate_on_arming_hold_mode() { + let mut config = Config::default(); + config.set_binding("a", ButtonId::Back, Binding::Single(Action::Pan)); + let mut orch = orchestrator(config); + orch.devices = vec![dev("a", 1, true)]; + orch.set_injection_available(true); + orch.rebuild(); + + let hold_armed = |orch: &Orchestrator| { + orch.shared + .capture_plans + .borrow() + .first() + .is_some_and(|plan| { + plan.target + .spec + .divert_hold_buttons + .iter() + .any(|&(_, button)| button == ButtonId::Back) + }) + }; + let sensor_dpi = |orch: &Orchestrator| { + orch.shared + .capture_plans + .borrow() + .first() + .and_then(|plan| plan.target.spec.sensor_dpi) + }; + assert!( + hold_armed(&orch), + "a missing DPI must not disable hold-mode when injection can deliver" + ); + assert_eq!( + sensor_dpi(&orch), + Some(crate::capture_plan::FALLBACK_HOLD_SENSOR_DPI) + ); + + orch.config.set_dpi("a", Dpi::new(1000)); + orch.rebuild(); + assert!( + hold_armed(&orch), + "injection plus a committed DPI must keep the raw-XY hold armed" + ); + assert_eq!(sensor_dpi(&orch), Some(Dpi::new(1000))); + + orch.set_injection_available(false); + assert!( + !hold_armed(&orch), + "revoking injection must disarm hold-mode so the cursor is not frozen" + ); +} + +#[test] +fn live_sensor_cache_wins_over_committed_config_dpi() { + let mut config = Config::default(); + config.set_binding("a", ButtonId::Back, Binding::Single(Action::Pan)); + config.set_dpi("a", Dpi::new(400)); + let mut orch = orchestrator(config); + orch.devices = vec![dev("a", 6, true)]; + let route = orch.devices[0] + .route + .clone() + .expect("fixture device has a route"); + openlogi_hid::remember_sensor_dpi(&route, Dpi::new(1600)); + orch.set_injection_available(true); + orch.rebuild(); + + let plan = orch + .shared + .capture_plans + .borrow() + .first() + .cloned() + .expect("online device publishes a plan"); + assert_eq!( + plan.dispatch.sensor_dpi, + Some(Dpi::new(1600)), + "the cached reading has to reach the millimetre conversion" + ); + assert_eq!( + plan.target.spec.sensor_dpi, + Some(Dpi::new(400)), + "the armed spec stays on the committed DPI, so a cache fill cannot \ + retire the capture session" + ); +} diff --git a/crates/openlogi-agent-core/src/runtime.rs b/crates/openlogi-agent-core/src/runtime.rs index 8baf052ec..d24487be9 100644 --- a/crates/openlogi-agent-core/src/runtime.rs +++ b/crates/openlogi-agent-core/src/runtime.rs @@ -250,7 +250,11 @@ impl ActionRuntime { } /// Reject new button input, emit terminal cancellation, and join the worker. + /// + /// Also flushes hold-mode inject sessions. The agent calls `process::exit` + /// after this, which skips `Drop`; this is the guaranteed terminal emit. pub fn shutdown(&mut self) { + openlogi_inject::flush_gesture_sessions(); let _ = self.buttons.shutdown(); } } diff --git a/crates/openlogi-agent-core/src/watchers/gesture.rs b/crates/openlogi-agent-core/src/watchers/gesture.rs index 7ed8a0349..1d6d7635f 100644 --- a/crates/openlogi-agent-core/src/watchers/gesture.rs +++ b/crates/openlogi-agent-core/src/watchers/gesture.rs @@ -223,10 +223,16 @@ fn reconcile_session( wanted: Option<(&CaptureTarget, &DispatchPlan)>, dispatcher: &mut InputDispatcher, ) { - if session.reconcile(wanted) == ReconcileAction::DispatchChanged { - dispatcher.cancel_session(session.id()); - let config_key = session.dispatch().config_key.clone(); - session.rekey(&config_key); + match session.reconcile(wanted) { + ReconcileAction::DispatchChanged => { + dispatcher.cancel_session(session.id()); + let config_key = session.dispatch().config_key.clone(); + session.rekey(&config_key); + } + ReconcileAction::Retiring => { + dispatcher.lock_holds(session.id()); + } + ReconcileAction::None => {} } } @@ -452,7 +458,7 @@ impl GestureManagerState { if let Some((session, plan)) = dispatch_context { self.input_dispatcher.dispatch(session, plan, event.input); } else { - self.input_dispatcher.cancel_session(&event.session); + self.input_dispatcher.retire_session(&event.session); debug!( key = key.as_str(), epoch = event.session.epoch(), @@ -482,7 +488,7 @@ impl GestureManagerState { }, ); } - self.input_dispatcher.cancel_session(&dispatch_session); + self.input_dispatcher.retire_session(&dispatch_session); if device_io_allowed && let Some(deadline) = restart_deadline(unexpected, Instant::now()) { @@ -564,24 +570,34 @@ async fn manage( &capture_plans, ); } - result = capture_plans.changed() => match result { - Ok(()) => reconcile = true, - Err(_) => return, - }, - result = receiver_requests.changed() => match result { - Ok(()) => reconcile = true, - Err(_) => return, - }, + result = capture_plans.changed() => { + if result.is_err() { + state.input_dispatcher.shutdown_holds(); + return; + } + reconcile = true; + } + result = receiver_requests.changed() => { + if result.is_err() { + state.input_dispatcher.shutdown_holds(); + return; + } + reconcile = true; + } allowed = device_io.changed() => match allowed { Some(true) => reconcile = true, Some(false) => {} - None => return, + None => { + state.input_dispatcher.shutdown_holds(); + return; + } }, open = wait_for_registry_change( &mut registry_changes, !state.pending_restores.is_empty(), ) => { if !open { + state.input_dispatcher.shutdown_holds(); return; } if device_io.allows_io() { diff --git a/crates/openlogi-agent-core/src/watchers/gesture/dispatch.rs b/crates/openlogi-agent-core/src/watchers/gesture/dispatch.rs index 899a1612d..d87e51861 100644 --- a/crates/openlogi-agent-core/src/watchers/gesture/dispatch.rs +++ b/crates/openlogi-agent-core/src/watchers/gesture/dispatch.rs @@ -1,5 +1,6 @@ //! Resolve captured HID++ inputs against the active per-device plan. +mod hold; mod wheel; use std::collections::HashMap; @@ -8,8 +9,9 @@ use std::time::Instant; use openlogi_core::binding::{Action, Binding, ButtonId, default_binding}; use openlogi_core::config::ThumbwheelSensitivity; use openlogi_hid::CapturedInput; -use tracing::debug; +use tracing::{debug, warn}; +use self::hold::HoldSessions; use self::wheel::{ScrollScale, WheelAccumulators, WheelOutput, WheelRotation}; use super::GestureOutputs; use crate::capture_plan::DispatchPlan; @@ -99,6 +101,7 @@ pub(super) struct InputDispatcher { outputs: GestureOutputs, wheels: SessionWheels, gesture_presses: GesturePresses, + holds: HoldSessions, } impl InputDispatcher { @@ -109,6 +112,7 @@ impl InputDispatcher { outputs, wheels: SessionWheels::default(), gesture_presses: GesturePresses::default(), + holds: HoldSessions::default(), } } @@ -128,12 +132,45 @@ impl InputDispatcher { } /// Cancel every input lifecycle retained for one capture session. + /// + /// A still-live epoch (dispatch-plan refresh) ends the hold but stays + /// writable so a later press can begin again. Call [`Self::retire_session`] + /// when the epoch is dead. pub(super) fn cancel_session(&mut self, session: &HidppSessionId) { + if let Some(command) = self.holds.end_open(session) { + hold::emit(command); + } self.outputs.cancel_session(session); self.wheels.cancel_session(session); self.gesture_presses.cancel_session(session); } + /// End a live hold and lock the epoch. Retirement still owns drain + /// inputs, so a late HoldBegin must not open a pinch nothing closes. + pub(super) fn lock_holds(&mut self, session: &HidppSessionId) { + if let Some(command) = self.holds.close_session(session) { + hold::emit(command); + } + } + + /// Epoch is gone. Late HoldBegin/HoldMotion must not reopen inject. + pub(super) fn retire_session(&mut self, session: &HidppSessionId) { + if let Some(command) = self.holds.close_session(session) { + hold::emit(command); + } + self.outputs.cancel_session(session); + self.wheels.cancel_session(session); + self.gesture_presses.cancel_session(session); + } + + /// Watcher or process teardown: end every hold and flush inject. + pub(super) fn shutdown_holds(&mut self) { + for command in self.holds.close_all() { + hold::emit(command); + } + hold::flush_inject(); + } + /// Route one captured input from `session` to its bound action or /// re-synthesised scroll output. pub(super) fn dispatch( @@ -171,43 +208,14 @@ impl InputDispatcher { } } CapturedInput::ButtonDown(button) => { - // A raw-XY gesture source owns its click/swipe map; its physical - // lifecycle is still tracked, but it must not also fire the - // single-action projection on down. - let is_gesture = plan.gesture_bindings.contains_key(&button) - || plan.side_gesture_bindings.contains_key(&button); - let binding = (!is_gesture).then(|| plan.bindings.get(&button)).flatten(); - if let Some(binding) = binding { - debug!(key, ?button, action = %binding.click_action().label(), "HID++ button → binding"); - } else { - debug!(key, ?button, "HID++ button with no binding — ignored"); - } - let press = self - .outputs - .actions - .try_hidpp_button_down(session, button, binding); - if is_gesture { - if let Some(press) = press { - self.gesture_presses.start(session, button, press); - } else { - self.gesture_presses.end(session, button); - } - } + self.dispatch_button_down(session, plan, button); } CapturedInput::ButtonUp(button) => { self.outputs.actions.try_hidpp_button_up(session, button); self.gesture_presses.end(session, button); } CapturedInput::ButtonPulse(button) => { - let binding = plan.bindings.get(&button); - if let Some(binding) = binding { - debug!(key, ?button, action = %binding.click_action().label(), "HID++ button pulse → binding"); - } else { - debug!(key, ?button, "HID++ button pulse with no binding — ignored"); - } - self.outputs - .actions - .dispatch_hidpp_button_pulse(session, button, binding); + self.dispatch_button_pulse(session, plan, button); } CapturedInput::Scroll { increments, @@ -237,9 +245,121 @@ impl InputDispatcher { CapturedInput::ThumbwheelDirection { .. } => { unreachable!("thumb-wheel direction reports return before dispatch") } + CapturedInput::HoldBegin(_) + | CapturedInput::HoldMotion { .. } + | CapturedInput::HoldEnd { .. } => self.dispatch_hold(session, plan, input), + } + } + + fn dispatch_button_down( + &mut self, + session: &HidppSessionId, + plan: &DispatchPlan, + button: ButtonId, + ) { + let key = session.device_key(); + if warn_suppressed_hold_click(key, plan, button) { + return; + } + // A raw-XY gesture source owns its click/swipe map; its physical + // lifecycle is still tracked, but it must not also fire the + // single-action projection on down. + let is_gesture = plan.gesture_bindings.contains_key(&button) + || plan.side_gesture_bindings.contains_key(&button); + let binding = hidpp_click_binding(plan, button); + if let Some(binding) = binding { + debug!(key, ?button, action = %binding.click_action().label(), "HID++ button → binding"); + } else { + debug!(key, ?button, "HID++ button with no binding — ignored"); + } + let press = self + .outputs + .actions + .try_hidpp_button_down(session, button, binding); + if is_gesture { + if let Some(press) = press { + self.gesture_presses.start(session, button, press); + } else { + self.gesture_presses.end(session, button); + } + } + } + + fn dispatch_button_pulse( + &mut self, + session: &HidppSessionId, + plan: &DispatchPlan, + button: ButtonId, + ) { + let key = session.device_key(); + if warn_suppressed_hold_click(key, plan, button) { + return; + } + let binding = hidpp_click_binding(plan, button); + if let Some(binding) = binding { + debug!(key, ?button, action = %binding.click_action().label(), "HID++ button pulse → binding"); + } else { + debug!(key, ?button, "HID++ button pulse with no binding — ignored"); + } + self.outputs + .actions + .dispatch_hidpp_button_pulse(session, button, binding); + } + + fn dispatch_hold( + &mut self, + session: &HidppSessionId, + plan: &DispatchPlan, + input: CapturedInput, + ) { + let command = match input { + CapturedInput::HoldBegin(button) => self.holds.begin(session, button, plan), + CapturedInput::HoldMotion { button, dx, dy } => { + self.holds.motion(session, button, dx, dy) + } + CapturedInput::HoldEnd { button, release } => self.holds.end(session, button, release), + _ => unreachable!("dispatch_hold is only called for Hold*"), + }; + if let Some(command) = command { + hold::emit(command); } } } +/// A hold-mode binding on the click path is a failed delivery, not a +/// one-shot scroll. When hold is unarmed the plan plain-diverts the CID +/// so firmware cannot keep scrolling; this rejects the resulting press. +fn hidpp_hold_click_suppressed(plan: &DispatchPlan, button: ButtonId) -> Option<&Action> { + plan.hold_bindings.get(&button) +} + +/// Log and refuse a hold-mode binding that arrived on the click path. +fn warn_suppressed_hold_click(key: &str, plan: &DispatchPlan, button: ButtonId) -> bool { + let Some(action) = hidpp_hold_click_suppressed(plan, button) else { + return false; + }; + warn!( + key, + ?button, + action = %action.label(), + "hold-mode action cannot be delivered as a click — ignored" + ); + true +} + +/// Click binding that would be handed to the action runtime. Hold-mode +/// buttons are never a click, even when they still appear in `bindings`. +fn hidpp_click_binding(plan: &DispatchPlan, button: ButtonId) -> Option<&Binding> { + if hidpp_hold_click_suppressed(plan, button).is_some() { + return None; + } + let is_gesture = plan.gesture_bindings.contains_key(&button) + || plan.side_gesture_bindings.contains_key(&button); + if is_gesture { + return None; + } + plan.bindings.get(&button) +} + #[cfg(test)] mod tests; diff --git a/crates/openlogi-agent-core/src/watchers/gesture/dispatch/hold.rs b/crates/openlogi-agent-core/src/watchers/gesture/dispatch/hold.rs new file mode 100644 index 000000000..62dc181b2 --- /dev/null +++ b/crates/openlogi-agent-core/src/watchers/gesture/dispatch/hold.rs @@ -0,0 +1,301 @@ +//! Sans-I/O hold-mode driver: one live pan or zoom per capture session. +//! +//! Raw-XY arrives in sensor counts. Inject wants screen pixels (pan) or a +//! magnification increment (zoom). Both conversions are DPI-normalised so +//! felt speed does not change when the user cycles the sensor. + +use std::collections::HashMap; + +use openlogi_core::binding::{Action, ButtonId}; +use openlogi_core::config::ZoomSensitivity; +use openlogi_core::hid::Dpi; +use openlogi_hid::HoldRelease; + +use crate::capture_plan::DispatchPlan; +use crate::runtime::HidppSessionId; + +/// Millimetres in one inch. DPI is counts per inch, so +/// `counts * `[`MM_PER_INCH`]` / dpi` is physical travel. +const MM_PER_INCH: f32 = 25.4; + +/// Screen pixels of pan produced by one millimetre of mouse travel. +/// +/// A 1080-pixel screen therefore takes `1080 / 22 ≈ 49 mm` of hand travel +/// (about two inches) at any sensor DPI. That is a short desktop swipe, not +/// a flick across the whole mousepad. +const PAN_PIXELS_PER_MM: f32 = 22.0; + +/// Magnification increment per millimetre of vertical travel, at +/// [`ZoomSensitivity::DEFAULT`]. +/// +/// Twenty millimetres of travel accumulates `1.0`, which is a doubling of +/// the view. Dragging up (negative raw-XY `dy`) zooms in. +const ZOOM_MAGNIFICATION_PER_MM: f32 = 0.05; + +/// Inject commands produced by one hold-mode transition. The dispatcher +/// applies these; tests assert the commands so they never post real events. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(super) enum HoldCommand { + PanBegin, + Pan { + dx: f32, + dy: f32, + }, + PanEnd, + Zoom { + amount: f32, + }, + ZoomEnd, + /// A Zoom-bound button clicked without dragging: fire the discrete + /// native smart zoom instead of closing an empty pinch. + SmartZoom, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum HoldKind { + Pan, + Zoom, +} + +impl HoldKind { + fn from_action(action: &Action) -> Option { + match action { + Action::Pan => Some(Self::Pan), + Action::Zoom => Some(Self::Zoom), + _ => None, + } + } +} + +/// Hold-mode feel settings, copied out of the dispatch plan when a hold +/// opens. Pan inversion and zoom scale must stay fixed for the life of one +/// gesture even if settings are adopted while the button is down. +#[derive(Clone, Copy, Debug, PartialEq)] +struct HoldFeel { + zoom_sensitivity: ZoomSensitivity, + invert_pan: bool, +} + +/// Per-session slot. [`Self::Closed`] is terminal for that capture epoch. +#[derive(Clone, Copy, Debug, PartialEq)] +enum SessionHold { + /// No hold yet this epoch; [`HoldSessions::begin`] is allowed. + Idle, + Open { + button: ButtonId, + kind: HoldKind, + dpi: Dpi, + /// Feel settings snapshotted at button-down, so a config refresh + /// mid-gesture cannot change the scale under the user's hand. + feel: HoldFeel, + }, + /// Capture epoch was cancelled. Every later event is ignored. + Closed, +} + +/// Hold-mode state keyed by capture-session incarnation. +#[derive(Default)] +pub(super) struct HoldSessions { + by_session: HashMap, +} + +impl HoldSessions { + /// Open a hold if this epoch is still writable and `button` is bound. + pub(super) fn begin( + &mut self, + session: &HidppSessionId, + button: ButtonId, + plan: &DispatchPlan, + ) -> Option { + if matches!( + self.slot(session), + SessionHold::Closed | SessionHold::Open { .. } + ) { + return None; + } + let kind = plan + .hold_bindings + .get(&button) + .and_then(HoldKind::from_action)?; + let dpi = plan.sensor_dpi.filter(|dpi| u16::from(*dpi) > 0)?; + self.by_session.insert( + session.clone(), + SessionHold::Open { + button, + kind, + dpi, + feel: HoldFeel { + zoom_sensitivity: plan.zoom_sensitivity, + invert_pan: plan.invert_pan, + }, + }, + ); + match kind { + HoldKind::Pan => Some(HoldCommand::PanBegin), + // Zoom opens on the first motion (`post_zoom_continuous` reopens + // a pinch; begin must not emit a zero increment). + HoldKind::Zoom => None, + } + } + + /// Stream one raw-XY report. Ignored unless this exact button is live. + pub(super) fn motion( + &mut self, + session: &HidppSessionId, + button: ButtonId, + dx: i16, + dy: i16, + ) -> Option { + let Some(SessionHold::Open { + button: held, + kind, + dpi, + feel, + }) = self.by_session.get_mut(session) + else { + return None; + }; + if *held != button { + return None; + } + let (kind, dpi, feel) = (*kind, *dpi, *feel); + match kind { + HoldKind::Pan => { + let (px, py) = pan_pixels(dx, dy, dpi); + let (px, py) = if feel.invert_pan { + (-px, -py) + } else { + (px, py) + }; + (px != 0.0 || py != 0.0).then_some(HoldCommand::Pan { dx: px, dy: py }) + } + HoldKind::Zoom => { + let amount = zoom_magnification(dy, dpi, feel.zoom_sensitivity); + (amount != 0.0).then_some(HoldCommand::Zoom { amount }) + } + } + } + + /// End the live hold. A late end after teardown or a completed hold + /// does not emit. + /// + /// A Zoom hold the user released without clearing the physical click/drag + /// deadzone is a click, and clicks fire the discrete smart zoom: the two + /// gestures compose on one button, which is why smart zoom cannot fire on + /// button-down. It has to wait and see whether a drag follows. + /// + /// [`HoldRelease::Interrupted`] is not a click, no matter how still the + /// hold was. Capture interrupts a stream on reconnect, on teardown, and + /// on the stale bound, all of them with the control still under the + /// user's finger — a smart zoom there would fire into whatever happens to + /// be frontmost, unasked. + pub(super) fn end( + &mut self, + session: &HidppSessionId, + button: ButtonId, + release: HoldRelease, + ) -> Option { + let SessionHold::Open { + button: held, kind, .. + } = self.slot(session) + else { + return None; + }; + if held != button { + return None; + } + self.by_session.insert(session.clone(), SessionHold::Idle); + Some(match (kind, release) { + (HoldKind::Zoom, HoldRelease::Released { traveled: false }) => HoldCommand::SmartZoom, + (kind, _) => end_command(kind), + }) + } + + /// End a live hold but leave the epoch writable. A profile switch or + /// dispatch-plan refresh must close inject without treating the next + /// press as a late event on a dead session. + pub(super) fn end_open(&mut self, session: &HidppSessionId) -> Option { + let SessionHold::Open { kind, .. } = self.slot(session) else { + return None; + }; + self.by_session.insert(session.clone(), SessionHold::Idle); + Some(end_command(kind)) + } + + /// Tear down any open hold and lock the epoch. Used for session Done, + /// retirement, and stale input — a late begin must not start a hold + /// nothing will close. + pub(super) fn close_session(&mut self, session: &HidppSessionId) -> Option { + let command = match self.slot(session) { + SessionHold::Open { kind, .. } => Some(end_command(kind)), + SessionHold::Idle | SessionHold::Closed => None, + }; + self.by_session.insert(session.clone(), SessionHold::Closed); + command + } + + /// End every open hold and lock every known epoch (watcher / process exit). + pub(super) fn close_all(&mut self) -> Vec { + let sessions: Vec<_> = self.by_session.keys().cloned().collect(); + sessions + .iter() + .filter_map(|session| self.close_session(session)) + .collect() + } + + fn slot(&self, session: &HidppSessionId) -> SessionHold { + self.by_session + .get(session) + .copied() + .unwrap_or(SessionHold::Idle) + } +} + +fn end_command(kind: HoldKind) -> HoldCommand { + match kind { + HoldKind::Pan => HoldCommand::PanEnd, + HoldKind::Zoom => HoldCommand::ZoomEnd, + } +} + +/// Apply one command to the process-global inject sessions. +pub(super) fn emit(command: HoldCommand) { + match command { + HoldCommand::PanBegin => openlogi_inject::post_pan_begin(), + HoldCommand::Pan { dx, dy } => openlogi_inject::post_pan(dx, dy), + HoldCommand::PanEnd => openlogi_inject::post_pan_end(), + HoldCommand::Zoom { amount } => openlogi_inject::post_zoom_continuous(amount), + HoldCommand::ZoomEnd => openlogi_inject::post_zoom_end(), + HoldCommand::SmartZoom => openlogi_inject::post_smart_zoom(), + } +} + +/// Close every inject session. Safe when none are open; required on +/// `process::exit` because that skips [`Drop`]. +pub(super) fn flush_inject() { + openlogi_inject::flush_gesture_sessions(); +} + +fn millimetres(counts: i16, dpi: Dpi) -> f32 { + let dpi = f32::from(dpi); + if dpi == 0.0 { + return 0.0; + } + f32::from(counts) * MM_PER_INCH / dpi +} + +fn pan_pixels(dx: i16, dy: i16, dpi: Dpi) -> (f32, f32) { + ( + millimetres(dx, dpi) * PAN_PIXELS_PER_MM, + millimetres(dy, dpi) * PAN_PIXELS_PER_MM, + ) +} + +/// Positive amount zooms in. Raw-XY `+y` is down, so a negative `dy` (drag +/// toward the user) is a zoom-in. +fn zoom_magnification(dy: i16, dpi: Dpi, sensitivity: ZoomSensitivity) -> f32 { + -millimetres(dy, dpi) * ZOOM_MAGNIFICATION_PER_MM * sensitivity.zoom_multiplier() +} + +#[cfg(test)] +mod tests; diff --git a/crates/openlogi-agent-core/src/watchers/gesture/dispatch/hold/tests.rs b/crates/openlogi-agent-core/src/watchers/gesture/dispatch/hold/tests.rs new file mode 100644 index 000000000..c50352e79 --- /dev/null +++ b/crates/openlogi-agent-core/src/watchers/gesture/dispatch/hold/tests.rs @@ -0,0 +1,448 @@ +use std::collections::BTreeMap; + +use openlogi_core::binding::{Action, Binding, ButtonId}; +use openlogi_core::config::{ThumbwheelSensitivity, ZoomSensitivity}; +use openlogi_core::hid::Dpi; + +use openlogi_hid::HoldRelease; + +use super::{HoldCommand, HoldSessions, MM_PER_INCH, millimetres, pan_pixels, zoom_magnification}; +use crate::capture_plan::DispatchPlan; +use crate::runtime::HidppSessionId; + +/// The user let go after clearing the click/drag deadzone. +const DRAG: HoldRelease = HoldRelease::Released { traveled: true }; +/// The user let go without clearing it. +const CLICK: HoldRelease = HoldRelease::Released { traveled: false }; + +fn session(epoch: u64) -> HidppSessionId { + HidppSessionId::with_epoch("mouse-a", epoch) +} + +fn plan(button: ButtonId, action: Action, dpi: u16) -> DispatchPlan { + DispatchPlan { + config_key: "mouse-a".into(), + bindings: BTreeMap::from([(button, Binding::Single(action.clone()))]), + gesture_bindings: BTreeMap::new(), + side_gesture_bindings: BTreeMap::new(), + thumbwheel_sensitivity: ThumbwheelSensitivity::DEFAULT, + hold_bindings: BTreeMap::from([(button, action)]), + sensor_dpi: Some(Dpi::new(dpi)), + zoom_sensitivity: ZoomSensitivity::DEFAULT, + invert_pan: false, + } +} + +fn pan_plan(dpi: u16) -> DispatchPlan { + plan(ButtonId::Back, Action::Pan, dpi) +} + +fn zoom_plan(dpi: u16) -> DispatchPlan { + plan(ButtonId::Forward, Action::Zoom, dpi) +} + +/// Counts for `mm` millimetres of travel at `dpi`. This is the DPI +/// definition (`counts / dpi` inches × 25.4), not a copy of the pan/zoom +/// scale constants. +fn counts_for_mm(mm: f32, dpi: u16) -> i16 { + let rounded = (mm * f32::from(dpi) / MM_PER_INCH).round(); + assert!( + (f32::from(i16::MIN)..=f32::from(i16::MAX)).contains(&rounded), + "test travel stays inside i16" + ); + #[expect( + clippy::cast_possible_truncation, + reason = "rounded value is range-checked against i16 above" + )] + { + rounded as i16 + } +} + +#[test] +fn one_inch_of_travel_is_dpi_independent() { + // 25.4 mm is one inch. At 1000 DPI that is 1000 counts by definition. + assert_eq!(counts_for_mm(25.4, 1000), 1000); + let inch_at_1000 = millimetres(1000, Dpi::new(1000)); + let inch_at_1600 = millimetres(counts_for_mm(25.4, 1600), Dpi::new(1600)); + assert!( + (inch_at_1000 - 25.4).abs() < 0.05, + "1000 counts at 1000 DPI must be one inch, got {inch_at_1000} mm" + ); + assert!( + (inch_at_1600 - 25.4).abs() < 0.05, + "the same inch at 1600 DPI must not scale with the extra counts" + ); +} + +#[test] +fn one_1080p_screen_of_pan_is_about_two_inches() { + // 1080 px / 22 px per mm ≈ 49.1 mm. At 1000 DPI that is not 1080 counts + // (a counts-as-pixels mapping) and not 12 counts (the old deadzone). + let (dx, dy) = pan_pixels(counts_for_mm(49.1, 1000), 0, Dpi::new(1000)); + assert!( + (dx - 1080.0).abs() < 15.0, + "49.1 mm of travel should pan one 1080p screen, got {dx} px" + ); + assert!( + dy.abs() < f32::EPSILON, + "horizontal travel must not invent a vertical pan, got {dy}" + ); + + let (low, _) = pan_pixels(counts_for_mm(49.1, 800), 0, Dpi::new(800)); + let (high, _) = pan_pixels(counts_for_mm(49.1, 1600), 0, Dpi::new(1600)); + assert!( + (low - high).abs() < 15.0, + "the same millimetres must pan the same pixels at 800 and 1600 DPI" + ); +} + +#[test] +fn twenty_millimetres_of_upward_drag_doubles_zoom() { + // Dragging up is negative raw-XY. 20 mm × 0.05 / mm = 1.0. + let default = ZoomSensitivity::DEFAULT; + let at_1000 = zoom_magnification(counts_for_mm(-20.0, 1000), Dpi::new(1000), default); + let at_2000 = zoom_magnification(counts_for_mm(-20.0, 2000), Dpi::new(2000), default); + assert!( + (at_1000 - 1.0).abs() < 0.03, + "20 mm up at 1000 DPI should double the view, got {at_1000}" + ); + assert!( + (at_2000 - 1.0).abs() < 0.03, + "the same 20 mm at 2000 DPI must not double twice" + ); +} + +#[test] +fn pan_begin_stream_end() { + let mut holds = HoldSessions::default(); + let session = session(7); + let plan = pan_plan(1000); + + assert_eq!( + holds.begin(&session, ButtonId::Back, &plan), + Some(HoldCommand::PanBegin) + ); + let inch = counts_for_mm(25.4, 1000); + let (dx, dy) = pan_pixels(inch, 0, Dpi::new(1000)); + assert_eq!( + holds.motion(&session, ButtonId::Back, inch, 0), + Some(HoldCommand::Pan { dx, dy }) + ); + assert_eq!( + holds.end(&session, ButtonId::Back, DRAG), + Some(HoldCommand::PanEnd) + ); +} + +#[test] +fn end_without_motion_still_closes_pan() { + let mut holds = HoldSessions::default(); + let session = session(7); + assert_eq!( + holds.begin(&session, ButtonId::Back, &pan_plan(1000)), + Some(HoldCommand::PanBegin) + ); + assert_eq!( + holds.end(&session, ButtonId::Back, CLICK), + Some(HoldCommand::PanEnd) + ); +} + +#[test] +fn zoom_opens_on_first_motion_and_ends_on_button_up() { + let mut holds = HoldSessions::default(); + let session = session(7); + let plan = zoom_plan(1000); + + assert_eq!(holds.begin(&session, ButtonId::Forward, &plan), None); + let amount = zoom_magnification(-200, Dpi::new(1000), ZoomSensitivity::DEFAULT); + assert_eq!( + holds.motion(&session, ButtonId::Forward, 0, -200), + Some(HoldCommand::Zoom { amount }) + ); + assert_eq!( + holds.end(&session, ButtonId::Forward, DRAG), + Some(HoldCommand::ZoomEnd) + ); +} + +#[test] +fn teardown_then_late_motion_does_not_reopen() { + let mut holds = HoldSessions::default(); + let session = session(7); + assert_eq!( + holds.begin(&session, ButtonId::Forward, &zoom_plan(1000)), + None + ); + assert_eq!(holds.close_session(&session), Some(HoldCommand::ZoomEnd)); + assert_eq!( + holds.motion(&session, ButtonId::Forward, 0, -400), + None, + "late motion must not call post_zoom_continuous after teardown" + ); + assert_eq!( + holds.begin(&session, ButtonId::Forward, &zoom_plan(1000)), + None, + "a closed epoch must not accept a new begin" + ); +} + +#[test] +fn teardown_then_late_end_does_not_emit() { + let mut holds = HoldSessions::default(); + let session = session(7); + holds.begin(&session, ButtonId::Back, &pan_plan(1000)); + assert_eq!(holds.close_session(&session), Some(HoldCommand::PanEnd)); + assert_eq!(holds.end(&session, ButtonId::Back, DRAG), None); +} + +#[test] +fn profile_switch_ends_the_hold_and_the_next_press_can_begin() { + let mut holds = HoldSessions::default(); + let session = session(7); + holds.begin(&session, ButtonId::Back, &pan_plan(1000)); + assert_eq!(holds.end_open(&session), Some(HoldCommand::PanEnd)); + assert_eq!(holds.motion(&session, ButtonId::Back, 40, 0), None); + assert_eq!(holds.end(&session, ButtonId::Back, DRAG), None); + assert_eq!( + holds.begin(&session, ButtonId::Back, &pan_plan(1000)), + Some(HoldCommand::PanBegin) + ); +} + +#[test] +fn shutdown_ends_every_open_hold() { + let mut holds = HoldSessions::default(); + holds.begin(&session(7), ButtonId::Back, &pan_plan(1000)); + holds.begin(&session(8), ButtonId::Forward, &zoom_plan(1000)); + let commands = holds.close_all(); + assert!(commands.contains(&HoldCommand::PanEnd)); + assert!(commands.contains(&HoldCommand::ZoomEnd)); + assert_eq!(holds.motion(&session(7), ButtonId::Back, 20, 0), None); +} + +#[test] +fn missing_dpi_or_binding_does_not_open() { + let mut holds = HoldSessions::default(); + let session = session(7); + let mut plan = pan_plan(1000); + plan.sensor_dpi = None; + assert_eq!(holds.begin(&session, ButtonId::Back, &plan), None); + + let mut unbound = pan_plan(1000); + unbound.hold_bindings.clear(); + assert_eq!(holds.begin(&session, ButtonId::Back, &unbound), None); + assert_eq!(holds.motion(&session, ButtonId::Back, 50, 0), None); +} + +#[test] +fn pan_commands_never_reach_the_zoom_sink() { + let mut holds = HoldSessions::default(); + let session = session(7); + let plan = pan_plan(1000); + let mut commands = Vec::new(); + commands.extend(holds.begin(&session, ButtonId::Back, &plan)); + commands.extend(holds.motion(&session, ButtonId::Back, 80, -40)); + commands.extend(holds.end(&session, ButtonId::Back, DRAG)); + assert!( + commands.iter().all(|command| matches!( + command, + HoldCommand::PanBegin | HoldCommand::Pan { .. } | HoldCommand::PanEnd + )), + "Pan must not emit Zoom/ZoomEnd: {commands:?}" + ); + assert!( + commands + .iter() + .any(|command| matches!(command, HoldCommand::Pan { .. })), + "the pan sink must actually be reached" + ); +} + +#[test] +fn zoom_commands_never_reach_the_pan_sink() { + let mut holds = HoldSessions::default(); + let session = session(7); + let plan = zoom_plan(1000); + let mut commands = Vec::new(); + commands.extend(holds.begin(&session, ButtonId::Forward, &plan)); + commands.extend(holds.motion(&session, ButtonId::Forward, 0, -200)); + commands.extend(holds.end(&session, ButtonId::Forward, DRAG)); + assert!( + commands + .iter() + .all(|command| matches!(command, HoldCommand::Zoom { .. } | HoldCommand::ZoomEnd)), + "Zoom must not emit Pan/PanEnd: {commands:?}" + ); + assert!( + commands + .iter() + .any(|command| matches!(command, HoldCommand::Zoom { .. })), + "the zoom sink must actually be reached" + ); +} + +#[test] +fn swapping_the_bound_action_swaps_the_sink() { + // Same button, same motion: only the hold binding chooses the sink. + let session = session(7); + let motion = (0_i16, -200_i16); + let mut pan = HoldSessions::default(); + pan.begin(&session, ButtonId::Back, &pan_plan(1000)); + let pan_cmd = pan.motion(&session, ButtonId::Back, motion.0, motion.1); + let mut zoom = HoldSessions::default(); + zoom.begin(&session, ButtonId::Forward, &zoom_plan(1000)); + let zoom_cmd = zoom.motion(&session, ButtonId::Forward, motion.0, motion.1); + assert!(matches!(pan_cmd, Some(HoldCommand::Pan { .. }))); + assert!(matches!(zoom_cmd, Some(HoldCommand::Zoom { .. }))); + assert_ne!( + std::mem::discriminant(&pan_cmd.unwrap()), + std::mem::discriminant(&zoom_cmd.unwrap()), + "identical raw-XY must not collapse Pan and Zoom into one command" + ); +} + +#[test] +fn a_completed_hold_can_begin_again_on_the_same_epoch() { + let mut holds = HoldSessions::default(); + let session = session(7); + let plan = pan_plan(1000); + holds.begin(&session, ButtonId::Back, &plan); + holds.end(&session, ButtonId::Back, DRAG); + assert_eq!( + holds.begin(&session, ButtonId::Back, &plan), + Some(HoldCommand::PanBegin), + "a normal button-up returns the epoch to Idle so the next press works" + ); +} + +#[test] +fn zoom_sensitivity_scales_the_magnification_rate() { + let counts = counts_for_mm(-20.0, 1000); + let dpi = Dpi::new(1000); + let at_default = zoom_magnification(counts, dpi, ZoomSensitivity::DEFAULT); + let at_double = zoom_magnification( + counts, + dpi, + ZoomSensitivity::from_rounded(f32::from(ZoomSensitivity::DEFAULT) * 2.0), + ); + let at_min = zoom_magnification(counts, dpi, ZoomSensitivity::MIN); + assert!( + (at_double - at_default * 2.0).abs() < 0.03, + "twice the sensitivity must zoom twice as fast, got {at_double} vs {at_default}" + ); + assert!( + at_min < at_default, + "the minimum must be slower than the default, got {at_min} vs {at_default}" + ); + assert!( + at_min > 0.0, + "the minimum must still zoom in on an upward drag, got {at_min}" + ); +} + +#[test] +fn inverting_pan_flips_both_axes() { + let session = session(7); + let mut natural = HoldSessions::default(); + natural.begin(&session, ButtonId::Back, &pan_plan(1000)); + let Some(HoldCommand::Pan { dx, dy }) = natural.motion(&session, ButtonId::Back, 120, -60) + else { + panic!("natural pan must emit"); + }; + + let mut inverted_plan = pan_plan(1000); + inverted_plan.invert_pan = true; + let mut inverted = HoldSessions::default(); + inverted.begin(&session, ButtonId::Back, &inverted_plan); + let Some(HoldCommand::Pan { dx: idx, dy: idy }) = + inverted.motion(&session, ButtonId::Back, 120, -60) + else { + panic!("inverted pan must emit"); + }; + + assert!( + (idx + dx).abs() < f32::EPSILON && (idy + dy).abs() < f32::EPSILON, + "inverting must negate both axes: ({dx}, {dy}) became ({idx}, {idy})" + ); + assert!( + dx != 0.0 && dy != 0.0, + "the fixture must move on both axes or the assertion proves nothing" + ); +} + +#[test] +fn a_zoom_button_clicked_without_dragging_fires_smart_zoom() { + let mut holds = HoldSessions::default(); + let session = session(7); + assert_eq!( + holds.begin(&session, ButtonId::Forward, &zoom_plan(1000)), + None, + "zoom opens on first motion, not on button-down" + ); + assert_eq!( + holds.end(&session, ButtonId::Forward, CLICK), + Some(HoldCommand::SmartZoom) + ); +} + +#[test] +fn a_zoom_button_dragged_ends_the_pinch_rather_than_smart_zooming() { + // The two gestures share one button, which is why smart zoom cannot fire + // on button-down: it has to wait and see whether a drag follows. + let mut holds = HoldSessions::default(); + let session = session(7); + holds.begin(&session, ButtonId::Forward, &zoom_plan(1000)); + assert!( + holds + .motion(&session, ButtonId::Forward, 0, -200) + .is_some_and(|command| matches!(command, HoldCommand::Zoom { .. })) + ); + assert_eq!( + holds.end(&session, ButtonId::Forward, DRAG), + Some(HoldCommand::ZoomEnd) + ); +} + +#[test] +fn a_pan_button_clicked_without_dragging_does_not_smart_zoom() { + let mut holds = HoldSessions::default(); + let session = session(7); + holds.begin(&session, ButtonId::Back, &pan_plan(1000)); + assert_eq!( + holds.end(&session, ButtonId::Back, CLICK), + Some(HoldCommand::PanEnd), + "smart zoom belongs to the zoom binding only" + ); +} + +#[test] +fn tearing_down_a_zoom_hold_never_smart_zooms() { + // Shutdown, profile switch and stale input close the gesture. None of + // them is a user clicking the button. + let session = session(7); + for command in [ + { + let mut holds = HoldSessions::default(); + holds.begin(&session, ButtonId::Forward, &zoom_plan(1000)); + holds.close_session(&session) + }, + { + let mut holds = HoldSessions::default(); + holds.begin(&session, ButtonId::Forward, &zoom_plan(1000)); + holds.end_open(&session) + }, + // The one capture actually uses: a reconnect, a capture stop, or the + // stale bound reaches dispatch as an interrupted HoldEnd, which took + // the same route as a release and fired a smart zoom into whatever + // was frontmost. + { + let mut holds = HoldSessions::default(); + holds.begin(&session, ButtonId::Forward, &zoom_plan(1000)); + holds.end(&session, ButtonId::Forward, HoldRelease::Interrupted) + }, + ] { + assert_eq!(command, Some(HoldCommand::ZoomEnd)); + } +} diff --git a/crates/openlogi-agent-core/src/watchers/gesture/dispatch/tests.rs b/crates/openlogi-agent-core/src/watchers/gesture/dispatch/tests.rs index a7a0f2387..18d8906f3 100644 --- a/crates/openlogi-agent-core/src/watchers/gesture/dispatch/tests.rs +++ b/crates/openlogi-agent-core/src/watchers/gesture/dispatch/tests.rs @@ -1,7 +1,16 @@ +use std::collections::BTreeMap; +use std::time::Instant; + +use openlogi_core::binding::{Action, Binding, ButtonId}; +use openlogi_core::config::{ThumbwheelSensitivity, ZoomSensitivity}; +use openlogi_core::hid::Dpi; use openlogi_hid::thumbwheel::WheelResolution; +use crate::capture_plan::DispatchPlan; +use crate::runtime::HidppSessionId; + use super::wheel::{ScrollScale, WheelOutput, WheelRotation}; -use super::*; +use super::{SessionWheels, hidpp_click_binding, hidpp_hold_click_suppressed}; fn rotation(magnitude: i32) -> WheelRotation { let increments = i16::try_from(magnitude).expect("test magnitude fits in i16"); @@ -66,3 +75,53 @@ fn replacement_session_does_not_inherit_partial_progress() { "a new session must start with no action progress" ); } + +fn click_plan(hold: Option<(ButtonId, Action)>, click: &(ButtonId, Action)) -> DispatchPlan { + let mut bindings = BTreeMap::from([(click.0, Binding::Single(click.1.clone()))]); + let mut hold_bindings = BTreeMap::new(); + if let Some((button, action)) = hold { + bindings.insert(button, Binding::Single(action.clone())); + hold_bindings.insert(button, action); + } + DispatchPlan { + config_key: "mouse-a".into(), + bindings, + gesture_bindings: BTreeMap::new(), + side_gesture_bindings: BTreeMap::new(), + thumbwheel_sensitivity: ThumbwheelSensitivity::DEFAULT, + hold_bindings, + sensor_dpi: Some(Dpi::new(1000)), + zoom_sensitivity: ZoomSensitivity::DEFAULT, + invert_pan: false, + } +} + +#[test] +fn unarmed_hold_binding_is_not_a_click_or_a_scroll() { + // Against the previous resolver this returned Some(Pan) and the click + // path would hand Pan to execute() — AgentSide today, a silent scroll + // the day someone wires it. The hold must do nothing as a click. + let plan = click_plan( + Some((ButtonId::Forward, Action::Pan)), + &(ButtonId::MiddleClick, Action::Copy), + ); + assert_eq!( + hidpp_click_binding(&plan, ButtonId::Forward), + None, + "Forward = Pan must not fire as a one-shot action" + ); + assert_eq!( + hidpp_click_binding(&plan, ButtonId::Back), + None, + "an unbound hold-adjacent button must not invent a binding" + ); + assert_eq!( + hidpp_click_binding(&plan, ButtonId::MiddleClick).map(Binding::click_action), + Some(Action::Copy), + "stripping hold-mode must not drop a real click binding" + ); + assert_eq!( + hidpp_hold_click_suppressed(&plan, ButtonId::Forward), + Some(&Action::Pan) + ); +} diff --git a/crates/openlogi-agent-core/src/watchers/keyboard.rs b/crates/openlogi-agent-core/src/watchers/keyboard.rs index ddcd23146..01dc3ff2e 100644 --- a/crates/openlogi-agent-core/src/watchers/keyboard.rs +++ b/crates/openlogi-agent-core/src/watchers/keyboard.rs @@ -169,7 +169,13 @@ fn dispatch_input( } CapturedInput::Gesture(..) | CapturedInput::Scroll { .. } - | CapturedInput::ThumbwheelDirection { .. } => {} + | CapturedInput::ThumbwheelDirection { .. } + | CapturedInput::HoldBegin(_) + | CapturedInput::HoldMotion { .. } + | CapturedInput::HoldEnd { .. } => { + // Keyboard capture never emits a hold-mode stream. Mouse holds + // are driven by the gesture dispatcher. + } } } diff --git a/crates/openlogi-agent/Cargo.toml b/crates/openlogi-agent/Cargo.toml index cc8bc343f..25a0e6237 100644 --- a/crates/openlogi-agent/Cargo.toml +++ b/crates/openlogi-agent/Cargo.toml @@ -26,6 +26,7 @@ openlogi-agent-core = { path = "../openlogi-agent-core" } openlogi-core = { path = "../openlogi-core" } openlogi-hid = { path = "../openlogi-hid" } openlogi-hook = { path = "../openlogi-hook" } +openlogi-inject = { path = "../openlogi-inject" } openlogi-ipc = { path = "../openlogi-ipc" } tarpc = { workspace = true } interprocess = { workspace = true } diff --git a/crates/openlogi-agent/src/binary_watch/relaunch.rs b/crates/openlogi-agent/src/binary_watch/relaunch.rs index c5601c074..7403bae30 100644 --- a/crates/openlogi-agent/src/binary_watch/relaunch.rs +++ b/crates/openlogi-agent/src/binary_watch/relaunch.rs @@ -40,6 +40,9 @@ pub(super) fn restart(path: &Path) { path = %path.display(), "executable changed on disk — restarting as the new binary" ); + // `exec` replaces the image without `Drop`. Flush first so an open + // pan or a held Ctrl from zoom is closed before the old image is gone. + crate::shutdown::prepare_process_exit(); // Forward our argv (none today) so a future flag survives the restart. let err = std::process::Command::new(path) .args(std::env::args_os().skip(1)) @@ -108,11 +111,7 @@ fn schedule_macos_relaunch(path: &Path) -> std::io::Result<()> { #[cfg(target_os = "macos")] fn schedule_macos_relaunch_and_exit(path: &Path) -> std::io::Result<()> { schedule_macos_relaunch(path)?; - #[expect( - clippy::exit, - reason = "the delayed successor is already scheduled and waits for this process to release the singleton lock and IPC socket" - )] - std::process::exit(0) + crate::shutdown::flush_and_exit(0) } /// The `.app` root of a packaged helper binary, `None` for a bare dev binary. @@ -132,11 +131,7 @@ pub(super) fn restart(path: &Path) { path = %path.display(), "executable changed on disk — exiting so the new binary can start" ); - #[expect( - clippy::exit, - reason = "windows has no `exec`, and this watcher thread cannot return a status to `main`, which is blocked on the agent core; releasing the singleton lock by exiting is what lets the replaced binary start" - )] - std::process::exit(0); + crate::shutdown::flush_and_exit(0); } #[cfg(target_os = "macos")] diff --git a/crates/openlogi-agent/src/shutdown.rs b/crates/openlogi-agent/src/shutdown.rs index 8982efa06..1bfc87b05 100644 --- a/crates/openlogi-agent/src/shutdown.rs +++ b/crates/openlogi-agent/src/shutdown.rs @@ -1,5 +1,11 @@ -//! Signal-driven shutdown: the `SIGTERM`/`SIGINT` listeners and the exit -//! path that releases the input hook before the process ends. +//! Signal-driven shutdown and the process-exit funnel. +//! +//! `SIGTERM`/`SIGINT` release the input hook then join this crate's one +//! exit path. Tray Quit, AppKit-loop return, and binary-watch relaunch +//! cannot see [`InputServices`], so they join the same funnel: flush any +//! open pan/zoom session (idempotent) and only then `process::exit`. +//! `std::process::exit` lives in exactly one function so a later exit +//! site is correct by construction. use openlogi_hook::Hook; use tracing::info; @@ -69,6 +75,36 @@ impl ShutdownSignals { } } +/// Work that must finish before this process image is gone. +/// +/// `process::exit` and `exec` skip `Drop`. Hold-mode pan/zoom is a real OS +/// gesture (or a held Ctrl on Linux/Windows); leaving it open strands the +/// focused app. This seals rather than flushes: the gesture watcher is not +/// joined here, so a thread still streaming would otherwise reopen a pinch +/// between the last flush and the exit. Sealing is idempotent and a no-op +/// when nothing is open. Isolated from [`flush_and_exit`] so the Linux `exec` +/// relaunch can run the same work without ending a process that is about to +/// be replaced, and so tests can prove it without terminating the harness. +/// +/// A panic here must not prevent the caller from exiting: an exit path that +/// hangs or unwinds is worse than a stuck pinch. +pub(crate) fn prepare_process_exit() { + let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + openlogi_inject::seal_gesture_sessions(); + })); +} + +/// The crate's only `process::exit`. Every death path that cannot return an +/// [`std::process::ExitCode`] to `main` comes through here. +pub(crate) fn flush_and_exit(code: i32) -> ! { + prepare_process_exit(); + #[expect( + clippy::exit, + reason = "AppKit, the Windows tray pump, and the binary-watch thread cannot return an ExitCode to main; this is the crate's only process::exit, after the gesture flush" + )] + std::process::exit(code) +} + /// Release the input hook, then end the process. The run loop is not the /// process — macOS keeps the AppKit tray loop on the main thread — so the /// exit has to be explicit, and it must run the hook's destructor. @@ -80,9 +116,84 @@ pub(crate) fn release_hook_and_exit( info!(reason, "releasing the input hook and exiting"); drop(hook); inputs.shutdown(); - #[expect( - clippy::exit, - reason = "a signalled shutdown must end the process, and the loop that observed it runs off the main thread" - )] - std::process::exit(0) + flush_and_exit(0) +} + +#[cfg(test)] +mod tests { + use std::path::{Path, PathBuf}; + + use super::prepare_process_exit; + + /// Strip `//` comments so doc-comments mentioning `process::exit` do not + /// look like call sites. `https://` is truncated too; that cannot hide a + /// real `process::exit`. + fn code_without_line_comments(src: &str) -> String { + src.lines() + .map(|line| match line.find("//") { + Some(i) => &line[..i], + None => line, + }) + .collect::>() + .join("\n") + } + + fn production_src(src: &str) -> &str { + src.split("#[cfg(test)]").next().unwrap_or(src) + } + + fn collect_rust_sources(dir: &Path, files: &mut Vec) { + for entry in std::fs::read_dir(dir).expect("agent src should be readable") { + let path = entry.expect("directory entry").path(); + if path.is_dir() { + if path.file_name().is_some_and(|name| name == "bin") { + continue; + } + collect_rust_sources(&path, files); + } else if path.extension().is_some_and(|ext| ext == "rs") { + files.push(path); + } + } + } + + fn rust_sources_under(root: &Path) -> Vec { + let mut files = Vec::new(); + collect_rust_sources(root, &mut files); + files.sort(); + files + } + + #[test] + fn prepare_process_exit_is_safe_when_nothing_is_open() { + prepare_process_exit(); + prepare_process_exit(); + } + + #[test] + fn every_process_exit_goes_through_the_funnel() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + let mut elsewhere = Vec::new(); + let mut funnel_exits = 0; + for path in rust_sources_under(&root) { + let src = std::fs::read_to_string(&path).expect("source should be readable"); + let code = code_without_line_comments(production_src(&src)); + let hits = code.matches("std::process::exit").count(); + if hits == 0 { + continue; + } + if path.file_name().is_some_and(|name| name == "shutdown.rs") { + funnel_exits += hits; + } else { + elsewhere.push(path.display().to_string()); + } + } + assert!( + elsewhere.is_empty(), + "process::exit must live only in flush_and_exit; found in {elsewhere:?}" + ); + assert_eq!( + funnel_exits, 1, + "shutdown.rs must contain exactly one process::exit (the funnel)" + ); + } } diff --git a/crates/openlogi-agent/src/tray.rs b/crates/openlogi-agent/src/tray.rs index 8f1ebe9b6..5bd7293cc 100644 --- a/crates/openlogi-agent/src/tray.rs +++ b/crates/openlogi-agent/src/tray.rs @@ -286,11 +286,7 @@ fn quit_agent() -> ! { } crate::overlay::evict_on_quit(); info!("menu-bar Quit — exiting agent"); - #[expect( - clippy::exit, - reason = "reached from an AppKit menu action on the main thread: the run loop owns `main`'s stack frame, so no status can travel back to it" - )] - std::process::exit(0) + crate::shutdown::flush_and_exit(0) } /// Whether an OpenLogi GUI process is currently running (prod or dev bundle). @@ -325,11 +321,7 @@ pub fn run_app_loop( ) -> ! { let Some(mtm) = MainThreadMarker::new() else { warn!("agent AppKit loop not started off the main thread — exiting"); - #[expect( - clippy::exit, - reason = "this branch means `run_app_loop` was called off the process main thread, where AppKit cannot run at all; the function is `-> !` and `main` returns `()`, so a failure status has nowhere to propagate" - )] - std::process::exit(1); + crate::shutdown::flush_and_exit(1); }; let app = NSApplication::sharedApplication(mtm); app.setActivationPolicy(NSApplicationActivationPolicy::Accessory); @@ -349,11 +341,7 @@ pub fn run_app_loop( info!(show_in_menu_bar, "agent AppKit loop started"); app.run(); - #[expect( - clippy::exit, - reason = "AppKit only returns from `run()` once the loop is torn down, and the agent core is still running on another thread; this function is `-> !` with no return path, so the process ends here" - )] - std::process::exit(0); + crate::shutdown::flush_and_exit(0); } /// Observe display/session sleep and user-visible resume transitions. Generic diff --git a/crates/openlogi-agent/src/tray_windows.rs b/crates/openlogi-agent/src/tray_windows.rs index f29811797..161c94ab3 100644 --- a/crates/openlogi-agent/src/tray_windows.rs +++ b/crates/openlogi-agent/src/tray_windows.rs @@ -445,11 +445,7 @@ fn quit(hwnd: HWND) { } crate::overlay::evict_on_quit(); info!("tray Quit — exiting agent"); - #[expect( - clippy::exit, - reason = "reached from the window procedure on the tray thread: the status cannot travel back through an `extern \"system\"` callback, and ending the message pump would only end this thread while `main` keeps running the agent core" - )] - std::process::exit(0); + crate::shutdown::flush_and_exit(0); } /// NUL-terminated UTF-16 for win32 W-APIs. diff --git a/crates/openlogi-core/src/binding.rs b/crates/openlogi-core/src/binding.rs index 23fc92fe5..374f026d4 100644 --- a/crates/openlogi-core/src/binding.rs +++ b/crates/openlogi-core/src/binding.rs @@ -37,7 +37,7 @@ pub use effect::{Effect, MediaKey, MouseButton, NativeAction, Script, Shortcut}; pub use gesture::GestureDirection; pub use key_combo::{KeyCombo, KeyComboParseError, KeyboardUsage, KeyboardUsageError}; pub use swipe::{ - GESTURE_HOLD_FOR_SWIPE, GESTURE_SWIPE_DEADZONE, GESTURE_SWIPE_THRESHOLD, SwipeAccumulator, - detect_swipe, + GESTURE_HOLD_FOR_SWIPE, GESTURE_SWIPE_DEADZONE, GESTURE_SWIPE_THRESHOLD, HOLD_DRAG_DEADZONE_MM, + HOLD_STALE, StreamRelease, SwipeAccumulator, detect_swipe, hold_drag_threshold_counts, }; pub use value::{Binding, LONG_PRESS_THRESHOLD, LongPressBinding}; diff --git a/crates/openlogi-core/src/binding/action.rs b/crates/openlogi-core/src/binding/action.rs index a41332a23..2df37fe0b 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), + /// Hold-mode two-axis pan: the cursor freezes and pointer travel scrolls + /// content on both axes until release. Appended because the serde variant + /// index is the wire format — new variants only ever go at the end. + Pan, + /// Hold-mode continuous pinch-zoom. Distinct from the window-menu "Zoom" + /// locale key (Italian `Ridimensiona`); this variant's catalog label is + /// "Pinch Zoom" so i18n must not reuse that key. + Zoom, } /// One step in a [`Action::Workflow`]. A workflow is a `Vec` @@ -285,6 +293,8 @@ macro_rules! for_each_unit_action { ScrollDown "Scroll Down" Scroll ArrowDown, HorizontalScrollLeft "Scroll Left" Scroll ScrollLeft, HorizontalScrollRight "Scroll Right" Scroll ScrollRight, + Pan "Pan" Scroll Mouse, + Zoom "Pinch Zoom" Scroll Search, } }; } @@ -368,4 +378,15 @@ impl Action { _ => None, } } + + /// Whether this action is a hold-mode control that needs a raw-XY HID++ + /// divert for the lifetime of the press. + /// + /// One definition: catalog filters, capture-plan derivation, and + /// [`RingAction`](super::action_ring::RingAction) validation all ask this + /// rather than matching `Pan | Zoom` in parallel. + #[must_use] + pub const fn is_hold_mode(&self) -> bool { + matches!(self, Self::Pan | Self::Zoom) + } } diff --git a/crates/openlogi-core/src/binding/action_ring.rs b/crates/openlogi-core/src/binding/action_ring.rs index 1259dc258..dd80f0270 100644 --- a/crates/openlogi-core/src/binding/action_ring.rs +++ b/crates/openlogi-core/src/binding/action_ring.rs @@ -109,6 +109,10 @@ pub enum RingActionError { /// A ring cannot recursively open itself. #[error("Show Actions Ring cannot be assigned inside an Actions Ring")] RecursiveTrigger, + /// Hold-mode actions need a raw-XY divert for the lifetime of a button + /// press; a ring slot is a one-shot activation and cannot drive that. + #[error("hold-mode actions cannot be placed in an Actions Ring")] + HoldMode, } /// An action that is valid inside an Actions Ring. @@ -142,6 +146,9 @@ impl RingAction { } fn validate_ring_action(action: &Action) -> Result<(), RingActionError> { + if action.is_hold_mode() { + return Err(RingActionError::HoldMode); + } match action { Action::None => Err(RingActionError::EmptyAction), Action::ShowActionsRing => Err(RingActionError::RecursiveTrigger), @@ -362,6 +369,11 @@ mod tests { RingAction::new(Action::ShowActionsRing), Err(RingActionError::RecursiveTrigger) ); + assert_eq!(RingAction::new(Action::Pan), Err(RingActionError::HoldMode)); + assert_eq!( + RingAction::new(Action::Zoom), + Err(RingActionError::HoldMode) + ); } #[test] diff --git a/crates/openlogi-core/src/binding/effect.rs b/crates/openlogi-core/src/binding/effect.rs index 8010cb6a1..fe4d14a0d 100644 --- a/crates/openlogi-core/src/binding/effect.rs +++ b/crates/openlogi-core/src/binding/effect.rs @@ -1,6 +1,6 @@ //! A platform-neutral synthesis IR. //! -//! [`Action`] has one variant per user-facing behaviour (52 of them), but the +//! [`Action`] has one variant per user-facing behaviour, but the //! three `openlogi-inject` backends don't care about most of that //! granularity — they care about *mechanism*: "press this chord", "click //! this mouse button", "fire this media key", "there is no portable way to @@ -62,8 +62,8 @@ pub enum Effect<'a> { /// Type this text via unicode input. Text(&'a str), /// Handled entirely by the agent/hook layer — DPI presets, SmartShift, - /// the Actions Ring, and launching an application. The injector logs - /// and does nothing. + /// the Actions Ring, launching an application, and hold-mode Pan/Zoom. + /// The injector logs and does nothing. /// /// [`Action::OpenApplication`] is included here even though /// `openlogi_inject::execute` does open it: that happens in the @@ -266,7 +266,9 @@ impl Action { | Action::SetDpiPreset(_) | Action::ToggleSmartShift | Action::ShowActionsRing - | Action::OpenApplication(_) => Effect::AgentSide, + | Action::OpenApplication(_) + | Action::Pan + | Action::Zoom => Effect::AgentSide, Action::ScrollUp => Effect::Scroll { dx: 0, dy: 1 }, Action::ScrollDown => Effect::Scroll { dx: 0, dy: -1 }, diff --git a/crates/openlogi-core/src/binding/swipe.rs b/crates/openlogi-core/src/binding/swipe.rs index 43fe1eaa8..7a1deca56 100644 --- a/crates/openlogi-core/src/binding/swipe.rs +++ b/crates/openlogi-core/src/binding/swipe.rs @@ -3,7 +3,9 @@ //! shared by both gesture-capture paths. This is input processing, distinct //! from the `Action` vocabulary the parent [`binding`](super) module defines. -use std::time::Instant; +use std::time::{Duration, Instant}; + +use crate::hid::Dpi; use super::GestureDirection; @@ -17,7 +19,40 @@ pub const GESTURE_SWIPE_DEADZONE: i32 = 40; /// swipe. Distinguishes a deliberate hold-and-swipe from a quick click whose /// cursor happened to be moving. Shared by both gesture paths (the HID++ thumb /// pad and the OS-hook Middle/Back/Forward). -pub const GESTURE_HOLD_FOR_SWIPE: std::time::Duration = std::time::Duration::from_millis(160); +pub const GESTURE_HOLD_FOR_SWIPE: Duration = Duration::from_millis(160); +/// Physical click/drag deadzone for hold-mode travel. A side-button press +/// itself moves the mouse 0.5–2mm, so a raw-count threshold (12 counts is +/// 0.30mm at 1000 DPI) reads every click as a drag. +pub const HOLD_DRAG_DEADZONE_MM: f64 = 2.5; +/// How long a hold-mode stream may go without a raw-XY sample before it is +/// presumed to have lost its button-up. Matches the OS-hook stale bound so a +/// dropped HID++ release cannot leave a gesture session open indefinitely. +/// +/// Measured from the last sample, not from the press: a hold-mode pan is +/// meant to run for as long as the user keeps panning. +pub const HOLD_STALE: Duration = Duration::from_secs(10); + +/// Raw-XY counts equal to [`HOLD_DRAG_DEADZONE_MM`] at `dpi`. +/// +/// This is the only hold-mode threshold. There is no count-based overload: a +/// caller that has a sensor reading cannot accidentally use a DPI-blind path. +#[must_use] +pub fn hold_drag_threshold_counts(dpi: Dpi) -> i32 { + // 2.5mm / 25.4mm-per-inch * dpi counts-per-inch = 25 * dpi / 254. + let counts = u32::from(u16::from(dpi)).saturating_mul(25) / 254; + i32::try_from(counts).unwrap_or(i32::MAX) +} + +/// How a hold-mode stream ended. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum StreamRelease { + /// No stream was open. A late end must not fire an action. + Idle, + /// The hold never cleared the physical deadzone — a click, not a drag. + Click, + /// Travel cleared the deadzone. Must not also report as a click. + Drag, +} /// Classify the *running* raw-XY travel of a held gesture button into a /// directional swipe, the instant it commits — or `None` while it's still too @@ -84,22 +119,45 @@ pub struct SwipeAccumulator { /// When the current hold began, or `None` when not holding. Gates a /// deliberate swipe against a quick click whose cursor happened to move. held_since: Option, + /// The most recent evidence this hold is alive: the press itself, then + /// every sample fed into an open stream. Separate from `held_since` + /// because the two answer different questions — how long the user has + /// been holding, versus how long the device has been quiet. + last_activity: Option, /// Accumulated raw-XY travel since the hold began (saturating, so an /// arbitrarily long hold can never overflow). dx: i32, dy: i32, /// Set once a direction has committed this hold, so it fires exactly once - /// and the release isn't then also read as a click. + /// and the release isn't then also read as a click. In stream mode, set + /// once travel clears the physical deadzone. fired: bool, + /// Squared count threshold while a hold-mode stream is open. Discrete + /// swipe holds leave this `None` so [`Self::accumulate`] keeps classifying + /// directions. Required at [`Self::begin_stream`] so a caller with DPI + /// cannot start a stream on the count-blind swipe path. + stream_threshold_sq: Option, } impl SwipeAccumulator { /// Begin a fresh hold, resetting the travel accumulator and commit state. pub fn begin(&mut self) { - self.held_since = Some(Instant::now()); + let now = Instant::now(); + self.held_since = Some(now); + self.last_activity = Some(now); self.dx = 0; self.dy = 0; self.fired = false; + self.stream_threshold_sq = None; + } + + /// Begin a hold-mode stream whose click/drag threshold is + /// [`HOLD_DRAG_DEADZONE_MM`] at `dpi`. This is the only way to start a + /// streaming hold. + pub fn begin_stream(&mut self, dpi: Dpi) { + self.begin(); + let threshold = u64::from(hold_drag_threshold_counts(dpi).cast_unsigned()); + self.stream_threshold_sq = Some(threshold.saturating_mul(threshold)); } /// Whether a hold is in progress (between [`Self::begin`] and [`Self::end`]), @@ -109,12 +167,19 @@ impl SwipeAccumulator { self.held_since.is_some() } + /// Instant of the newest sample in the current hold, for the session's + /// stale-hold bound. `None` when not holding. + #[must_use] + pub fn last_activity(&self) -> Option { + self.last_activity + } + /// Feed a pointer-move / raw-XY delta into the current hold. Returns /// `Some(direction)` exactly once per hold — the instant travel commits, and /// only after the hold passes [`GESTURE_HOLD_FOR_SWIPE`] — and `None` while - /// still too short, already committed, or not holding. + /// still too short, already committed, not holding, or in stream mode. pub fn accumulate(&mut self, dx: i32, dy: i32) -> Option { - if self.fired || self.held_since.is_none() { + if self.stream_threshold_sq.is_some() || self.fired || self.held_since.is_none() { return None; } self.dx = self.dx.saturating_add(dx); @@ -129,16 +194,63 @@ impl SwipeAccumulator { None } - /// 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 - /// was no hold to end (a stray release reports no click). + /// Feed a raw-XY delta into a hold-mode stream. Returns the delta while + /// the stream is live — including before the deadzone clears, so the + /// injector can start on the first sample — and `None` when not streaming. + /// A torn-down hold stays terminal: this never re-opens one. + pub fn accumulate_stream(&mut self, dx: i32, dy: i32) -> Option<(i32, i32)> { + let threshold_sq = self.stream_threshold_sq?; + self.held_since?; + self.last_activity = Some(Instant::now()); + self.dx = self.dx.saturating_add(dx); + self.dy = self.dy.saturating_add(dy); + let travel_sq = i64::from(self.dx) + .saturating_mul(i64::from(self.dx)) + .saturating_add(i64::from(self.dy).saturating_mul(i64::from(self.dy))); + if u64::try_from(travel_sq).is_ok_and(|sq| sq >= threshold_sq) { + self.fired = true; + } + Some((dx, dy)) + } + + /// End the current hold. Returns `true` when an in-progress discrete hold + /// ended without committing a swipe — the caller should fire the plain + /// `Click` action — and `false` when a swipe already fired, a stream was + /// open (use [`Self::end_stream`]), or there was no hold to end. pub fn end(&mut self) -> bool { + if self.stream_threshold_sq.is_some() { + let _ = self.end_stream(); + return false; + } let was_click = self.held_since.is_some() && !self.fired; self.held_since = None; + self.last_activity = None; was_click } + /// End a hold-mode stream. A late call after teardown is [`StreamRelease::Idle`] + /// and must not fire an action. + pub fn end_stream(&mut self) -> StreamRelease { + if self.stream_threshold_sq.is_none() || self.held_since.is_none() { + self.held_since = None; + self.last_activity = None; + self.stream_threshold_sq = None; + return StreamRelease::Idle; + } + let traveled = self.fired; + self.held_since = None; + self.last_activity = None; + self.stream_threshold_sq = None; + self.fired = false; + self.dx = 0; + self.dy = 0; + if traveled { + StreamRelease::Drag + } else { + StreamRelease::Click + } + } + /// Test-only seam: backdate the current hold so its [`GESTURE_HOLD_FOR_SWIPE`] /// gate is already satisfied, letting a test exercise a committed swipe /// without sleeping. Real code never calls this — [`Self::begin`] records the @@ -149,6 +261,24 @@ impl SwipeAccumulator { self.held_since = Instant::now().checked_sub(GESTURE_HOLD_FOR_SWIPE * 2); } } + + /// Test-only seam: age the current hold's last sample past + /// [`HOLD_STALE`] — the hold has gone quiet. + #[doc(hidden)] + pub fn backdate_hold_past_stale_for_test(&mut self) { + if self.held_since.is_some() { + self.last_activity = Instant::now().checked_sub(HOLD_STALE * 2); + } + } + + /// Test-only seam: age only the press instant past [`HOLD_STALE`] — the + /// hold has been down a long time but is still producing samples. + #[doc(hidden)] + pub fn backdate_press_past_stale_for_test(&mut self) { + if self.held_since.is_some() { + self.held_since = Instant::now().checked_sub(HOLD_STALE * 2); + } + } } #[cfg(test)] @@ -342,4 +472,110 @@ mod tests { assert!(acc.end(), "the held release is a click"); assert!(!acc.end(), "the redundant second release is not a click"); } + + // ── Hold-mode stream (physical deadzone, never a swipe direction) ───────── + + #[test] + fn hold_drag_threshold_scales_with_dpi() { + // 2.5mm at 400 DPI is ~39 counts; at 1600 DPI it is ~157. A DPI-blind + // constant (the old 12-count click deadzone, or GESTURE_SWIPE_THRESHOLD) + // would make these two equal and this test fail. + let low = hold_drag_threshold_counts(Dpi::new(400)); + let high = hold_drag_threshold_counts(Dpi::new(1600)); + assert!(low > 0, "400 DPI must still have a physical deadzone"); + assert!( + high > low * 3, + "quadrupling DPI must roughly quadruple the count threshold, got {low} vs {high}" + ); + assert_ne!(low, GESTURE_SWIPE_THRESHOLD); + assert_ne!(high, GESTURE_SWIPE_THRESHOLD); + } + + #[test] + fn the_same_raw_counts_are_a_drag_at_low_dpi_and_a_click_at_high_dpi() { + // 80 counts is past 2.5mm at 400 DPI and short of it at 1600 DPI. If + // begin_stream were swapped for the DPI-blind swipe path, both holds + // would classify the same way. + let travel = 80; + assert!(travel > hold_drag_threshold_counts(Dpi::new(400))); + assert!(travel < hold_drag_threshold_counts(Dpi::new(1600))); + + let mut low = SwipeAccumulator::default(); + low.begin_stream(Dpi::new(400)); + assert_eq!(low.accumulate_stream(travel, 0), Some((travel, 0))); + assert_eq!(low.end_stream(), StreamRelease::Drag); + + let mut high = SwipeAccumulator::default(); + high.begin_stream(Dpi::new(1600)); + assert_eq!(high.accumulate_stream(travel, 0), Some((travel, 0))); + assert_eq!(high.end_stream(), StreamRelease::Click); + } + + #[test] + fn a_stream_that_cleared_the_deadzone_is_not_a_click() { + let mut acc = SwipeAccumulator::default(); + acc.begin_stream(Dpi::new(1000)); + let step = hold_drag_threshold_counts(Dpi::new(1000)) + 1; + assert_eq!(acc.accumulate_stream(step, 0), Some((step, 0))); + assert_eq!(acc.end_stream(), StreamRelease::Drag); + assert_eq!( + acc.end_stream(), + StreamRelease::Idle, + "a late end after teardown must not fire" + ); + } + + #[test] + fn a_stream_that_never_moved_is_a_click() { + let mut acc = SwipeAccumulator::default(); + acc.begin_stream(Dpi::new(1000)); + assert_eq!(acc.accumulate_stream(1, 0), Some((1, 0))); + assert_eq!(acc.end_stream(), StreamRelease::Click); + } + + #[test] + fn stream_mode_never_commits_a_swipe_direction() { + let mut acc = SwipeAccumulator::default(); + acc.begin_stream(Dpi::new(1000)); + acc.backdate_hold_for_test(); + assert_eq!( + acc.accumulate(GESTURE_SWIPE_THRESHOLD + 100, 0), + None, + "a caller with DPI must not fall onto the discrete swipe classifier" + ); + assert!( + acc.accumulate_stream(10, 0).is_some(), + "the stream stays open under a rejected swipe classification" + ); + assert!(!acc.end(), "ending a stream through end() is not a click"); + } + + #[test] + fn accumulate_stream_does_not_reopen_a_torn_down_hold() { + let mut acc = SwipeAccumulator::default(); + acc.begin_stream(Dpi::new(1000)); + let _ = acc.end_stream(); + assert_eq!( + acc.accumulate_stream(200, 200), + None, + "late motion must not silently re-open a session" + ); + assert_eq!(acc.end_stream(), StreamRelease::Idle); + } + + #[test] + fn stream_sums_sub_threshold_deltas_until_they_clear_the_deadzone() { + let mut acc = SwipeAccumulator::default(); + acc.begin_stream(Dpi::new(400)); + let threshold = hold_drag_threshold_counts(Dpi::new(400)); + let step = threshold / 2; + assert_eq!(acc.accumulate_stream(step, 0), Some((step, 0))); + assert_eq!(acc.end_stream(), StreamRelease::Click); + + acc.begin_stream(Dpi::new(400)); + assert_eq!(acc.accumulate_stream(step, 0), Some((step, 0))); + assert_eq!(acc.accumulate_stream(step, 0), Some((step, 0))); + assert_eq!(acc.accumulate_stream(step, 0), Some((step, 0))); + assert_eq!(acc.end_stream(), StreamRelease::Drag); + } } diff --git a/crates/openlogi-core/src/binding/tests.rs b/crates/openlogi-core/src/binding/tests.rs index 85d0b293b..bd8922536 100644 --- a/crates/openlogi-core/src/binding/tests.rs +++ b/crates/openlogi-core/src/binding/tests.rs @@ -361,6 +361,7 @@ fn persisted_action_variant_names_are_stable() { "NextTrack", "None", "OpenApplication", + "Pan", "Paste", "PlayPause", "PrevTab", @@ -387,6 +388,7 @@ fn persisted_action_variant_names_are_stable() { "VolumeDown", "VolumeUp", "Workflow", + "Zoom", ]; expected.sort_unstable(); assert_eq!(actual, expected); @@ -465,6 +467,8 @@ fn category_scroll_variants() { assert_eq!(Action::ScrollDown.category(), Category::Scroll); assert_eq!(Action::HorizontalScrollLeft.category(), Category::Scroll); assert_eq!(Action::HorizontalScrollRight.category(), Category::Scroll); + assert_eq!(Action::Pan.category(), Category::Scroll); + assert_eq!(Action::Zoom.category(), Category::Scroll); } #[test] @@ -618,6 +622,8 @@ fn power_user_and_device_side_actions_lower_to_the_expected_bucket() { Action::SetDpiPreset(2), Action::ToggleSmartShift, Action::ShowActionsRing, + Action::Pan, + Action::Zoom, ] { assert_matches!(action.effect(), Effect::AgentSide); } @@ -642,3 +648,24 @@ fn scroll_actions_lower_to_unit_direction() { Effect::Scroll { dx: 1, dy: 0 } ); } + +#[test] +fn hold_mode_predicate_is_only_pan_and_zoom() { + assert!(Action::Pan.is_hold_mode()); + assert!(Action::Zoom.is_hold_mode()); + assert_eq!(Action::Pan.label(), "Pan"); + assert_eq!(Action::Zoom.label(), "Pinch Zoom"); + assert!(Action::catalog().contains(&Action::Pan)); + assert!(Action::catalog().contains(&Action::Zoom)); + assert_ne!( + Action::Zoom.label(), + "Zoom", + "must not reuse the window-menu Zoom locale key" + ); + assert!(!Action::ScrollUp.is_hold_mode()); + assert!(!Binding::Single(Action::Copy).is_hold_mode()); + assert!(Binding::Single(Action::Pan).is_hold_mode()); + assert!( + !Binding::Gesture(BTreeMap::from([(GestureDirection::Click, Action::Pan)])).is_hold_mode() + ); +} diff --git a/crates/openlogi-core/src/binding/value.rs b/crates/openlogi-core/src/binding/value.rs index 5f60ac986..07b09295e 100644 --- a/crates/openlogi-core/src/binding/value.rs +++ b/crates/openlogi-core/src/binding/value.rs @@ -115,6 +115,18 @@ impl Binding { matches!(self, Binding::Gesture(_)) } + /// Whether this binding is a hold-mode [`Single`](Binding::Single) that + /// needs a raw-XY divert. Gesture and long-press shapes cannot drive a + /// hold; they answer false even if a hand-edited map smuggled `Pan`/`Zoom` + /// into a direction slot. + #[must_use] + pub fn is_hold_mode(&self) -> bool { + match self { + Binding::Single(action) => action.is_hold_mode(), + Binding::Gesture(_) | Binding::LongPress(_) => false, + } + } + /// Promote a [`Single`](Binding::Single) binding in place to a /// [`Gesture`](Binding::Gesture), keeping its action as the /// [`GestureDirection::Click`] entry and leaving the swipe arms unbound. diff --git a/crates/openlogi-core/src/bindings.rs b/crates/openlogi-core/src/bindings.rs index 8c27df307..c998eb800 100644 --- a/crates/openlogi-core/src/bindings.rs +++ b/crates/openlogi-core/src/bindings.rs @@ -96,6 +96,26 @@ pub fn hidpp_gesture_maps_for( .collect() } +/// Buttons whose effective binding is a hold-mode [`Action`] (`Pan` / `Zoom`). +/// +/// The HID++ capture session owns these while they are armed. The OS-hook map +/// must omit them so a session restart cannot start a second dispatch path +/// with no matching button-up. +#[must_use] +pub fn hold_mode_bindings_for( + config: &Config, + config_key: Option<&str>, + app_bundle: Option<&str>, +) -> BTreeMap { + button_bindings_for(config, config_key, app_bundle) + .into_iter() + .filter_map(|(button, binding)| match binding { + Binding::Single(action) if action.is_hold_mode() => Some((button, action)), + Binding::Single(_) | Binding::Gesture(_) | Binding::LongPress(_) => None, + }) + .collect() +} + /// Per-direction maps for every OS-hook button (Middle/Back/Forward) in /// gesture mode on `config_key`, with `app_bundle`'s per-app overlay applied, /// for the OS hook to resolve a hold+swipe. Gesture mode is per-button (see @@ -344,4 +364,23 @@ mod tests { "a demoted dedicated button must dispatch nothing over HID++" ); } + + #[test] + fn hold_mode_bindings_collect_only_single_pan_and_zoom() { + let mut cfg = Config::default(); + cfg.set_binding("2b042", ButtonId::Back, Binding::Single(Action::Pan)); + cfg.set_binding("2b042", ButtonId::Forward, Binding::Single(Action::Zoom)); + cfg.set_binding( + "2b042", + ButtonId::MiddleClick, + Binding::Single(Action::Copy), + ); + cfg.set_gesture_mode("2b042", ButtonId::GestureButton, true); + + let hold = hold_mode_bindings_for(&cfg, Some("2b042"), None); + assert_eq!(hold.get(&ButtonId::Back), Some(&Action::Pan)); + assert_eq!(hold.get(&ButtonId::Forward), Some(&Action::Zoom)); + assert!(!hold.contains_key(&ButtonId::MiddleClick)); + assert!(!hold.contains_key(&ButtonId::GestureButton)); + } } diff --git a/crates/openlogi-core/src/config.rs b/crates/openlogi-core/src/config.rs index d4ba4ac55..532bf0c77 100644 --- a/crates/openlogi-core/src/config.rs +++ b/crates/openlogi-core/src/config.rs @@ -35,6 +35,7 @@ pub use settings::{ AppIcon, AppSettings, Appearance, AssetSourcePreference, CameraControls, DeviceViewMode, Lighting, SMARTSHIFT_AUTO_DISENGAGE_DEFAULT, SMARTSHIFT_MIN_AUTO_DISENGAGE, ScrollResolution, SmartShift, ThumbwheelSensitivity, UiScale, VerticalScrollSensitivity, WheelMode, + ZoomSensitivity, }; use crate::binding::{ diff --git a/crates/openlogi-core/src/config/settings.rs b/crates/openlogi-core/src/config/settings.rs index 2871921d8..2686238b8 100644 --- a/crates/openlogi-core/src/config/settings.rs +++ b/crates/openlogi-core/src/config/settings.rs @@ -248,6 +248,17 @@ pub struct AppSettings { /// only diverted from native scrolling once this leaves the default. #[serde(default)] pub thumbwheel_sensitivity: ThumbwheelSensitivity, + /// Hold-mode pinch-zoom responsiveness. Scales how much magnification a + /// millimetre of pointer travel produces while a Zoom-bound button is + /// held. [`ZoomSensitivity::DEFAULT`] means 1x of the base rate. + #[serde(default)] + pub zoom_sensitivity: ZoomSensitivity, + /// Whether hold-mode pan moves the view instead of the content. Off by + /// default, which matches a trackpad's natural scrolling: the content + /// follows the hand. On, the viewport follows the hand instead, the way + /// dragging a scrollbar does. + #[serde(default)] + pub invert_pan: bool, /// Light/dark appearance preference. Defaults to following the OS. #[serde(default)] pub appearance: Appearance, @@ -428,6 +439,74 @@ impl From for i32 { } } +/// Hold-mode pinch-zoom responsiveness on OpenLogi's `1..=100` scale. +#[nutype( + const_fn, + validate(greater_or_equal = SENSITIVITY_MIN, less_or_equal = SENSITIVITY_MAX), + derive( + Debug, + Clone, + Copy, + PartialEq, + Eq, + PartialOrd, + Ord, + TryFrom, + Into, + Display, + Serialize, + Deserialize + ) +)] +pub struct ZoomSensitivity(u8); + +impl ZoomSensitivity { + /// Lowest selectable sensitivity. + pub const MIN: Self = match Self::try_new(SENSITIVITY_MIN) { + Ok(value) => value, + Err(_) => panic!("valid minimum zoom sensitivity"), + }; + /// Highest selectable sensitivity. + pub const MAX: Self = match Self::try_new(SENSITIVITY_MAX) { + Ok(value) => value, + Err(_) => panic!("valid maximum zoom sensitivity"), + }; + /// Out-of-the-box sensitivity. At this value zoom runs at 1x of the + /// hold-mode base rate. + pub const DEFAULT: Self = match Self::try_new(SENSITIVITY_DEFAULT) { + Ok(value) => value, + Err(_) => panic!("valid default zoom sensitivity"), + }; + + /// Round and clamp a floating-point slider value into the valid range. + #[must_use] + pub fn from_rounded(value: f32) -> Self { + let raw = rounded_sensitivity(value); + let Ok(value) = Self::try_new(raw) else { + unreachable!("clamped zoom sensitivity is always valid"); + }; + value + } + + /// Magnification-per-travel multiplier relative to [`Self::DEFAULT`]. + #[must_use] + pub fn zoom_multiplier(self) -> f32 { + f32::from(self.into_inner()) / f32::from(Self::DEFAULT.into_inner()) + } +} + +impl Default for ZoomSensitivity { + fn default() -> Self { + Self::DEFAULT + } +} + +impl From for f32 { + fn from(sensitivity: ZoomSensitivity) -> Self { + Self::from(sensitivity.into_inner()) + } +} + fn rounded_sensitivity(value: f32) -> u8 { let value = if value.is_nan() { f32::from(SENSITIVITY_MIN) @@ -460,6 +539,8 @@ impl Default for AppSettings { capture_mouse_events: true, smooth_scroll: false, vertical_scroll_sensitivity: VerticalScrollSensitivity::DEFAULT, + zoom_sensitivity: ZoomSensitivity::DEFAULT, + invert_pan: false, auto_download_assets: true, asset_source: AssetSourcePreference::Automatic, language: None, diff --git a/crates/openlogi-desktop/src/features/action_ring/editor.rs b/crates/openlogi-desktop/src/features/action_ring/editor.rs index cfb8aadff..d28f50ed0 100644 --- a/crates/openlogi-desktop/src/features/action_ring/editor.rs +++ b/crates/openlogi-desktop/src/features/action_ring/editor.rs @@ -385,5 +385,14 @@ mod tests { assert!(actions.contains(&Action::MissionControl)); assert!(!actions.contains(&Action::None)); assert!(!actions.contains(&Action::ShowActionsRing)); + assert!( + !actions.contains(&Action::Pan), + "a ring tap cannot run hold-mode pan" + ); + assert!( + !actions.contains(&Action::Zoom), + "a ring tap cannot run hold-mode zoom" + ); + assert!(!actions.iter().any(Action::is_hold_mode)); } } diff --git a/crates/openlogi-desktop/src/features/keyboard/function_row.rs b/crates/openlogi-desktop/src/features/keyboard/function_row.rs index 1036863c1..b7e47a0df 100644 --- a/crates/openlogi-desktop/src/features/keyboard/function_row.rs +++ b/crates/openlogi-desktop/src/features/keyboard/function_row.rs @@ -40,8 +40,8 @@ use super::editors::{ use crate::app::{glow_canvas, keyboard_glow}; use crate::features::mouse::geometry::asset_dimensions_for_png; use crate::features::mouse::picker::{ - PickFn, action_icon_path, action_rows, compact_panel, divider, editor_scroll_list, - editor_section, + ActionCatalogKind, PickFn, action_icon_path, action_rows, compact_panel, divider, + editor_scroll_list, editor_section, }; use crate::services::assets::{GlowGeometry, ResolvedAsset}; use crate::state::{AppState, DeviceRecord, StateEvent}; @@ -862,7 +862,14 @@ fn panel_action_rows( view: &Entity, pal: &Palette, ) -> Vec { - let mut children = action_rows("panel-action", current, on_pick, *pal); + // A keypress cannot keep a hold alive — same Instant catalog as a swipe slot. + let mut children = action_rows( + "panel-action", + current, + ActionCatalogKind::Instant, + on_pick, + *pal, + ); let power_user_actions: &[(PowerUserKind, &str, &'static str)] = &[ ( diff --git a/crates/openlogi-desktop/src/features/mouse/inspector.rs b/crates/openlogi-desktop/src/features/mouse/inspector.rs index f90a3f581..a8726b508 100644 --- a/crates/openlogi-desktop/src/features/mouse/inspector.rs +++ b/crates/openlogi-desktop/src/features/mouse/inspector.rs @@ -16,8 +16,8 @@ use openlogi_core::binding::{Action, ButtonId, GestureDirection, default_binding use super::hotspots::MouseControlId; use super::picker::{ - GESTURE_BUTTON_ICON, PickFn, action_icon_path, action_rows_matching, editor_section, - gesture_direction_icon, + ActionCatalogKind, GESTURE_BUTTON_ICON, PickFn, action_icon_path, action_rows_matching, + editor_section, gesture_direction_icon, }; use super::thumbwheel::ThumbwheelPreset; use super::view::MouseModelView; @@ -213,6 +213,7 @@ fn button_inspector( Some(&action), picker.search, &on_pick, + ActionCatalogKind::Button, pal, cx, )) @@ -266,6 +267,7 @@ fn inherited_gesture_inspector( None, picker.search, &on_pick, + ActionCatalogKind::Button, pal, cx, )) @@ -329,6 +331,7 @@ fn gesture_inspector( Some(¤t), picker.search, &on_pick, + ActionCatalogKind::Instant, pal, cx, )) @@ -621,11 +624,12 @@ fn action_library( current: Option<&Action>, action_search: &Entity, on_pick: &PickFn, + kind: ActionCatalogKind, pal: Palette, cx: &Context, ) -> impl IntoElement { let query = action_search.read(cx).value(); - let rows = action_rows_matching(id_prefix, current, &query, on_pick, pal); + let rows = action_rows_matching(id_prefix, current, &query, kind, on_pick, pal); v_flex() .gap_2() .pt_1() diff --git a/crates/openlogi-desktop/src/features/mouse/picker.rs b/crates/openlogi-desktop/src/features/mouse/picker.rs index e7da9e552..5d36acdde 100644 --- a/crates/openlogi-desktop/src/features/mouse/picker.rs +++ b/crates/openlogi-desktop/src/features/mouse/picker.rs @@ -7,7 +7,7 @@ use gpui::{ Styled, Window, div, prelude::FluentBuilder as _, px, rgb, svg, }; use gpui_component::{Icon, IconName, Selectable as _, h_flex, v_flex}; -use openlogi_core::binding::{Action, Category, GestureDirection}; +use openlogi_core::binding::{Action, ActionRingIcon, Category, GestureDirection}; use crate::ui::components::MenuRow; use crate::ui::section::section_label; @@ -19,11 +19,36 @@ pub(crate) const EDITOR_LIST_MAX_H: f32 = 360.; /// Commit callback invoked when an action row is clicked. pub(crate) type PickFn = Rc; +/// Which bindings a catalog is allowed to offer. +/// +/// Hold-mode actions need a press that stays down. A swipe slot, function key, +/// or ring tap cannot keep that hold alive, so those surfaces use +/// [`ActionCatalogKind::Instant`]. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum ActionCatalogKind { + /// A physical button: hold-mode pan and zoom are offered here. + Button, + /// A tap, keypress, or swipe: hold-mode actions cannot fire. + Instant, +} + +impl ActionCatalogKind { + fn admits(self, action: &Action) -> bool { + match self { + Self::Button => true, + Self::Instant => !action.is_hold_mode(), + } + } +} + /// The action catalog grouped by [`Category`], preserving catalog order within /// each group and first-seen order across groups. -pub(crate) fn grouped_catalog() -> Vec<(Category, Vec)> { +pub(crate) fn grouped_catalog(kind: ActionCatalogKind) -> Vec<(Category, Vec)> { let mut sections: Vec<(Category, Vec)> = Vec::new(); for action in Action::catalog() { + if !kind.admits(&action) { + continue; + } let category = action.category(); if let Some(section) = sections .iter_mut() @@ -104,6 +129,28 @@ pub(crate) fn action_icon_path(action: &Action) -> &'static str { "action-icons/keyboard.svg" } Action::RunAppleScript(_) | Action::RunShellCommand(_) => "action-icons/terminal.svg", + // Same glyphs the ring registry assigns — a hand-edited binding must + // not show a different icon than the picker row that offered it. + Action::Pan | Action::Zoom => ActionRingIcon::for_action(action).asset_path(), + } +} + +/// Host-honest picker caption for a hold-mode action. +/// +/// macOS can deliver a phased trackpad pan and a real pinch; Linux and +/// Windows degrade to wheel ticks and Ctrl+wheel, so those hosts must not +/// promise rubber-band, momentum, or pinch. +pub(crate) fn hold_mode_hint(action: &Action) -> Option<&'static str> { + match action { + Action::Pan if cfg!(target_os = "macos") => { + Some("Hold and drag to scroll in any direction.") + } + Action::Pan => Some("Hold and drag to scroll."), + Action::Zoom if cfg!(target_os = "macos") => { + Some("Hold and drag up or down to pinch-zoom.") + } + Action::Zoom => Some("Hold and drag to Ctrl+zoom."), + _ => None, } } @@ -111,10 +158,11 @@ pub(crate) fn action_icon_path(action: &Action) -> &'static str { pub(crate) fn action_rows( id_prefix: &'static str, current: Option<&Action>, + kind: ActionCatalogKind, on_pick: &PickFn, pal: Palette, ) -> Vec { - action_rows_matching(id_prefix, current, "", on_pick, pal) + action_rows_matching(id_prefix, current, "", kind, on_pick, pal) } /// Build action rows filtered by localized action or category name. @@ -122,13 +170,14 @@ pub(crate) fn action_rows_matching( id_prefix: &'static str, current: Option<&Action>, query: &str, + kind: ActionCatalogKind, on_pick: &PickFn, pal: Palette, ) -> Vec { let query = query.trim().to_lowercase(); let mut catalog_index = 0usize; let mut sections = Vec::new(); - for (category, actions) in grouped_catalog() { + for (category, actions) in grouped_catalog(kind) { let category_label = rust_i18n::t!(category.label()); let category_matches = category_label.to_lowercase().contains(&query); // Number the full catalog before filtering so typing in the search box @@ -158,7 +207,11 @@ pub(crate) fn action_rows_matching( .children(actions.into_iter().map(|(action_key, action)| { let selected = current == Some(&action); let label = tr!(action.label()); - let accessible_label = label.clone(); + let hint = hold_mode_hint(&action); + let accessible_label = match hint { + Some(key) => format!("{label} — {}", rust_i18n::t!(key)).into(), + None => label.clone(), + }; let icon_path = action_icon_path(&action); let on_pick = on_pick.clone(); MenuRow::new((id_prefix, action_key)) @@ -176,7 +229,14 @@ pub(crate) fn action_rows_matching( .flex_none() .text_color(pal.text_muted), ) - .child(div().child(label)), + .child(v_flex().min_w_0().child(div().child(label)).children( + hint.map(|key| { + div() + .text_caption() + .text_color(pal.text_muted) + .child(tr!(key)) + }), + )), ) .when(selected, |row| { row.child( @@ -239,12 +299,80 @@ pub(crate) fn editor_scroll_list( mod tests { use super::*; - #[test] - fn gesture_action_catalog_includes_actions_ring() { - let actions = grouped_catalog() + fn catalog_actions(kind: ActionCatalogKind) -> Vec { + grouped_catalog(kind) .into_iter() .flat_map(|(_, actions)| actions) - .collect::>(); - assert!(actions.contains(&Action::ShowActionsRing)); + .collect() + } + + #[test] + fn button_catalog_offers_hold_mode() { + let actions = catalog_actions(ActionCatalogKind::Button); + assert!(actions.contains(&Action::Pan)); + assert!(actions.contains(&Action::Zoom)); + assert!(actions.contains(&Action::ScrollUp)); + } + + #[test] + fn instant_catalog_omits_hold_mode() { + let actions = catalog_actions(ActionCatalogKind::Instant); + assert!(!actions.contains(&Action::Pan)); + assert!(!actions.contains(&Action::Zoom)); + assert!( + !actions.iter().any(Action::is_hold_mode), + "a swipe or keypress catalog leaked a hold-mode action" + ); + assert!(actions.contains(&Action::ScrollUp)); + assert!(actions.contains(&Action::Copy)); + } + + #[test] + fn hold_mode_icons_match_the_ring_registry() { + assert_eq!( + action_icon_path(&Action::Pan), + ActionRingIcon::for_action(&Action::Pan).asset_path() + ); + assert_eq!( + action_icon_path(&Action::Zoom), + ActionRingIcon::for_action(&Action::Zoom).asset_path() + ); + assert_eq!(action_icon_path(&Action::Pan), "action-icons/mouse.svg"); + assert_eq!(action_icon_path(&Action::Zoom), "action-icons/search.svg"); + } + + #[test] + fn hold_mode_hints_are_honest_on_this_host() { + assert_eq!(hold_mode_hint(&Action::ScrollUp), None); + assert_eq!(hold_mode_hint(&Action::Copy), None); + if cfg!(target_os = "macos") { + assert_eq!( + hold_mode_hint(&Action::Pan), + Some("Hold and drag to scroll in any direction.") + ); + assert_eq!( + hold_mode_hint(&Action::Zoom), + Some("Hold and drag up or down to pinch-zoom.") + ); + } else { + assert_eq!( + hold_mode_hint(&Action::Pan), + Some("Hold and drag to scroll.") + ); + assert_eq!( + hold_mode_hint(&Action::Zoom), + Some("Hold and drag to Ctrl+zoom.") + ); + } + } + + #[test] + fn gesture_action_catalog_includes_actions_ring() { + for kind in [ActionCatalogKind::Button, ActionCatalogKind::Instant] { + assert!( + catalog_actions(kind).contains(&Action::ShowActionsRing), + "{kind:?} picker dropped the Actions Ring" + ); + } } } diff --git a/crates/openlogi-desktop/src/services/i18n.rs b/crates/openlogi-desktop/src/services/i18n.rs index a6ccc435a..2923794b6 100644 --- a/crates/openlogi-desktop/src/services/i18n.rs +++ b/crates/openlogi-desktop/src/services/i18n.rs @@ -163,14 +163,24 @@ mod tests { "blurb key missing from zh-TW.yml" ); - rust_i18n::set_locale("it"); - assert_eq!(rust_i18n::t!("Settings"), "Impostazioni"); - assert_eq!(rust_i18n::t!("Left Click"), "Click sinistro"); - assert_eq!(rust_i18n::t!("Cancel"), "Annulla"); + assert_italian_hold_mode_keys_are_not_window_zoom(); // English is the Crowdin source locale. rust_i18n::set_locale("en"); assert_eq!(rust_i18n::t!("Settings"), "Settings"); assert_eq!(rust_i18n::t!(BLURB), BLURB); } + + /// Called under [`locale_file_resolves_keys`]'s locale lock. Window-menu + /// Zoom is "Ridimensiona"; the hold action must keep its own key. + fn assert_italian_hold_mode_keys_are_not_window_zoom() { + rust_i18n::set_locale("it"); + assert_eq!(rust_i18n::t!("Settings"), "Impostazioni"); + assert_eq!(rust_i18n::t!("Left Click"), "Click sinistro"); + assert_eq!(rust_i18n::t!("Cancel"), "Annulla"); + assert_eq!(rust_i18n::t!("Zoom"), "Ridimensiona"); + assert_eq!(rust_i18n::t!("Pinch Zoom"), "Zoom pinch"); + assert_ne!(rust_i18n::t!("Pinch Zoom"), rust_i18n::t!("Zoom")); + assert_eq!(rust_i18n::t!("Pan"), "Scorrimento"); + } } diff --git a/crates/openlogi-desktop/src/state/settings.rs b/crates/openlogi-desktop/src/state/settings.rs index 00ebaeb56..77342e4cb 100644 --- a/crates/openlogi-desktop/src/state/settings.rs +++ b/crates/openlogi-desktop/src/state/settings.rs @@ -4,7 +4,7 @@ use super::{AppState, StateEvent}; use gpui::Context; use openlogi_core::config::{ AppIcon, AppSettings, Appearance, AssetSourcePreference, DeviceViewMode, ThumbwheelSensitivity, - UiScale, VerticalScrollSensitivity, + UiScale, VerticalScrollSensitivity, ZoomSensitivity, }; impl AppState { @@ -270,6 +270,28 @@ impl AppState { .edit(|config| config.app_settings.vertical_scroll_sensitivity = sensitivity); self.persist_and_reload("vertical scroll sensitivity"); } + /// Set hold-mode zoom responsiveness and persist it. The agent picks the + /// value up on config reload and applies it to the next hold. No-op when + /// unchanged; disk failures restore the persisted value. + pub fn set_zoom_sensitivity(&mut self, sensitivity: ZoomSensitivity) { + if self.config.app_settings.zoom_sensitivity == sensitivity { + return; + } + self.config + .edit(|config| config.app_settings.zoom_sensitivity = sensitivity); + self.persist_and_reload("zoom sensitivity"); + } + + /// Set whether hold-mode pan moves the view instead of the content. + pub fn set_invert_pan(&mut self, inverted: bool) { + if self.config.app_settings.invert_pan == inverted { + return; + } + self.config + .edit(|config| config.app_settings.invert_pan = inverted); + self.persist_and_reload("pan direction"); + } + pub fn set_auto_download_assets(&mut self, enabled: bool) { if self.config.app_settings.auto_download_assets == enabled { return; diff --git a/crates/openlogi-desktop/src/windows/settings.rs b/crates/openlogi-desktop/src/windows/settings.rs index 142b4e5c6..0d8665ad6 100644 --- a/crates/openlogi-desktop/src/windows/settings.rs +++ b/crates/openlogi-desktop/src/windows/settings.rs @@ -35,6 +35,7 @@ pub(super) use gpui_updater::{UpdateStatus, Updater}; pub(super) use openlogi_core::brand::{HELP_URL, RELEASES_URL, REPO_URL}; pub(super) use openlogi_core::config::{ Appearance, AssetSourcePreference, ThumbwheelSensitivity, UiScale, VerticalScrollSensitivity, + ZoomSensitivity, }; pub(super) use crate::app::menu::{CloseWindow, Minimize, Zoom}; @@ -119,8 +120,9 @@ pub struct SettingsView { initial_page: SettingsPage, language_select: Entity>>, asset_source_select: Entity>>, - thumbwheel_sensitivity_slider: Entity, - vertical_scroll_sensitivity_slider: Entity, + /// The General page's sensitivity sliders, owned here so their values + /// survive a page switch. + sensitivity_sliders: general::SensitivitySliders, /// Shared app-wide updater, surfaced on the Updates page. A launch-time /// check result is already visible when the window opens. updater: Entity, @@ -212,9 +214,7 @@ impl SettingsView { cx.subscribe_in(&asset_source_select, window, Self::on_asset_source_select) .detach(); - let thumbwheel_sensitivity_slider = Self::thumbwheel_sensitivity_slider(window, cx); - let vertical_scroll_sensitivity_slider = - Self::vertical_scroll_sensitivity_slider(window, cx); + let sensitivity_sliders = Self::sensitivity_sliders(window, cx); // Poll the agent's live event monitor while this window is open. The task // is held in the view, so closing Settings drops it, polling stops, and @@ -261,8 +261,7 @@ impl SettingsView { initial_page, language_select, asset_source_select, - thumbwheel_sensitivity_slider, - vertical_scroll_sensitivity_slider, + sensitivity_sliders, updater, updater_obs, copied: false, @@ -326,6 +325,62 @@ impl SettingsView { slider } + /// Build the General page's sensitivity sliders. Each subscribes itself + /// to this view, so they are constructed once and outlive page switches. + fn sensitivity_sliders( + window: &mut Window, + cx: &mut Context, + ) -> general::SensitivitySliders { + general::SensitivitySliders { + vertical_scroll: Self::vertical_scroll_sensitivity_slider(window, cx), + thumbwheel: Self::thumbwheel_sensitivity_slider(window, cx), + zoom: Self::zoom_sensitivity_slider(window, cx), + } + } + + fn zoom_sensitivity_slider(window: &mut Window, cx: &mut Context) -> Entity { + let current = AppState::try_read(cx).map_or(ZoomSensitivity::DEFAULT, |state| { + state.app_settings().zoom_sensitivity + }); + let slider = cx.new(|_| { + SliderState::new() + .min(f32::from(ZoomSensitivity::MIN)) + .max(f32::from(ZoomSensitivity::MAX)) + .default_value(f32::from(current)) + }); + cx.subscribe_in(&slider, window, Self::on_zoom_sensitivity_slider) + .detach(); + slider + } + + /// Commit the hold-mode zoom sensitivity once the slider is released. + #[expect( + clippy::unused_self, + reason = "gpui subscription handlers must take &mut self" + )] + fn on_zoom_sensitivity_slider( + &mut self, + slider: &Entity, + event: &SliderEvent, + window: &mut Window, + cx: &mut Context, + ) { + if let SliderEvent::Release(value) = event { + let sensitivity = ZoomSensitivity::from_rounded(value.start()); + let committed = AppState::update(cx, |state, cx| { + state.set_zoom_sensitivity(sensitivity); + cx.emit(StateEvent::SettingsChanged); + state.app_settings().zoom_sensitivity + }); + // A failed write restores AppState's persisted configuration. + // Re-seat the slider so it cannot keep showing a rejected value. + slider.update(cx, |slider, cx| { + slider.set_value(f32::from(committed), window, cx); + }); + } + cx.notify(); + } + /// Commit the thumb-wheel sensitivity slider. The label tracks the live /// slider value on every `Change`; persistence happens once on `Release`. #[expect( @@ -491,10 +546,7 @@ impl Render for SettingsView { group_ix: None, }) .page(general::general_page( - general::SensitivitySliders { - vertical_scroll: self.vertical_scroll_sensitivity_slider.clone(), - thumbwheel: self.thumbwheel_sensitivity_slider.clone(), - }, + self.sensitivity_sliders.clone(), self.registration_status, )) .page(updates::updates_page(self.updater.clone())); diff --git a/crates/openlogi-desktop/src/windows/settings/general.rs b/crates/openlogi-desktop/src/windows/settings/general.rs index 59a4805f0..490e948c1 100644 --- a/crates/openlogi-desktop/src/windows/settings/general.rs +++ b/crates/openlogi-desktop/src/windows/settings/general.rs @@ -3,18 +3,21 @@ use super::{ App, AppState, Entity, FluentBuilder, IconName, InteractiveElement, ParentElement, SettingField, SettingGroup, SettingItem, SettingPage, Slider, SliderState, StateEvent, Styled, - ThumbwheelSensitivity, VerticalScrollSensitivity, div, h_flex, px, theme, v_flex, + ThumbwheelSensitivity, VerticalScrollSensitivity, ZoomSensitivity, div, h_flex, px, theme, + v_flex, }; use crate::ui::theme::Typography as _; use gpui_base::Button as BaseButton; use crate::platform::registration::ServiceStatus; -/// The page's two sensitivity sliders, named so a call site cannot swap two +/// The page's sensitivity sliders, named so a call site cannot swap two /// same-typed `Entity`s without the compiler noticing. +#[derive(Clone)] pub(super) struct SensitivitySliders { pub(super) vertical_scroll: Entity, pub(super) thumbwheel: Entity, + pub(super) zoom: Entity, } pub(super) fn general_page( @@ -24,6 +27,7 @@ pub(super) fn general_page( let SensitivitySliders { vertical_scroll, thumbwheel, + zoom, } = sliders; let group = SettingGroup::new() .item(smooth_scrolling_item()) @@ -49,6 +53,16 @@ pub(super) fn general_page( "Scales the thumb wheel's horizontal scroll speed and how readily custom wheel actions trigger." )), ) + .item( + SettingItem::new( + tr!("Zoom Sensitivity"), + SettingField::render(move |_, _, cx| zoom_sensitivity_field(&zoom, cx)), + ) + .description(tr!( + "Scales how far pinch zoom magnifies for a given amount of pointer travel." + )), + ) + .item(invert_pan_item()) .item(launch_at_login_item()); // Switched off under System Settings › Login Items: nothing can start @@ -118,6 +132,35 @@ fn smooth_scrolling_item() -> SettingItem { )) } +/// The hold-mode pan direction switch. +fn invert_pan_item() -> SettingItem { + SettingItem::new( + tr!("Invert pan direction"), + SettingField::switch( + |cx| AppState::try_read(cx).is_some_and(|s| s.app_settings().invert_pan), + |enabled, cx| { + AppState::update(cx, move |state, cx| { + state.set_invert_pan(enabled); + cx.emit(StateEvent::SettingsChanged); + }); + }, + ), + ) + .description(tr!( + "Move the view instead of the content while panning. Off matches a trackpad, where content follows your hand." + )) +} + +fn zoom_sensitivity_field(slider: &Entity, cx: &mut App) -> gpui::Div { + let value = ZoomSensitivity::from_rounded(slider.read(cx).value().start()); + sensitivity_field( + slider, + value.to_string(), + value == ZoomSensitivity::DEFAULT, + cx, + ) +} + fn thumbwheel_sensitivity_field(slider: &Entity, cx: &mut App) -> gpui::Div { let value = ThumbwheelSensitivity::from_rounded(slider.read(cx).value().start()); sensitivity_field( diff --git a/crates/openlogi-device/src/lib.rs b/crates/openlogi-device/src/lib.rs index 78bce48ef..7945b2af6 100644 --- a/crates/openlogi-device/src/lib.rs +++ b/crates/openlogi-device/src/lib.rs @@ -52,7 +52,8 @@ pub use pairing::{ }; pub use session::gesture::{ CaptureChannel, CaptureSessionFailure, CaptureSessionOutcome, CapturedInput, GestureError, - PendingCaptureRestore, run_capture_session, run_capture_session_with_registry_spec, + HoldRelease, PendingCaptureRestore, run_capture_session, + run_capture_session_with_registry_spec, }; pub use session::host_switch::{ HostSwitchError, HostSwitchStopReason, run_host_switch_session, switch_linked_hosts, @@ -65,14 +66,15 @@ pub use write::{ HapticWaveform, HidppFeatureErrorKind, HidppOperation, LITRA_BEAM_PRODUCT_ID, LITRA_GLOW_PRODUCT_ID, LightCommand, LightingMethod, LitraDescriptor, LitraModel, ReprogControlEntry, ScrollReportingTarget, ScrollResolution, ScrollWheelMode, WriteError, - apply_litra, commands_for_light_settings, dump_features, dump_firmware_entities, - dump_reprog_controls, encode_litra_command, ensure_haptics_armed_on, find_litra, get_backlight, - get_dpi, get_dpi_info, get_dpi_info_on, get_scroll_wheel_mode, get_scroll_wheel_mode_on, - get_smartshift_status, get_smartshift_status_on, litra_model_for_route, matches_litra, - play_haptic, play_haptic_on, read_battery_raw, set_backlight_enabled, set_dpi, set_dpi_on, - set_fn_lock, set_fn_lock_on, set_keyboard_color, set_keyboard_color_on, - set_keyboard_color_with, set_keyboard_color_with_on, set_scroll_inversion, - set_scroll_inversion_on, set_scroll_resolution, set_scroll_resolution_on, - set_scroll_wheel_mode, set_scroll_wheel_mode_on, set_smartshift, set_smartshift_on, - set_smartshift_sensitivity, toggle_smartshift, toggle_smartshift_on, + apply_litra, cached_sensor_dpi, commands_for_light_settings, dump_features, + dump_firmware_entities, dump_reprog_controls, encode_litra_command, ensure_haptics_armed_on, + find_litra, get_backlight, get_dpi, get_dpi_info, get_dpi_info_on, get_dpi_on, + get_scroll_wheel_mode, get_scroll_wheel_mode_on, get_smartshift_status, + get_smartshift_status_on, litra_model_for_route, matches_litra, play_haptic, play_haptic_on, + read_battery_raw, remember_sensor_dpi, set_backlight_enabled, set_dpi, set_dpi_on, set_fn_lock, + set_fn_lock_on, set_keyboard_color, set_keyboard_color_on, set_keyboard_color_with, + set_keyboard_color_with_on, set_scroll_inversion, set_scroll_inversion_on, + set_scroll_resolution, set_scroll_resolution_on, set_scroll_wheel_mode, + set_scroll_wheel_mode_on, set_smartshift, set_smartshift_on, set_smartshift_sensitivity, + toggle_smartshift, toggle_smartshift_on, }; diff --git a/crates/openlogi-device/src/session/gesture.rs b/crates/openlogi-device/src/session/gesture.rs index 50d3c8105..d0313473d 100644 --- a/crates/openlogi-device/src/session/gesture.rs +++ b/crates/openlogi-device/src/session/gesture.rs @@ -31,12 +31,16 @@ use hidpp::{ }, protocol::v20, }; -use openlogi_core::binding::{ButtonId, GestureDirection, SwipeAccumulator}; +use openlogi_core::binding::{ + ButtonId, GestureDirection, HOLD_STALE, StreamRelease, SwipeAccumulator, +}; +use openlogi_core::hid::Dpi; use tokio::sync::{mpsc, oneshot}; use tracing::{debug, info, warn}; use crate::backend::{BackendError, HidBackend}; use crate::channel::route::{DeviceRoute, open_route_channel}; +use crate::write::cached_sensor_dpi; use crate::{ChannelRegistry, DeviceIoGate, SharedChannel}; use liveness::{CaptureLiveness, ChannelActivity, LivenessDecision, PingOutcome}; @@ -91,6 +95,52 @@ pub enum CapturedInput { /// An instantaneous firmware-reported tap with no observable hold /// duration, such as the thumb-wheel touch sensor. ButtonPulse(ButtonId), + /// A hold-mode control (`Pan` / `Zoom`) began. + /// + /// Dedicated rather than [`Self::ButtonDown`]: the button runtime must + /// not treat this as a one-shot press, and a control already down when + /// the session starts never emits this. Motion uses [`Self::HoldMotion`]; + /// the matching [`Self::HoldEnd`] carries whether travel cleared the + /// physical deadzone. + HoldBegin(ButtonId), + /// Raw-XY delta for an in-progress hold-mode stream. A torn-down hold + /// stays terminal: late motion never re-opens one. + HoldMotion { + /// The button whose hold owns this motion. + button: ButtonId, + /// Horizontal raw-XY delta. + dx: i16, + /// Vertical raw-XY delta. + dy: i16, + }, + /// The hold-mode stream ended. See [`HoldRelease`] for why, which is not + /// the same question as how far it traveled. + HoldEnd { + /// The button whose hold ended. + button: ButtonId, + /// Whether the user let go, and if so what they did while holding. + release: HoldRelease, + }, +} + +/// Why a hold-mode stream ended. +/// +/// Click-versus-drag is only a meaningful question when the user actually let +/// go. Capture also closes streams out from under a control that is still +/// down — a reconnect re-arms the diverts, teardown drops them, the stale +/// bound gives up on a lost button-up — and none of those are a click, +/// however little the hold traveled. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum HoldRelease { + /// The user released the control. + Released { + /// Whether travel cleared the physical click/drag deadzone. `false` + /// is a click without a drag. + traveled: bool, + }, + /// Capture closed the stream while the control was still held. The + /// injector must close its session; nothing else may fire. + Interrupted, } /// The hold that owns raw-XY motion, or the absence of one. Raw-XY reports @@ -114,23 +164,50 @@ enum HoldState { /// A second armed source is held alongside the holder. Overlap motion /// could belong to either control — dropped until the overlap ends. overlap: bool, - /// The hold's next raw-XY sample must be dropped: the haptic panel's - /// first sample after contact is an absolute position jump, not a - /// delta (see [`reprog_controls::HAPTIC_PANEL_CID`]). + /// The hold's next raw-XY sample must be dropped. See + /// [`skip_first_sample`] for the two firmware artifacts this covers. skip_first_raw_xy: bool, + /// Discrete swipe vs hold-mode stream. Hold-mode travel never goes + /// through the swipe classifier. + kind: HoldKind, }, } -/// Begin a hold for `cid`, its swipe accumulator started fresh. -fn begin_hold(cid: u16, button: ButtonId, overlap: bool, skip_first_raw_xy: bool) -> HoldState { +/// Whether a hold classifies a discrete swipe or streams raw-XY. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum HoldKind { + /// Gesture-mode swipe: commit a direction or click. + Swipe, + /// Hold-mode Pan/Zoom: stream deltas, never a `Gesture(..., Click)`. + Stream, +} + +/// Begin a hold for `cid`. `kind` chooses the accumulator mode; stream holds +/// require `dpi` so the deadzone is physical. +fn begin_hold( + cid: u16, + button: ButtonId, + overlap: bool, + skip_first_raw_xy: bool, + kind: HoldKind, + dpi: Option, +) -> HoldState { let mut swipe = SwipeAccumulator::default(); - swipe.begin(); + match (kind, dpi) { + (HoldKind::Stream, Some(dpi)) => swipe.begin_stream(dpi), + (HoldKind::Stream, None) => { + warn!(cid, %button, "hold-mode stream armed without DPI — dropping the press"); + return HoldState::Idle; + } + (HoldKind::Swipe, _) => swipe.begin(), + } HoldState::Holding { cid, button, swipe, overlap, skip_first_raw_xy, + kind, } } @@ -149,6 +226,69 @@ struct CaptureAccum { dpi_down: bool, /// Diverted standard-button CIDs held in the last event. buttons_down: Vec, + /// Hold-mode CIDs held in the last event. Seeded across reconnect so an + /// already-down control is not a press. + hold_downs: Vec, +} + +impl CaptureAccum { + /// Close any open hold-mode stream, returning the terminal end that must + /// reach the injector before this accum is wiped (reconnect, teardown). + /// + /// Always [`HoldRelease::Interrupted`]: the control is still down as far + /// as anyone here knows, so however far this hold traveled, the user has + /// not clicked anything. + #[must_use] + fn take_terminal_stream_end(&mut self) -> Option { + match std::mem::take(&mut self.hold) { + HoldState::Holding { + button, + mut swipe, + kind: HoldKind::Stream, + .. + } => { + let _ = swipe.end_stream(); + Some(CapturedInput::HoldEnd { + button, + release: HoldRelease::Interrupted, + }) + } + HoldState::Holding { mut swipe, .. } => { + let _ = swipe.end(); + None + } + HoldState::Idle => None, + } + } + + /// Expire a hold-mode stream that never saw its release. + /// + /// The bound is on quiet time, not on how long the control has been down. + /// A hold-mode pan is meant to run for as long as the user keeps panning, + /// and measuring from the press ended every gesture mid-motion after + /// [`HOLD_STALE`]. What actually indicates a lost button-up is silence: + /// the firmware streams raw XY only while the control is down, so when + /// the release goes missing, so does the motion. + /// + /// A control held perfectly still for [`HOLD_STALE`] is therefore read as + /// abandoned, and re-arms on its next press. The sensor resolves 0.03mm + /// at 950 DPI, so a hand resting on the mouse rarely goes that quiet. + #[must_use] + fn expire_stale_stream(&mut self, now: std::time::Instant) -> Option { + let HoldState::Holding { + swipe, + kind: HoldKind::Stream, + .. + } = &self.hold + else { + return None; + }; + let last_activity = swipe.last_activity()?; + if now.saturating_duration_since(last_activity) < HOLD_STALE { + return None; + } + self.take_terminal_stream_end() + } } #[cfg(test)] @@ -160,6 +300,25 @@ impl CaptureAccum { swipe.backdate_hold_for_test(); } } + + /// Silence the current hold-mode stream for longer than [`HOLD_STALE`]. + fn backdate_hold_past_stale_for_test(&mut self) { + if let HoldState::Holding { swipe, .. } = &mut self.hold { + swipe.backdate_hold_past_stale_for_test(); + } + } + + /// Age only the press behind the current hold-mode stream past + /// [`HOLD_STALE`], leaving its samples current. + fn backdate_press_past_stale_for_test(&mut self) { + if let HoldState::Holding { swipe, .. } = &mut self.hold { + swipe.backdate_press_past_stale_for_test(); + } + } + + fn seed_hold_already_down_for_test(&mut self, cids: &[u16]) { + self.hold_downs = cids.to_vec(); + } } /// HID++-divertable standard buttons: the `0x1b04` control ID and the @@ -212,6 +371,20 @@ pub struct CaptureSpec { /// [`DIVERTABLE_STANDARD_BUTTONS`] and non-gesturing /// [`GESTURE_SOURCE_BUTTONS`] whose binding leaves the default. pub divert_buttons: Vec<(u16, ButtonId)>, + /// Hold-mode (`Pan` / `Zoom`) CIDs diverted with raw-XY, the same + /// reporting mode [`Self::divert_gesture_buttons`] uses. A control whose + /// firmware cannot stream raw-XY is not armed. + pub divert_hold_buttons: Vec<(u16, ButtonId)>, + /// Sensor DPI used to size the hold-mode click/drag deadzone. The plan + /// always supplies one (live read, config, or a named factory default). + pub sensor_dpi: Option, + /// How many hold-mode bindings the plan asked to arm. Compared with + /// [`ArmedControls`]'s hold list so a silent skip is visible in the + /// `control capture active` log. + pub hold_requested: usize, + /// Whether synthesised pan/zoom can be delivered. The only remaining + /// reason to leave `divert_hold_buttons` empty when holds are bound. + pub injection_available: bool, } /// Capture the controls selected by `spec` on `route` until `shutdown` @@ -306,9 +479,7 @@ async fn run_capture_session_on( // Publish this device's open channel so DPI/SmartShift writes reuse it // instead of opening their own. Cleared on the way out. - if let Ok(mut slot) = channel_slot.write() { - *slot = Some(shared.clone()); - } + publish_session_channel(&channel_slot, &shared); let accum = Arc::new(Mutex::new(CaptureAccum::default())); let reprog_index = armed.reprog.as_ref().map(ReprogControlsV4::feature_index); @@ -324,6 +495,9 @@ async fn run_capture_session_on( .map_or(WheelResolution::UNKNOWN, ArmedThumbwheel::resolution); let dpi_set = armed.dpi_cids.clone(); let button_set = armed.button_cids.clone(); + let hold_button_set = armed.hold_button_cids.clone(); + let planned_dpi = spec.sensor_dpi; + let route = shared.route().clone(); let activity = Arc::new(ChannelActivity::default()); let listener = chan.add_msg_listener_guarded({ let accum = Arc::clone(&accum); @@ -343,13 +517,17 @@ async fn run_capture_session_on( // Recover the guard even if a prior holder panicked — the // critical section is panic-free, so the data is consistent. let mut acc = accum.lock().unwrap_or_else(PoisonError::into_inner); - handle_reprog_with_gesture_buttons( + handle_reprog_sets( &mut acc, event, - &gesture_cids, - &dpi_set, - &gesture_button_set, - &button_set, + &ReprogSets { + gesture_cids: &gesture_cids, + dpi_cids: &dpi_set, + gesture_button_cids: &gesture_button_set, + button_cids: &button_set, + hold_button_cids: &hold_button_set, + sensor_dpi: live_hold_dpi(&route, planned_dpi), + }, &sink, ); return; @@ -380,7 +558,7 @@ async fn run_capture_session_on( .ok() .flatten() .map(|info| WirelessDeviceStatusFeature::new(Arc::clone(&chan), device_index, info.index)); - log_capture_active(device_index, &armed, wireless.is_some()); + log_capture_active(device_index, &armed, &spec, wireless.is_some()); let stop = monitor_capture( CaptureMonitor { root: &root, @@ -390,28 +568,40 @@ async fn run_capture_session_on( registry, shared: &shared, activity: &activity, + sink: &sink, }, wireless, shutdown, device_io, ) .await; + emit_taken_stream_end(&accum, &sink); // The slot is one last-writer-wins cell shared by every session, so a // sibling may have published its own channel after ours. Clear it only // while it still holds *this* session's channel — evicting the sibling's // would silently demote its DPI/SmartShift writes to the fresh-open slow // path. + clear_session_channel_if_ours(&channel_slot, &chan); + let outcome = finish_capture(listener, stop, armed, shared, registry).await; + debug!(index = device_index, "control capture stopped"); + Ok(outcome) +} + +fn publish_session_channel(channel_slot: &CaptureChannel, shared: &SharedChannel) { + if let Ok(mut slot) = channel_slot.write() { + *slot = Some(shared.clone()); + } +} + +fn clear_session_channel_if_ours(channel_slot: &CaptureChannel, chan: &Arc) { if let Ok(mut slot) = channel_slot.write() && slot .as_ref() - .is_some_and(|shared| Arc::ptr_eq(shared.channel(), &chan)) + .is_some_and(|shared| Arc::ptr_eq(shared.channel(), chan)) { *slot = None; } - let outcome = finish_capture(listener, stop, armed, shared, registry).await; - debug!(index = device_index, "control capture stopped"); - Ok(outcome) } /// Restore or hand off one stopped session while its listener still owns every @@ -473,6 +663,8 @@ struct ArmedControls { gesture_cids: Vec, /// Raw-XY-capable standard-button CIDs diverted as gesture sources. gesture_button_cids: Vec<(u16, ButtonId)>, + /// Hold-mode CIDs diverted with raw-XY. + hold_button_cids: Vec<(u16, ButtonId)>, /// DPI/ModeShift CIDs diverted as plain buttons. dpi_cids: Vec, /// Standard-button CIDs diverted per the session's [`CaptureSpec`], with @@ -551,6 +743,10 @@ impl ArmedControls { || self .gesture_button_cids .iter() + .any(|&(cid, _)| cid == reporting.cid) + || self + .hold_button_cids + .iter() .any(|&(cid, _)| cid == reporting.cid); let change = divert_change(reporting.original, raw_xy); if let Err(error) = rc.set_cid_reporting_full(reporting.cid, change).await { @@ -570,17 +766,35 @@ impl ArmedControls { } } -fn log_capture_active(device_index: u8, armed: &ArmedControls, wake_rearm: bool) { +fn log_capture_active( + device_index: u8, + armed: &ArmedControls, + spec: &CaptureSpec, + wake_rearm: bool, +) { info!( index = device_index, gesture_sources = armed.gesture_cids.len(), gesture_buttons = armed.gesture_button_cids.len(), + hold_buttons = armed.hold_button_cids.len(), + hold_requested = spec.hold_requested, + injection_available = spec.injection_available, + sensor_dpi = ?spec.sensor_dpi, dpi_buttons = armed.dpi_cids.len(), buttons = armed.button_cids.len(), thumbwheel = armed.thumb.is_some(), wake_rearm, "control capture active" ); + if spec.hold_requested > 0 && armed.hold_button_cids.is_empty() { + warn!( + index = device_index, + injection_available = spec.injection_available, + sensor_dpi = ?spec.sensor_dpi, + hold_requested = spec.hold_requested, + "hold mode not armed" + ); + } } /// Borrowed state used while monitoring one armed capture session. @@ -592,6 +806,55 @@ struct CaptureMonitor<'a> { registry: Option<&'a ChannelRegistry>, shared: &'a SharedChannel, activity: &'a ChannelActivity, + sink: &'a mpsc::UnboundedSender, +} + +impl CaptureMonitor<'_> { + fn emit_expired_stream(&self) { + emit_expired_stream(self.accum, self.sink); + } + + fn emit_terminal_stream_end(&self) { + emit_taken_stream_end(self.accum, self.sink); + } + + fn stream_timer_deadline(&self, idle_deadline: tokio::time::Instant) -> tokio::time::Instant { + let stream_open = matches!( + self.accum + .lock() + .unwrap_or_else(PoisonError::into_inner) + .hold, + HoldState::Holding { + kind: HoldKind::Stream, + .. + } + ); + if stream_open { + idle_deadline.min(tokio::time::Instant::now() + std::time::Duration::from_secs(1)) + } else { + idle_deadline + } + } +} + +fn emit_taken_stream_end(accum: &Mutex, sink: &mpsc::UnboundedSender) { + if let Some(end) = accum + .lock() + .unwrap_or_else(PoisonError::into_inner) + .take_terminal_stream_end() + { + let _ = sink.send(end); + } +} + +fn emit_expired_stream(accum: &Mutex, sink: &mpsc::UnboundedSender) { + if let Some(end) = accum + .lock() + .unwrap_or_else(PoisonError::into_inner) + .expire_stale_stream(std::time::Instant::now()) + { + let _ = sink.send(end); + } } /// Keep a capture session alive and reapply its volatile diversions whenever @@ -617,8 +880,9 @@ async fn monitor_capture( // strike before considering a liveness ping. liveness.record_activity(tokio::time::Instant::now(), context.activity.generation()); } + context.emit_expired_stream(); let activity_generation = liveness.activity_generation(); - let idle_deadline = liveness.idle_deadline(); + let timer_deadline = context.stream_timer_deadline(liveness.idle_deadline()); tokio::select! { biased; @@ -657,14 +921,13 @@ async fn monitor_capture( continue; }; info!(?broadcast, "device reconnected — re-arming control capture"); - *context.accum.lock().unwrap_or_else(PoisonError::into_inner) = - CaptureAccum::default(); + context.emit_terminal_stream_end(); context.armed.rearm(&device_io).await; } generation = context.activity.changed_after(activity_generation) => { liveness.record_activity(tokio::time::Instant::now(), generation); } - () = tokio::time::sleep_until(idle_deadline) => { + () = tokio::time::sleep_until(timer_deadline) => { if !liveness.ping_due( tokio::time::Instant::now(), context.activity.generation(), @@ -724,6 +987,7 @@ async fn arm_controls( } if armed.gesture_cids.is_empty() && armed.gesture_button_cids.is_empty() + && armed.hold_button_cids.is_empty() && armed.dpi_cids.is_empty() && armed.button_cids.is_empty() && armed.thumb.is_none() @@ -772,6 +1036,7 @@ async fn arm_controls_into( armed.gesture_button_cids.push((cid, button)); } } + arm_hold_buttons(&rc, &controls, spec, armed).await?; for &cid in &reprog_controls::DPI_MODE_SHIFT_CIDS { if controls.iter().any(|c| c.cid == cid && c.is_divertable()) { arm_reprog_control(&rc, cid, false, &mut armed.reporting).await?; @@ -787,6 +1052,10 @@ async fn arm_controls_into( .gesture_button_cids .iter() .any(|&(gesture_cid, _)| gesture_cid == cid) + || armed + .hold_button_cids + .iter() + .any(|&(hold_cid, _)| hold_cid == cid) { continue; } @@ -837,6 +1106,37 @@ async fn arm_controls_into( Ok(()) } +async fn arm_hold_buttons( + rc: &ReprogControlsV4, + controls: &[reprog_controls::CtrlIdInfo], + spec: &CaptureSpec, + armed: &mut ArmedControls, +) -> Result<(), GestureError> { + for &(cid, button) in &spec.divert_hold_buttons { + if let Some(control) = controls.iter().find(|c| c.cid == cid) { + if control.is_divertable() && control.supports_raw_xy() { + arm_reprog_control(rc, cid, true, &mut armed.reporting).await?; + armed.hold_button_cids.push((cid, button)); + } else { + warn!( + cid = format_args!("{cid:#06x}"), + %button, + divertable = control.is_divertable(), + raw_xy = control.supports_raw_xy(), + "hold-mode control cannot stream raw-XY — not armed" + ); + } + } else { + warn!( + cid = format_args!("{cid:#06x}"), + %button, + "hold-mode control is not on this device — not armed" + ); + } + } + Ok(()) +} + async fn arm_reprog_control( rc: &ReprogControlsV4, cid: u16, @@ -903,9 +1203,26 @@ pub(crate) async fn enumerate_controls( Ok(controls) } +/// Prefer the process-wide sensor cache so a DPI-cycle write updates the +/// deadzone on the next press without waiting for the capture session to rearm. +fn live_hold_dpi(route: &DeviceRoute, planned: Option) -> Option { + cached_sensor_dpi(route).or(planned) +} + +/// CID sets a capture listener attributes `0x1b04` events against. +struct ReprogSets<'a> { + gesture_cids: &'a [u16], + dpi_cids: &'a [u16], + gesture_button_cids: &'a [(u16, ButtonId)], + button_cids: &'a [(u16, ButtonId)], + hold_button_cids: &'a [(u16, ButtonId)], + sensor_dpi: Option, +} + /// Update `acc` and emit on a decoded `0x1b04` event: preserve physical button /// edges, and commit a gesture swipe the instant it crosses the threshold /// (mid-swipe, like Options+) rather than on release. +#[cfg(test)] fn handle_reprog_with_gesture_buttons( acc: &mut CaptureAccum, event: RawControlEvent, @@ -915,109 +1232,228 @@ fn handle_reprog_with_gesture_buttons( button_cids: &[(u16, ButtonId)], sink: &mpsc::UnboundedSender, ) { + handle_reprog_sets( + acc, + event, + &ReprogSets { + gesture_cids, + dpi_cids, + gesture_button_cids, + button_cids, + hold_button_cids: &[], + sensor_dpi: None, + }, + sink, + ); +} + +fn handle_reprog_sets( + acc: &mut CaptureAccum, + event: RawControlEvent, + sets: &ReprogSets<'_>, + sink: &mpsc::UnboundedSender, +) { + if let Some(end) = acc.expire_stale_stream(std::time::Instant::now()) { + let _ = sink.send(end); + } match event { RawControlEvent::DivertedButtons(cids) => { - // The swipe accumulator belongs to the raw-XY gesture diverts. - // When a gesture-source control is instead diverted as a plain - // button (a single binding, not gesture mode), its press must flow - // through the `button_cids` loop only — not also emit a click. - let held: Vec<(u16, ButtonId)> = gesture_cids + handle_diverted_buttons(acc, cids, sets, sink); + } + RawControlEvent::RawXy { dx, dy } => { + handle_raw_xy(acc, dx, dy, sink); + } + } +} + +fn handle_diverted_buttons( + acc: &mut CaptureAccum, + cids: [u16; 4], + sets: &ReprogSets<'_>, + sink: &mpsc::UnboundedSender, +) { + // The swipe accumulator belongs to the raw-XY gesture diverts. + // When a gesture-source control is instead diverted as a plain + // button (a single binding, not gesture mode), its press must flow + // through the `button_cids` loop only — not also emit a click. + let swipe_held: Vec<(u16, ButtonId)> = sets + .gesture_cids + .iter() + .filter(|cid| cids.contains(cid)) + .filter_map(|&cid| gesture_source_button(cid).map(|b| (cid, b))) + .chain( + sets.gesture_button_cids .iter() - .filter(|cid| cids.contains(cid)) - .filter_map(|&cid| gesture_source_button(cid).map(|b| (cid, b))) - .chain( - gesture_button_cids - .iter() - .copied() - .filter(|(cid, _)| cids.contains(cid)), - ) - .collect(); - acc.hold = match std::mem::take(&mut acc.hold) { - // The holder is still down. While a second armed source is - // held alongside it, unattributed raw-XY motion is dropped - // (see [`HoldState::Holding::overlap`]). + .copied() + .filter(|(cid, _)| cids.contains(cid)), + ) + .collect(); + let hold_held: Vec<(u16, ButtonId)> = sets + .hold_button_cids + .iter() + .copied() + .filter(|(cid, _)| cids.contains(cid)) + .collect(); + let motion: Vec<(u16, ButtonId, HoldKind)> = swipe_held + .iter() + .copied() + .map(|(cid, button)| (cid, button, HoldKind::Swipe)) + .chain( + hold_held + .iter() + .copied() + .map(|(cid, button)| (cid, button, HoldKind::Stream)), + ) + .collect(); + acc.hold = transition_hold(acc, cids, &motion, sets.sensor_dpi, sink); + // Gesture semantics stay separate from the physical lifecycle: + // click/swipe remains one completed action, while every armed + // source also contributes one rising and one falling edge to the + // shared button runtime. Hold-mode CIDs use HoldBegin/HoldEnd + // instead of ButtonDown/ButtonUp. + for &cid in &acc.gestures_down { + if !swipe_held.iter().any(|(held_cid, _)| *held_cid == cid) + && let Some(button) = captured_gesture_button(cid, sets.gesture_button_cids) + { + let _ = sink.send(CapturedInput::ButtonUp(button)); + } + } + for &(cid, button) in &swipe_held { + if !acc.gestures_down.contains(&cid) { + let _ = sink.send(CapturedInput::ButtonDown(button)); + } + } + acc.gestures_down = swipe_held.into_iter().map(|(cid, _)| cid).collect(); + acc.hold_downs = hold_held.into_iter().map(|(cid, _)| cid).collect(); + + let dpi_down = sets.dpi_cids.iter().any(|cid| cids.contains(cid)); + if dpi_down && !acc.dpi_down { + let _ = sink.send(CapturedInput::ButtonDown(ButtonId::DpiToggle)); + } else if !dpi_down && acc.dpi_down { + let _ = sink.send(CapturedInput::ButtonUp(ButtonId::DpiToggle)); + } + acc.dpi_down = dpi_down; + + for &(cid, button) in sets.button_cids { + let down = cids.contains(&cid); + let was_down = acc.buttons_down.contains(&cid); + if down && !was_down { + let _ = sink.send(CapturedInput::ButtonDown(button)); + acc.buttons_down.push(cid); + } else if !down && was_down { + let _ = sink.send(CapturedInput::ButtonUp(button)); + acc.buttons_down.retain(|&c| c != cid); + } + } +} + +fn transition_hold( + acc: &mut CaptureAccum, + cids: [u16; 4], + motion: &[(u16, ButtonId, HoldKind)], + sensor_dpi: Option, + sink: &mpsc::UnboundedSender, +) -> HoldState { + match std::mem::take(&mut acc.hold) { + // The holder is still down. While a second armed source is + // held alongside it, unattributed raw-XY motion is dropped + // (see [`HoldState::Holding::overlap`]). + HoldState::Holding { + cid, + button, + swipe, + skip_first_raw_xy, + kind, + .. + } if cids.contains(&cid) => HoldState::Holding { + cid, + button, + swipe, + overlap: motion.len() > 1, + skip_first_raw_xy, + kind, + }, + previous => { + let from_idle = matches!(previous, HoldState::Idle); + match previous { HoldState::Holding { - cid, button, - swipe, - skip_first_raw_xy, + mut swipe, + kind: HoldKind::Stream, .. - } if cids.contains(&cid) => HoldState::Holding { - cid, - button, - swipe, - overlap: held.len() > 1, - skip_first_raw_xy, - }, - previous => { - // No holder, or the holder released: a released hold that - // never committed a direction is a plain click... - if let HoldState::Holding { - button, mut swipe, .. - } = previous - && swipe.end() - { + } => { + let traveled = matches!(swipe.end_stream(), StreamRelease::Drag); + debug!(%button, traveled, "hold-mode stream ended"); + let _ = sink.send(CapturedInput::HoldEnd { + button, + release: HoldRelease::Released { traveled }, + }); + } + HoldState::Holding { + button, mut swipe, .. + } => { + if swipe.end() { debug!(%button, "gesture click"); let _ = sink.send(CapturedInput::Gesture(button, GestureDirection::Click)); } - // ...and the first still-held source begins (or takes - // over) the hold. A source not down in the previous event - // is a fresh touch, so the panel's contact-jump discard - // applies; one that was already held has had its jump - // dropped during the overlap. - match held.first() { - Some(&(cid, button)) => begin_hold( - cid, - button, - held.len() > 1, - cid == reprog_controls::HAPTIC_PANEL_CID - && !acc.gestures_down.contains(&cid), - ), - None => HoldState::Idle, - } - } - }; - // Gesture semantics stay separate from the physical lifecycle: - // click/swipe remains one completed action, while every armed - // source also contributes one rising and one falling edge to the - // shared button runtime. - for &cid in &acc.gestures_down { - if !held.iter().any(|(held_cid, _)| *held_cid == cid) - && let Some(button) = captured_gesture_button(cid, gesture_button_cids) - { - let _ = sink.send(CapturedInput::ButtonUp(button)); } + HoldState::Idle => {} } - for &(cid, button) in &held { - if !acc.gestures_down.contains(&cid) { - let _ = sink.send(CapturedInput::ButtonDown(button)); - } - } - acc.gestures_down = held.into_iter().map(|(cid, _)| cid).collect(); - - let dpi_down = dpi_cids.iter().any(|cid| cids.contains(cid)); - if dpi_down && !acc.dpi_down { - let _ = sink.send(CapturedInput::ButtonDown(ButtonId::DpiToggle)); - } else if !dpi_down && acc.dpi_down { - let _ = sink.send(CapturedInput::ButtonUp(ButtonId::DpiToggle)); - } - acc.dpi_down = dpi_down; - - for &(cid, button) in button_cids { - let down = cids.contains(&cid); - let was_down = acc.buttons_down.contains(&cid); - if down && !was_down { - let _ = sink.send(CapturedInput::ButtonDown(button)); - acc.buttons_down.push(cid); - } else if !down && was_down { - let _ = sink.send(CapturedInput::ButtonUp(button)); - acc.buttons_down.retain(|&c| c != cid); + // A source already down when the session became Idle (reconnect + // or a seeded start) is not a press. Takeover from a live + // holder still begins, even if the next source was overlapping. + match motion.first() { + Some(&(cid, button, kind)) if !from_idle || !already_down(acc, cid, kind) => { + let next = begin_hold( + cid, + button, + motion.len() > 1, + skip_first_sample(acc, cid, kind), + kind, + sensor_dpi, + ); + if matches!( + next, + HoldState::Holding { + kind: HoldKind::Stream, + .. + } + ) { + let _ = sink.send(CapturedInput::HoldBegin(button)); + } + next } + _ => HoldState::Idle, } } - RawControlEvent::RawXy { dx, dy } => { - handle_raw_xy(acc, dx, dy, sink); - } + } +} + +/// Whether a hold's first raw-XY sample is a firmware artifact rather than +/// travel the user made, and so must be dropped. +/// +/// Two unrelated artifacts land here. The haptic panel's first sample after +/// contact is an absolute position, not a delta, so summing it commits a +/// bogus direction instantly. +/// +/// And a hold-mode control only streams diverted raw XY while it is down, so +/// the first report after the divert engages carries whatever the sensor +/// banked before the press. Measured on an MX Master 3S at 950 DPI: 1694 x +/// 1619 counts arriving 10 ms after button-down, 63 mm of travel, an implied +/// 6 m/s of hand speed where real panning in the same session never passed +/// 1.3 m/s. Passed through it scrolled the view most of a screen the instant +/// pan opened, and it cleared the click/drag deadzone on a press that never +/// moved. +fn skip_first_sample(acc: &CaptureAccum, cid: u16, kind: HoldKind) -> bool { + let haptic_contact_jump = + cid == reprog_controls::HAPTIC_PANEL_CID && !acc.gestures_down.contains(&cid); + haptic_contact_jump || matches!(kind, HoldKind::Stream) +} + +fn already_down(acc: &CaptureAccum, cid: u16, kind: HoldKind) -> bool { + match kind { + HoldKind::Swipe => acc.gestures_down.contains(&cid), + HoldKind::Stream => acc.hold_downs.contains(&cid), } } @@ -1034,6 +1470,7 @@ fn handle_raw_xy( swipe, overlap, skip_first_raw_xy, + kind, .. } = &mut acc.hold else { @@ -1044,18 +1481,33 @@ fn handle_raw_xy( if *overlap { return; } - // The haptic panel's first sample after contact is a position jump; - // summing it would commit a bogus direction instantly. + // Neither a contact jump nor a pre-press backlog is travel the user made + // during this hold; see [`skip_first_sample`]. if *skip_first_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)); + match *kind { + HoldKind::Stream => { + if swipe + .accumulate_stream(i32::from(dx), i32::from(dy)) + .is_some() + { + let _ = sink.send(CapturedInput::HoldMotion { + button: *button, + dx, + dy, + }); + } + } + HoldKind::Swipe => { + // Commit the instant a clean direction emerges (mid-swipe, once + // per hold); the accumulator gates on hold duration internally. + if let Some(direction) = swipe.accumulate(i32::from(dx), i32::from(dy)) { + debug!(?direction, %button, "gesture committed"); + let _ = sink.send(CapturedInput::Gesture(*button, direction)); + } + } } } diff --git a/crates/openlogi-device/src/session/gesture/tests.rs b/crates/openlogi-device/src/session/gesture/tests.rs index a7b75f6ca..6984097be 100644 --- a/crates/openlogi-device/src/session/gesture/tests.rs +++ b/crates/openlogi-device/src/session/gesture/tests.rs @@ -1,3 +1,6 @@ +use openlogi_core::binding::hold_drag_threshold_counts; +use openlogi_core::hid::Dpi; + use super::*; use crate::backend::NodeId; use crate::channel::scripted::{ScriptedRawHidChannel, scripted_channel}; @@ -994,3 +997,387 @@ fn contact_without_rotation_or_a_tap_carries_no_input() { None ); } + +const HOLD_BACK: &[(u16, ButtonId)] = &[(0x0053, ButtonId::Back)]; +const HOLD_DPI: Dpi = Dpi::new(1000); + +fn hold_down() -> RawControlEvent { + RawControlEvent::DivertedButtons([0x0053, 0, 0, 0]) +} + +fn handle_hold( + acc: &mut CaptureAccum, + event: RawControlEvent, + sink: &mpsc::UnboundedSender, +) { + handle_reprog_sets( + acc, + event, + &ReprogSets { + gesture_cids: &[], + dpi_cids: &[], + gesture_button_cids: &[], + button_cids: &[], + hold_button_cids: HOLD_BACK, + sensor_dpi: Some(HOLD_DPI), + }, + sink, + ); +} + +fn drain(rx: &mut mpsc::UnboundedReceiver) -> Vec { + std::iter::from_fn(|| rx.try_recv().ok()).collect() +} + +#[test] +fn hold_mode_streams_raw_xy_and_never_fires_a_gesture_click() { + let (tx, mut rx) = mpsc::unbounded_channel(); + let mut acc = CaptureAccum::default(); + + handle_hold(&mut acc, hold_down(), &tx); + handle_hold( + &mut acc, + RawControlEvent::RawXy { + dx: 9_000, + dy: 9_000, + }, + &tx, + ); + handle_hold(&mut acc, RawControlEvent::RawXy { dx: 40, dy: -15 }, &tx); + handle_hold(&mut acc, release(), &tx); + + assert_eq!( + drain(&mut rx), + vec![ + CapturedInput::HoldBegin(ButtonId::Back), + CapturedInput::HoldMotion { + button: ButtonId::Back, + dx: 40, + dy: -15 + }, + CapturedInput::HoldEnd { + button: ButtonId::Back, + release: HoldRelease::Released { traveled: false } + }, + ] + ); +} + +#[test] +fn hold_mode_no_drag_release_is_not_a_click() { + let (tx, mut rx) = mpsc::unbounded_channel(); + let mut acc = CaptureAccum::default(); + + handle_hold(&mut acc, hold_down(), &tx); + handle_hold(&mut acc, RawControlEvent::RawXy { dx: 2, dy: 1 }, &tx); + handle_hold(&mut acc, release(), &tx); + + let events = drain(&mut rx); + assert!( + events + .iter() + .all(|input| !matches!(input, CapturedInput::Gesture(..))), + "hold-mode travel must never fire Gesture(..., Click): {events:?}" + ); + assert_eq!( + events.last(), + Some(&CapturedInput::HoldEnd { + button: ButtonId::Back, + release: HoldRelease::Released { traveled: false } + }) + ); +} + +#[test] +fn hold_mode_travel_past_the_physical_deadzone_is_traveled() { + let (tx, mut rx) = mpsc::unbounded_channel(); + let mut acc = CaptureAccum::default(); + let step = i16::try_from(hold_drag_threshold_counts(HOLD_DPI) + 1).expect("threshold fits i16"); + + handle_hold(&mut acc, hold_down(), &tx); + handle_hold( + &mut acc, + RawControlEvent::RawXy { + dx: 9_000, + dy: 9_000, + }, + &tx, + ); + handle_hold(&mut acc, RawControlEvent::RawXy { dx: step, dy: 0 }, &tx); + handle_hold(&mut acc, release(), &tx); + + assert_eq!( + drain(&mut rx).last(), + Some(&CapturedInput::HoldEnd { + button: ButtonId::Back, + release: HoldRelease::Released { traveled: true } + }) + ); +} + +#[test] +fn hold_mode_overlap_drops_unattributed_raw_xy() { + let (tx, mut rx) = mpsc::unbounded_channel(); + let mut acc = CaptureAccum::default(); + let hold_both = &[(0x0053, ButtonId::Back), (0x0056, ButtonId::Forward)]; + let both = RawControlEvent::DivertedButtons([0x0053, 0x0056, 0, 0]); + let sets = ReprogSets { + gesture_cids: &[], + dpi_cids: &[], + gesture_button_cids: &[], + button_cids: &[], + hold_button_cids: hold_both, + sensor_dpi: Some(HOLD_DPI), + }; + + handle_reprog_sets(&mut acc, hold_down(), &sets, &tx); + handle_reprog_sets(&mut acc, both, &sets, &tx); + handle_reprog_sets( + &mut acc, + RawControlEvent::RawXy { dx: 80, dy: 80 }, + &sets, + &tx, + ); + + assert!( + drain(&mut rx) + .iter() + .all(|input| !matches!(input, CapturedInput::HoldMotion { .. })), + "overlap motion must be dropped, not attributed" + ); +} + +#[test] +fn reconnect_emits_hold_end_before_wiping_the_stream() { + let (tx, mut rx) = mpsc::unbounded_channel(); + let mut acc = CaptureAccum::default(); + + handle_hold(&mut acc, hold_down(), &tx); + handle_hold(&mut acc, RawControlEvent::RawXy { dx: 8, dy: 0 }, &tx); + let _ = drain(&mut rx); + + let end = acc + .take_terminal_stream_end() + .expect("an open hold-mode stream must emit its end before wipe"); + assert_eq!( + end, + CapturedInput::HoldEnd { + button: ButtonId::Back, + release: HoldRelease::Interrupted + }, + "a wipe closes the stream under a control that is still down" + ); + + handle_hold(&mut acc, RawControlEvent::RawXy { dx: 80, dy: 0 }, &tx); + assert!( + drain(&mut rx).is_empty(), + "late motion after the terminal end must not re-open the stream" + ); + + handle_hold(&mut acc, hold_down(), &tx); + assert!( + drain(&mut rx).is_empty(), + "a control still down after the wipe is not a new press" + ); + + handle_hold(&mut acc, release(), &tx); + handle_hold(&mut acc, hold_down(), &tx); + assert_eq!( + drain(&mut rx).first(), + Some(&CapturedInput::HoldBegin(ButtonId::Back)), + "a later rising edge after a real release must still begin a hold" + ); +} + +#[test] +fn already_down_hold_control_is_not_a_press() { + let (tx, mut rx) = mpsc::unbounded_channel(); + let mut acc = CaptureAccum::default(); + acc.seed_hold_already_down_for_test(&[0x0053]); + + handle_hold(&mut acc, hold_down(), &tx); + handle_hold(&mut acc, RawControlEvent::RawXy { dx: 80, dy: 0 }, &tx); + handle_hold(&mut acc, release(), &tx); + + assert!( + drain(&mut rx).is_empty(), + "a control already down when the session starts is not a press" + ); +} + +#[test] +fn a_stale_hold_expires_without_waiting_for_release() { + let (tx, mut rx) = mpsc::unbounded_channel(); + let mut acc = CaptureAccum::default(); + + handle_hold(&mut acc, hold_down(), &tx); + let _ = drain(&mut rx); + acc.backdate_hold_past_stale_for_test(); + handle_hold(&mut acc, RawControlEvent::RawXy { dx: 1, dy: 0 }, &tx); + + assert_eq!( + drain(&mut rx), + vec![CapturedInput::HoldEnd { + button: ButtonId::Back, + release: HoldRelease::Interrupted + }], + "a dropped release must expire the hold rather than stream forever" + ); +} + +#[test] +fn a_hold_streaming_motion_never_expires_however_long_it_runs() { + // The bound was measured from the press, so a pan held past HOLD_STALE + // was force-ended mid-gesture: reproduced on hardware as a pan that quit + // 10.14 s after button-down while the user was still dragging. + let (tx, mut rx) = mpsc::unbounded_channel(); + let mut acc = CaptureAccum::default(); + + handle_hold(&mut acc, hold_down(), &tx); + handle_hold(&mut acc, RawControlEvent::RawXy { dx: 9_000, dy: 0 }, &tx); + // Held far longer than the bound, still streaming. + acc.backdate_press_past_stale_for_test(); + handle_hold(&mut acc, RawControlEvent::RawXy { dx: 500, dy: 0 }, &tx); + acc.backdate_press_past_stale_for_test(); + handle_hold(&mut acc, RawControlEvent::RawXy { dx: 500, dy: 0 }, &tx); + handle_hold(&mut acc, release(), &tx); + + let events = drain(&mut rx); + let ends = events + .iter() + .filter(|input| matches!(input, CapturedInput::HoldEnd { .. })) + .count(); + assert_eq!(ends, 1, "one release, one end: {events:?}"); + assert_eq!( + events.last(), + Some(&CapturedInput::HoldEnd { + button: ButtonId::Back, + release: HoldRelease::Released { traveled: true } + }) + ); +} + +#[test] +fn hold_deadzone_prefers_cached_sensor_dpi_over_the_armed_plan() { + // 50 counts is a drag at 400 DPI (threshold ≈ 39) and a click at 1000 + // (threshold ≈ 98). The cycle write must win, or the deadzone drifts. + let route = DeviceRoute::Direct { + vendor_id: 0x046d, + product_id: 0x0d02, + }; + crate::remember_sensor_dpi(&route, Dpi::new(400)); + let planned = Some(Dpi::new(1000)); + assert_eq!( + live_hold_dpi(&route, planned), + Some(Dpi::new(400)), + "a DPI-cycle write must size the next press, not the stale plan" + ); + + let (tx, mut rx) = mpsc::unbounded_channel(); + let mut acc = CaptureAccum::default(); + let sets = ReprogSets { + gesture_cids: &[], + dpi_cids: &[], + gesture_button_cids: &[], + button_cids: &[], + hold_button_cids: HOLD_BACK, + sensor_dpi: live_hold_dpi(&route, planned), + }; + handle_reprog_sets(&mut acc, hold_down(), &sets, &tx); + handle_reprog_sets( + &mut acc, + RawControlEvent::RawXy { + dx: 9_000, + dy: 9_000, + }, + &sets, + &tx, + ); + handle_reprog_sets( + &mut acc, + RawControlEvent::RawXy { dx: 50, dy: 0 }, + &sets, + &tx, + ); + handle_reprog_sets(&mut acc, release(), &sets, &tx); + assert_eq!( + drain(&mut rx).last(), + Some(&CapturedInput::HoldEnd { + button: ButtonId::Back, + release: HoldRelease::Released { traveled: true } + }), + "50 counts at the cached 400 DPI must clear 2.5 mm; the planned 1000 would not" + ); +} + +#[test] +fn the_pre_press_backlog_never_reaches_a_hold_mode_stream() { + // Captured on an MX Master 3S at 950 DPI: the first report of a hold + // arrived 10 ms after button-down carrying 1694 x 1619 counts, which is + // 63 mm of travel. Delivered, it scrolled a 1080p view most of a screen + // and marked a press that never moved as a drag. + let (tx, mut rx) = mpsc::unbounded_channel(); + let mut acc = CaptureAccum::default(); + + handle_hold(&mut acc, hold_down(), &tx); + handle_hold( + &mut acc, + RawControlEvent::RawXy { + dx: -1694, + dy: 1619, + }, + &tx, + ); + handle_hold(&mut acc, release(), &tx); + + assert_eq!( + drain(&mut rx), + vec![ + CapturedInput::HoldBegin(ButtonId::Back), + CapturedInput::HoldEnd { + button: ButtonId::Back, + release: HoldRelease::Released { traveled: false } + }, + ], + "backlog banked before the press must neither pan nor count as travel" + ); +} + +#[test] +fn every_press_drops_its_own_backlog_report() { + // The divert re-arms per press, so the drop is per hold, not once per + // capture session. + let (tx, mut rx) = mpsc::unbounded_channel(); + let mut acc = CaptureAccum::default(); + + for _ in 0..3 { + handle_hold(&mut acc, hold_down(), &tx); + handle_hold( + &mut acc, + RawControlEvent::RawXy { + dx: -1694, + dy: 1619, + }, + &tx, + ); + handle_hold(&mut acc, RawControlEvent::RawXy { dx: 4, dy: 3 }, &tx); + handle_hold(&mut acc, release(), &tx); + } + + let motion: Vec<_> = drain(&mut rx) + .into_iter() + .filter(|input| matches!(input, CapturedInput::HoldMotion { .. })) + .collect(); + assert_eq!( + motion, + vec![ + CapturedInput::HoldMotion { + button: ButtonId::Back, + dx: 4, + dy: 3 + }; + 3 + ], + "each press must flush a fresh backlog and stream only real travel" + ); +} diff --git a/crates/openlogi-device/src/write.rs b/crates/openlogi-device/src/write.rs index 1bdc0ab66..0bbe4d64a 100644 --- a/crates/openlogi-device/src/write.rs +++ b/crates/openlogi-device/src/write.rs @@ -24,6 +24,7 @@ mod haptic; mod hires_wheel; mod lighting; mod litra; +mod sensor_dpi; mod smartshift; pub use backlight::{get_backlight, set_backlight_enabled}; @@ -32,7 +33,8 @@ pub use diagnostics::{ dump_firmware_entities, dump_reprog_controls, read_battery_raw, }; pub use dpi::{ - Dpi, DpiCapabilities, DpiInfo, get_dpi, get_dpi_info, get_dpi_info_on, set_dpi, set_dpi_on, + Dpi, DpiCapabilities, DpiInfo, get_dpi, get_dpi_info, get_dpi_info_on, get_dpi_on, set_dpi, + set_dpi_on, }; pub use error::{HidppFeatureErrorKind, HidppOperation, WriteError}; pub use fn_lock::{set_fn_lock, set_fn_lock_on}; @@ -53,6 +55,7 @@ pub use litra::{ apply as apply_litra, encode_command as encode_litra_command, find_litra, litra_model_for_route, matches_litra, }; +pub use sensor_dpi::{cached_sensor_dpi, remember_sensor_dpi}; pub use smartshift::{ get_smartshift_status, get_smartshift_status_on, set_smartshift, set_smartshift_on, set_smartshift_sensitivity, toggle_smartshift, toggle_smartshift_on, diff --git a/crates/openlogi-device/src/write/dpi.rs b/crates/openlogi-device/src/write/dpi.rs index ef3662522..fb2ef8781 100644 --- a/crates/openlogi-device/src/write/dpi.rs +++ b/crates/openlogi-device/src/write/dpi.rs @@ -15,6 +15,7 @@ use crate::SharedChannel; use crate::backend::HidBackend; use crate::channel::route::DeviceRoute; +use super::sensor_dpi::remember_sensor_dpi; use super::{HidppOperation, WriteError, classify_hidpp_error, with_route}; // DpiCapabilities and DpiInfo are pure IPC wire data with no HID++ I/O, so @@ -189,10 +190,12 @@ pub(super) fn expand_dpi_ranges(ranges: &[DpiRange]) -> Vec { /// surface that wants to display the current value without writing. pub async fn get_dpi(backend: &dyn HidBackend, route: &DeviceRoute) -> Result { let index = route.device_index(); - with_route(backend, route, move |channel| async move { + let dpi = with_route(backend, route, move |channel| async move { get_dpi_on_channel(&channel, index).await }) - .await + .await?; + remember_sensor_dpi(route, dpi); + Ok(dpi) } async fn get_dpi_on_channel( @@ -209,6 +212,13 @@ async fn get_dpi_on_channel( .map_err(|e| classify_hidpp_error(e, HidppOperation::ReadDpi, feature.id())) } +/// Read the current sensor DPI on an already-open [`SharedChannel`]. +pub async fn get_dpi_on(shared: &SharedChannel) -> Result { + let dpi = get_dpi_on_channel(shared.channel(), shared.device_index()).await?; + remember_sensor_dpi(shared.route(), dpi); + Ok(dpi) +} + /// Classify a HID++ error from the DPI functions of `feature_hex`. A device /// that announces the feature but rejects a function (`Unsupported` / /// `InvalidFunctionId`) or returns a structurally invalid DPI description @@ -230,10 +240,12 @@ pub async fn get_dpi_info( route: &DeviceRoute, ) -> Result { let index = route.device_index(); - with_route(backend, route, move |channel| async move { + let info = with_route(backend, route, move |channel| async move { get_dpi_info_on_channel(&channel, index).await }) - .await + .await?; + remember_sensor_dpi(route, info.current); + Ok(info) } pub(super) async fn get_dpi_info_on_channel( @@ -275,10 +287,12 @@ pub async fn set_dpi( dpi: Dpi, ) -> Result<(), WriteError> { let index = route.device_index(); - with_route(backend, route, move |channel| async move { + let cached = with_route(backend, route, move |channel| async move { set_dpi_on_channel(&channel, index, dpi).await }) - .await + .await?; + remember_sensor_dpi(route, cached); + Ok(()) } /// The DPI write itself, on an already-open channel at HID++ `index`. Shared by @@ -288,7 +302,7 @@ pub(super) async fn set_dpi_on_channel( channel: &Arc, index: u8, dpi: Dpi, -) -> Result<(), WriteError> { +) -> Result { let mut device = Device::new(Arc::clone(channel), index) .await .map_err(|_| WriteError::DeviceUnreachable { index })?; @@ -301,7 +315,7 @@ pub(super) async fn set_dpi_on_channel( // silent failure mode that's otherwise invisible — devices in low-power // states or with unsupported DPI ranges can ACK the write yet keep the old // value. We log a warning but still return Ok because the request reached - // the device. + // the device. Cache the firmware's actual reading when we have one. if let Ok(actual) = feature.current_dpi().await { if actual == dpi { debug!(index, %dpi, "wrote DPI (verified)"); @@ -314,19 +328,24 @@ pub(super) async fn set_dpi_on_channel( likely out of the device's supported range" ); } + Ok(actual) } else { debug!(index, %dpi, "wrote DPI (read-back skipped)"); + Ok(dpi) } - Ok(()) } /// Write DPI on an already-open [`SharedChannel`] — the fast path that skips /// enumeration and channel setup. pub async fn set_dpi_on(shared: &SharedChannel, dpi: Dpi) -> Result<(), WriteError> { - set_dpi_on_channel(shared.channel(), shared.device_index(), dpi).await + let cached = set_dpi_on_channel(shared.channel(), shared.device_index(), dpi).await?; + remember_sensor_dpi(shared.route(), cached); + Ok(()) } /// Read current DPI and supported values on an already-open [`SharedChannel`]. pub async fn get_dpi_info_on(shared: &SharedChannel) -> Result { - get_dpi_info_on_channel(shared.channel(), shared.device_index()).await + let info = get_dpi_info_on_channel(shared.channel(), shared.device_index()).await?; + remember_sensor_dpi(shared.route(), info.current); + Ok(info) } diff --git a/crates/openlogi-device/src/write/sensor_dpi.rs b/crates/openlogi-device/src/write/sensor_dpi.rs new file mode 100644 index 000000000..353928646 --- /dev/null +++ b/crates/openlogi-device/src/write/sensor_dpi.rs @@ -0,0 +1,97 @@ +//! Process-wide last-known sensor DPI, keyed by [`DeviceRoute`]. +//! +//! HID++ `getSensorDpi` is the live value hold-mode uses to size its +//! millimetre deadzone. The read is async; capture-plan derivation is not. +//! Every successful DPI read or write updates this cache so the next plan +//! rebuild sees the device rather than an empty preset list. + +use std::collections::HashMap; +use std::sync::{Mutex, OnceLock}; + +use crate::channel::route::DeviceRoute; + +use super::Dpi; + +static CACHE: OnceLock>> = OnceLock::new(); + +fn cache() -> &'static Mutex> { + CACHE.get_or_init(|| Mutex::new(HashMap::new())) +} + +fn cache_key(route: &DeviceRoute) -> String { + match route { + DeviceRoute::Bolt { receiver_uid, slot } => { + format!("bolt:{}:{slot}", receiver_uid.to_ascii_lowercase()) + } + DeviceRoute::Unifying { receiver_uid, slot } => { + format!("unifying:{}:{slot}", receiver_uid.to_ascii_lowercase()) + } + DeviceRoute::Direct { + vendor_id, + product_id, + } => format!("direct:{vendor_id:04x}:{product_id:04x}"), + DeviceRoute::RawHid { identity, .. } => format!("raw:{identity}"), + } +} + +/// Last successful `getSensorDpi` / DPI write for `route`, if any. +#[must_use] +pub fn cached_sensor_dpi(route: &DeviceRoute) -> Option { + cache().lock().ok()?.get(&cache_key(route)).copied() +} + +/// Record a live sensor reading (or a write we just confirmed) for `route`. +pub fn remember_sensor_dpi(route: &DeviceRoute, dpi: Dpi) { + if let Ok(mut guard) = cache().lock() { + guard.insert(cache_key(route), dpi); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn route(product_id: u16) -> DeviceRoute { + DeviceRoute::Direct { + vendor_id: 0x046d, + product_id, + } + } + + #[test] + fn remember_then_lookup_is_per_route() { + let a = route(0x0101); + let b = route(0x0102); + remember_sensor_dpi(&a, Dpi::new(800)); + remember_sensor_dpi(&b, Dpi::new(1600)); + assert_eq!(cached_sensor_dpi(&a), Some(Dpi::new(800))); + assert_eq!(cached_sensor_dpi(&b), Some(Dpi::new(1600))); + remember_sensor_dpi(&a, Dpi::new(1200)); + assert_eq!( + cached_sensor_dpi(&a), + Some(Dpi::new(1200)), + "a later write must replace the cached reading" + ); + assert_eq!( + cached_sensor_dpi(&b), + Some(Dpi::new(1600)), + "updating one route must not clobber another" + ); + } + + #[test] + fn bolt_and_unifying_slots_do_not_share_an_entry() { + let bolt = DeviceRoute::Bolt { + receiver_uid: "Cafe".into(), + slot: 2, + }; + let unifying = DeviceRoute::Unifying { + receiver_uid: "cafe".into(), + slot: 2, + }; + remember_sensor_dpi(&bolt, Dpi::new(400)); + remember_sensor_dpi(&unifying, Dpi::new(2000)); + assert_eq!(cached_sensor_dpi(&bolt), Some(Dpi::new(400))); + assert_eq!(cached_sensor_dpi(&unifying), Some(Dpi::new(2000))); + } +} diff --git a/crates/openlogi-device/src/write/tests.rs b/crates/openlogi-device/src/write/tests.rs index 378b33fac..d47f3975f 100644 --- a/crates/openlogi-device/src/write/tests.rs +++ b/crates/openlogi-device/src/write/tests.rs @@ -223,6 +223,25 @@ async fn shared_read_and_lighting_apis_use_the_supplied_channel() -> Result<(), Ok(()) } +#[tokio::test] +async fn get_dpi_on_caches_the_sensor_reading() -> Result<(), WriteError> { + let (raw, _handle) = ScriptedRawHidChannel::with_responder(scripted_response); + let channel = scripted_channel(raw).await; + let route = DeviceRoute::Direct { + vendor_id: 0x046d, + product_id: 0x0d01, + }; + let shared = SharedChannel::new(channel, route.clone()); + let dpi = get_dpi_on(&shared).await?; + assert_eq!(dpi, Dpi::new(800)); + assert_eq!( + cached_sensor_dpi(&route), + Some(Dpi::new(800)), + "hold-mode must be able to read the same value without another HID++ round-trip" + ); + Ok(()) +} + #[test] fn stepped_dpi_ranges_expand_onto_their_step_grid() { assert_eq!( diff --git a/crates/openlogi-inject/Cargo.toml b/crates/openlogi-inject/Cargo.toml index 43b3b0a03..d5ad43864 100644 --- a/crates/openlogi-inject/Cargo.toml +++ b/crates/openlogi-inject/Cargo.toml @@ -38,7 +38,11 @@ objc2-app-kit = { workspace = true, features = [ "NSWorkspace", "NSRunningApplication", ] } -objc2-core-graphics = { workspace = true, features = ["CGEvent"] } +objc2-core-graphics = { workspace = true, features = [ + "CGEvent", + "CGEventSource", + "CGEventTypes", +] } objc2-foundation = { workspace = true } [target.'cfg(target_os = "windows")'.dependencies] diff --git a/crates/openlogi-inject/examples/inject_action.rs b/crates/openlogi-inject/examples/inject_action.rs index 5a8053990..ef3e9f86d 100644 --- a/crates/openlogi-inject/examples/inject_action.rs +++ b/crates/openlogi-inject/examples/inject_action.rs @@ -22,6 +22,10 @@ //! //! # Inject a scroll sequence: //! sudo ./target/debug/examples/inject_action ScrollDown ScrollDown ScrollDown ScrollUp +//! +//! # Hold-mode pan (pixel scroll with phase) and zoom (pinch / Ctrl+wheel): +//! sudo ./target/debug/examples/inject_action --delay 2 PanBegin Pan:8:6 Pan:8:6 PanEnd +//! sudo ./target/debug/examples/inject_action --delay 2 Zoom:0.05 Zoom:0.05 ZoomEnd //! ``` //! //! # Available actions @@ -34,6 +38,7 @@ //! PlayPause NextTrack PrevTrack VolumeUp VolumeDown MuteVolume //! CycleDpiPresets ToggleSmartShift //! ScrollUp ScrollDown HorizontalScrollLeft HorizontalScrollRight +//! PanBegin Pan:: PanEnd Zoom: ZoomEnd FlushGestures use std::time::Duration; @@ -44,6 +49,52 @@ use openlogi_core::binding::{Action, KeyCombo}; #[cfg(target_os = "linux")] use openlogi_inject::action_device_path; +enum Step { + Action(Action), + PanBegin, + Pan { dx: f32, dy: f32 }, + PanEnd, + Zoom(f32), + ZoomEnd, + FlushGestures, +} + +fn parse_step(s: &str) -> Result { + match s { + "PanBegin" => return Ok(Step::PanBegin), + "PanEnd" => return Ok(Step::PanEnd), + "ZoomEnd" => return Ok(Step::ZoomEnd), + "FlushGestures" => return Ok(Step::FlushGestures), + _ => {} + } + if let Some(rest) = s.strip_prefix("Pan:") { + let mut parts = rest.split(':'); + let dx = parts + .next() + .ok_or_else(|| format!("Pan: expected Pan::, got {s}"))?; + let dy = parts + .next() + .ok_or_else(|| format!("Pan: expected Pan::, got {s}"))?; + if parts.next().is_some() { + return Err(format!("Pan: expected Pan::, got {s}")); + } + let dx: f32 = dx + .parse() + .map_err(|_| format!("Pan: dx is not a number: {dx}"))?; + let dy: f32 = dy + .parse() + .map_err(|_| format!("Pan: dy is not a number: {dy}"))?; + return Ok(Step::Pan { dx, dy }); + } + if let Some(rest) = s.strip_prefix("Zoom:") { + let amount: f32 = rest + .parse() + .map_err(|_| format!("Zoom: amount is not a number: {rest}"))?; + return Ok(Step::Zoom(amount)); + } + parse_action(s).map(Step::Action) +} + fn parse_action(s: &str) -> Result { // `CustomShortcut` has its own CLI syntax (serde expects a table for the // tuple variant), so parse it by hand. @@ -67,7 +118,7 @@ fn main() { let mut initial_delay_secs: f64 = 2.0; let mut between_ms: u64 = 200; let mut verbose = false; - let mut actions: Vec = Vec::new(); + let mut steps: Vec = Vec::new(); while let Some(arg) = args.next() { match arg.as_str() { @@ -103,8 +154,8 @@ fn main() { print_usage(); return; } - name => match parse_action(name) { - Ok(action) => actions.push(action), + name => match parse_step(name) { + Ok(step) => steps.push(step), Err(e) => { eprintln!("error: {e}"); eprintln!("Run with --help for the list of available actions."); @@ -114,7 +165,7 @@ fn main() { } } - if actions.is_empty() { + if steps.is_empty() { eprintln!("error: no actions specified"); print_usage(); std::process::exit(1); @@ -145,22 +196,54 @@ fn main() { let delay = Duration::from_secs_f64(initial_delay_secs); println!( "Injecting {} action(s) in {:.1}s — focus the target window now...", - actions.len(), + steps.len(), initial_delay_secs ); std::thread::sleep(delay); let between = Duration::from_millis(between_ms); - for (i, action) in actions.iter().enumerate() { - println!(" → {}", action.label()); - openlogi_inject::execute(action); - if i + 1 < actions.len() { + for (i, step) in steps.iter().enumerate() { + fire(step); + if i + 1 < steps.len() { std::thread::sleep(between); } } println!("Done."); } +fn fire(step: &Step) { + match step { + Step::Action(action) => { + println!(" → {}", action.label()); + openlogi_inject::execute(action); + } + Step::PanBegin => { + println!(" → PanBegin"); + openlogi_inject::post_pan_begin(); + } + Step::Pan { dx, dy } => { + println!(" → Pan({dx}, {dy})"); + openlogi_inject::post_pan(*dx, *dy); + } + Step::PanEnd => { + println!(" → PanEnd"); + openlogi_inject::post_pan_end(); + } + Step::Zoom(amount) => { + println!(" → Zoom({amount})"); + openlogi_inject::post_zoom_continuous(*amount); + } + Step::ZoomEnd => { + println!(" → ZoomEnd"); + openlogi_inject::post_zoom_end(); + } + Step::FlushGestures => { + println!(" → FlushGestures"); + openlogi_inject::flush_gesture_sessions(); + } + } +} + fn print_usage() { eprintln!( "Usage: inject_action [--delay ] [--between ] [-v] [ ...]\n\ @@ -181,14 +264,18 @@ fn print_usage() { CycleDpiPresets ToggleSmartShift\n\ ScrollUp ScrollDown HorizontalScrollLeft HorizontalScrollRight\n\ CustomShortcut::\n\ + PanBegin Pan:: PanEnd Zoom: ZoomEnd FlushGestures\n\ \n\ CustomShortcut modifier bits: 0x01=Cmd/Ctrl 0x02=Shift 0x04=Ctrl 0x08=Option/Alt\n\ CustomShortcut key_hex: macOS kVK_* code (e.g. 0x08=C, 0x09=V, 0x7E=Up)\n\ + Pan:: is screen pixels (+x right, +y down). Zoom: zooms in when positive.\n\ \n\ Examples:\n\ inject_action --delay 3 Copy\n\ inject_action --delay 2 --between 500 VolumeUp VolumeDown PlayPause\n\ inject_action ScrollDown ScrollDown ScrollDown\n\ - inject_action CustomShortcut:0x01:0x08 # Ctrl+C" + inject_action CustomShortcut:0x01:0x08 # Ctrl+C\n\ + inject_action --delay 2 PanBegin Pan:8:6 Pan:8:6 PanEnd\n\ + inject_action --delay 2 Zoom:0.05 Zoom:0.05 ZoomEnd" ); } diff --git a/crates/openlogi-inject/src/inject.rs b/crates/openlogi-inject/src/inject.rs index 7fd4442bf..446ff0b27 100644 --- a/crates/openlogi-inject/src/inject.rs +++ b/crates/openlogi-inject/src/inject.rs @@ -8,7 +8,6 @@ #[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))] use std::collections::HashMap; -#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))] use std::sync::{LazyLock, Mutex, PoisonError}; #[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))] @@ -16,6 +15,8 @@ use openlogi_core::binding::KeyboardUsage; use openlogi_core::binding::{Action, KeyCombo}; use openlogi_core::scroll::ScrollDelta; +mod gesture; + #[cfg(target_os = "macos")] mod macos; @@ -148,6 +149,20 @@ impl HeldOutput { static HELD_OUTPUT: LazyLock> = LazyLock::new(|| Mutex::new(HeldOutput::default())); +/// Hold-mode pan and zoom session state, and the lock that orders their +/// output. +/// +/// Every `post_*` below emits while still holding this guard. Computing a +/// frame under the lock and posting after it is released lets two threads +/// interleave: a watcher that decided on `Began` can be descheduled while +/// shutdown decides on `Ended`, post it first, and leave the gesture open — +/// on Linux and Windows that is a Ctrl key held down with nothing left to +/// release it, since `process::exit` skips [`Drop`]. Posting under the guard +/// costs one serialised syscall per frame at HID report rate, and no platform +/// emitter re-enters this mutex. +static GESTURE_SESSIONS: LazyLock> = + LazyLock::new(|| Mutex::new(gesture::GestureSessions::default())); + #[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))] fn held_keys(combo: &KeyCombo) -> Vec { let mut keys = Vec::with_capacity(4); @@ -411,6 +426,166 @@ pub enum SmoothScrollPhase { Cancelled, } +/// Open a pixel-precise pan session (macOS scroll phase `began`). +/// +/// A second begin while a session is already open is a no-op so the OS never +/// sees an incoherent phase sequence. A closed session opens again here, which +/// is how the next hold starts; only [`seal_gesture_sessions`] is terminal. +pub fn post_pan_begin() { + let mut sessions = GESTURE_SESSIONS + .lock() + .unwrap_or_else(PoisonError::into_inner); + if let Some(frame) = sessions.begin_pan() { + emit_pan(frame); + } +} + +/// Stream one pan report inside an open session (macOS scroll phase +/// `changed`, `kCGScrollEventUnitPixel`). +/// +/// Screen convention: +x right, +y down. Fractional pixels are banked across +/// calls. A late report after [`post_pan_end`] is ignored. +pub fn post_pan(dx: f32, dy: f32) { + let mut sessions = GESTURE_SESSIONS + .lock() + .unwrap_or_else(PoisonError::into_inner); + if let Some(frame) = sessions.pan(dx, dy) { + emit_pan(frame); + } +} + +/// Close the pan session (macOS scroll phase `ended`). Idempotent. +pub fn post_pan_end() { + let mut sessions = GESTURE_SESSIONS + .lock() + .unwrap_or_else(PoisonError::into_inner); + if let Some(frame) = sessions.end_pan() { + emit_pan(frame); + } +} + +/// Stream one increment of continuous magnification into an open pinch, +/// opening one if needed. Positive zooms in. +/// +/// On macOS this is a HID-layer gesture (`NSEventTypeGesture` / 29, field +/// 110 = Zoom). AppKit promotes that to `NSEventTypeMagnify`. Phase bits are +/// `CGGesturePhase`, not `NSEventPhase`. On Linux and Windows it degrades to +/// Ctrl+wheel detents. +pub fn post_zoom_continuous(amount: f32) { + let mut sessions = GESTURE_SESSIONS + .lock() + .unwrap_or_else(PoisonError::into_inner); + if let Some(frame) = sessions.zoom_continuous(amount) { + emit_zoom(frame); + } +} + +/// Close an open pinch immediately. Idempotent. +pub fn post_zoom_end() { + let mut sessions = GESTURE_SESSIONS + .lock() + .unwrap_or_else(PoisonError::into_inner); + if let Some(frame) = sessions.end_zoom() { + emit_zoom(frame); + } +} + +/// Fire one native smart-zoom toggle at the pointer: zoom in, press again to +/// return. This is the discrete counterpart to [`post_zoom_continuous`], and +/// what a Zoom-bound button does when it is clicked rather than dragged. +/// +/// macOS only. See the platform implementations for why. +pub fn post_smart_zoom() { + // A click still moves the mouse a little, and any motion at all opens a + // pinch — the deadzone decides whether the hold was a drag, not whether + // inject saw travel. Close that pinch first: two live zoom gestures at + // once is not a state any application is asked to handle. + // `post_zoom_end` is idempotent. + post_zoom_end(); + cfg_select! { + target_os = "macos" => { + macos::post_smart_zoom(); + } + target_os = "linux" => { + linux::post_smart_zoom(); + } + target_os = "windows" => { + windows::post_smart_zoom(); + } + _ => {} + } +} + +/// Close every open gesture session now — shutdown, capture interrupt. +/// +/// The agent calls `process::exit` in places, which skips `Drop`. Call this +/// on every teardown path; it is the only guaranteed terminal emit. +pub fn flush_gesture_sessions() { + let mut sessions = GESTURE_SESSIONS + .lock() + .unwrap_or_else(PoisonError::into_inner); + let frames = sessions.flush(); + if let Some(frame) = frames.pan { + emit_pan(frame); + } + if let Some(frame) = frames.zoom { + emit_zoom(frame); + } +} + +/// Close every open gesture session and refuse to open another for the rest +/// of this process. The last call before `process::exit` or `exec`. +/// +/// [`flush_gesture_sessions`] alone is not terminal: a watcher thread still +/// streaming through teardown reopens a pinch on its next sample, because +/// [`post_zoom_continuous`] opens one from closed by contract. +pub fn seal_gesture_sessions() { + let mut sessions = GESTURE_SESSIONS + .lock() + .unwrap_or_else(PoisonError::into_inner); + let frames = sessions.seal(); + if let Some(frame) = frames.pan { + emit_pan(frame); + } + if let Some(frame) = frames.zoom { + emit_zoom(frame); + } +} + +fn emit_pan(frame: gesture::PanFrame) { + cfg_select! { + target_os = "macos" => { + macos::post_pan_frame(frame); + } + target_os = "linux" => { + linux::post_pan_frame(frame); + } + target_os = "windows" => { + windows::post_pan_frame(frame); + } + _ => { + let _ = frame; + } + } +} + +fn emit_zoom(frame: gesture::ZoomFrame) { + cfg_select! { + target_os = "macos" => { + macos::post_zoom_frame(frame); + } + target_os = "linux" => { + linux::post_zoom_frame(frame); + } + target_os = "windows" => { + windows::post_zoom_frame(frame); + } + _ => { + let _ = frame; + } + } +} + /// Synthesise one frame of a finite smooth-scroll animation. /// /// On macOS wheel ticks become continuous pixel events at ten points per tick, diff --git a/crates/openlogi-inject/src/inject/gesture.rs b/crates/openlogi-inject/src/inject/gesture.rs new file mode 100644 index 000000000..d9b04a3fc --- /dev/null +++ b/crates/openlogi-inject/src/inject/gesture.rs @@ -0,0 +1,470 @@ +//! Sans-I/O hold-mode pan and zoom session state. +//! +//! The agent calls `process::exit` in places, so a `Drop` impl cannot be the +//! terminal path. Callers must drive [`GestureSessions::flush`] (via +//! [`super::flush_gesture_sessions`]) on every teardown, and +//! [`GestureSessions::seal`] on the way out of the process. A late pan after +//! `end` must not reopen the session; zoom continuous reopens by contract, +//! which is why the exit path seals rather than only flushing. + +use openlogi_core::scroll::ScrollDelta; + +use super::{QuantizedScroll, ScrollQuantizer}; + +/// Trackpad-style scroll / magnify phase bits (`CGScrollPhase` / +/// `CGGesturePhase`). These are **not** `NSEventPhase` values: AppKit's +/// Changed is `1 << 2` (4), while the CG fields use 2. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum GesturePhase { + Began, + Changed, + Ended, +} + +#[cfg(target_os = "macos")] +impl GesturePhase { + /// `kCGScrollPhase*` / `kCGGesturePhase*` from the macOS SDK + /// `CGEventTypes.h` (`Began = 1`, `Changed = 2`, `Ended = 4`). These + /// are not `NSEventPhase` values: AppKit's Changed is `1 << 2` (4). + pub(super) const fn cg_phase_bits(self) -> i64 { + match self { + Self::Began => 1, + Self::Changed => 2, + Self::Ended => 4, + } + } +} + +/// One pixel-unit pan report in **screen** space: +x right, +y down. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) struct PanFrame { + pub phase: GesturePhase, + pub dx: i32, + pub dy: i32, +} + +/// One continuous-magnification report. Positive `amount` zooms in. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(super) struct ZoomFrame { + pub phase: GesturePhase, + pub amount: f32, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +enum Live { + #[default] + Closed, + Open, +} + +/// Process-global pair of hold-mode gesture sessions. +#[derive(Default)] +pub(super) struct GestureSessions { + pan: Live, + pan_pixels: ScrollQuantizer, + zoom: Live, + /// Set once by [`Self::seal`], never cleared. Closing a session is not + /// enough on the way out: `zoom_continuous` opens a pinch from `Closed` + /// by contract, so a watcher thread still streaming during teardown would + /// reopen one after the final flush, and `process::exit` skips [`Drop`]. + /// Off macOS that leaves a held Ctrl with nothing to release it. + sealed: bool, +} + +impl GestureSessions { + /// Open a pan session. A second begin while already open is a no-op so + /// the OS never sees `began` twice without an `ended`. + pub(super) fn begin_pan(&mut self) -> Option { + if self.sealed || self.pan == Live::Open { + return None; + } + self.pan = Live::Open; + self.pan_pixels = ScrollQuantizer::default(); + Some(PanFrame { + phase: GesturePhase::Began, + dx: 0, + dy: 0, + }) + } + + /// Stream one pan report. Ignored unless a session is open — a torn-down + /// hold must stay terminal. + pub(super) fn pan(&mut self, dx: f32, dy: f32) -> Option { + if self.pan != Live::Open { + return None; + } + if !dx.is_finite() || !dy.is_finite() { + return None; + } + let QuantizedScroll { x, y } = self + .pan_pixels + .quantize(ScrollDelta::pixels(f64::from(dx), f64::from(dy)), 1.0); + if x == 0 && y == 0 { + return None; + } + Some(PanFrame { + phase: GesturePhase::Changed, + dx: x, + dy: y, + }) + } + + /// Close the pan session. Idempotent. + pub(super) fn end_pan(&mut self) -> Option { + if self.pan != Live::Open { + return None; + } + self.pan = Live::Closed; + self.pan_pixels = ScrollQuantizer::default(); + Some(PanFrame { + phase: GesturePhase::Ended, + dx: 0, + dy: 0, + }) + } + + /// Stream one magnification increment, opening a pinch if needed. + pub(super) fn zoom_continuous(&mut self, amount: f32) -> Option { + if self.sealed || !amount.is_finite() { + return None; + } + match self.zoom { + Live::Closed => { + self.zoom = Live::Open; + Some(ZoomFrame { + phase: GesturePhase::Began, + amount, + }) + } + Live::Open => Some(ZoomFrame { + phase: GesturePhase::Changed, + amount, + }), + } + } + + /// Close an open pinch immediately. Idempotent. + pub(super) fn end_zoom(&mut self) -> Option { + if self.zoom != Live::Open { + return None; + } + self.zoom = Live::Closed; + Some(ZoomFrame { + phase: GesturePhase::Ended, + amount: 0.0, + }) + } + + /// Close every open session. Safe to call when both are already closed. + /// A later press may still open a new one. + pub(super) fn flush(&mut self) -> FlushFrames { + FlushFrames { + pan: self.end_pan(), + zoom: self.end_zoom(), + } + } + + /// Close every open session and refuse to open another. Terminal, for the + /// last moment before `process::exit` or `exec`. + pub(super) fn seal(&mut self) -> FlushFrames { + let frames = self.flush(); + self.sealed = true; + frames + } +} + +/// Frames produced by one [`GestureSessions::flush`]. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(super) struct FlushFrames { + pub pan: Option, + pub zoom: Option, +} + +/// Invert screen-space pan (+x right, +y down) into [`ScrollDelta`] pixels +/// (+x scrolls right, +y scrolls up) so content follows the hand on both +/// axes. Mouse-right is therefore scroll-left; mouse-down is scroll-down. +pub(super) fn scroll_pixels_from_screen_pan(dx: i32, dy: i32) -> (i32, i32) { + (dx.saturating_neg(), dy.saturating_neg()) +} + +/// Line/point relationship carried by native macOS continuous scroll events, +/// reused so Linux/Windows wheel-tick pan stays the same physical scale. +#[cfg(any(test, target_os = "linux", target_os = "windows"))] +pub(super) const POINTS_PER_WHEEL_TICK: f64 = 10.0; + +/// Convert screen-space integer pixels to wheel ticks for platforms that +/// cannot emit pixel-unit scroll. +#[cfg(any(test, target_os = "linux", target_os = "windows"))] +pub(super) fn wheel_ticks_from_screen_pixels(dx: i32, dy: i32) -> (f64, f64) { + let (sx, sy) = scroll_pixels_from_screen_pan(dx, dy); + ( + f64::from(sx) / POINTS_PER_WHEEL_TICK, + f64::from(sy) / POINTS_PER_WHEEL_TICK, + ) +} + +/// Banks magnification increments into whole wheel detents for Linux/Windows +/// Ctrl+wheel zoom. One detent is [`MAGNIFICATION_PER_WHEEL_DETENT`]. +#[cfg(any(test, target_os = "linux", target_os = "windows"))] +#[derive(Default)] +pub(super) struct WheelDetentBank { + inner: ScrollQuantizer, +} + +/// Magnification increment that equals one Ctrl+wheel detent. Chosen to match +/// a typical browser step (~10% per notch), not a tautology of the emitter. +#[cfg(any(test, target_os = "linux", target_os = "windows"))] +pub(super) const MAGNIFICATION_PER_WHEEL_DETENT: f64 = 0.1; + +#[cfg(any(test, target_os = "linux", target_os = "windows"))] +impl WheelDetentBank { + /// Absorb one increment and return the signed whole-detent count to emit. + pub(super) fn ingest(&mut self, amount: f32) -> i32 { + if !amount.is_finite() { + return 0; + } + self.inner + .quantize( + ScrollDelta::wheel_ticks(0.0, f64::from(amount)), + 1.0 / MAGNIFICATION_PER_WHEEL_DETENT, + ) + .y + } + + pub(super) fn reset(&mut self) { + self.inner = ScrollQuantizer::default(); + } +} + +#[cfg(test)] +mod tests { + use super::{ + GesturePhase, GestureSessions, Live, PanFrame, WheelDetentBank, ZoomFrame, + scroll_pixels_from_screen_pan, wheel_ticks_from_screen_pixels, + }; + + #[cfg(target_os = "macos")] + #[test] + fn gesture_phase_uses_cg_bits_not_nsevent_phase() { + assert_eq!(GesturePhase::Began.cg_phase_bits(), 1); + assert_eq!(GesturePhase::Changed.cg_phase_bits(), 2); + assert_eq!(GesturePhase::Ended.cg_phase_bits(), 4); + } + + #[test] + fn double_begin_does_not_emit_a_second_began() { + let mut sessions = GestureSessions::default(); + assert_eq!( + sessions.begin_pan(), + Some(PanFrame { + phase: GesturePhase::Began, + dx: 0, + dy: 0 + }) + ); + assert_eq!(sessions.begin_pan(), None); + assert_eq!(sessions.pan, Live::Open); + } + + #[test] + fn late_pan_after_end_does_not_reopen() { + let mut sessions = GestureSessions::default(); + sessions.begin_pan(); + assert_eq!( + sessions.end_pan(), + Some(PanFrame { + phase: GesturePhase::Ended, + dx: 0, + dy: 0 + }) + ); + assert_eq!(sessions.pan(12.0, -4.0), None); + assert_eq!(sessions.end_pan(), None); + assert_eq!(sessions.pan, Live::Closed); + } + + #[test] + fn fractional_pan_banks_until_nearest_pixel() { + let mut sessions = GestureSessions::default(); + sessions.begin_pan(); + // 0.4 is closer to 0 than 1; a second 0.4 crosses the midpoint. + assert_eq!(sessions.pan(0.4, 0.0), None); + assert_eq!( + sessions.pan(0.4, 0.0), + Some(PanFrame { + phase: GesturePhase::Changed, + dx: 1, + dy: 0 + }) + ); + } + + #[test] + fn pan_keeps_screen_down_positive_until_the_scroll_seam() { + let mut sessions = GestureSessions::default(); + sessions.begin_pan(); + assert_eq!( + sessions.pan(3.0, 5.0), + Some(PanFrame { + phase: GesturePhase::Changed, + dx: 3, + dy: 5 + }) + ); + } + + // Content-follows-hand pan: invert each screen axis independently into + // `ScrollDelta` space (+x scrolls right, +y scrolls up). A combined + // (dx, dy) assertion hid the live bug where only Y was inverted. + + #[test] + fn mouse_right_scrolls_left_so_content_follows() { + assert_eq!(scroll_pixels_from_screen_pan(7, 0), (-7, 0)); + } + + #[test] + fn mouse_left_scrolls_right_so_content_follows() { + assert_eq!(scroll_pixels_from_screen_pan(-7, 0), (7, 0)); + } + + #[test] + fn mouse_down_scrolls_down_so_content_follows() { + assert_eq!(scroll_pixels_from_screen_pan(0, 7), (0, -7)); + } + + #[test] + fn mouse_up_scrolls_up_so_content_follows() { + assert_eq!(scroll_pixels_from_screen_pan(0, -7), (0, 7)); + } + + #[test] + fn screen_min_saturates_per_axis() { + assert_eq!(scroll_pixels_from_screen_pan(i32::MIN, 0), (i32::MAX, 0)); + assert_eq!(scroll_pixels_from_screen_pan(0, i32::MIN), (0, i32::MAX)); + } + + #[test] + fn wheel_tick_horizontal_matches_pixel_sign() { + assert_eq!(wheel_ticks_from_screen_pixels(10, 0), (-1.0, 0.0)); + assert_eq!(wheel_ticks_from_screen_pixels(-10, 0), (1.0, 0.0)); + assert_eq!(wheel_ticks_from_screen_pixels(-5, 0), (0.5, 0.0)); + } + + #[test] + fn wheel_tick_vertical_matches_pixel_sign() { + assert_eq!(wheel_ticks_from_screen_pixels(0, 10), (0.0, -1.0)); + assert_eq!(wheel_ticks_from_screen_pixels(0, -10), (0.0, 1.0)); + } + + #[test] + fn zoom_begins_on_the_first_delta_and_changes_after() { + let mut sessions = GestureSessions::default(); + assert_eq!( + sessions.zoom_continuous(0.02), + Some(ZoomFrame { + phase: GesturePhase::Began, + amount: 0.02 + }) + ); + assert_eq!( + sessions.zoom_continuous(-0.01), + Some(ZoomFrame { + phase: GesturePhase::Changed, + amount: -0.01 + }) + ); + assert_eq!( + sessions.end_zoom(), + Some(ZoomFrame { + phase: GesturePhase::Ended, + amount: 0.0 + }) + ); + assert_eq!(sessions.end_zoom(), None); + } + + #[test] + fn zoom_continuous_reopens_after_end() { + let mut sessions = GestureSessions::default(); + sessions.zoom_continuous(0.01); + sessions.end_zoom(); + assert_eq!( + sessions.zoom_continuous(0.03), + Some(ZoomFrame { + phase: GesturePhase::Began, + amount: 0.03 + }) + ); + } + + #[test] + fn non_finite_input_does_not_open_or_move_a_session() { + let mut sessions = GestureSessions::default(); + assert_eq!(sessions.pan(1.0, 1.0), None); + assert_eq!(sessions.zoom_continuous(f32::NAN), None); + assert_eq!(sessions.zoom, Live::Closed); + sessions.begin_pan(); + assert_eq!(sessions.pan(f32::INFINITY, 0.0), None); + assert_eq!(sessions.pan, Live::Open); + } + + #[test] + fn flush_ends_open_sessions_once() { + let mut sessions = GestureSessions::default(); + sessions.begin_pan(); + sessions.zoom_continuous(0.05); + let first = sessions.flush(); + assert_eq!(first.pan.map(|f| f.phase), Some(GesturePhase::Ended)); + assert_eq!(first.zoom.map(|f| f.phase), Some(GesturePhase::Ended)); + let second = sessions.flush(); + assert_eq!(second.pan, None); + assert_eq!(second.zoom, None); + assert_eq!(sessions.pan(8.0, 0.0), None); + } + + #[test] + fn a_sealed_session_cannot_be_reopened_by_a_late_watcher() { + let mut sessions = GestureSessions::default(); + sessions.zoom_continuous(0.05); + sessions.begin_pan(); + let frames = sessions.seal(); + assert_eq!(frames.pan.map(|f| f.phase), Some(GesturePhase::Ended)); + assert_eq!(frames.zoom.map(|f| f.phase), Some(GesturePhase::Ended)); + assert_eq!( + sessions.zoom_continuous(0.05), + None, + "zoom opens a pinch from closed by contract, which is exactly what \ + must not happen after the exit flush" + ); + assert_eq!(sessions.begin_pan(), None); + assert_eq!(sessions.pan(8.0, 0.0), None); + } + + #[test] + fn a_plain_flush_still_lets_the_next_hold_open() { + let mut sessions = GestureSessions::default(); + sessions.begin_pan(); + sessions.flush(); + assert_eq!( + sessions.begin_pan(), + Some(PanFrame { + phase: GesturePhase::Began, + dx: 0, + dy: 0 + }), + "only the exit path is terminal" + ); + } + + #[test] + fn detent_bank_emits_only_after_crossing_a_notch() { + let mut bank = WheelDetentBank::default(); + // 0.04 magnification is 0.4 of a detent — below the rounding midpoint. + // A second 0.04 crosses it. Opposite motion pays the residual back. + assert_eq!(bank.ingest(0.04), 0); + assert_eq!(bank.ingest(0.04), 1); + assert_eq!(bank.ingest(-0.08), -1); + bank.reset(); + assert_eq!(bank.ingest(0.04), 0); + } +} diff --git a/crates/openlogi-inject/src/inject/linux.rs b/crates/openlogi-inject/src/inject/linux.rs index b5fe8d196..f08684bc1 100644 --- a/crates/openlogi-inject/src/inject/linux.rs +++ b/crates/openlogi-inject/src/inject/linux.rs @@ -17,9 +17,13 @@ use openlogi_core::binding::{ }; use openlogi_core::scroll::ScrollDelta; +use super::gesture::{ + GesturePhase, PanFrame, WheelDetentBank, ZoomFrame, wheel_ticks_from_screen_pixels, +}; use super::{HeldKey, KeyPhase, QuantizedScroll, ScrollQuantizer}; const HIGH_RES_UNITS_PER_TICK: f64 = 120.0; +const HIGH_RES_UNITS_PER_TICK_I32: i32 = 120; #[derive(Default)] struct ScrollOutput { @@ -30,6 +34,13 @@ struct ScrollOutput { static SCROLL_OUTPUT: LazyLock> = LazyLock::new(|| Mutex::new(ScrollOutput::default())); +/// Dedicated banks so hold-mode pan/zoom do not share residuals with +/// [`post_scroll`] or with each other. +static PAN_SCROLL: LazyLock> = + LazyLock::new(|| Mutex::new(ScrollOutput::default())); +static ZOOM_DETENTS: LazyLock> = + LazyLock::new(|| Mutex::new(WheelDetentBank::default())); + /// Linux implementation: classify `action` into an [`Effect`] and inject the /// resulting events via a shared `uinput` virtual device. pub(super) fn execute(action: &Action) { @@ -433,6 +444,84 @@ fn push_scroll_axes( } } +/// Linux has no scroll-phase or pixel-unit fields. Two-axis `REL_*WHEEL` +/// is the existing high-resolution degradation; 10 screen pixels = 1 tick. +pub(super) fn post_pan_frame(frame: PanFrame) { + if matches!(frame.phase, GesturePhase::Began | GesturePhase::Ended) + && frame.dx == 0 + && frame.dy == 0 + { + return; + } + let (tick_x, tick_y) = wheel_ticks_from_screen_pixels(frame.dx, frame.dy); + let Ok(mut output) = PAN_SCROLL.lock() else { + tracing::warn!("Linux pan quantizer mutex poisoned"); + return; + }; + let delta = ScrollDelta::wheel_ticks(tick_x, tick_y); + let high_resolution = output + .high_resolution + .quantize(delta, HIGH_RES_UNITS_PER_TICK); + let legacy = output.legacy.quantize(delta, 1.0); + drop(output); + + let mut events = Vec::with_capacity(5); + push_scroll_axes( + &mut events, + high_resolution, + RelativeAxisCode::REL_HWHEEL_HI_RES, + RelativeAxisCode::REL_WHEEL_HI_RES, + ); + push_scroll_axes( + &mut events, + legacy, + RelativeAxisCode::REL_HWHEEL, + RelativeAxisCode::REL_WHEEL, + ); + if !events.is_empty() { + events.push(syn()); + emit(&events); + } +} + +/// Continuous pinch degrades to Ctrl+wheel detents. `amount` is the same +/// magnification increment macOS posts; one detent is +/// [`MAGNIFICATION_PER_WHEEL_DETENT`]. +pub(super) fn post_zoom_frame(frame: ZoomFrame) { + match frame.phase { + GesturePhase::Began => { + emit(&[key_ev(KeyCode::KEY_LEFTCTRL, 1), syn()]); + emit_zoom_detents(frame.amount); + } + GesturePhase::Changed => emit_zoom_detents(frame.amount), + GesturePhase::Ended => { + if let Ok(mut bank) = ZOOM_DETENTS.lock() { + bank.reset(); + } + emit(&[key_ev(KeyCode::KEY_LEFTCTRL, 0), syn()]); + } + } +} + +fn emit_zoom_detents(amount: f32) { + let Ok(mut bank) = ZOOM_DETENTS.lock() else { + tracing::warn!("Linux zoom detent mutex poisoned"); + return; + }; + let detents = bank.ingest(amount); + drop(bank); + if detents != 0 { + emit(&[ + rel_ev(RelativeAxisCode::REL_WHEEL, detents), + rel_ev( + RelativeAxisCode::REL_WHEEL_HI_RES, + detents.saturating_mul(HIGH_RES_UNITS_PER_TICK_I32), + ), + syn(), + ]); + } +} + /// Force the virtual device to initialise (if it hasn't already) and return /// its `/dev/input/eventN` node path. /// @@ -788,3 +877,9 @@ mod tests { } } } + +/// Smart zoom has no native equivalent here, so a click on a Zoom-bound +/// button does nothing. Upstream PR #1119 draws the same platform line for +/// the standalone action: the gesture is a macOS one, and Ctrl+wheel cannot +/// express "toggle to a sensible zoom level and back". +pub(super) fn post_smart_zoom() {} diff --git a/crates/openlogi-inject/src/inject/macos.rs b/crates/openlogi-inject/src/inject/macos.rs index fd3718a90..7e8488b4a 100644 --- a/crates/openlogi-inject/src/inject/macos.rs +++ b/crates/openlogi-inject/src/inject/macos.rs @@ -15,6 +15,7 @@ use openlogi_core::binding::{ }; use openlogi_core::scroll::ScrollDelta; +use super::gesture::{PanFrame, ZoomFrame, scroll_pixels_from_screen_pan}; use super::{ HeldKey, HeldModifiers, KeyPhase, QuantizedScroll, ScrollQuantizer, SmoothScrollPhase, }; @@ -720,6 +721,436 @@ fn set_continuous_axis( event.set_integer_value_field(fixed_field, points * FIXED_POINT_SCALE / POINTS_PER_LINE); } +/// Mach absolute time in nanoseconds — the clock `CGEventTimestamp` documents +/// as "nanoseconds since startup". +#[expect( + unsafe_code, + reason = "clock_gettime_nsec_np is a libSystem clock read with no pointer exchange" +)] +fn current_mach_timestamp_ns() -> u64 { + const CLOCK_UPTIME_RAW: i32 = 8; + unsafe extern "C" { + fn clock_gettime_nsec_np(clock_id: i32) -> u64; + } + // SAFETY: `clock_gettime_nsec_np` is a libSystem function; it takes a + // clock id and returns nanoseconds. No pointer is exchanged. + unsafe { clock_gettime_nsec_np(CLOCK_UPTIME_RAW) } +} + +fn finish_objc_gesture_event(ev: &objc2_core_graphics::CGEvent) { + use objc2_core_graphics::{CGEvent as ObjcCGEvent, CGEventField}; + + ObjcCGEvent::set_integer_value_field( + Some(ev), + CGEventField::EventSourceUserData, + super::SYNTHETIC_EVENT_USER_DATA, + ); + // Stamp a current Mach-based timestamp so the associated IOHID queue + // timestamp is populated. Upstream PR #1119 documents why: without it + // Safari accepts a zoom-in but refuses the later gesture that toggles + // back out. + ObjcCGEvent::set_timestamp(Some(ev), current_mach_timestamp_ns()); +} + +pub(super) fn post_pan_frame(frame: PanFrame) { + use objc2_core_graphics::{ + CGEvent as ObjcCGEvent, CGEventField, CGEventSource as ObjcSource, + CGEventSourceStateID as ObjcSourceState, CGEventTapLocation as ObjcTap, CGScrollEventUnit, + }; + + let (sx, sy) = scroll_pixels_from_screen_pan(frame.dx, frame.dy); + let Some(src) = ObjcSource::new(ObjcSourceState::HIDSystemState) else { + tracing::warn!("CGEventSource::new failed for pan"); + return; + }; + let Some(ev) = + ObjcCGEvent::new_scroll_wheel_event2(Some(&src), CGScrollEventUnit::Pixel, 2, sy, sx, 0) + else { + tracing::warn!("CGEvent::new_scroll_wheel_event2 failed for pan"); + return; + }; + set_objc_continuous_scroll_fields(&ev, sx, sy); + ObjcCGEvent::set_integer_value_field( + Some(&ev), + CGEventField::ScrollWheelEventScrollPhase, + frame.phase.cg_phase_bits(), + ); + ObjcCGEvent::set_integer_value_field(Some(&ev), CGEventField::ScrollWheelEventMomentumPhase, 0); + finish_objc_gesture_event(&ev); + ObjcCGEvent::post(ObjcTap::HIDEventTap, Some(&ev)); +} + +fn set_objc_continuous_scroll_fields(event: &objc2_core_graphics::CGEvent, sx: i32, sy: i32) { + use objc2_core_graphics::{CGEvent as ObjcCGEvent, CGEventField}; + + const POINTS_PER_LINE: i64 = 10; + const FIXED_POINT_SCALE: i64 = 1 << 16; + + ObjcCGEvent::set_integer_value_field( + Some(event), + CGEventField::ScrollWheelEventIsContinuous, + 1, + ); + set_objc_continuous_axis( + event, + sy, + CGEventField::ScrollWheelEventDeltaAxis1, + CGEventField::ScrollWheelEventFixedPtDeltaAxis1, + CGEventField::ScrollWheelEventPointDeltaAxis1, + POINTS_PER_LINE, + FIXED_POINT_SCALE, + ); + set_objc_continuous_axis( + event, + sx, + CGEventField::ScrollWheelEventDeltaAxis2, + CGEventField::ScrollWheelEventFixedPtDeltaAxis2, + CGEventField::ScrollWheelEventPointDeltaAxis2, + POINTS_PER_LINE, + FIXED_POINT_SCALE, + ); +} + +fn set_objc_continuous_axis( + event: &objc2_core_graphics::CGEvent, + points: i32, + line_field: objc2_core_graphics::CGEventField, + fixed_field: objc2_core_graphics::CGEventField, + point_field: objc2_core_graphics::CGEventField, + points_per_line: i64, + fixed_point_scale: i64, +) { + use objc2_core_graphics::CGEvent as ObjcCGEvent; + + let points = i64::from(points); + // CoreGraphics recomputes line / fixedPt / pointDelta siblings on each + // write. A line-delta of 0 (any |points| < 10) zeros the others, so the + // pixel value must be written after the line field or it does not + // survive. Real two-finger type-22 events keep that pixel in pointDelta + // and leave fixedPt at 0 for these sub-line frames — writing the line + // field after fixedPt is what clears the constructor's leftover 16.16. + ObjcCGEvent::set_integer_value_field( + Some(event), + fixed_field, + points * fixed_point_scale / points_per_line, + ); + ObjcCGEvent::set_integer_value_field(Some(event), line_field, points / points_per_line); + ObjcCGEvent::set_integer_value_field(Some(event), point_field, points); +} + +/// Private CGEvent fields from WebKit's CoreGraphicsTestSPI. Not in the +/// public `CGEventField` enum. +const GESTURE_HID_TYPE: objc2_core_graphics::CGEventField = objc2_core_graphics::CGEventField(110); +const GESTURE_ZOOM_VALUE: objc2_core_graphics::CGEventField = + objc2_core_graphics::CGEventField(113); +const GESTURE_PHASE: objc2_core_graphics::CGEventField = objc2_core_graphics::CGEventField(132); +/// `kIOHIDEventTypeZoom` in IOHIDEventTypes.h (NULL=0 … Zoom=8). +const HID_EVENT_TYPE_ZOOM: i64 = 8; + +/// `kIOHIDEventTypeZoomToggle` in IOHIDEventTypes.h. AppKit surfaces a +/// gesture carrying it as `NSEventTypeSmartMagnify`. +const HID_EVENT_TYPE_ZOOM_TOGGLE: i64 = 22; +/// Private payload markers that real trackpad and Logitech smart-zoom events +/// both carry, and that receiving applications check for. +const GESTURE_PAYLOAD_KIND: objc2_core_graphics::CGEventField = + objc2_core_graphics::CGEventField(59); +const GESTURE_PAYLOAD_KIND_VALUE: i64 = 0x2000_0100; +const GESTURE_PAYLOAD_FLAGS: objc2_core_graphics::CGEventField = + objc2_core_graphics::CGEventField(102); +const GESTURE_PAYLOAD_FLAGS_VALUE: i64 = 0x3f; + +/// HID-layer gesture type (`NSEventTypeGesture`). +/// +/// Real trackpad pinches arrive as this, never as `NSEventTypeMagnify` (30). +/// AppKit is what promotes type 29 + field 110 = Zoom into a magnify NSEvent. +/// Posting 30 at this layer is not a pinch — measured against a real trackpad +/// stream, type 30 never appeared. +fn zoom_cg_event_type() -> objc2_core_graphics::CGEventType { + use objc2_app_kit::NSEventType; + use objc2_core_graphics::CGEventType; + + CGEventType(u32::try_from(NSEventType::Gesture.0).unwrap_or(29)) +} + +fn fill_zoom_event(ev: &objc2_core_graphics::CGEvent, frame: ZoomFrame) { + use objc2_core_graphics::CGEvent as ObjcCGEvent; + + ObjcCGEvent::set_type(Some(ev), zoom_cg_event_type()); + ObjcCGEvent::set_integer_value_field(Some(ev), GESTURE_HID_TYPE, HID_EVENT_TYPE_ZOOM); + ObjcCGEvent::set_integer_value_field(Some(ev), GESTURE_PHASE, frame.phase.cg_phase_bits()); + ObjcCGEvent::set_double_value_field(Some(ev), GESTURE_ZOOM_VALUE, f64::from(frame.amount)); + finish_objc_gesture_event(ev); +} + +pub(super) fn post_zoom_frame(frame: ZoomFrame) { + use objc2_core_graphics::{ + CGEvent as ObjcCGEvent, CGEventSource as ObjcSource, + CGEventSourceStateID as ObjcSourceState, CGEventTapLocation as ObjcTap, + }; + + let Some(src) = ObjcSource::new(ObjcSourceState::HIDSystemState) else { + tracing::warn!("CGEventSource::new failed for zoom"); + return; + }; + let Some(ev) = ObjcCGEvent::new(Some(&src)) else { + tracing::warn!("CGEvent::new failed for zoom"); + return; + }; + fill_zoom_event(&ev, frame); + ObjcCGEvent::post(ObjcTap::HIDEventTap, Some(&ev)); +} + +/// Post one native smart-zoom toggle at the pointer: zoom in, then press +/// again to return. +/// +/// The event synthesis — the `ZoomToggle` subtype and the two private payload +/// markers above — is Kyle Foley's work from upstream PR #1119 +/// (), reused here with +/// thanks. It is driven from somewhere different: #1119 offers smart zoom as +/// its own bindable action, while this is what a Zoom-bound button does when +/// it is clicked instead of dragged. +/// +/// Unlike the continuous pinch, the source is the combined session state +/// rather than HID, which is what real smart-zoom events carry. +pub(super) fn post_smart_zoom() { + use objc2_core_graphics::{ + CGEvent as ObjcCGEvent, CGEventSource as ObjcSource, + CGEventSourceStateID as ObjcSourceState, CGEventTapLocation as ObjcTap, + }; + + let Some(src) = ObjcSource::new(ObjcSourceState::CombinedSessionState) else { + tracing::warn!("CGEventSource::new failed for smart zoom"); + return; + }; + let Some(ev) = ObjcCGEvent::new(Some(&src)) else { + tracing::warn!("CGEvent::new failed for smart zoom"); + return; + }; + fill_smart_zoom_event(&ev); + ObjcCGEvent::post(ObjcTap::HIDEventTap, Some(&ev)); +} + +fn fill_smart_zoom_event(ev: &objc2_core_graphics::CGEvent) { + use objc2_core_graphics::CGEvent as ObjcCGEvent; + + ObjcCGEvent::set_type(Some(ev), zoom_cg_event_type()); + ObjcCGEvent::set_integer_value_field(Some(ev), GESTURE_HID_TYPE, HID_EVENT_TYPE_ZOOM_TOGGLE); + ObjcCGEvent::set_integer_value_field( + Some(ev), + GESTURE_PAYLOAD_KIND, + GESTURE_PAYLOAD_KIND_VALUE, + ); + ObjcCGEvent::set_integer_value_field( + Some(ev), + GESTURE_PAYLOAD_FLAGS, + GESTURE_PAYLOAD_FLAGS_VALUE, + ); + finish_objc_gesture_event(ev); +} + +#[cfg(test)] +mod zoom_event_tests { + use objc2_app_kit::NSEventType; + use objc2_core_graphics::{ + CGEvent as ObjcCGEvent, CGEventField, CGEventSource as ObjcSource, + CGEventSourceStateID as ObjcSourceState, + }; + + use super::{ + GESTURE_HID_TYPE, GESTURE_PAYLOAD_FLAGS, GESTURE_PAYLOAD_FLAGS_VALUE, GESTURE_PAYLOAD_KIND, + GESTURE_PAYLOAD_KIND_VALUE, GESTURE_PHASE, GESTURE_ZOOM_VALUE, HID_EVENT_TYPE_ZOOM, + HID_EVENT_TYPE_ZOOM_TOGGLE, fill_smart_zoom_event, fill_zoom_event, zoom_cg_event_type, + }; + use crate::inject::gesture::{GesturePhase, ZoomFrame}; + + /// Magnitude from the real trackpad capture (event #17). Independent of + /// our writer — 1002733568 is what the probe read on fields 115/117/164. + const CAPTURE_F32_BITS: u32 = 1_002_733_568; + const CAPTURE_ZOOM_IN: f32 = f32::from_bits(CAPTURE_F32_BITS); + + fn filled(frame: ZoomFrame) -> impl core::ops::Deref { + let src = ObjcSource::new(ObjcSourceState::HIDSystemState).expect("CGEventSource"); + let ev = ObjcCGEvent::new(Some(&src)).expect("CGEvent"); + fill_zoom_event(&ev, frame); + ev + } + + #[test] + fn nsevent_gesture_is_29_and_magnify_is_30() { + // NSEvent.h, measured against a real trackpad: every pinch was type 29. + assert_eq!( + u32::try_from(NSEventType::Gesture.0).expect("Gesture fits u32"), + 29 + ); + assert_eq!( + u32::try_from(NSEventType::Magnify.0).expect("Magnify fits u32"), + 30 + ); + } + + #[test] + fn zoom_frame_uses_hid_gesture_not_appkit_magnify() { + assert_eq!(zoom_cg_event_type().0, 29); + assert_ne!(zoom_cg_event_type().0, 30); + let ev = filled(ZoomFrame { + phase: GesturePhase::Changed, + amount: CAPTURE_ZOOM_IN, + }); + assert_eq!(ObjcCGEvent::r#type(Some(&ev)).0, 29); + assert_eq!( + ObjcCGEvent::integer_value_field(Some(&ev), GESTURE_HID_TYPE), + HID_EVENT_TYPE_ZOOM + ); + assert_eq!( + ObjcCGEvent::integer_value_field(Some(&ev), GESTURE_PHASE), + 2 + ); + let mag = ObjcCGEvent::double_value_field(Some(&ev), GESTURE_ZOOM_VALUE); + assert_eq!(mag.to_bits(), f64::from(CAPTURE_ZOOM_IN).to_bits()); + // Writing 113 alone populates these; they are one storage word. + assert_eq!( + ObjcCGEvent::double_value_field(Some(&ev), CGEventField(114)).to_bits(), + mag.to_bits() + ); + assert_eq!( + ObjcCGEvent::double_value_field(Some(&ev), CGEventField(116)).to_bits(), + mag.to_bits() + ); + assert_eq!( + ObjcCGEvent::double_value_field(Some(&ev), CGEventField(118)).to_bits(), + mag.to_bits() + ); + assert_eq!( + ObjcCGEvent::integer_value_field(Some(&ev), CGEventField(115)), + i64::from(CAPTURE_F32_BITS) + ); + } + + #[test] + fn zoom_event_phase_bits_match_cg_gesture_phase() { + for (phase, bits) in [ + (GesturePhase::Began, 1), + (GesturePhase::Changed, 2), + (GesturePhase::Ended, 4), + ] { + let ev = filled(ZoomFrame { phase, amount: 0.0 }); + assert_eq!( + ObjcCGEvent::integer_value_field(Some(&ev), GESTURE_PHASE), + bits, + "{phase:?}" + ); + } + } + + #[test] + fn smart_zoom_is_a_zoom_toggle_gesture_at_the_pointer() { + let src = ObjcSource::new(ObjcSourceState::CombinedSessionState).expect("CGEventSource"); + let ev = ObjcCGEvent::new(Some(&src)).expect("CGEvent"); + let pointer = ObjcCGEvent::location(Some(&ev)); + + fill_smart_zoom_event(&ev); + + assert_eq!(ObjcCGEvent::r#type(Some(&ev)).0, zoom_cg_event_type().0); + assert_eq!( + ObjcCGEvent::integer_value_field(Some(&ev), GESTURE_HID_TYPE), + HID_EVENT_TYPE_ZOOM_TOGGLE, + "a discrete toggle is not the continuous Zoom subtype" + ); + assert_ne!( + HID_EVENT_TYPE_ZOOM_TOGGLE, HID_EVENT_TYPE_ZOOM, + "the two zoom gestures must stay distinguishable" + ); + assert_eq!( + ObjcCGEvent::integer_value_field(Some(&ev), GESTURE_PAYLOAD_KIND), + GESTURE_PAYLOAD_KIND_VALUE + ); + assert_eq!( + ObjcCGEvent::integer_value_field(Some(&ev), GESTURE_PAYLOAD_FLAGS), + GESTURE_PAYLOAD_FLAGS_VALUE + ); + assert_ne!( + ObjcCGEvent::timestamp(Some(&ev)), + 0, + "an unstamped gesture leaves Safari latched after the first toggle" + ); + assert_eq!( + ObjcCGEvent::location(Some(&ev)), + pointer, + "smart zoom acts where the pointer already is" + ); + } +} + +#[cfg(test)] +mod pan_event_tests { + use objc2_core_graphics::{ + CGEvent as ObjcCGEvent, CGEventField, CGEventSource as ObjcSource, + CGEventSourceStateID as ObjcSourceState, CGScrollEventUnit, + }; + + use super::set_objc_continuous_scroll_fields; + + /// Real two-finger Began frame (capture event #12): `pointDelta=(-2, 0)`, + /// `fixedPt` and line delta both zero. + const CAPTURE_BEGAN_POINT_Y: i32 = -2; + const CAPTURE_BEGAN_POINT_X: i32 = 0; + + #[test] + fn continuous_pan_keeps_pixel_delta_in_point_delta() { + let src = ObjcSource::new(ObjcSourceState::HIDSystemState).expect("CGEventSource"); + let ev = ObjcCGEvent::new_scroll_wheel_event2( + Some(&src), + CGScrollEventUnit::Pixel, + 2, + CAPTURE_BEGAN_POINT_Y, + CAPTURE_BEGAN_POINT_X, + 0, + ) + .expect("CGEventCreateScrollWheelEvent2"); + set_objc_continuous_scroll_fields(&ev, CAPTURE_BEGAN_POINT_X, CAPTURE_BEGAN_POINT_Y); + assert_eq!( + ObjcCGEvent::integer_value_field( + Some(&ev), + CGEventField::ScrollWheelEventPointDeltaAxis1 + ), + i64::from(CAPTURE_BEGAN_POINT_Y) + ); + assert_eq!( + ObjcCGEvent::integer_value_field( + Some(&ev), + CGEventField::ScrollWheelEventPointDeltaAxis2 + ), + i64::from(CAPTURE_BEGAN_POINT_X) + ); + assert_eq!( + ObjcCGEvent::integer_value_field( + Some(&ev), + CGEventField::ScrollWheelEventFixedPtDeltaAxis1 + ), + 0 + ); + assert_eq!( + ObjcCGEvent::integer_value_field( + Some(&ev), + CGEventField::ScrollWheelEventFixedPtDeltaAxis2 + ), + 0 + ); + assert_eq!( + ObjcCGEvent::integer_value_field(Some(&ev), CGEventField::ScrollWheelEventDeltaAxis1), + 0 + ); + assert_eq!( + ObjcCGEvent::integer_value_field(Some(&ev), CGEventField::ScrollWheelEventDeltaAxis2), + 0 + ); + assert_eq!( + ObjcCGEvent::integer_value_field(Some(&ev), CGEventField::ScrollWheelEventIsContinuous), + 1 + ); + } +} + /// Raw FFI surface for the AXUIElement/CF calls used by [`ax_browser_navigate`] /// and its helpers below. Kept as module-level items (rather than nested in /// `ax_browser_navigate`) so each helper is independently readable and short. diff --git a/crates/openlogi-inject/src/inject/windows.rs b/crates/openlogi-inject/src/inject/windows.rs index 8407fc3c1..fb050a3e9 100644 --- a/crates/openlogi-inject/src/inject/windows.rs +++ b/crates/openlogi-inject/src/inject/windows.rs @@ -16,6 +16,9 @@ use openlogi_core::binding::{ }; use openlogi_core::scroll::ScrollDelta; +use super::gesture::{ + GesturePhase, PanFrame, WheelDetentBank, ZoomFrame, wheel_ticks_from_screen_pixels, +}; use super::{HeldKey, KeyPhase, ScrollQuantizer}; const WHEEL_DELTA: i32 = 120; @@ -23,6 +26,10 @@ const WHEEL_DELTA_F64: f64 = 120.0; static SCROLL_QUANTIZER: LazyLock> = LazyLock::new(|| Mutex::new(ScrollQuantizer::default())); +static PAN_SCROLL: LazyLock> = + LazyLock::new(|| Mutex::new(ScrollQuantizer::default())); +static ZOOM_DETENTS: LazyLock> = + LazyLock::new(|| Mutex::new(WheelDetentBank::default())); const VK_D: u16 = 0x44; const VK_L: u16 = 0x4C; @@ -268,6 +275,67 @@ pub(super) fn post_scroll(delta: ScrollDelta) { } } +/// Windows has no scroll-phase or pixel-unit fields. `MOUSEEVENTF_WHEEL` / +/// `HWHEEL` is the existing degradation; 10 screen pixels = 1 tick. +pub(super) fn post_pan_frame(frame: PanFrame) { + if matches!(frame.phase, GesturePhase::Began | GesturePhase::Ended) + && frame.dx == 0 + && frame.dy == 0 + { + return; + } + let (tick_x, tick_y) = wheel_ticks_from_screen_pixels(frame.dx, frame.dy); + let Ok(mut quantizer) = PAN_SCROLL.lock() else { + tracing::warn!("Windows pan quantizer mutex poisoned"); + return; + }; + let delta = quantizer.quantize(ScrollDelta::wheel_ticks(tick_x, tick_y), WHEEL_DELTA_F64); + drop(quantizer); + + let mut inputs = Vec::with_capacity(2); + if delta.y != 0 { + inputs.push(mouse_input(MOUSEEVENTF_WHEEL, delta.y)); + } + if delta.x != 0 { + inputs.push(mouse_input(MOUSEEVENTF_HWHEEL, delta.x)); + } + if !inputs.is_empty() { + send_inputs(&inputs); + } +} + +/// Continuous pinch degrades to Ctrl+wheel detents, same contract as Linux. +pub(super) fn post_zoom_frame(frame: ZoomFrame) { + match frame.phase { + GesturePhase::Began => { + send_inputs(&[key_input(VK_CONTROL, false)]); + emit_zoom_detents(frame.amount); + } + GesturePhase::Changed => emit_zoom_detents(frame.amount), + GesturePhase::Ended => { + if let Ok(mut bank) = ZOOM_DETENTS.lock() { + bank.reset(); + } + send_inputs(&[key_input(VK_CONTROL, true)]); + } + } +} + +fn emit_zoom_detents(amount: f32) { + let Ok(mut bank) = ZOOM_DETENTS.lock() else { + tracing::warn!("Windows zoom detent mutex poisoned"); + return; + }; + let detents = bank.ingest(amount); + drop(bank); + if detents != 0 { + send_inputs(&[mouse_input( + MOUSEEVENTF_WHEEL, + detents.saturating_mul(WHEEL_DELTA), + )]); + } +} + fn post_custom_shortcut(combo: &KeyCombo) { let Some(vk) = super::hid_usage_to_windows(combo.key().code()) else { tracing::warn!( @@ -443,3 +511,9 @@ mod tests { } } } + +/// Smart zoom has no native equivalent here, so a click on a Zoom-bound +/// button does nothing. Upstream PR #1119 draws the same platform line for +/// the standalone action: the gesture is a macOS one, and Ctrl+wheel cannot +/// express "toggle to a sensible zoom level and back". +pub(super) fn post_smart_zoom() {} diff --git a/crates/openlogi-inject/src/lib.rs b/crates/openlogi-inject/src/lib.rs index 9ebe57a30..f51632d11 100644 --- a/crates/openlogi-inject/src/lib.rs +++ b/crates/openlogi-inject/src/lib.rs @@ -4,7 +4,8 @@ mod inject; pub use inject::{ HeldChord, SYNTHETIC_EVENT_USER_DATA, SmoothScrollPhase, ax_navigate_browser, execute, - post_scroll, post_smooth_scroll, press_hold, + flush_gesture_sessions, post_pan, post_pan_begin, post_pan_end, post_scroll, post_smart_zoom, + post_smooth_scroll, post_zoom_continuous, post_zoom_end, press_hold, seal_gesture_sessions, }; #[cfg(target_os = "linux")] diff --git a/crates/openlogi-ipc/src/ipc.rs b/crates/openlogi-ipc/src/ipc.rs index dd99d2fe9..57256de6c 100644 --- a/crates/openlogi-ipc/src/ipc.rs +++ b/crates/openlogi-ipc/src/ipc.rs @@ -61,7 +61,11 @@ 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: `Action::Pan` and `Action::Zoom` appended (hold-mode button actions). +/// Existing variant indexes are unchanged; a payload that carries the +/// new variants is still a wire change, and the handshake is strict-equal +/// (same reason v28 bumped for `HoldShortcut`). +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..1e925c2f1 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,17 @@ 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); +} + +/// Appended `Action` variants keep earlier indexes and occupy the next two +/// slots. Pin both facts so a mid-list insert fails here instead of at a +/// mismatched agent/GUI pair. +#[test] +fn hold_mode_actions() { + assert_wire(&Action::None, "00"); + assert_wire(&Action::Pan, "35"); + assert_wire(&Action::Zoom, "36"); } #[test] diff --git a/crates/openlogi-ui/locales/be.yml b/crates/openlogi-ui/locales/be.yml index cb04e173a..5ee2208b4 100644 --- a/crates/openlogi-ui/locales/be.yml +++ b/crates/openlogi-ui/locales/be.yml @@ -248,6 +248,12 @@ _version: 1 "Scroll Down": "Пракруціць уніз" "Scroll Left": "Пракруціць улева" "Scroll Right": "Пракруціць управа" +"Pan": "Панарамаванне" +"Pinch Zoom": "Маштабаванне шчыпком" +"Hold and drag to scroll in any direction.": "Утрымлівайце і перацягвайце, каб пракручваць у любым кірунку." +"Hold and drag to scroll.": "Утрымлівайце і перацягвайце, каб пракручваць." +"Hold and drag up or down to pinch-zoom.": "Утрымлівайце і перацягвайце ўверх або ўніз для маштабавання шчыпком." +"Hold and drag to Ctrl+zoom.": "Утрымлівайце і перацягвайце для Ctrl+маштабавання." "Add Device": "Дадаць прыладу" "Add Device…": "Дадаць прыладу…" "Put the device in pairing mode, then start searching.": "Перавядзіце прыладу ў рэжым спалучэння, пасля чаго пачніце пошук." @@ -312,6 +318,10 @@ _version: 1 "Scales traditional mouse-wheel vertical distance without changing trackpad scrolling.": "Маштабуе традыцыйную вертыкальную адлегласць колам мышы без змены пракруткі трэкпада." "Thumb Wheel Sensitivity": "Адчувальнасць бакавага кола" "Scales the thumb wheel's horizontal scroll speed and how readily custom wheel actions trigger.": "Змяняе хуткасць гарызантальнай прагорткі бакавым колам і тое, насколькі хутка спрацоўваюць уласныя дзеянні кола." +"Zoom Sensitivity": "Адчувальнасць маштабавання" +"Scales how far pinch zoom magnifies for a given amount of pointer travel.": "Вызначае, наколькі моцна маштабуе шчыпок пры зададзеным руху мышы." +"Invert pan direction": "Інвертаваць кірунак панарамавання" +"Move the view instead of the content while panning. Off matches a trackpad, where content follows your hand.": "Пры панарамаванні перамяшчаць від, а не змесціва. Выключана адпавядае тачпаду, дзе змесціва рухаецца за рукой." "Device offline — reconnect to read DPI range": "Прылада па-за сеткай — падключыце яе зноў, каб прачытаць дыяпазон DPI" "Loading device DPI range…": "Загрузка дыяпазону DPI прылады…" "DPI read failed: %{message}": "Не ўдалося прачытаць DPI: %{message}" diff --git a/crates/openlogi-ui/locales/da.yml b/crates/openlogi-ui/locales/da.yml index f54f5fa63..d884cefaf 100644 --- a/crates/openlogi-ui/locales/da.yml +++ b/crates/openlogi-ui/locales/da.yml @@ -248,6 +248,12 @@ _version: 1 "Scroll Down": "Rul ned" "Scroll Left": "Rul til venstre" "Scroll Right": "Rul til højre" +"Pan": "Panorer" +"Pinch Zoom": "Knib-zoom" +"Hold and drag to scroll in any direction.": "Hold nede og træk for at rulle i alle retninger." +"Hold and drag to scroll.": "Hold nede og træk for at rulle." +"Hold and drag up or down to pinch-zoom.": "Hold nede og træk op eller ned for at knib-zoome." +"Hold and drag to Ctrl+zoom.": "Hold nede og træk for at Ctrl+zoome." "Add Device": "Tilføj enhed" "Add Device…": "Tilføj enhed…" "Put the device in pairing mode, then start searching.": "Sæt enheden i parringstilstand, og begynd derefter at søge." @@ -312,6 +318,10 @@ _version: 1 "Scales traditional mouse-wheel vertical distance without changing trackpad scrolling.": "Scales traditional mouse-wheel vertical distance without changing trackpad scrolling." "Thumb Wheel Sensitivity": "Følsomhed for tommelfingerhjul" "Scales the thumb wheel's horizontal scroll speed and how readily custom wheel actions trigger.": "Skalerer tommelfingerhjulets vandrette rullehastighed og hvor let tilpassede hjulhandlinger udløses." +"Zoom Sensitivity": "Zoomfølsomhed" +"Scales how far pinch zoom magnifies for a given amount of pointer travel.": "Skalerer hvor meget knib-zoom forstørrer for en given musebevægelse." +"Invert pan direction": "Vend panoreringsretningen om" +"Move the view instead of the content while panning. Off matches a trackpad, where content follows your hand.": "Flyt visningen i stedet for indholdet under panorering. Fra svarer til en pegeplade, hvor indholdet følger din hånd." "Device offline — reconnect to read DPI range": "Device offline — reconnect to read DPI range" "Loading device DPI range…": "Loading device DPI range…" "DPI read failed: %{message}": "DPI read failed: %{message}" diff --git a/crates/openlogi-ui/locales/de.yml b/crates/openlogi-ui/locales/de.yml index 6e91bc124..beb9d6b23 100644 --- a/crates/openlogi-ui/locales/de.yml +++ b/crates/openlogi-ui/locales/de.yml @@ -248,6 +248,12 @@ _version: 1 "Scroll Down": "Nach unten scrollen" "Scroll Left": "Nach links scrollen" "Scroll Right": "Nach rechts scrollen" +"Pan": "Verschieben" +"Pinch Zoom": "Pinch-Zoom" +"Hold and drag to scroll in any direction.": "Gedrückt halten und ziehen, um in jede Richtung zu scrollen." +"Hold and drag to scroll.": "Gedrückt halten und ziehen, um zu scrollen." +"Hold and drag up or down to pinch-zoom.": "Gedrückt halten und nach oben oder unten ziehen, um per Pinch zu zoomen." +"Hold and drag to Ctrl+zoom.": "Gedrückt halten und ziehen, um mit Strg zu zoomen." "Add Device": "Gerät hinzufügen" "Add Device…": "Gerät hinzufügen…" "Put the device in pairing mode, then start searching.": "Versetze das Gerät in den Kopplungsmodus und starte dann die Suche." @@ -312,6 +318,10 @@ _version: 1 "Scales traditional mouse-wheel vertical distance without changing trackpad scrolling.": "Scales traditional mouse-wheel vertical distance without changing trackpad scrolling." "Thumb Wheel Sensitivity": "Daumenrad-Empfindlichkeit" "Scales the thumb wheel's horizontal scroll speed and how readily custom wheel actions trigger.": "Skaliert die horizontale Scrollgeschwindigkeit des Daumenrads und wie leicht benutzerdefinierte Radaktionen ausgelöst werden." +"Zoom Sensitivity": "Zoom-Empfindlichkeit" +"Scales how far pinch zoom magnifies for a given amount of pointer travel.": "Skaliert, wie stark der Pinch-Zoom bei einer bestimmten Mausbewegung vergrößert." +"Invert pan direction": "Verschieberichtung umkehren" +"Move the view instead of the content while panning. Off matches a trackpad, where content follows your hand.": "Beim Verschieben die Ansicht statt des Inhalts bewegen. Aus entspricht einem Trackpad, bei dem der Inhalt der Hand folgt." "Device offline — reconnect to read DPI range": "Device offline — reconnect to read DPI range" "Loading device DPI range…": "Loading device DPI range…" "DPI read failed: %{message}": "DPI read failed: %{message}" diff --git a/crates/openlogi-ui/locales/el.yml b/crates/openlogi-ui/locales/el.yml index f7faeb79b..5995b56b0 100644 --- a/crates/openlogi-ui/locales/el.yml +++ b/crates/openlogi-ui/locales/el.yml @@ -248,6 +248,12 @@ _version: 1 "Scroll Down": "Κύλιση προς τα κάτω" "Scroll Left": "Κύλιση προς τα αριστερά" "Scroll Right": "Κύλιση προς τα δεξιά" +"Pan": "Μετατόπιση" +"Pinch Zoom": "Ζουμ με τσίμπημα" +"Hold and drag to scroll in any direction.": "Κρατήστε πατημένο και σύρετε για κύλιση προς οποιαδήποτε κατεύθυνση." +"Hold and drag to scroll.": "Κρατήστε πατημένο και σύρετε για κύλιση." +"Hold and drag up or down to pinch-zoom.": "Κρατήστε πατημένο και σύρετε πάνω ή κάτω για ζουμ με τσίμπημα." +"Hold and drag to Ctrl+zoom.": "Κρατήστε πατημένο και σύρετε για Ctrl+ζουμ." "Add Device": "Προσθήκη συσκευής" "Add Device…": "Προσθήκη συσκευής…" "Put the device in pairing mode, then start searching.": "Θέστε τη συσκευή σε λειτουργία αντιστοίχισης και μετά ξεκινήστε την αναζήτηση." @@ -312,6 +318,10 @@ _version: 1 "Scales traditional mouse-wheel vertical distance without changing trackpad scrolling.": "Scales traditional mouse-wheel vertical distance without changing trackpad scrolling." "Thumb Wheel Sensitivity": "Ευαισθησία πλαϊνού τροχού" "Scales the thumb wheel's horizontal scroll speed and how readily custom wheel actions trigger.": "Ρυθμίζει την ταχύτητα οριζόντιας κύλισης του πλαϊνού τροχού και την ευκολία ενεργοποίησης προσαρμοσμένων ενεργειών τροχού." +"Zoom Sensitivity": "Ευαισθησία ζουμ" +"Scales how far pinch zoom magnifies for a given amount of pointer travel.": "Καθορίζει πόσο μεγεθύνει το ζουμ με τσίμπημα για δεδομένη κίνηση του ποντικιού." +"Invert pan direction": "Αντιστροφή κατεύθυνσης μετατόπισης" +"Move the view instead of the content while panning. Off matches a trackpad, where content follows your hand.": "Μετακίνηση της προβολής αντί του περιεχομένου κατά τη μετατόπιση. Ανενεργό ταιριάζει με το trackpad, όπου το περιεχόμενο ακολουθεί το χέρι σας." "Device offline — reconnect to read DPI range": "Device offline — reconnect to read DPI range" "Loading device DPI range…": "Loading device DPI range…" "DPI read failed: %{message}": "DPI read failed: %{message}" diff --git a/crates/openlogi-ui/locales/en.yml b/crates/openlogi-ui/locales/en.yml index 5956e1d51..32bbbfc91 100644 --- a/crates/openlogi-ui/locales/en.yml +++ b/crates/openlogi-ui/locales/en.yml @@ -248,6 +248,12 @@ _version: 1 "Scroll Down": "Scroll Down" "Scroll Left": "Scroll Left" "Scroll Right": "Scroll Right" +"Pan": "Pan" +"Pinch Zoom": "Pinch Zoom" +"Hold and drag to scroll in any direction.": "Hold and drag to scroll in any direction." +"Hold and drag to scroll.": "Hold and drag to scroll." +"Hold and drag up or down to pinch-zoom.": "Hold and drag up or down to pinch-zoom." +"Hold and drag to Ctrl+zoom.": "Hold and drag to Ctrl+zoom." "Add Device": "Add Device" "Add Device…": "Add Device…" "Put the device in pairing mode, then start searching.": "Put the device in pairing mode, then start searching." @@ -312,6 +318,10 @@ _version: 1 "Scales traditional mouse-wheel vertical distance without changing trackpad scrolling.": "Scales traditional mouse-wheel vertical distance without changing trackpad scrolling." "Thumb Wheel Sensitivity": "Thumb Wheel Sensitivity" "Scales the thumb wheel's horizontal scroll speed and how readily custom wheel actions trigger.": "Scales the thumb wheel's horizontal scroll speed and how readily custom wheel actions trigger." +"Zoom Sensitivity": "Zoom Sensitivity" +"Scales how far pinch zoom magnifies for a given amount of pointer travel.": "Scales how far pinch zoom magnifies for a given amount of pointer travel." +"Invert pan direction": "Invert pan direction" +"Move the view instead of the content while panning. Off matches a trackpad, where content follows your hand.": "Move the view instead of the content while panning. Off matches a trackpad, where content follows your hand." "Device offline — reconnect to read DPI range": "Device offline — reconnect to read DPI range" "Loading device DPI range…": "Loading device DPI range…" "DPI read failed: %{message}": "DPI read failed: %{message}" diff --git a/crates/openlogi-ui/locales/es.yml b/crates/openlogi-ui/locales/es.yml index f73caa99f..804191895 100644 --- a/crates/openlogi-ui/locales/es.yml +++ b/crates/openlogi-ui/locales/es.yml @@ -248,6 +248,12 @@ _version: 1 "Scroll Down": "Desplazar hacia abajo" "Scroll Left": "Desplazar a la izquierda" "Scroll Right": "Desplazar a la derecha" +"Pan": "Desplazar" +"Pinch Zoom": "Zoom pellizco" +"Hold and drag to scroll in any direction.": "Mantén pulsado y arrastra para desplazarte en cualquier dirección." +"Hold and drag to scroll.": "Mantén pulsado y arrastra para desplazarte." +"Hold and drag up or down to pinch-zoom.": "Mantén pulsado y arrastra arriba o abajo para hacer zoom pellizco." +"Hold and drag to Ctrl+zoom.": "Mantén pulsado y arrastra para hacer Ctrl+zoom." "Add Device": "Añadir dispositivo" "Add Device…": "Añadir dispositivo…" "Put the device in pairing mode, then start searching.": "Pon el dispositivo en modo de emparejamiento y luego inicia la búsqueda." @@ -312,6 +318,10 @@ _version: 1 "Scales traditional mouse-wheel vertical distance without changing trackpad scrolling.": "Scales traditional mouse-wheel vertical distance without changing trackpad scrolling." "Thumb Wheel Sensitivity": "Sensibilidad de la rueda lateral" "Scales the thumb wheel's horizontal scroll speed and how readily custom wheel actions trigger.": "Ajusta la velocidad de desplazamiento horizontal de la rueda lateral y la facilidad con que se activan las acciones personalizadas de la rueda." +"Zoom Sensitivity": "Sensibilidad del zoom" +"Scales how far pinch zoom magnifies for a given amount of pointer travel.": "Ajusta cuánto amplía el zoom pellizco para un movimiento dado del ratón." +"Invert pan direction": "Invertir la dirección de desplazamiento" +"Move the view instead of the content while panning. Off matches a trackpad, where content follows your hand.": "Mover la vista en lugar del contenido al desplazar. Desactivado coincide con un trackpad, donde el contenido sigue tu mano." "Device offline — reconnect to read DPI range": "Device offline — reconnect to read DPI range" "Loading device DPI range…": "Loading device DPI range…" "DPI read failed: %{message}": "DPI read failed: %{message}" diff --git a/crates/openlogi-ui/locales/fi.yml b/crates/openlogi-ui/locales/fi.yml index e43419dcd..2589060b3 100644 --- a/crates/openlogi-ui/locales/fi.yml +++ b/crates/openlogi-ui/locales/fi.yml @@ -248,6 +248,12 @@ _version: 1 "Scroll Down": "Vieritä alas" "Scroll Left": "Vieritä vasemmalle" "Scroll Right": "Vieritä oikealle" +"Pan": "Panoroi" +"Pinch Zoom": "Nipistyszoomaus" +"Hold and drag to scroll in any direction.": "Pidä pohjassa ja vedä vierittääksesi mihin tahansa suuntaan." +"Hold and drag to scroll.": "Pidä pohjassa ja vedä vierittääksesi." +"Hold and drag up or down to pinch-zoom.": "Pidä pohjassa ja vedä ylös tai alas nipistyszoomausta varten." +"Hold and drag to Ctrl+zoom.": "Pidä pohjassa ja vedä Ctrl+zoomausta varten." "Add Device": "Lisää laite" "Add Device…": "Lisää laite…" "Put the device in pairing mode, then start searching.": "Aseta laite paritustilaan ja aloita sitten haku." @@ -312,6 +318,10 @@ _version: 1 "Scales traditional mouse-wheel vertical distance without changing trackpad scrolling.": "Scales traditional mouse-wheel vertical distance without changing trackpad scrolling." "Thumb Wheel Sensitivity": "Peukalorullan herkkyys" "Scales the thumb wheel's horizontal scroll speed and how readily custom wheel actions trigger.": "Säätää peukalorullan vaakavieritysnopeutta ja sitä, kuinka herkästi mukautetut rullatoiminnot laukeavat." +"Zoom Sensitivity": "Zoomausherkkyys" +"Scales how far pinch zoom magnifies for a given amount of pointer travel.": "Säätää, kuinka paljon nipistyszoomaus suurentaa tietyllä hiiren liikkeellä." +"Invert pan direction": "Käännä panorointisuunta" +"Move the view instead of the content while panning. Off matches a trackpad, where content follows your hand.": "Siirrä näkymää sisällön sijaan panoroitaessa. Pois päältä vastaa ohjauslevyä, jossa sisältö seuraa kättäsi." "Device offline — reconnect to read DPI range": "Device offline — reconnect to read DPI range" "Loading device DPI range…": "Loading device DPI range…" "DPI read failed: %{message}": "DPI read failed: %{message}" diff --git a/crates/openlogi-ui/locales/fr.yml b/crates/openlogi-ui/locales/fr.yml index d1af6e22b..a6ceb6a2c 100644 --- a/crates/openlogi-ui/locales/fr.yml +++ b/crates/openlogi-ui/locales/fr.yml @@ -248,6 +248,12 @@ _version: 1 "Scroll Down": "Défiler vers le bas" "Scroll Left": "Défiler vers la gauche" "Scroll Right": "Défiler vers la droite" +"Pan": "Déplacer" +"Pinch Zoom": "Zoom par pincement" +"Hold and drag to scroll in any direction.": "Maintenez et faites glisser pour faire défiler dans n’importe quelle direction." +"Hold and drag to scroll.": "Maintenez et faites glisser pour faire défiler." +"Hold and drag up or down to pinch-zoom.": "Maintenez et faites glisser vers le haut ou le bas pour zoomer par pincement." +"Hold and drag to Ctrl+zoom.": "Maintenez et faites glisser pour zoomer avec Ctrl." "Add Device": "Ajouter un appareil" "Add Device…": "Ajouter un appareil…" "Put the device in pairing mode, then start searching.": "Mettez l'appareil en mode association, puis lancez la recherche." @@ -312,6 +318,10 @@ _version: 1 "Scales traditional mouse-wheel vertical distance without changing trackpad scrolling.": "Scales traditional mouse-wheel vertical distance without changing trackpad scrolling." "Thumb Wheel Sensitivity": "Sensibilité de la molette latérale" "Scales the thumb wheel's horizontal scroll speed and how readily custom wheel actions trigger.": "Ajuste la vitesse de défilement horizontal de la molette latérale et la facilité de déclenchement des actions personnalisées." +"Zoom Sensitivity": "Sensibilité du zoom" +"Scales how far pinch zoom magnifies for a given amount of pointer travel.": "Ajuste l'ampleur du zoom par pincement pour un déplacement donné de la souris." +"Invert pan direction": "Inverser le sens du déplacement" +"Move the view instead of the content while panning. Off matches a trackpad, where content follows your hand.": "Déplacer la vue plutôt que le contenu. Désactivé correspond au trackpad, où le contenu suit votre main." "Device offline — reconnect to read DPI range": "Device offline — reconnect to read DPI range" "Loading device DPI range…": "Loading device DPI range…" "DPI read failed: %{message}": "DPI read failed: %{message}" diff --git a/crates/openlogi-ui/locales/it.yml b/crates/openlogi-ui/locales/it.yml index 268277aeb..972dd66f7 100644 --- a/crates/openlogi-ui/locales/it.yml +++ b/crates/openlogi-ui/locales/it.yml @@ -248,6 +248,12 @@ _version: 1 "Scroll Down": "Scorri giù" "Scroll Left": "Scorri a sinistra" "Scroll Right": "Scorri a destra" +"Pan": "Scorrimento" +"Pinch Zoom": "Zoom pinch" +"Hold and drag to scroll in any direction.": "Tieni premuto e trascina per scorrere in qualsiasi direzione." +"Hold and drag to scroll.": "Tieni premuto e trascina per scorrere." +"Hold and drag up or down to pinch-zoom.": "Tieni premuto e trascina su o giù per lo zoom pinch." +"Hold and drag to Ctrl+zoom.": "Tieni premuto e trascina per lo zoom con Ctrl." "Add Device": "Aggiungi dispositivo" "Add Device…": "Aggiungi dispositivo…" "Put the device in pairing mode, then start searching.": "Metti il dispositivo in modalità di associazione, quindi avvia la ricerca." @@ -312,6 +318,10 @@ _version: 1 "Scales traditional mouse-wheel vertical distance without changing trackpad scrolling.": "Scales traditional mouse-wheel vertical distance without changing trackpad scrolling." "Thumb Wheel Sensitivity": "Sensibilità rotella pollice" "Scales the thumb wheel's horizontal scroll speed and how readily custom wheel actions trigger.": "Regola la velocità di scorrimento orizzontale della rotella del pollice e la facilità con cui si attivano le azioni personalizzate." +"Zoom Sensitivity": "Sensibilità dello zoom" +"Scales how far pinch zoom magnifies for a given amount of pointer travel.": "Regola quanto ingrandisce lo zoom pinch per un dato movimento del mouse." +"Invert pan direction": "Inverti la direzione di scorrimento" +"Move the view instead of the content while panning. Off matches a trackpad, where content follows your hand.": "Sposta la vista invece del contenuto durante lo scorrimento. Disattivato corrisponde al trackpad, dove il contenuto segue la mano." "Device offline — reconnect to read DPI range": "Device offline — reconnect to read DPI range" "Loading device DPI range…": "Loading device DPI range…" "DPI read failed: %{message}": "DPI read failed: %{message}" diff --git a/crates/openlogi-ui/locales/ja.yml b/crates/openlogi-ui/locales/ja.yml index 6212c936a..a43af249b 100644 --- a/crates/openlogi-ui/locales/ja.yml +++ b/crates/openlogi-ui/locales/ja.yml @@ -248,6 +248,12 @@ _version: 1 "Scroll Down": "下にスクロール" "Scroll Left": "左にスクロール" "Scroll Right": "右にスクロール" +"Pan": "パン" +"Pinch Zoom": "ピンチズーム" +"Hold and drag to scroll in any direction.": "押しながらドラッグして任意の方向にスクロールします。" +"Hold and drag to scroll.": "押しながらドラッグしてスクロールします。" +"Hold and drag up or down to pinch-zoom.": "押しながら上下にドラッグしてピンチズームします。" +"Hold and drag to Ctrl+zoom.": "押しながらドラッグして Ctrl+ズームします。" "Add Device": "デバイスを追加" "Add Device…": "デバイスを追加…" "Put the device in pairing mode, then start searching.": "デバイスをペアリングモードにしてから検索を開始してください。" @@ -312,6 +318,10 @@ _version: 1 "Scales traditional mouse-wheel vertical distance without changing trackpad scrolling.": "Scales traditional mouse-wheel vertical distance without changing trackpad scrolling." "Thumb Wheel Sensitivity": "サムホイールの感度" "Scales the thumb wheel's horizontal scroll speed and how readily custom wheel actions trigger.": "サムホイールの横スクロール速度と、カスタムホイール操作の発動しやすさを調整します。" +"Zoom Sensitivity": "ズーム感度" +"Scales how far pinch zoom magnifies for a given amount of pointer travel.": "一定のマウス移動量に対してピンチズームで拡大する量を調整します。" +"Invert pan direction": "パン方向を反転" +"Move the view instead of the content while panning. Off matches a trackpad, where content follows your hand.": "パン時にコンテンツではなくビューを動かします。オフではトラックパッドと同じく、コンテンツが手の動きに追従します。" "Device offline — reconnect to read DPI range": "Device offline — reconnect to read DPI range" "Loading device DPI range…": "Loading device DPI range…" "DPI read failed: %{message}": "DPI read failed: %{message}" diff --git a/crates/openlogi-ui/locales/ko.yml b/crates/openlogi-ui/locales/ko.yml index a3f310e51..3890d8c52 100644 --- a/crates/openlogi-ui/locales/ko.yml +++ b/crates/openlogi-ui/locales/ko.yml @@ -248,6 +248,12 @@ _version: 1 "Scroll Down": "아래로 스크롤" "Scroll Left": "왼쪽으로 스크롤" "Scroll Right": "오른쪽으로 스크롤" +"Pan": "패닝" +"Pinch Zoom": "핀치 확대/축소" +"Hold and drag to scroll in any direction.": "누른 채로 드래그하여 아무 방향으로나 스크롤합니다." +"Hold and drag to scroll.": "누른 채로 드래그하여 스크롤합니다." +"Hold and drag up or down to pinch-zoom.": "누른 채로 위아래로 드래그하여 핀치 확대/축소합니다." +"Hold and drag to Ctrl+zoom.": "누른 채로 드래그하여 Ctrl+확대/축소합니다." "Add Device": "기기 추가" "Add Device…": "기기 추가…" "Put the device in pairing mode, then start searching.": "기기를 페어링 모드로 설정한 다음 검색을 시작하세요." @@ -312,6 +318,10 @@ _version: 1 "Scales traditional mouse-wheel vertical distance without changing trackpad scrolling.": "Scales traditional mouse-wheel vertical distance without changing trackpad scrolling." "Thumb Wheel Sensitivity": "썸휠 민감도" "Scales the thumb wheel's horizontal scroll speed and how readily custom wheel actions trigger.": "썸휠의 가로 스크롤 속도와 사용자 지정 휠 동작이 실행되는 정도를 조절합니다." +"Zoom Sensitivity": "확대/축소 감도" +"Scales how far pinch zoom magnifies for a given amount of pointer travel.": "같은 마우스 이동량에 대해 핀치 확대/축소가 얼마나 확대할지 조절합니다." +"Invert pan direction": "패닝 방향 반전" +"Move the view instead of the content while panning. Off matches a trackpad, where content follows your hand.": "패닝할 때 콘텐츠 대신 화면을 움직입니다. 끄면 트랙패드처럼 콘텐츠가 손을 따라갑니다." "Device offline — reconnect to read DPI range": "Device offline — reconnect to read DPI range" "Loading device DPI range…": "Loading device DPI range…" "DPI read failed: %{message}": "DPI read failed: %{message}" diff --git a/crates/openlogi-ui/locales/nb.yml b/crates/openlogi-ui/locales/nb.yml index c75045c3a..b81f8f33b 100644 --- a/crates/openlogi-ui/locales/nb.yml +++ b/crates/openlogi-ui/locales/nb.yml @@ -248,6 +248,12 @@ _version: 1 "Scroll Down": "Rull ned" "Scroll Left": "Rull til venstre" "Scroll Right": "Rull til høyre" +"Pan": "Panorer" +"Pinch Zoom": "Knippezoom" +"Hold and drag to scroll in any direction.": "Hold nede og dra for å rulle i alle retninger." +"Hold and drag to scroll.": "Hold nede og dra for å rulle." +"Hold and drag up or down to pinch-zoom.": "Hold nede og dra opp eller ned for å knippezoome." +"Hold and drag to Ctrl+zoom.": "Hold nede og dra for å Ctrl+zoome." "Add Device": "Legg til enhet" "Add Device…": "Legg til enhet …" "Put the device in pairing mode, then start searching.": "Sett enheten i paringsmodus, og start deretter søket." @@ -312,6 +318,10 @@ _version: 1 "Scales traditional mouse-wheel vertical distance without changing trackpad scrolling.": "Scales traditional mouse-wheel vertical distance without changing trackpad scrolling." "Thumb Wheel Sensitivity": "Tommelhjulfølsomhet" "Scales the thumb wheel's horizontal scroll speed and how readily custom wheel actions trigger.": "Justerer tommelhjulets horisontale rullehastighet og hvor lett egendefinerte hjulhandlinger utløses." +"Zoom Sensitivity": "Zoomfølsomhet" +"Scales how far pinch zoom magnifies for a given amount of pointer travel.": "Skalerer hvor mye knippezoom forstørrer for en gitt musebevegelse." +"Invert pan direction": "Snu panoreringsretningen" +"Move the view instead of the content while panning. Off matches a trackpad, where content follows your hand.": "Flytt visningen i stedet for innholdet under panorering. Av tilsvarer en styreflate, der innholdet følger hånden din." "Device offline — reconnect to read DPI range": "Device offline — reconnect to read DPI range" "Loading device DPI range…": "Loading device DPI range…" "DPI read failed: %{message}": "DPI read failed: %{message}" diff --git a/crates/openlogi-ui/locales/nl.yml b/crates/openlogi-ui/locales/nl.yml index 88f3910b9..f124d8f8c 100644 --- a/crates/openlogi-ui/locales/nl.yml +++ b/crates/openlogi-ui/locales/nl.yml @@ -248,6 +248,12 @@ _version: 1 "Scroll Down": "Omlaag scrollen" "Scroll Left": "Naar links scrollen" "Scroll Right": "Naar rechts scrollen" +"Pan": "Verschuiven" +"Pinch Zoom": "Knijpzoom" +"Hold and drag to scroll in any direction.": "Houd ingedrukt en sleep om in elke richting te scrollen." +"Hold and drag to scroll.": "Houd ingedrukt en sleep om te scrollen." +"Hold and drag up or down to pinch-zoom.": "Houd ingedrukt en sleep omhoog of omlaag om te knijpzoomen." +"Hold and drag to Ctrl+zoom.": "Houd ingedrukt en sleep om te Ctrl+zoomen." "Add Device": "Apparaat toevoegen" "Add Device…": "Apparaat toevoegen…" "Put the device in pairing mode, then start searching.": "Zet het apparaat in koppelmodus en start daarna het zoeken." @@ -312,6 +318,10 @@ _version: 1 "Scales traditional mouse-wheel vertical distance without changing trackpad scrolling.": "Scales traditional mouse-wheel vertical distance without changing trackpad scrolling." "Thumb Wheel Sensitivity": "Duimwielgevoeligheid" "Scales the thumb wheel's horizontal scroll speed and how readily custom wheel actions trigger.": "Schaalt de horizontale scrollsnelheid van het duimwiel en hoe snel aangepaste wielacties worden geactiveerd." +"Zoom Sensitivity": "Zoomgevoeligheid" +"Scales how far pinch zoom magnifies for a given amount of pointer travel.": "Bepaalt hoeveel knijpzoom vergroot bij een bepaalde muisbeweging." +"Invert pan direction": "Verschuifrichting omkeren" +"Move the view instead of the content while panning. Off matches a trackpad, where content follows your hand.": "Verplaats de weergave in plaats van de inhoud tijdens het verschuiven. Uit komt overeen met een trackpad, waar de inhoud je hand volgt." "Device offline — reconnect to read DPI range": "Device offline — reconnect to read DPI range" "Loading device DPI range…": "Loading device DPI range…" "DPI read failed: %{message}": "DPI read failed: %{message}" diff --git a/crates/openlogi-ui/locales/pl.yml b/crates/openlogi-ui/locales/pl.yml index aa7f31110..083a81b6e 100644 --- a/crates/openlogi-ui/locales/pl.yml +++ b/crates/openlogi-ui/locales/pl.yml @@ -248,6 +248,12 @@ _version: 1 "Scroll Down": "Przewiń w dół" "Scroll Left": "Przewiń w lewo" "Scroll Right": "Przewiń w prawo" +"Pan": "Przesuwanie" +"Pinch Zoom": "Powiększanie szczypnięciem" +"Hold and drag to scroll in any direction.": "Przytrzymaj i przeciągnij, aby przewijać w dowolnym kierunku." +"Hold and drag to scroll.": "Przytrzymaj i przeciągnij, aby przewijać." +"Hold and drag up or down to pinch-zoom.": "Przytrzymaj i przeciągnij w górę lub w dół, aby powiększać szczypnięciem." +"Hold and drag to Ctrl+zoom.": "Przytrzymaj i przeciągnij, aby powiększać z Ctrl." "Add Device": "Dodaj urządzenie" "Add Device…": "Dodaj urządzenie…" "Put the device in pairing mode, then start searching.": "Przełącz urządzenie w tryb parowania, a następnie rozpocznij wyszukiwanie." @@ -312,6 +318,10 @@ _version: 1 "Scales traditional mouse-wheel vertical distance without changing trackpad scrolling.": "Scales traditional mouse-wheel vertical distance without changing trackpad scrolling." "Thumb Wheel Sensitivity": "Czułość rolki kciukowej" "Scales the thumb wheel's horizontal scroll speed and how readily custom wheel actions trigger.": "Skaluje prędkość poziomego przewijania rolki kciukowej oraz łatwość wyzwalania niestandardowych działań rolki." +"Zoom Sensitivity": "Czułość powiększania" +"Scales how far pinch zoom magnifies for a given amount of pointer travel.": "Skaluje, jak mocno powiększa szczypnięcie przy danym ruchu myszy." +"Invert pan direction": "Odwróć kierunek przesuwania" +"Move the view instead of the content while panning. Off matches a trackpad, where content follows your hand.": "Przesuwaj widok zamiast zawartości. Wyłączone odpowiada gładzikowi, gdzie zawartość podąża za ręką." "Device offline — reconnect to read DPI range": "Urządzenie offline — połącz ponownie, aby odczytać zakres DPI" "Loading device DPI range…": "Wczytywanie zakresu DPI urządzenia…" "DPI read failed: %{message}": "Odczyt DPI nie powiódł się: %{message}" diff --git a/crates/openlogi-ui/locales/pt-BR.yml b/crates/openlogi-ui/locales/pt-BR.yml index 9e09457d9..335af4ab9 100644 --- a/crates/openlogi-ui/locales/pt-BR.yml +++ b/crates/openlogi-ui/locales/pt-BR.yml @@ -248,6 +248,12 @@ _version: 1 "Scroll Down": "Rolar para Baixo" "Scroll Left": "Rolar para a Esquerda" "Scroll Right": "Rolar para a Direita" +"Pan": "Deslocar" +"Pinch Zoom": "Zoom de pinça" +"Hold and drag to scroll in any direction.": "Mantenha pressionado e arraste para rolar em qualquer direção." +"Hold and drag to scroll.": "Mantenha pressionado e arraste para rolar." +"Hold and drag up or down to pinch-zoom.": "Mantenha pressionado e arraste para cima ou para baixo para o zoom de pinça." +"Hold and drag to Ctrl+zoom.": "Mantenha pressionado e arraste para Ctrl+zoom." "Add Device": "Adicionar Dispositivo" "Add Device…": "Adicionar Dispositivo…" "Put the device in pairing mode, then start searching.": "Coloque o dispositivo em modo de pareamento e inicie a busca." @@ -312,6 +318,10 @@ _version: 1 "Scales traditional mouse-wheel vertical distance without changing trackpad scrolling.": "Scales traditional mouse-wheel vertical distance without changing trackpad scrolling." "Thumb Wheel Sensitivity": "Sensibilidade da Roda do Polegar" "Scales the thumb wheel's horizontal scroll speed and how readily custom wheel actions trigger.": "Ajusta a velocidade da rolagem horizontal da roda do polegar e a facilidade com que as ações personalizadas da roda são acionadas." +"Zoom Sensitivity": "Sensibilidade do zoom" +"Scales how far pinch zoom magnifies for a given amount of pointer travel.": "Ajusta o quanto o zoom de pinça amplia para um dado movimento do mouse." +"Invert pan direction": "Inverter a direção do deslocamento" +"Move the view instead of the content while panning. Off matches a trackpad, where content follows your hand.": "Mover a visualização em vez do conteúdo ao deslocar. Desativado corresponde ao trackpad, onde o conteúdo acompanha sua mão." "Device offline — reconnect to read DPI range": "Device offline — reconnect to read DPI range" "Loading device DPI range…": "Loading device DPI range…" "DPI read failed: %{message}": "DPI read failed: %{message}" diff --git a/crates/openlogi-ui/locales/pt-PT.yml b/crates/openlogi-ui/locales/pt-PT.yml index 3cb9eb787..14284fe65 100644 --- a/crates/openlogi-ui/locales/pt-PT.yml +++ b/crates/openlogi-ui/locales/pt-PT.yml @@ -248,6 +248,12 @@ _version: 1 "Scroll Down": "Deslocar para baixo" "Scroll Left": "Deslocar para a esquerda" "Scroll Right": "Deslocar para a direita" +"Pan": "Deslocar" +"Pinch Zoom": "Zoom de pinça" +"Hold and drag to scroll in any direction.": "Mantenha premido e arraste para deslocar em qualquer direção." +"Hold and drag to scroll.": "Mantenha premido e arraste para deslocar." +"Hold and drag up or down to pinch-zoom.": "Mantenha premido e arraste para cima ou para baixo para o zoom de pinça." +"Hold and drag to Ctrl+zoom.": "Mantenha premido e arraste para Ctrl+zoom." "Add Device": "Adicionar dispositivo" "Add Device…": "Adicionar dispositivo…" "Put the device in pairing mode, then start searching.": "Coloque o dispositivo em modo de emparelhamento e, em seguida, inicie a procura." @@ -312,6 +318,10 @@ _version: 1 "Scales traditional mouse-wheel vertical distance without changing trackpad scrolling.": "Scales traditional mouse-wheel vertical distance without changing trackpad scrolling." "Thumb Wheel Sensitivity": "Sensibilidade da roda de polegar" "Scales the thumb wheel's horizontal scroll speed and how readily custom wheel actions trigger.": "Ajusta a velocidade de deslocamento horizontal da roda de polegar e a facilidade com que as ações personalizadas da roda são acionadas." +"Zoom Sensitivity": "Sensibilidade do zoom" +"Scales how far pinch zoom magnifies for a given amount of pointer travel.": "Ajusta o quanto o zoom de pinça amplia para um dado movimento do rato." +"Invert pan direction": "Inverter a direção do deslocamento" +"Move the view instead of the content while panning. Off matches a trackpad, where content follows your hand.": "Mover a vista em vez do conteúdo ao deslocar. Desativado corresponde ao trackpad, onde o conteúdo acompanha a sua mão." "Device offline — reconnect to read DPI range": "Device offline — reconnect to read DPI range" "Loading device DPI range…": "Loading device DPI range…" "DPI read failed: %{message}": "DPI read failed: %{message}" diff --git a/crates/openlogi-ui/locales/ru.yml b/crates/openlogi-ui/locales/ru.yml index ee892136c..ced5c3ea4 100644 --- a/crates/openlogi-ui/locales/ru.yml +++ b/crates/openlogi-ui/locales/ru.yml @@ -248,6 +248,12 @@ _version: 1 "Scroll Down": "Прокрутить вниз" "Scroll Left": "Прокрутить влево" "Scroll Right": "Прокрутить вправо" +"Pan": "Панорамирование" +"Pinch Zoom": "Масштаб щипком" +"Hold and drag to scroll in any direction.": "Удерживайте и перетаскивайте, чтобы прокручивать в любом направлении." +"Hold and drag to scroll.": "Удерживайте и перетаскивайте, чтобы прокручивать." +"Hold and drag up or down to pinch-zoom.": "Удерживайте и перетаскивайте вверх или вниз для масштаба щипком." +"Hold and drag to Ctrl+zoom.": "Удерживайте и перетаскивайте для Ctrl+масштаба." "Add Device": "Добавить устройство" "Add Device…": "Добавить устройство…" "Put the device in pairing mode, then start searching.": "Переведите устройство в режим сопряжения, затем начните поиск." @@ -312,6 +318,10 @@ _version: 1 "Scales traditional mouse-wheel vertical distance without changing trackpad scrolling.": "Scales traditional mouse-wheel vertical distance without changing trackpad scrolling." "Thumb Wheel Sensitivity": "Чувствительность колеса большого пальца" "Scales the thumb wheel's horizontal scroll speed and how readily custom wheel actions trigger.": "Масштабирует скорость горизонтальной прокрутки колеса и лёгкость срабатывания пользовательских действий колеса." +"Zoom Sensitivity": "Чувствительность масштабирования" +"Scales how far pinch zoom magnifies for a given amount of pointer travel.": "Определяет, насколько сильно масштаб щипком увеличивает при заданном движении мыши." +"Invert pan direction": "Инвертировать направление панорамирования" +"Move the view instead of the content while panning. Off matches a trackpad, where content follows your hand.": "При панорамировании перемещать вид, а не содержимое. Выключено соответствует трекпаду, где содержимое следует за рукой." "Device offline — reconnect to read DPI range": "Device offline — reconnect to read DPI range" "Loading device DPI range…": "Loading device DPI range…" "DPI read failed: %{message}": "DPI read failed: %{message}" diff --git a/crates/openlogi-ui/locales/sv.yml b/crates/openlogi-ui/locales/sv.yml index 109cf98f5..e7a8d6389 100644 --- a/crates/openlogi-ui/locales/sv.yml +++ b/crates/openlogi-ui/locales/sv.yml @@ -248,6 +248,12 @@ _version: 1 "Scroll Down": "Rulla ned" "Scroll Left": "Rulla vänster" "Scroll Right": "Rulla höger" +"Pan": "Panorera" +"Pinch Zoom": "Nypzoom" +"Hold and drag to scroll in any direction.": "Håll ned och dra för att rulla i valfri riktning." +"Hold and drag to scroll.": "Håll ned och dra för att rulla." +"Hold and drag up or down to pinch-zoom.": "Håll ned och dra upp eller ner för att nypzooma." +"Hold and drag to Ctrl+zoom.": "Håll ned och dra för att Ctrl+zooma." "Add Device": "Lägg till enhet" "Add Device…": "Lägg till enhet…" "Put the device in pairing mode, then start searching.": "Sätt enheten i ihopparningsläge och börja sedan söka." @@ -312,6 +318,10 @@ _version: 1 "Scales traditional mouse-wheel vertical distance without changing trackpad scrolling.": "Scales traditional mouse-wheel vertical distance without changing trackpad scrolling." "Thumb Wheel Sensitivity": "Tumhjulskänslighet" "Scales the thumb wheel's horizontal scroll speed and how readily custom wheel actions trigger.": "Anpassar tumhjulets horisontella rullningshastighet och hur lätt anpassade hjulåtgärder utlöses." +"Zoom Sensitivity": "Zoomkänslighet" +"Scales how far pinch zoom magnifies for a given amount of pointer travel.": "Skalar hur mycket nypzoom förstorar vid en viss musrörelse." +"Invert pan direction": "Invertera panoreringsriktningen" +"Move the view instead of the content while panning. Off matches a trackpad, where content follows your hand.": "Flytta vyn i stället för innehållet vid panorering. Av motsvarar en styrplatta, där innehållet följer handen." "Device offline — reconnect to read DPI range": "Device offline — reconnect to read DPI range" "Loading device DPI range…": "Loading device DPI range…" "DPI read failed: %{message}": "DPI read failed: %{message}" diff --git a/crates/openlogi-ui/locales/tr.yml b/crates/openlogi-ui/locales/tr.yml index e455d8499..89292d556 100644 --- a/crates/openlogi-ui/locales/tr.yml +++ b/crates/openlogi-ui/locales/tr.yml @@ -248,6 +248,12 @@ _version: 1 "Scroll Down": "Aşağı Kaydır" "Scroll Left": "Sola Kaydır" "Scroll Right": "Sağa Kaydır" +"Pan": "Kaydır" +"Pinch Zoom": "Kıstırma yakınlaştırma" +"Hold and drag to scroll in any direction.": "Basılı tutup sürükleyerek herhangi bir yöne kaydırın." +"Hold and drag to scroll.": "Basılı tutup sürükleyerek kaydırın." +"Hold and drag up or down to pinch-zoom.": "Basılı tutup yukarı veya aşağı sürükleyerek kıstırarak yakınlaştırın." +"Hold and drag to Ctrl+zoom.": "Basılı tutup sürükleyerek Ctrl+yakınlaştırın." "Add Device": "Cihaz Ekle" "Add Device…": "Cihaz Ekle…" "Put the device in pairing mode, then start searching.": "Cihazı eşleştirme moduna alın, sonra aramayı başlatın." @@ -312,6 +318,10 @@ _version: 1 "Scales traditional mouse-wheel vertical distance without changing trackpad scrolling.": "Scales traditional mouse-wheel vertical distance without changing trackpad scrolling." "Thumb Wheel Sensitivity": "Başparmak Tekerleği Hassasiyeti" "Scales the thumb wheel's horizontal scroll speed and how readily custom wheel actions trigger.": "Başparmak tekerleğinin yatay kaydırma hızını ve özel tekerlek eylemlerinin ne kadar kolay tetikleneceğini ölçekler." +"Zoom Sensitivity": "Yakınlaştırma hassasiyeti" +"Scales how far pinch zoom magnifies for a given amount of pointer travel.": "Belirli bir fare hareketinde kıstırma yakınlaştırmanın ne kadar büyüteceğini ölçekler." +"Invert pan direction": "Kaydırma yönünü ters çevir" +"Move the view instead of the content while panning. Off matches a trackpad, where content follows your hand.": "Kaydırırken içerik yerine görünümü hareket ettirir. Kapalıyken, içeriğin elinizi takip ettiği izleme dörtgeni gibi davranır." "Device offline — reconnect to read DPI range": "Cihaz çevrimdışı — DPI aralığını okumak için yeniden bağlanın" "Loading device DPI range…": "Cihazın DPI aralığı yükleniyor…" "DPI read failed: %{message}": "DPI okuması başarısız: %{message}" diff --git a/crates/openlogi-ui/locales/uk.yml b/crates/openlogi-ui/locales/uk.yml index 5c726aaf9..7476c2506 100644 --- a/crates/openlogi-ui/locales/uk.yml +++ b/crates/openlogi-ui/locales/uk.yml @@ -248,6 +248,12 @@ _version: 1 "Scroll Down": "Прокрутити вниз" "Scroll Left": "Прокрутити ліворуч" "Scroll Right": "Прокрутити праворуч" +"Pan": "Панорамування" +"Pinch Zoom": "Масштабування щипком" +"Hold and drag to scroll in any direction.": "Утримуйте й перетягуйте, щоб прокручувати в будь-якому напрямку." +"Hold and drag to scroll.": "Утримуйте й перетягуйте, щоб прокручувати." +"Hold and drag up or down to pinch-zoom.": "Утримуйте й перетягуйте вгору або вниз для масштабування щипком." +"Hold and drag to Ctrl+zoom.": "Утримуйте й перетягуйте для Ctrl+масштабування." "Add Device": "Додати пристрій" "Add Device…": "Додати пристрій…" "Put the device in pairing mode, then start searching.": "Переведіть пристрій у режим підключення, після чого почніть пошук." @@ -312,6 +318,10 @@ _version: 1 "Scales traditional mouse-wheel vertical distance without changing trackpad scrolling.": "Scales traditional mouse-wheel vertical distance without changing trackpad scrolling." "Thumb Wheel Sensitivity": "Чутливість бічного коліщатка" "Scales the thumb wheel's horizontal scroll speed and how readily custom wheel actions trigger.": "Змінює швидкість горизонтального прокручування бічним коліщатком і те, наскільки легко спрацьовують власні дії коліщатка." +"Zoom Sensitivity": "Чутливість масштабування" +"Scales how far pinch zoom magnifies for a given amount of pointer travel.": "Визначає, наскільки сильно масштабування щипком збільшує за заданого руху миші." +"Invert pan direction": "Інвертувати напрямок панорамування" +"Move the view instead of the content while panning. Off matches a trackpad, where content follows your hand.": "Під час панорамування переміщувати вид, а не вміст. Вимкнено відповідає трекпаду, де вміст рухається за рукою." "Device offline — reconnect to read DPI range": "Пристрій не в мережі — підключіть його знову, щоб прочитати діапазон DPI" "Loading device DPI range…": "Завантаження діапазону DPI пристрою…" "DPI read failed: %{message}": "Не вдалося прочитати DPI: %{message}" diff --git a/crates/openlogi-ui/locales/zh-CN.yml b/crates/openlogi-ui/locales/zh-CN.yml index a5e08c165..24413a3c9 100644 --- a/crates/openlogi-ui/locales/zh-CN.yml +++ b/crates/openlogi-ui/locales/zh-CN.yml @@ -248,6 +248,12 @@ _version: 1 "Scroll Down": "向下滚动" "Scroll Left": "向左滚动" "Scroll Right": "向右滚动" +"Pan": "平移" +"Pinch Zoom": "捏合缩放" +"Hold and drag to scroll in any direction.": "按住并拖动即可向任意方向滚动。" +"Hold and drag to scroll.": "按住并拖动即可滚动。" +"Hold and drag up or down to pinch-zoom.": "按住并上下拖动即可捏合缩放。" +"Hold and drag to Ctrl+zoom.": "按住并拖动即可 Ctrl+缩放。" "Add Device": "添加设备" "Add Device…": "添加设备…" "Put the device in pairing mode, then start searching.": "先让设备进入配对模式,然后开始搜索。" @@ -312,6 +318,10 @@ _version: 1 "Scales traditional mouse-wheel vertical distance without changing trackpad scrolling.": "调整传统鼠标滚轮的垂直滚动距离,不影响触控板滚动。" "Thumb Wheel Sensitivity": "拇指滚轮灵敏度" "Scales the thumb wheel's horizontal scroll speed and how readily custom wheel actions trigger.": "调整拇指滚轮的横向滚动速度,以及自定义滚轮操作的触发难易程度。" +"Zoom Sensitivity": "缩放灵敏度" +"Scales how far pinch zoom magnifies for a given amount of pointer travel.": "调整在给定鼠标移动距离下捏合缩放的放大幅度。" +"Invert pan direction": "反转平移方向" +"Move the view instead of the content while panning. Off matches a trackpad, where content follows your hand.": "平移时移动视图而不是内容。关闭时与触控板一致,内容跟随手的移动。" "Device offline — reconnect to read DPI range": "设备离线 —— 重新连接以读取 DPI 范围" "Loading device DPI range…": "正在读取设备 DPI 范围…" "DPI read failed: %{message}": "DPI 读取失败:%{message}" diff --git a/crates/openlogi-ui/locales/zh-HK.yml b/crates/openlogi-ui/locales/zh-HK.yml index 4183cb3f9..0fe67c21b 100644 --- a/crates/openlogi-ui/locales/zh-HK.yml +++ b/crates/openlogi-ui/locales/zh-HK.yml @@ -248,6 +248,12 @@ _version: 1 "Scroll Down": "向下捲動" "Scroll Left": "向左捲動" "Scroll Right": "向右捲動" +"Pan": "平移" +"Pinch Zoom": "雙指縮放" +"Hold and drag to scroll in any direction.": "按住並拖曳即可向任何方向捲動。" +"Hold and drag to scroll.": "按住並拖曳即可捲動。" +"Hold and drag up or down to pinch-zoom.": "按住並上下拖曳即可雙指縮放。" +"Hold and drag to Ctrl+zoom.": "按住並拖曳即可 Ctrl+縮放。" "Add Device": "新增裝置" "Add Device…": "新增裝置…" "Put the device in pairing mode, then start searching.": "先讓裝置進入配對模式,然後開始搜尋。" @@ -312,6 +318,10 @@ _version: 1 "Scales traditional mouse-wheel vertical distance without changing trackpad scrolling.": "調整傳統滑鼠滾輪的垂直捲動距離,不影響觸控板捲動。" "Thumb Wheel Sensitivity": "拇指滾輪靈敏度" "Scales the thumb wheel's horizontal scroll speed and how readily custom wheel actions trigger.": "調整拇指滾輪的水平捲動速度,以及自訂滾輪操作的觸發難易度。" +"Zoom Sensitivity": "縮放靈敏度" +"Scales how far pinch zoom magnifies for a given amount of pointer travel.": "調整在指定滑鼠移動距離下雙指縮放的放大幅度。" +"Invert pan direction": "反轉平移方向" +"Move the view instead of the content while panning. Off matches a trackpad, where content follows your hand.": "平移時移動檢視畫面而非內容。關閉時與觸控板一致,內容跟隨手的移動。" "Device offline — reconnect to read DPI range": "裝置離線 —— 重新連接以讀取 DPI 範圍" "Loading device DPI range…": "正在讀取裝置 DPI 範圍…" "DPI read failed: %{message}": "DPI 讀取失敗:%{message}" diff --git a/crates/openlogi-ui/locales/zh-TW.yml b/crates/openlogi-ui/locales/zh-TW.yml index 4125117c9..f316e01aa 100644 --- a/crates/openlogi-ui/locales/zh-TW.yml +++ b/crates/openlogi-ui/locales/zh-TW.yml @@ -248,6 +248,12 @@ _version: 1 "Scroll Down": "向下捲動" "Scroll Left": "向左捲動" "Scroll Right": "向右捲動" +"Pan": "平移" +"Pinch Zoom": "雙指縮放" +"Hold and drag to scroll in any direction.": "按住並拖曳即可向任何方向捲動。" +"Hold and drag to scroll.": "按住並拖曳即可捲動。" +"Hold and drag up or down to pinch-zoom.": "按住並上下拖曳即可雙指縮放。" +"Hold and drag to Ctrl+zoom.": "按住並拖曳即可 Ctrl+縮放。" "Add Device": "新增裝置" "Add Device…": "新增裝置…" "Put the device in pairing mode, then start searching.": "先讓裝置進入配對模式,然後開始搜尋。" @@ -312,6 +318,10 @@ _version: 1 "Scales traditional mouse-wheel vertical distance without changing trackpad scrolling.": "調整傳統滑鼠滾輪的垂直捲動距離,不影響觸控板捲動。" "Thumb Wheel Sensitivity": "拇指滾輪靈敏度" "Scales the thumb wheel's horizontal scroll speed and how readily custom wheel actions trigger.": "調整拇指滾輪的水平捲動速度,以及自訂滾輪操作的觸發難易度。" +"Zoom Sensitivity": "縮放靈敏度" +"Scales how far pinch zoom magnifies for a given amount of pointer travel.": "調整在指定滑鼠移動距離下雙指縮放的放大幅度。" +"Invert pan direction": "反轉平移方向" +"Move the view instead of the content while panning. Off matches a trackpad, where content follows your hand.": "平移時移動檢視畫面而非內容。關閉時與觸控板一致,內容跟隨手的移動。" "Device offline — reconnect to read DPI range": "裝置離線 —— 重新連線以讀取 DPI 範圍" "Loading device DPI range…": "正在讀取裝置 DPI 範圍…" "DPI read failed: %{message}": "DPI 讀取失敗:%{message}" diff --git a/crates/openlogi-ui/src/action_icons.rs b/crates/openlogi-ui/src/action_icons.rs index d87e52696..bff1ca825 100644 --- a/crates/openlogi-ui/src/action_icons.rs +++ b/crates/openlogi-ui/src/action_icons.rs @@ -147,4 +147,18 @@ mod tests { ); } } + + #[test] + fn hold_mode_action_icons_are_embedded() { + use openlogi_core::binding::Action; + + for action in [Action::Pan, Action::Zoom] { + let path = ActionRingIcon::for_action(&action).asset_path(); + let loaded = ActionIcons.load(path); + assert!( + matches!(loaded, Ok(Some(_))), + "missing embedded asset for {action:?} ({path})" + ); + } + } }