Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
775e33f
feat(hidpp): add ForceSensingButton and EnableHiddenFeatures wrappers
ultradaoto Jul 20, 2026
17a12fb
fix(hid): declare Win32_System_IO for WriteFile OVERLAPPED
ultradaoto Jul 20, 2026
e170371
feat(hid): add Action Ring panel diagnostics module
ultradaoto Jul 20, 2026
c812feb
feat(cli): add reverse-engineering diag subcommands
ultradaoto Jul 20, 2026
0e9b8bc
docs(hid): add MX Master 4 Action Ring panel captures
ultradaoto Jul 20, 2026
498e6fb
fix(cli): make the reverse-engineering diag tools clippy-clean
ultradaoto Jul 20, 2026
bf662f3
feat(hid): capture Action Ring taps in the live session
ultradaoto Jul 20, 2026
5d60f47
feat(core): make the Action Ring a bindable control
ultradaoto Jul 20, 2026
a1dc1e9
fix(hid): give first-contact Bolt slot walks a workable budget
ultradaoto Jul 20, 2026
a723bd1
fix(hid): only the pad CID opens the ring — 0x0050/0x0051 are clicks
ultradaoto Jul 20, 2026
00dbef6
feat(ipc): stream Action Ring presses to the GUI and execute actions …
ultradaoto Jul 20, 2026
c2ab68f
feat(gui): Action Ring overlay — tap, move, tap
ultradaoto Jul 20, 2026
e679735
fix(gui): ring overlay polish — true transparency, edge clamp, layout
ultradaoto Jul 20, 2026
db96b08
feat(hid): capture the MX Master 4 side Back/Forward buttons
ultradaoto Jul 20, 2026
a7747c3
fix(gui): frameless ring popup — window region, re-asserted transparency
ultradaoto Jul 20, 2026
a7f0ecb
feat(gui): Action Ring customization menu
ultradaoto Jul 20, 2026
1bcbd83
feat(core): Run and PasteText payload actions
ultradaoto Jul 24, 2026
74a880b
feat(inject): dispatch Run and PasteText
ultradaoto Jul 24, 2026
77709d0
feat(ipc): v12 — Action gains Run / PasteText on the wire
ultradaoto Jul 24, 2026
8a0f724
feat(gui): Run / Paste Text editor rows in the ring menu
ultradaoto Jul 24, 2026
0b5dc8d
feat(gui): tighten the ring to Options+ geometry
ultradaoto Jul 24, 2026
8392532
feat(gui): --background flag and an always-resident ring window
ultradaoto Jul 24, 2026
ac801ed
docs: Run / PasteText in the ring binding example
ultradaoto Jul 24, 2026
c29429d
feat(core): Win/Super modifier for CustomShortcut
ultradaoto Jul 24, 2026
072c3da
feat(inject): map MOD_WIN to VK_LWIN / Meta / Command
ultradaoto Jul 24, 2026
5a85247
feat(gui): full-page Action Ring editor
ultradaoto Jul 24, 2026
3f3de93
feat(core): Folder action — ring sub-menus
ultradaoto Jul 25, 2026
08d9ab1
feat(ipc): v13 — Action gains Folder on the wire
ultradaoto Jul 25, 2026
61b6ab8
feat(inject): guard Folder — containers never execute
ultradaoto Jul 25, 2026
f269cc6
feat(gui): ring folders — overlay sub-rings and editor drill-in
ultradaoto Jul 25, 2026
f4236eb
feat(core): parse human key chords into KeyCombo
ultradaoto Jul 25, 2026
3407f69
feat(gui): editable keyboard shortcuts and a layout pass on the ring …
ultradaoto Jul 25, 2026
7fb3860
feat(gui): record keyboard shortcuts by pressing them
ultradaoto Jul 25, 2026
51342e7
feat(core): Named actions — user labels for ring slots
ultradaoto Jul 25, 2026
7509029
feat(gui): name the action you are binding
ultradaoto Jul 25, 2026
a7a2a2f
fix(inject): hold synthesized chords long enough to be observed
ultradaoto Jul 25, 2026
740e874
test(gui): make the chord recorder deterministic
ultradaoto Jul 25, 2026
23903eb
Merge remote-tracking branch 'upstream/master' into feat/haptic-sense…
ultradaoto Jul 25, 2026
1626ff2
fix(core): stop an older build from wiping a newer config
ultradaoto Jul 25, 2026
ce0e5df
fix(gui): render the ring of the device whose pad fired
ultradaoto Jul 25, 2026
873496c
feat(gui): allow shortcuts, commands and snippets on every binding su…
ultradaoto Jul 25, 2026
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
5 changes: 5 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions crates/openlogi-agent-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,5 @@ workspace = true
bincode = "1.3"
# Only to construct an `InventoryError::Hid` in the watcher's classify tests.
async-hid = { workspace = true }
# `start_paused` virtual time for the ring long-poll timeout tests.
tokio = { workspace = true, features = ["test-util", "macros", "rt"] }
61 changes: 61 additions & 0 deletions crates/openlogi-agent-core/src/bindings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,25 @@ pub fn gesture_bindings_for(
bindings
}

