diff --git a/docs/content/en/api-reference/keys/page.mdx b/docs/content/en/api-reference/keys/page.mdx
index a9e9b8ce7..7c31e1af7 100644
--- a/docs/content/en/api-reference/keys/page.mdx
+++ b/docs/content/en/api-reference/keys/page.mdx
@@ -81,7 +81,7 @@ Subscribes to all raw input events (including unregistered keys).
```typescript
interface RawInputEvent {
device: 'keyboard' | 'mouse' | 'gamepad' | 'unknown';
- label: string; // e.g., 'A', 'MOUSE1', 'HIDB:1ccf:101c:9:3'
+ label: string; // e.g., 'A', 'MOUSE1', 'WHEELUP', 'HIDB:1ccf:101c:9:3'
labels: string[]; // all candidate labels for this event
state: 'DOWN' | 'UP';
}
@@ -95,6 +95,13 @@ HID device buttons (Windows) are delivered with `device: 'gamepad'` and
labels in the form `HIDB:vid:pid:usagePage:usage`. See the
[Knobs API](/docs/api-reference/knobs) for HID axis (knob) elements.
+The mouse wheel is delivered with `device: 'mouse'` and the labels
+`WHEELUP`, `WHEELDOWN`, `WHEELLEFT`, and `WHEELRIGHT`. A wheel has no release,
+so each notch is synthesized as a short tap: DOWN, then UP 50 ms later. When
+the next notch arrives within 50 ms, UP and DOWN are sent back to back, so the
+number of DOWN events always equals the number of notches. The wheel can also
+drive a knob element as an axis.
+
---
## Counter Events
diff --git a/docs/content/en/api-reference/knobs/page.mdx b/docs/content/en/api-reference/knobs/page.mdx
index 45b308b9c..dffb511d4 100644
--- a/docs/content/en/api-reference/knobs/page.mdx
+++ b/docs/content/en/api-reference/knobs/page.mdx
@@ -11,27 +11,37 @@ description: dmn.knobItems API reference for HID knob elements
Manage knob elements: rotary visualization elements bound to HID device axes
-(e.g., rhythm game controller knobs). While HID device **buttons** are mapped
+(e.g., rhythm game controller knobs) or the mouse wheel. While HID device **buttons** are mapped
and visualized exactly like keyboard keys, **axes** are visualized with
dedicated knob elements that rotate along with the physical knob.
- HID input recognition is currently supported on **Windows** only.
+ HID input recognition is currently supported on **Windows** only. The mouse
+ wheel axis works on both **Windows** and **macOS**.
## Identifiers
DmNote assigns stable string identifiers to HID inputs:
-| Kind | Format | Example |
-| ------ | ------------------------------ | --------------------- |
-| Button | `HIDB:vid:pid:usagePage:usage` | `HIDB:1ccf:101c:9:3` |
-| Axis | `HIDA:vid:pid:usagePage:usage` | `HIDA:1ccf:101c:1:48` |
+| Kind | Format | Example |
+| ---------------- | ------------------------------------- | --------------------- |
+| Button | `HIDB:vid:pid:usagePage:usage` | `HIDB:1ccf:101c:9:3` |
+| Axis | `HIDA:vid:pid:usagePage:usage` | `HIDA:1ccf:101c:1:48` |
+| Mouse wheel axis | `WHEEL:VERTICAL` / `WHEEL:HORIZONTAL` | `WHEEL:VERTICAL` |
Button identifiers appear as key labels (mappable like any keyboard key and
delivered through `dmn.keys.onRawInput` with `device: 'gamepad'`). Axis
identifiers are stored in a knob element's `axisId`.
+The mouse wheel axis is a virtual axis where 24 wheel notches make one
+revolution, so with `sensitivity` 1 a knob turns 360° every 24 notches. Wheel
+up (away from you) and tilt right are the positive direction. The horizontal
+axis uses the same unit but has no physical revolution behind it. On macOS the
+wheel is read from the mouse's HID reports, so trackpad and touch-surface
+scrolling is not recognized, and a mouse utility that takes over the wheel can
+prevent it from being recognized.
+
## Types
```typescript
@@ -39,7 +49,7 @@ identifiers are stored in a knob element's `axisId`.
// including the stable element `id` (UUID): it keeps identifying the same knob
// across reorders and mode switches, and full preset loads reissue new IDs (tab presets only for collections they include)
type KnobItemPosition = KeyPosition & {
- axisId: string; // bound HID axis ("HIDA:..."), empty if unbound
+ axisId: string; // bound axis ("HIDA:..." or "WHEEL:..."), empty if unbound
sensitivity: number; // rotation multiplier (default 1)
reverse: boolean; // invert rotation direction
};
diff --git a/docs/content/ko/api-reference/keys/page.mdx b/docs/content/ko/api-reference/keys/page.mdx
index 957b5c581..9572f8418 100644
--- a/docs/content/ko/api-reference/keys/page.mdx
+++ b/docs/content/ko/api-reference/keys/page.mdx
@@ -432,7 +432,7 @@ dmn.keys.onKeysReset(({ reason }) => {
```typescript
interface RawInputPayload {
device: 'keyboard' | 'mouse' | 'gamepad' | 'unknown';
- label: string; // "D", "MOUSE1", "HIDB:1ccf:101c:9:3" 등
+ label: string; // "D", "MOUSE1", "WHEELUP", "HIDB:1ccf:101c:9:3" 등
labels: string[]; // 모든 레이블 목록
state: string; // "DOWN" | "UP"
}
@@ -448,6 +448,12 @@ HID 기기 버튼(Windows)은 `device: 'gamepad'`와
`HIDB:vid:pid:usagePage:usage` 형식의 레이블로 전달됩니다. HID 축(노브)
요소는 [노브 API](/docs/api-reference/knobs)를 참고하세요.
+마우스 휠은 `device: 'mouse'`와 `WHEELUP`·`WHEELDOWN`·`WHEELLEFT`·`WHEELRIGHT`
+레이블로 전달됩니다. 휠에는 떼는 동작이 없어서 한 칸마다 DOWN을 보내고 50ms 뒤에
+UP을 보내는 짧은 탭으로 합성합니다. 50ms 안에 다음 칸이 오면 UP과 DOWN을 바로
+이어 보내므로 DOWN 수는 언제나 칸 수와 같습니다. 휠은 노브 요소의 축으로도 쓸 수
+있습니다.
+
구독자가 없으면 백엔드에서 이벤트를 emit하지 않아 성능 오버헤드가 없습니다.
diff --git a/docs/content/ko/api-reference/knobs/page.mdx b/docs/content/ko/api-reference/knobs/page.mdx
index 4493431a7..e5ed6980d 100644
--- a/docs/content/ko/api-reference/knobs/page.mdx
+++ b/docs/content/ko/api-reference/knobs/page.mdx
@@ -10,28 +10,37 @@ description: HID 노브 요소를 위한 dmn.knobItems API 레퍼런스
coordinator를 거쳐 노브 레이아웃을 쓸 수 있습니다.
-노브 요소를 관리합니다. HID 기기 축(예: 리듬게임 컨트롤러의 노브)에 바인딩되어
-물리 노브와 함께 회전하는 시각화 요소입니다. HID 기기의 **버튼**은
+노브 요소를 관리합니다. HID 기기 축(예: 리듬게임 컨트롤러의 노브)이나 마우스 휠에
+바인딩되어 물리 노브와 함께 회전하는 시각화 요소입니다. HID 기기의 **버튼**은
키보드 키와 완전히 동일하게 매핑·시각화되고, **축**은 전용 노브 요소로
시각화됩니다.
- HID 입력 인식은 현재 **Windows**에서만 지원됩니다.
+ HID 입력 인식은 현재 **Windows**에서만 지원됩니다. 마우스 휠 축은
+ **Windows**와 **macOS** 모두에서 동작합니다.
## 식별자
DmNote는 HID 입력에 고정 문자열 식별자를 부여합니다:
-| 종류 | 형식 | 예시 |
-| ---- | ------------------------------ | --------------------- |
-| 버튼 | `HIDB:vid:pid:usagePage:usage` | `HIDB:1ccf:101c:9:3` |
-| 축 | `HIDA:vid:pid:usagePage:usage` | `HIDA:1ccf:101c:1:48` |
+| 종류 | 형식 | 예시 |
+| ------------ | ------------------------------------- | --------------------- |
+| 버튼 | `HIDB:vid:pid:usagePage:usage` | `HIDB:1ccf:101c:9:3` |
+| 축 | `HIDA:vid:pid:usagePage:usage` | `HIDA:1ccf:101c:1:48` |
+| 마우스 휠 축 | `WHEEL:VERTICAL` / `WHEEL:HORIZONTAL` | `WHEEL:VERTICAL` |
버튼 식별자는 키 라벨로 사용되며(일반 키보드 키처럼 매핑 가능,
`dmn.keys.onRawInput`에서 `device: 'gamepad'`로 전달) 축 식별자는 노브
요소의 `axisId`에 저장됩니다.
+마우스 휠 축은 휠 24칸을 1회전으로 보는 가상 축입니다. `sensitivity`가 1이면
+24칸마다 노브가 360° 돕니다. 휠을 앞쪽(사용자 반대쪽)으로 굴리거나 오른쪽으로
+기울이는 방향이 양수입니다. 가로 축도 같은 단위를 쓰지만 실제 회전량과는
+관계가 없습니다. macOS에서는 마우스의 HID 보고를 직접 읽기 때문에 트랙패드나
+터치 표면 스크롤은 인식하지 않습니다. 휠을 가로채는 마우스 유틸리티를 쓰면
+인식되지 않을 수 있습니다.
+
## 타입
```typescript
@@ -39,7 +48,7 @@ DmNote는 HID 입력에 고정 문자열 식별자를 부여합니다:
// 요소 안정 id(UUID)도 포함: 재정렬·모드 전환에도 같은 노브를 가리키며,
// 전체 프리셋 로드 시에는 새 ID가 발급됩니다 (탭 프리셋은 담긴 컬렉션만)
type KnobItemPosition = KeyPosition & {
- axisId: string; // 바인딩된 HID 축("HIDA:..."), 미바인딩이면 빈 문자열
+ axisId: string; // 바인딩된 축("HIDA:..." 또는 "WHEEL:..."), 미바인딩이면 빈 문자열
sensitivity: number; // 회전 배율 (기본 1)
reverse: boolean; // 회전 방향 반전
};
diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock
index e0472f34d..857862995 100644
--- a/src-tauri/Cargo.lock
+++ b/src-tauri/Cargo.lock
@@ -1225,6 +1225,7 @@ dependencies = [
"anyhow",
"base64 0.23.1",
"cocoa",
+ "core-foundation 0.10.1",
"cpal",
"dirs-next",
"fern",
diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml
index 1fb8a3f0e..e019ec6f1 100644
--- a/src-tauri/Cargo.toml
+++ b/src-tauri/Cargo.toml
@@ -84,6 +84,7 @@ self-replace = "1.5"
[target."cfg(target_os = \"macos\")".dependencies]
libc = "0.2"
+core-foundation = "0.10"
rdev = "0.5.3"
objc = "0.2"
objc2 = "0.6"
diff --git a/src-tauri/src/keyboard/daemon/macos.rs b/src-tauri/src/keyboard/daemon/macos.rs
index 9b73a906d..0b7e6a24a 100644
--- a/src-tauri/src/keyboard/daemon/macos.rs
+++ b/src-tauri/src/keyboard/daemon/macos.rs
@@ -338,6 +338,8 @@ pub(super) fn run_macos() -> Result<()> {
}
let output = super::start_output_writer(Box::new(std::io::stdout()))?;
+ let wheel = super::wheel::spawn_wheel_engine(output.clone())?;
+ super::macos_wheel::start_hid_wheel_capture(wheel);
// rdev::listen — 접근성 + 입력 모니터링 권한 필수
// 권한 부여 직후 CGEventTap 생성 실패 가능 — 재시도 처리
diff --git a/src-tauri/src/keyboard/daemon/macos_wheel.rs b/src-tauri/src/keyboard/daemon/macos_wheel.rs
new file mode 100644
index 000000000..7217668f9
--- /dev/null
+++ b/src-tauri/src/keyboard/daemon/macos_wheel.rs
@@ -0,0 +1,443 @@
+//! macOS 휠 어댑터: IOHIDManager로 마우스의 HID 휠 원본 값만 추출해 공용 엔진에 전달
+//! - CGEvent 스크롤 값은 가속·자연스러운 스크롤 반전이 적용돼 칸 수로 쓸 수 없음
+//! - 트랙패드는 Wheel·AC Pan 요소가 없어 매칭되지 않음
+//! - 판정·환산은 wheel.rs, 여기서는 네이티브 호출과 원본 값 수집만
+
+use std::{
+ cell::{Cell, RefCell},
+ collections::HashMap,
+ ffi::c_void,
+ thread,
+ time::Duration,
+};
+
+use core_foundation::{
+ array::{CFArray, CFArrayGetCount, CFArrayGetValueAtIndex, CFArrayRef},
+ base::{kCFAllocatorDefault, CFAllocatorRef, CFIndex, CFRelease, TCFType},
+ date::CFAbsoluteTimeGetCurrent,
+ dictionary::{CFDictionary, CFDictionaryRef},
+ number::CFNumber,
+ runloop::{
+ kCFRunLoopDefaultMode, CFRunLoop, CFRunLoopRef, CFRunLoopTimer, CFRunLoopTimerContext,
+ CFRunLoopTimerRef,
+ },
+ string::{CFString, CFStringRef},
+};
+
+use super::wheel::{
+ HidResolutionMultiplier, WheelSample, WheelSender, HID_PAGE_CONSUMER, HID_PAGE_GENERIC_DESKTOP,
+ HID_USAGE_AC_PAN, HID_USAGE_WHEEL,
+};
+
+type IOReturn = i32;
+type IOOptionBits = u32;
+type IOHIDManagerRef = *mut c_void;
+type IOHIDDeviceRef = *mut c_void;
+type IOHIDElementRef = *mut c_void;
+type IOHIDValueRef = *mut c_void;
+type IOHIDDeviceCallback = extern "C" fn(
+ context: *mut c_void,
+ result: IOReturn,
+ sender: *mut c_void,
+ device: IOHIDDeviceRef,
+);
+type IOHIDValueCallback = extern "C" fn(
+ context: *mut c_void,
+ result: IOReturn,
+ sender: *mut c_void,
+ value: IOHIDValueRef,
+);
+
+const K_IO_RETURN_SUCCESS: IOReturn = 0;
+/// 입력 감시 권한 없음 (TCC 대상 장치 열기 실패)
+const K_IO_RETURN_NOT_PERMITTED: IOReturn = 0xe000_02e2_u32 as i32;
+const K_IOHID_REQUEST_TYPE_LISTEN_EVENT: u32 = 1;
+const K_IOHID_ACCESS_TYPE_GRANTED: u32 = 0;
+const K_IOHID_OPTIONS_TYPE_NONE: IOOptionBits = 0;
+const K_IOHID_ELEMENT_TYPE_FEATURE: u32 = 257;
+const K_IOHID_ELEMENT_COLLECTION_TYPE_LOGICAL: u32 = 0x02;
+const HID_USAGE_MOUSE: u32 = 0x02;
+const HID_USAGE_POINTER: u32 = 0x01;
+const HID_USAGE_RESOLUTION_MULTIPLIER: u32 = 0x48;
+/// manager 생성 실패 시 재시도 간격
+const HID_REOPEN_INTERVAL: Duration = Duration::from_secs(2);
+/// 권한 거부 뒤 허용 여부 확인 간격(초)
+const HID_ACCESS_POLL_SECONDS: f64 = 2.0;
+
+#[link(name = "IOKit", kind = "framework")]
+extern "C" {
+ fn IOHIDManagerCreate(allocator: CFAllocatorRef, options: IOOptionBits) -> IOHIDManagerRef;
+ fn IOHIDManagerSetDeviceMatchingMultiple(manager: IOHIDManagerRef, multiple: CFArrayRef);
+ fn IOHIDManagerSetInputValueMatchingMultiple(manager: IOHIDManagerRef, multiple: CFArrayRef);
+ fn IOHIDManagerRegisterDeviceMatchingCallback(
+ manager: IOHIDManagerRef,
+ callback: IOHIDDeviceCallback,
+ context: *mut c_void,
+ );
+ fn IOHIDManagerRegisterDeviceRemovalCallback(
+ manager: IOHIDManagerRef,
+ callback: IOHIDDeviceCallback,
+ context: *mut c_void,
+ );
+ fn IOHIDManagerRegisterInputValueCallback(
+ manager: IOHIDManagerRef,
+ callback: IOHIDValueCallback,
+ context: *mut c_void,
+ );
+ fn IOHIDManagerScheduleWithRunLoop(
+ manager: IOHIDManagerRef,
+ run_loop: CFRunLoopRef,
+ run_loop_mode: CFStringRef,
+ );
+ fn IOHIDManagerOpen(manager: IOHIDManagerRef, options: IOOptionBits) -> IOReturn;
+ fn IOHIDManagerClose(manager: IOHIDManagerRef, options: IOOptionBits) -> IOReturn;
+ fn IOHIDDeviceCopyMatchingElements(
+ device: IOHIDDeviceRef,
+ matching: CFDictionaryRef,
+ options: IOOptionBits,
+ ) -> CFArrayRef;
+ fn IOHIDDeviceGetValue(
+ device: IOHIDDeviceRef,
+ element: IOHIDElementRef,
+ value: *mut IOHIDValueRef,
+ ) -> IOReturn;
+ fn IOHIDValueGetElement(value: IOHIDValueRef) -> IOHIDElementRef;
+ fn IOHIDValueGetIntegerValue(value: IOHIDValueRef) -> CFIndex;
+ fn IOHIDElementGetDevice(element: IOHIDElementRef) -> IOHIDDeviceRef;
+ fn IOHIDElementGetCookie(element: IOHIDElementRef) -> u32;
+ fn IOHIDElementGetType(element: IOHIDElementRef) -> u32;
+ fn IOHIDElementGetUsagePage(element: IOHIDElementRef) -> u32;
+ fn IOHIDElementGetUsage(element: IOHIDElementRef) -> u32;
+ fn IOHIDElementGetParent(element: IOHIDElementRef) -> IOHIDElementRef;
+ fn IOHIDElementGetCollectionType(element: IOHIDElementRef) -> u32;
+ fn IOHIDElementGetReportID(element: IOHIDElementRef) -> u32;
+ fn IOHIDElementGetLogicalMin(element: IOHIDElementRef) -> CFIndex;
+ fn IOHIDElementGetLogicalMax(element: IOHIDElementRef) -> CFIndex;
+ fn IOHIDElementGetPhysicalMin(element: IOHIDElementRef) -> CFIndex;
+ fn IOHIDElementGetPhysicalMax(element: IOHIDElementRef) -> CFIndex;
+ fn IOHIDElementGetUnitExponent(element: IOHIDElementRef) -> u32;
+ fn IOHIDCheckAccess(request_type: u32) -> u32;
+}
+
+/// 장치 포인터 + 요소 쿠키 → 해당 휠 요소에 적용되는 배율 Feature
+type MultiplierCache = HashMap<(usize, u32), Option>;
+
+/// 콜백 컨텍스트 (manager 스레드 전용)
+struct HidContext {
+ wheel: WheelSender,
+ multipliers: RefCell,
+ /// 권한 거부를 본 시점의 접근 상태. 허용으로 바뀌면 manager 재구성
+ denied_access: Cell