Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 8 additions & 7 deletions crates/openlogi-agent-core/src/runtime/hook.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ use std::thread;
use std::time::{Duration, Instant};

use openlogi_core::binding::{
Action, Binding, ButtonId, GestureDirection, SwipeAccumulator, default_binding,
Action, Binding, ButtonId, GestureDirection, SwipeAccumulator, SwipeStep, default_binding,
};
use openlogi_core::config::{KeyModifiers, KeyTrigger};
use openlogi_hook::{
Expand Down Expand Up @@ -138,12 +138,12 @@ impl HoldState {
}

/// Feed a pointer-move delta into the active hold, tagging a committed swipe
/// with its exact press token and held button. Returns one commit per hold,
/// or `None` while still too short, already fired, or not holding.
fn accumulate(&mut self, dx: i32, dy: i32) -> Option<(PressToken, ButtonId, GestureDirection)> {
/// with its exact press token and held button. Subsequent movement is tagged
/// separately so ordinary bindings remain one-shot.
fn accumulate(&mut self, dx: i32, dy: i32) -> Option<(PressToken, ButtonId, SwipeStep)> {
let held = self.current.as_ref()?;
self.swipe
.accumulate(dx, dy)
.accumulate_repeating(dx, dy)
.map(|dir| (held.press.clone(), held.button, dir))
}

Expand Down Expand Up @@ -364,14 +364,15 @@ fn handle_moved(
dispatcher: &ActionDispatcher,
) -> EventDisposition {
let commit = HOLD.with_borrow_mut(|h| h.accumulate(delta_x, delta_y));
if let Some((press, button, dir)) = commit {
if let Some((press, button, step)) = commit {
let dir = step.direction();
let action = hooks.try_read().ok().map(|m| {
m.gestures
.get(&button)
.and_then(|dirs| dirs.get(&dir).cloned())
.unwrap_or_else(|| resolve_gesture_click(&m.gestures, button))
});
if let Some(action) = action {
if let Some(action) = action.filter(|action| step.accepts(action)) {
info!(button = %button, ?dir, action = %action.label(), "gesture swipe → executing bound action");
dispatcher.try_dispatch_while_pressed(&press, &action);
}
Expand Down
32 changes: 26 additions & 6 deletions crates/openlogi-agent-core/src/runtime/hook/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,20 @@ fn accumulate_tags_a_committed_swipe_with_the_held_press() {

assert_eq!(
hold.accumulate(GESTURE_SWIPE_THRESHOLD + 10, 0),
Some((press.clone(), ButtonId::Back, GestureDirection::Right))
Some((
press.clone(),
ButtonId::Back,
SwipeStep::First(GestureDirection::Right)
))
);
assert_eq!(
hold.accumulate(50, 0),
None,
"commits at most once per hold"
Some((
press.clone(),
ButtonId::Back,
SwipeStep::Repeat(GestureDirection::Right)
)),
"subsequent travel retains the exact press and is tagged as a repeat"
);
assert_eq!(hold.end(ButtonId::Back), Some((press, false)));
}
Expand All @@ -69,7 +77,11 @@ fn a_same_button_repress_restarts_the_stale_hold() {
hold.swipe.backdate_hold_for_test();
assert_eq!(
hold.accumulate(GESTURE_SWIPE_THRESHOLD + 10, 0),
Some((replacement, ButtonId::Back, GestureDirection::Right))
Some((
replacement,
ButtonId::Back,
SwipeStep::First(GestureDirection::Right)
))
);
}

Expand All @@ -88,7 +100,11 @@ fn an_aged_hold_yields_to_a_new_buttons_press() {
hold.swipe.backdate_hold_for_test();
assert_eq!(
hold.accumulate(GESTURE_SWIPE_THRESHOLD + 10, 0),
Some((replacement, ButtonId::Forward, GestureDirection::Right))
Some((
replacement,
ButtonId::Forward,
SwipeStep::First(GestureDirection::Right)
))
);
}

Expand All @@ -105,7 +121,11 @@ fn begin_is_first_wins_while_a_hold_is_active() {

assert_eq!(
hold.accumulate(GESTURE_SWIPE_THRESHOLD + 10, 0),
Some((first.clone(), ButtonId::Back, GestureDirection::Right))
Some((
first.clone(),
ButtonId::Back,
SwipeStep::First(GestureDirection::Right)
))
);
assert_eq!(hold.end(ButtonId::Forward), None);
assert_eq!(hold.end(ButtonId::Back), Some((first, false)));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,8 @@ impl InputDispatcher {
return;
}
match input {
CapturedInput::Gesture(button, direction) => {
CapturedInput::Gesture(button, direction)
| CapturedInput::GestureRepeat(button, direction) => {
let Some(press) = self.gesture_presses.get(session, button) else {
debug!(key, %button, ?direction, "gesture from a canceled button lifecycle — ignored");
return;
Expand All @@ -157,6 +158,10 @@ impl InputDispatcher {
.get(&button)
.or_else(|| plan.side_gesture_bindings.get(&button))
.and_then(|map| map.get(&direction))
.filter(|action| {
!matches!(input, CapturedInput::GestureRepeat(..))
|| action.repeats_on_motion()
})
{
debug!(key, %button, ?direction, action = %action.label(), "gesture → action");
if !self
Expand Down
1 change: 1 addition & 0 deletions crates/openlogi-agent-core/src/watchers/keyboard.rs
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ fn dispatch_input(
dispatcher.dispatch_hidpp_button_pulse(session, button, bindings.bindings.get(&button));
}
CapturedInput::Gesture(..)
| CapturedInput::GestureRepeat(..)
| CapturedInput::Scroll { .. }
| CapturedInput::ThumbwheelDirection { .. } => {}
}
Expand Down
1 change: 1 addition & 0 deletions crates/openlogi-core/src/binding.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ mod effect;
mod gesture;
mod key_combo;
mod swipe;
pub use swipe::SwipeStep;
mod value;

#[cfg(test)]
Expand Down
18 changes: 18 additions & 0 deletions crates/openlogi-core/src/binding/action.rs
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,14 @@ pub enum Action {
/// cancellation and shutdown. Dispatchers without a release context must
/// degrade this action to a balanced tap rather than leave keys held.
HoldShortcut(KeyCombo),
/// Zoom in one step in the active application.
ZoomIn,
/// Zoom out one step in the active application.
ZoomOut,
/// Zoom in repeatedly as a held gesture continues moving.
ZoomInContinuous,
/// Zoom out repeatedly as a held gesture continues moving.
ZoomOutContinuous,
}

/// One step in a [`Action::Workflow`]. A workflow is a `Vec<WorkflowStep>`
Expand Down Expand Up @@ -256,6 +264,10 @@ macro_rules! for_each_unit_action {
NextTab "Next Tab" "actions.next_tab" Browser NextTab,
PrevTab "Previous Tab" "actions.previous_tab" Browser PreviousTab,
ReloadPage "Reload Page" "actions.reload_page" Browser Reload,
ZoomIn "Zoom In (one step)" "actions.zoom_in" Browser Search,
ZoomOut "Zoom Out (one step)" "actions.zoom_out" Browser Search,
ZoomInContinuous "Zoom In (continuous gesture)" "actions.zoom_in_continuous" Browser Search,
ZoomOutContinuous "Zoom Out (continuous gesture)" "actions.zoom_out_continuous" Browser Search,
// Navigation
MissionControl "Mission Control" "actions.mission_control" Navigation Grid,
AppExpose "App Exposé" "actions.app_expose" Navigation Layers,
Expand Down Expand Up @@ -393,6 +405,12 @@ macro_rules! derive_action_core {
for_each_unit_action!(derive_action_core);

impl Action {
/// Whether additional travel in the same gesture may fire this action.
#[must_use]
pub fn repeats_on_motion(&self) -> bool {
matches!(self, Self::ZoomInContinuous | Self::ZoomOutContinuous)
}

/// The chord whose output must remain down until the originating press
/// ends, or `None` for an instantaneous action.
#[must_use]
Expand Down
6 changes: 6 additions & 0 deletions crates/openlogi-core/src/binding/effect.rs
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,10 @@ pub enum Shortcut {
PrevTab,
/// Reload the current page.
ReloadPage,
/// Increase application zoom by one step.
ZoomIn,
/// Decrease application zoom by one step.
ZoomOut,
}

impl Shortcut {
Expand Down Expand Up @@ -269,6 +273,8 @@ impl Action {
| Action::OpenApplication(_) => Effect::AgentSide,

Action::ScrollUp => Effect::Scroll { dx: 0, dy: 1 },
Action::ZoomIn | Action::ZoomInContinuous => Effect::Shortcut(Shortcut::ZoomIn),
Action::ZoomOut | Action::ZoomOutContinuous => Effect::Shortcut(Shortcut::ZoomOut),
Action::ScrollDown => Effect::Scroll { dx: 0, dy: -1 },
Action::HorizontalScrollLeft => Effect::Scroll { dx: -1, dy: 0 },
Action::HorizontalScrollRight => Effect::Scroll { dx: 1, dy: 0 },
Expand Down
79 changes: 78 additions & 1 deletion crates/openlogi-core/src/binding/swipe.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,32 @@

use std::time::Instant;

use super::GestureDirection;
use super::{Action, GestureDirection};

/// A gesture's initial commitment or subsequent movement while still held.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SwipeStep {
/// The first committed direction; all bound actions may fire.
First(GestureDirection),
/// Further travel; only continuous gesture actions may fire.
Repeat(GestureDirection),
}

impl SwipeStep {
/// Direction of this movement step.
#[must_use]
pub fn direction(self) -> GestureDirection {
match self {
Self::First(direction) | Self::Repeat(direction) => direction,
}
}

/// Preserve one-shot bindings while allowing movement-driven zoom.
#[must_use]
pub fn accepts(self, action: &Action) -> bool {
matches!(self, Self::First(_)) || action.repeats_on_motion()
}
}

/// Minimum dominant-axis travel (raw-XY units) before a held gesture commits to
/// a direction. Tuned to match Logitech Options+'s responsiveness.
Expand Down Expand Up @@ -129,6 +154,31 @@ impl SwipeAccumulator {
None
}

/// Emit the initial swipe and additional distance-based steps. Reversing
/// direction discards leftover travel, so reversing a zoom responds promptly.
/// No timer generates repeats: holding still produces no output.
pub fn accumulate_repeating(&mut self, dx: i32, dy: i32) -> Option<SwipeStep> {
if !self.fired {
let direction = self.accumulate(dx, dy)?;
self.dx = 0;
self.dy = 0;
return Some(SwipeStep::First(direction));
}
self.held_since?;
if dx != 0 && self.dx.signum() != dx.signum() {
self.dx = 0;
}
if dy != 0 && self.dy.signum() != dy.signum() {
self.dy = 0;
}
self.dx = self.dx.saturating_add(dx);
self.dy = self.dy.saturating_add(dy);
let direction = detect_swipe(self.dx, self.dy)?;
self.dx = 0;
self.dy = 0;
Some(SwipeStep::Repeat(direction))
}

/// End the current hold. Returns `true` when an in-progress hold ended
/// without committing a swipe — the caller should fire the plain `Click`
/// action — and `false` when a swipe already fired mid-motion, or when there
Expand All @@ -155,6 +205,33 @@ impl SwipeAccumulator {
mod tests {
use super::*;

#[test]
fn continuous_zoom_reverses_and_stops_at_release() {
let mut swipe = SwipeAccumulator::default();
assert_eq!(swipe.accumulate_repeating(0, -100), None);
swipe.begin();
assert_eq!(swipe.accumulate_repeating(0, -100), None);
swipe.backdate_hold_for_test();
let first = swipe.accumulate_repeating(0, -1).unwrap();
assert_eq!(first, SwipeStep::First(GestureDirection::Up));
assert!(first.accepts(&Action::ZoomIn));
assert_eq!(swipe.accumulate_repeating(0, 0), None);
assert_eq!(swipe.accumulate_repeating(0, -49), None);
let repeat = swipe.accumulate_repeating(0, -1).unwrap();
assert!(repeat.accepts(&Action::ZoomInContinuous));
assert!(!repeat.accepts(&Action::ZoomIn));
assert!(!repeat.accepts(&Action::MissionControl));
assert_eq!(swipe.accumulate_repeating(0, -30), None);
assert_eq!(
swipe.accumulate_repeating(0, 50),
Some(SwipeStep::Repeat(GestureDirection::Down))
);
assert!(!swipe.end(), "zooming must not fire the click binding");
assert_eq!(swipe.accumulate_repeating(0, 100), None);
swipe.begin();
assert!(swipe.end(), "a fresh stationary press remains a click");
}

// ── Gesture classification ────────────────────────────────────────────────

#[test]
Expand Down
4 changes: 4 additions & 0 deletions crates/openlogi-core/src/binding/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,10 @@ fn persisted_action_variant_names_are_stable() {
"VolumeDown",
"VolumeUp",
"Workflow",
"ZoomIn",
"ZoomInContinuous",
"ZoomOut",
"ZoomOutContinuous",
];
expected.sort_unstable();
assert_eq!(actual, expected);
Expand Down
6 changes: 5 additions & 1 deletion crates/openlogi-desktop/src/features/mouse/picker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,11 @@ pub(crate) fn action_icon_path(action: &Action) -> &'static str {
Action::Undo => "action-icons/undo-2.svg",
Action::Redo => "action-icons/redo-2.svg",
Action::SelectAll | Action::Workflow(_) => "action-icons/list-checks.svg",
Action::Find => "action-icons/search.svg",
Action::Find
| Action::ZoomIn
| Action::ZoomOut
| Action::ZoomInContinuous
| Action::ZoomOutContinuous => "action-icons/search.svg",
Action::Save => "action-icons/save.svg",
Action::BrowserBack => "action-icons/arrow-left.svg",
Action::BrowserForward => "action-icons/arrow-right.svg",
Expand Down
16 changes: 9 additions & 7 deletions crates/openlogi-device/src/session/gesture.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ use hidpp::{
},
protocol::v20,
};
use openlogi_core::binding::{ButtonId, GestureDirection, SwipeAccumulator};
use openlogi_core::binding::{ButtonId, GestureDirection, SwipeAccumulator, SwipeStep};
use tokio::sync::{mpsc, oneshot};
use tracing::{debug, info, warn};

Expand Down Expand Up @@ -62,6 +62,8 @@ pub enum CapturedInput {
/// tagged with the source control so dispatch resolves it against that
/// button's own direction map.
Gesture(ButtonId, GestureDirection),
/// Additional travel during a committed hold, for continuous actions only.
GestureRepeat(ButtonId, GestureDirection),
/// A diverted button's physical down edge.
ButtonDown(ButtonId),
/// Thumb-wheel rotation to re-synthesise on the configured scroll axis.
Expand Down Expand Up @@ -1050,12 +1052,12 @@ fn handle_raw_xy(
*skip_first_raw_xy = false;
return;
}
// Commit the instant a clean direction emerges (mid-swipe, once per hold);
// the accumulator gates on hold duration internally and drops travel that
// arrives outside a hold.
if let Some(direction) = swipe.accumulate(i32::from(dx), i32::from(dy)) {
debug!(?direction, %button, "gesture committed");
let _ = sink.send(CapturedInput::Gesture(*button, direction));
if let Some(step) = swipe.accumulate_repeating(i32::from(dx), i32::from(dy)) {
let input = match step {
SwipeStep::First(direction) => CapturedInput::Gesture(*button, direction),
SwipeStep::Repeat(direction) => CapturedInput::GestureRepeat(*button, direction),
};
let _ = sink.send(input);
}
}

Expand Down
27 changes: 27 additions & 0 deletions crates/openlogi-device/src/session/gesture/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,33 @@ fn release() -> RawControlEvent {
RawControlEvent::DivertedButtons([0, 0, 0, 0])
}

#[test]
fn zoom_motion_repeats_reverse_and_cannot_outlive_the_hid_hold() {
let (tx, mut rx) = mpsc::unbounded_channel();
let mut acc = CaptureAccum::default();
handle_reprog(&mut acc, press(), BOTH, &[], &[], &tx);
acc.backdate_hold_for_test();
for dy in [-50, -50, 50] {
handle_raw_xy(&mut acc, 0, dy, &tx);
}
handle_reprog(&mut acc, release(), BOTH, &[], &[], &tx);
handle_raw_xy(&mut acc, 0, -100, &tx);
let mut captured = Vec::new();
while let Ok(input) = rx.try_recv() {
captured.push(input);
}
assert_eq!(
captured,
vec![
CapturedInput::ButtonDown(ButtonId::GestureButton),
CapturedInput::Gesture(ButtonId::GestureButton, GestureDirection::Up),
CapturedInput::GestureRepeat(ButtonId::GestureButton, GestureDirection::Up),
CapturedInput::GestureRepeat(ButtonId::GestureButton, GestureDirection::Down),
CapturedInput::ButtonUp(ButtonId::GestureButton),
]
);
}

/// Read the next completed gesture while leaving lifecycle assertions to the
/// dedicated edge tests below.
fn next_gesture(
Expand Down
Loading
Loading