/// Whether the Action Ring pad's effective binding opens the on-screen ring
/// (a [`Binding::Ring`]) rather than firing a single action.
///
/// A device with no stored `ActionRing` binding gets the canonical default —
/// which *is* a ring — so a fresh MX Master 4 opens the ring out of the box.
/// A per-app overlay that demotes the pad to a single action disarms the ring
/// for that app, mirroring how a gesture owner is demoted per app.
#[must_use]
pub fn ring_armed_for(config: &Config, config_key: Option<&str>, app_bundle: Option<&str>) -> bool {
let Some(key) = config_key else {
// No config key yet (no device selected) — the default binding rules.
return true;
};
config
.effective_bindings(key, app_bundle)
.remove(&ButtonId::ActionRing)
.is_none_or(|binding| binding.is_ring())
}

/// Per-direction maps for the OS-hook gesture buttons (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.
Expand Down Expand Up @@ -160,6 +179,48 @@ mod tests {
);
}

#[test]
fn ring_stays_armed_by_default_and_disarms_on_a_single_binding() {
let mut cfg = Config::default();
// No stored binding: the canonical Ring default arms the overlay.
assert!(ring_armed_for(&cfg, Some("2b042"), None));
assert!(
ring_armed_for(&cfg, None, None),
"no selected device still means the default ring binding"
);

// The user demotes the pad to a plain action — taps dispatch instead.
cfg.set_binding("2b042", ButtonId::ActionRing, Action::Copy.into());
assert!(!ring_armed_for(&cfg, Some("2b042"), None));

// An explicit ring binding re-arms it.
cfg.set_binding(
"2b042",
ButtonId::ActionRing,
openlogi_core::binding::default_binding_for(ButtonId::ActionRing),
);
assert!(ring_armed_for(&cfg, Some("2b042"), None));
}

#[test]
fn per_app_single_override_disarms_the_ring_for_that_app_only() {
let mut cfg = Config::default();
cfg.set_per_app_binding(
"2b042",
"com.microsoft.VSCode",
ButtonId::ActionRing,
Some(Action::Paste),
);
assert!(
!ring_armed_for(&cfg, Some("2b042"), Some("com.microsoft.VSCode")),
"the overlay must not open where the pad is a plain action"
);
assert!(
ring_armed_for(&cfg, Some("2b042"), Some("com.other.App")),
"other apps keep the ring"
);
}

#[test]
fn oshook_gestures_collects_only_os_hook_gesture_buttons() {
let mut cfg = Config::default();
Expand Down
49 changes: 46 additions & 3 deletions crates/openlogi-agent-core/src/ipc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
//! flow long-polls [`Agent::next_pairing`], which the agent holds open until a
//! pairing event arrives or the request deadline elapses.

use openlogi_core::binding::Action;
use openlogi_core::config::Lighting;
use openlogi_core::device::DeviceInventory;
use openlogi_hid::{
Expand All @@ -28,7 +29,37 @@ use serde::{Deserialize, Serialize};
/// v8: [`WriteError`] carries typed HID++ operation failures.
/// v9: `poll_event_monitor` appended + [`MonitorEvent`] (live event monitor).
/// v10: `Capabilities::hires_wheel` appended.
pub const PROTOCOL_VERSION: u32 = 10;
/// v11: `next_ring_press` + `execute_action` appended (Action Ring overlay).
/// v12: [`Action`] gains the appended `Run` / `PasteText` variants (they ride
/// inside `execute_action` and config snapshots).
/// v13: [`Action`] gains the appended `Folder` variant (ring sub-menus).
/// v14: [`Action`] gains the appended `Named` variant (user action labels).
/// v15: [`RingPress`] gains the appended `device_key` field, so the overlay
/// resolves slots against the pad's own device.
pub const PROTOCOL_VERSION: u32 = 15;

/// One Action Ring pad press, streamed to the GUI via
/// [`Agent::next_ring_press`] so the on-screen ring opens (or confirms a
/// selection) the moment the pad is tapped.
///
/// bincode encodes struct fields positionally — fields are append-only, like
/// the [`Agent`] trait methods.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RingPress {
/// Monotonic press counter within one agent run. Lets the GUI drop
/// stale presses that queued while it had no poll outstanding (e.g.
/// while reconnecting) instead of replaying a burst.
pub seq: u64,
/// Config key of the device whose pad fired, so the overlay renders that
/// device's ring.
///
/// Without it the GUI would resolve slots against its carousel selection,
/// which is a different device whenever the pad's mouse isn't the one on
/// screen — and a device with no ring binding falls back to the canonical
/// default ring, i.e. a ring the user never configured. Empty when the
/// agent has no active device key.
pub device_key: String,
}

/// Where the agent's device enumeration stands. The distinction matters
/// because an empty inventory list is ambiguous on its own: the GUI must keep
Expand Down Expand Up @@ -272,7 +303,19 @@ pub trait Agent {
/// Drain the events the hook has observed since the last poll, for the GUI's
/// live event monitor. The first poll enables monitoring; the agent
/// auto-disables it once polls stop (the GUI closed the panel or died), so
/// there is no explicit stop. Appended last — see the method-order note on
/// [`Agent::protocol_version`].
/// there is no explicit stop.
async fn poll_event_monitor() -> Vec<MonitorEvent>;
/// Long-poll the next Action Ring pad press. The agent answers immediately
/// when a press is queued, otherwise holds the request until one arrives or
/// its hold window elapses (`None` — the GUI simply re-polls). Only presses
/// while the pad's effective binding is ring-shaped arrive here; a
/// single-action binding dispatches in the agent instead. Appended for
/// v11 — method order is wire-sensitive (see [`Self::protocol_version`]).
async fn next_ring_press() -> Option<RingPress>;
/// Execute one bound action through the agent's dispatch path — how the
/// ring overlay fires the sector the user selected. Runs in the agent so
/// synthesis works exactly like any button binding (and is not tied to the
/// GUI's window focus). Appended for v11 — see the method-order note on
/// [`Self::protocol_version`].
async fn execute_action(action: Action);
}
1 change: 1 addition & 0 deletions crates/openlogi-agent-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ pub mod hook_runtime;
pub mod ipc;
pub mod orchestrator;
pub mod receiver_access;
pub mod ring;
pub mod transport;
pub mod watchers;

Expand Down
12 changes: 11 additions & 1 deletion crates/openlogi-agent-core/src/orchestrator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,12 @@ use openlogi_hid::{CaptureChannel, DeviceRoute};
use tracing::warn;

use crate::DpiCycleState;
use crate::bindings::{bindings_for, gesture_bindings_for, oshook_gestures_for};
use crate::bindings::{bindings_for, gesture_bindings_for, oshook_gestures_for, ring_armed_for};
use crate::device_order::DeviceStableId;
use crate::hook_runtime::{HookMaps, SharedHookMaps};
use crate::ipc::InventoryHealth;
use crate::receiver_access::ReceiverAccess;
use crate::ring::RingChannel;
use crate::watchers::gesture::GestureBindings;

/// The minimal per-device facts the agent needs: the config key (binding /
Expand Down Expand Up @@ -61,6 +62,10 @@ pub struct SharedRuntime {
/// Exclusive receiver access shared by HID++ capture and pairing. Capture
/// and pairing must never open the same receiver HID node concurrently.
pub receiver_access: ReceiverAccess,
/// Action Ring press channel: the gesture watcher pushes pad presses here
/// while the pad's effective binding is ring-shaped, and the IPC server's
/// `next_ring_press` long-poll drains them for the GUI overlay.
pub ring: RingChannel,
}

/// Owns the config + device selection and keeps [`SharedRuntime`] in sync.
Expand Down Expand Up @@ -112,6 +117,7 @@ impl Orchestrator {
)),
capture_channel: Arc::new(RwLock::new(None)),
receiver_access: ReceiverAccess::default(),
ring: RingChannel::default(),
};
let orch = Self {
config,
Expand Down Expand Up @@ -183,6 +189,10 @@ impl Orchestrator {
self.config.app_settings.thumbwheel_sensitivity,
Ordering::Relaxed,
);
self.shared.ring.set_armed(
ring_armed_for(&self.config, key, self.current_app.as_deref()),
key,
);
}

/// Apply a fresh inventory snapshot. Always refreshes the snapshot the IPC
Expand Down
Loading