From a6e7e319f378dab5becd7268be339037e8efa35e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Wed, 7 Oct 2026 20:14:11 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20=EB=A7=88=EC=9A=B0=EC=8A=A4=20?= =?UTF-8?q?=ED=9C=A0=20=EA=B3=B5=EC=9A=A9=20=EC=97=94=EC=A7=84=EA=B3=BC=20?= =?UTF-8?q?=ED=9C=A0=20=EC=9E=85=EB=A0=A5=20=EC=88=98=EC=A7=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/Cargo.lock | 1 + src-tauri/Cargo.toml | 1 + src-tauri/src/keyboard/daemon/macos.rs | 2 + src-tauri/src/keyboard/daemon/macos_wheel.rs | 443 ++++++++ src-tauri/src/keyboard/daemon/mod.rs | 14 +- src-tauri/src/keyboard/daemon/wheel.rs | 1060 ++++++++++++++++++ src-tauri/src/keyboard/daemon/windows.rs | 10 +- src-tauri/src/keyboard/daemon/windows_hid.rs | 5 +- 8 files changed, 1528 insertions(+), 8 deletions(-) create mode 100644 src-tauri/src/keyboard/daemon/macos_wheel.rs create mode 100644 src-tauri/src/keyboard/daemon/wheel.rs 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>, +} + +impl HidContext { + /// 장치 객체가 TCC 판정을 캐시하므로 허용 뒤에는 새 manager가 필요 + fn note_permission_denied(&self) { + if self.denied_access.get().is_none() { + let access = unsafe { IOHIDCheckAccess(K_IOHID_REQUEST_TYPE_LISTEN_EVENT) }; + eprintln!("[Wheel] HID open not permitted (access={access})"); + self.denied_access.set(Some(access)); + } + } + + fn permission_recovered(&self) -> bool { + self.denied_access.get().is_some_and(|at_denial| { + at_denial != K_IOHID_ACCESS_TYPE_GRANTED + && unsafe { IOHIDCheckAccess(K_IOHID_REQUEST_TYPE_LISTEN_EVENT) } + == K_IOHID_ACCESS_TYPE_GRANTED + }) + } +} + +fn is_wheel_usage(usage_page: u32, usage: u32) -> bool { + matches!( + (usage_page, usage), + (HID_PAGE_GENERIC_DESKTOP, HID_USAGE_WHEEL) | (HID_PAGE_CONSUMER, HID_USAGE_AC_PAN) + ) +} + +fn usage_dictionary( + page_key: &str, + page: u32, + usage_key: &str, + usage: u32, +) -> CFDictionary { + CFDictionary::from_CFType_pairs(&[ + (CFString::new(page_key), CFNumber::from(page as i32)), + (CFString::new(usage_key), CFNumber::from(usage as i32)), + ]) +} + +/// Copy 규칙으로 받은 요소 배열을 빌려 쓰고 해제 (요소 포인터는 배열이 살아 있는 동안만 사용) +unsafe fn with_elements( + elements: CFArrayRef, + use_elements: impl FnOnce(&[IOHIDElementRef]) -> R, +) -> Option { + if elements.is_null() { + return None; + } + let list: Vec = (0..CFArrayGetCount(elements)) + .map(|index| CFArrayGetValueAtIndex(elements, index) as IOHIDElementRef) + .collect(); + let result = use_elements(&list); + CFRelease(elements as *const c_void); + Some(result) +} + +unsafe fn nearest_logical_collection(element: IOHIDElementRef) -> IOHIDElementRef { + let mut ancestor = IOHIDElementGetParent(element); + while !ancestor.is_null() { + if IOHIDElementGetCollectionType(ancestor) == K_IOHID_ELEMENT_COLLECTION_TYPE_LOGICAL { + return ancestor; + } + ancestor = IOHIDElementGetParent(ancestor); + } + std::ptr::null_mut() +} + +/// 배율 적용 범위: Feature가 속한 Logical Collection, 없으면 같은 report (HUT Resolution Multiplier) +unsafe fn multiplier_applies(feature: IOHIDElementRef, wheel: IOHIDElementRef) -> bool { + let scope = nearest_logical_collection(feature); + if scope.is_null() { + return IOHIDElementGetReportID(feature) == IOHIDElementGetReportID(wheel); + } + let mut ancestor = IOHIDElementGetParent(wheel); + while !ancestor.is_null() { + if ancestor == scope { + return true; + } + ancestor = IOHIDElementGetParent(ancestor); + } + false +} + +unsafe fn read_multiplier( + device: IOHIDDeviceRef, + feature: IOHIDElementRef, +) -> Option { + let mut value: IOHIDValueRef = std::ptr::null_mut(); + let result = IOHIDDeviceGetValue(device, feature, &mut value); + if result != K_IO_RETURN_SUCCESS || value.is_null() { + // HID 초기 배율(1)로 처리 + eprintln!("[Wheel] resolution multiplier read failed: {result:#x}"); + return None; + } + Some(HidResolutionMultiplier { + value: IOHIDValueGetIntegerValue(value) as i64, + logical_min: IOHIDElementGetLogicalMin(feature) as i64, + logical_max: IOHIDElementGetLogicalMax(feature) as i64, + physical_min: IOHIDElementGetPhysicalMin(feature) as i64, + physical_max: IOHIDElementGetPhysicalMax(feature) as i64, + unit_exponent: IOHIDElementGetUnitExponent(feature), + }) +} + +/// 장치 연결: 휠 요소별 배율 Feature 원본값을 한 번 읽어 캐시 +extern "C" fn on_device_matched( + context: *mut c_void, + result: IOReturn, + _sender: *mut c_void, + device: IOHIDDeviceRef, +) { + let context = unsafe { &*(context as *const HidContext) }; + if result == K_IO_RETURN_NOT_PERMITTED { + context.note_permission_denied(); + return; + } + let entries = unsafe { + let elements = + IOHIDDeviceCopyMatchingElements(device, std::ptr::null(), K_IOHID_OPTIONS_TYPE_NONE); + with_elements(elements, |elements| { + let is_feature = |element: IOHIDElementRef| { + IOHIDElementGetUsagePage(element) == HID_PAGE_GENERIC_DESKTOP + && IOHIDElementGetUsage(element) == HID_USAGE_RESOLUTION_MULTIPLIER + && IOHIDElementGetType(element) == K_IOHID_ELEMENT_TYPE_FEATURE + }; + let features: Vec = elements + .iter() + .copied() + .filter(|e| is_feature(*e)) + .collect(); + elements + .iter() + .copied() + .filter(|element| { + is_wheel_usage( + IOHIDElementGetUsagePage(*element), + IOHIDElementGetUsage(*element), + ) + }) + .map(|wheel| { + let multiplier = features + .iter() + .find(|feature| multiplier_applies(**feature, wheel)) + .and_then(|feature| read_multiplier(device, *feature)); + (IOHIDElementGetCookie(wheel), multiplier) + }) + .collect::>() + }) + }; + + let mut multipliers = context.multipliers.borrow_mut(); + for (cookie, multiplier) in entries.unwrap_or_default() { + multipliers.insert((device as usize, cookie), multiplier); + } +} + +extern "C" fn on_device_removed( + context: *mut c_void, + _result: IOReturn, + _sender: *mut c_void, + device: IOHIDDeviceRef, +) { + let context = unsafe { &*(context as *const HidContext) }; + context + .multipliers + .borrow_mut() + .retain(|(owner, _), _| *owner != device as usize); +} + +extern "C" fn on_input_value( + context: *mut c_void, + _result: IOReturn, + _sender: *mut c_void, + value: IOHIDValueRef, +) { + let captured = super::InputCapture::now(); + let context = unsafe { &*(context as *const HidContext) }; + let (usage_page, usage, raw, key) = unsafe { + let element = IOHIDValueGetElement(value); + let device = IOHIDElementGetDevice(element); + ( + IOHIDElementGetUsagePage(element), + IOHIDElementGetUsage(element), + IOHIDValueGetIntegerValue(value) as i64, + (device as usize, IOHIDElementGetCookie(element)), + ) + }; + if raw == 0 || !is_wheel_usage(usage_page, usage) { + return; + } + let multiplier = context.multipliers.borrow().get(&key).copied().flatten(); + context.wheel.send( + captured, + WheelSample::MacHidValue { + usage_page, + usage, + value: raw, + multiplier, + }, + ); +} + +/// manager 생성·매칭·스케줄·열기. 생성 실패만 None (장치별 실패는 콜백·집계 결과로 기록) +unsafe fn open_manager(context: *mut c_void) -> Option { + let manager = IOHIDManagerCreate(kCFAllocatorDefault, K_IOHID_OPTIONS_TYPE_NONE); + if manager.is_null() { + return None; + } + let devices = CFArray::from_CFTypes(&[ + usage_dictionary( + "DeviceUsagePage", + HID_PAGE_GENERIC_DESKTOP, + "DeviceUsage", + HID_USAGE_MOUSE, + ), + usage_dictionary( + "DeviceUsagePage", + HID_PAGE_GENERIC_DESKTOP, + "DeviceUsage", + HID_USAGE_POINTER, + ), + ]); + let values = CFArray::from_CFTypes(&[ + usage_dictionary( + "UsagePage", + HID_PAGE_GENERIC_DESKTOP, + "Usage", + HID_USAGE_WHEEL, + ), + usage_dictionary("UsagePage", HID_PAGE_CONSUMER, "Usage", HID_USAGE_AC_PAN), + ]); + IOHIDManagerSetDeviceMatchingMultiple(manager, devices.as_concrete_TypeRef()); + IOHIDManagerSetInputValueMatchingMultiple(manager, values.as_concrete_TypeRef()); + IOHIDManagerRegisterDeviceMatchingCallback(manager, on_device_matched, context); + IOHIDManagerRegisterDeviceRemovalCallback(manager, on_device_removed, context); + IOHIDManagerRegisterInputValueCallback(manager, on_input_value, context); + IOHIDManagerScheduleWithRunLoop( + manager, + CFRunLoop::get_current().as_concrete_TypeRef(), + kCFRunLoopDefaultMode, + ); + // 집계 결과는 첫 실패만 담음 - 권한 거부는 장치 매칭 콜백에서도 기록 + let result = IOHIDManagerOpen(manager, K_IOHID_OPTIONS_TYPE_NONE); + if result == K_IO_RETURN_NOT_PERMITTED { + (*(context as *const HidContext)).note_permission_denied(); + } else if result != K_IO_RETURN_SUCCESS { + // 일부 장치만 실패(다른 프로그램 독점 등) - 열린 장치는 계속 수집 + eprintln!("[Wheel] IOHIDManagerOpen partial failure: {result:#x}"); + } + Some(manager) +} + +/// 권한 거부 뒤 허용되면 런루프를 멈춰 manager 재구성 +extern "C" fn on_access_poll(_timer: CFRunLoopTimerRef, info: *mut c_void) { + let context = unsafe { &*(info as *const HidContext) }; + if context.permission_recovered() { + CFRunLoop::get_current().stop(); + } +} + +/// HID 휠 수집 스레드 시작. 이 스레드의 CFRunLoop에서 모든 콜백 처리 +pub(super) fn start_hid_wheel_capture(wheel: WheelSender) { + let spawned = thread::Builder::new() + .name("keyboard-wheel-hid".into()) + .spawn(move || { + // context는 데몬 수명 동안 유지 + let context_ptr = Box::into_raw(Box::new(HidContext { + wheel, + multipliers: RefCell::new(HashMap::new()), + denied_access: Cell::new(None), + })) as *mut c_void; + let context = unsafe { &*(context_ptr as *const HidContext) }; + loop { + context.multipliers.borrow_mut().clear(); + context.denied_access.set(None); + let Some(manager) = (unsafe { open_manager(context_ptr) }) else { + thread::sleep(HID_REOPEN_INTERVAL); + continue; + }; + + let run_loop = CFRunLoop::get_current(); + let mut timer_context = CFRunLoopTimerContext { + version: 0, + info: context_ptr, + retain: None, + release: None, + copyDescription: None, + }; + let timer = CFRunLoopTimer::new( + unsafe { CFAbsoluteTimeGetCurrent() } + HID_ACCESS_POLL_SECONDS, + HID_ACCESS_POLL_SECONDS, + 0, + 0, + on_access_poll, + &mut timer_context, + ); + unsafe { run_loop.add_timer(&timer, kCFRunLoopDefaultMode) }; + CFRunLoop::run_current(); + + // 권한 회복: Close가 스케줄도 제거, 새 manager로 장치 TCC 재판정 + unsafe { run_loop.remove_timer(&timer, kCFRunLoopDefaultMode) }; + unsafe { + IOHIDManagerClose(manager, K_IOHID_OPTIONS_TYPE_NONE); + CFRelease(manager as *const c_void); + } + eprintln!("[Wheel] input monitoring granted, reopening HID manager"); + } + }); + if let Err(err) = spawned { + // 휠만 비활성, 키보드·마우스 버튼 입력은 계속 + eprintln!("[Wheel] HID capture thread failed: {err}"); + } +} diff --git a/src-tauri/src/keyboard/daemon/mod.rs b/src-tauri/src/keyboard/daemon/mod.rs index 7172d5cd4..54f82a3e7 100644 --- a/src-tauri/src/keyboard/daemon/mod.rs +++ b/src-tauri/src/keyboard/daemon/mod.rs @@ -23,6 +23,18 @@ mod windows_hid; #[cfg(target_os = "macos")] mod macos; +// macOS 휠 원본 수집 (IOHIDManager) +#[cfg(target_os = "macos")] +mod macos_wheel; + +// 마우스 휠 공용 엔진 (판정·탭 합성·가상 축) +#[cfg(any(target_os = "windows", target_os = "macos", test))] +mod wheel; + +/// 축 값 전송 최소 간격(ms), 고빈도 축 입력이 파이프·입력 스레드를 막지 않도록 (HID 축·휠 가상 축 공용) +#[cfg(any(target_os = "windows", target_os = "macos", test))] +pub(super) const AXIS_THROTTLE_MS: u64 = 12; + fn load_hotkeys_from_env() -> ShortcutsState { std::env::var("DMNOTE_HOTKEYS_V1") .ok() @@ -60,7 +72,7 @@ pub fn start_parent_liveness_watch() -> io::Result<()> { pub(super) enum DaemonOutput { Hook(HookMessage), Command(DaemonCommand), - #[cfg_attr(not(target_os = "windows"), allow(dead_code))] + #[cfg_attr(not(any(target_os = "windows", target_os = "macos")), allow(dead_code))] Axis(HidAxisMessage), } diff --git a/src-tauri/src/keyboard/daemon/wheel.rs b/src-tauri/src/keyboard/daemon/wheel.rs new file mode 100644 index 000000000..e47961257 --- /dev/null +++ b/src-tauri/src/keyboard/daemon/wheel.rs @@ -0,0 +1,1060 @@ +//! 마우스 휠 공용 엔진 +//! - 플랫폼 어댑터는 원본 표본만 전달, 정규화·칸 판정·탭 합성·가상 축은 여기서만 처리 +//! - 칸마다 짧은 탭(Down → TAP 후 Up), 다음 칸이 먼저 오면 즉시 Up 후 Down +//! - 가상 축은 기존 HidAxisMessage로 발행 (axisEventBus의 wrap 최단경로 계약 준수) + +use std::{ + io, + sync::mpsc::{self, RecvTimeoutError, Sender}, + thread, + time::{Duration, Instant}, +}; + +use super::{DaemonOutput, HoldTracker, InputCapture, OutputSender, AXIS_THROTTLE_MS}; +use crate::ipc::{HidAxisMessage, HookKeyState, HookMessage, InputDeviceKind}; + +/// 휠 한 칸 단위 (Windows WHEEL_DELTA와 동일) +const WHEEL_NOTCH_UNITS: f64 = 120.0; +/// 고해상도 누적의 부동소수 오차 허용치 +const WHEEL_UNIT_EPSILON: f64 = 1e-6; +/// 합성 탭 눌림 시간 +const WHEEL_TAP_MS: u64 = 50; +/// 가상 축 해상도: 가상 24칸 = 1회전 +const WHEEL_AXIS_FULL: u32 = 24 * 120; +/// 한 축 메시지의 최대 이동량 (wrap 최단경로 복원 한계 full/2 미만 유지) +const WHEEL_AXIS_MAX_STEP: i64 = WHEEL_AXIS_FULL as i64 / 4; + +#[cfg(any(target_os = "windows", test))] +const RI_MOUSE_WHEEL: u16 = 0x0400; +#[cfg(any(target_os = "windows", test))] +const RI_MOUSE_HWHEEL: u16 = 0x0800; +#[cfg(any(target_os = "macos", test))] +pub(super) const HID_PAGE_GENERIC_DESKTOP: u32 = 0x01; +#[cfg(any(target_os = "macos", test))] +pub(super) const HID_USAGE_WHEEL: u32 = 0x38; +#[cfg(any(target_os = "macos", test))] +pub(super) const HID_PAGE_CONSUMER: u32 = 0x0C; +#[cfg(any(target_os = "macos", test))] +pub(super) const HID_USAGE_AC_PAN: u32 = 0x238; + +/// HID Resolution Multiplier Feature 원본값 +#[cfg(any(target_os = "macos", test))] +#[derive(Debug, Clone, Copy, PartialEq)] +pub(super) struct HidResolutionMultiplier { + pub(super) value: i64, + pub(super) logical_min: i64, + pub(super) logical_max: i64, + pub(super) physical_min: i64, + pub(super) physical_max: i64, + /// HID 4비트 지수 원본 (8..=15는 음수) + pub(super) unit_exponent: u32, +} + +/// 플랫폼 원본 표본 +#[derive(Debug, Clone, Copy)] +pub(super) enum WheelSample { + /// Windows RAWMOUSE usButtonFlags·usButtonData + #[cfg(any(target_os = "windows", test))] + WindowsRawMouse { button_flags: u16, button_data: u16 }, + /// macOS IOHID 입력 값과 해당 장치의 배율 Feature + #[cfg(any(target_os = "macos", test))] + MacHidValue { + usage_page: u32, + usage: u32, + value: i64, + multiplier: Option, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +enum WheelAxis { + Vertical, + Horizontal, +} + +impl WheelAxis { + fn index(self) -> usize { + match self { + Self::Vertical => 0, + Self::Horizontal => 1, + } + } + + fn axis_id(self) -> &'static str { + match self { + Self::Vertical => "WHEEL:VERTICAL", + Self::Horizontal => "WHEEL:HORIZONTAL", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +enum WheelDirection { + Up, + Down, + Left, + Right, +} + +impl WheelDirection { + const ALL: [Self; 4] = [Self::Up, Self::Down, Self::Left, Self::Right]; + + fn from_axis(axis: WheelAxis, positive: bool) -> Self { + match (axis, positive) { + (WheelAxis::Vertical, true) => Self::Up, + (WheelAxis::Vertical, false) => Self::Down, + (WheelAxis::Horizontal, true) => Self::Right, + (WheelAxis::Horizontal, false) => Self::Left, + } + } + + fn index(self) -> usize { + match self { + Self::Up => 0, + Self::Down => 1, + Self::Left => 2, + Self::Right => 3, + } + } + + fn opposite(self) -> Self { + match self { + Self::Up => Self::Down, + Self::Down => Self::Up, + Self::Left => Self::Right, + Self::Right => Self::Left, + } + } + + fn label(self) -> &'static str { + match self { + Self::Up => "WHEELUP", + Self::Down => "WHEELDOWN", + Self::Left => "WHEELLEFT", + Self::Right => "WHEELRIGHT", + } + } + + fn physical_id(self) -> &'static str { + match self { + Self::Up => "mouse:wheel:up", + Self::Down => "mouse:wheel:down", + Self::Left => "mouse:wheel:left", + Self::Right => "mouse:wheel:right", + } + } +} + +/// RAWMOUSE 보고서에 휠 데이터가 있을 때만 표본으로 (이동 전용 보고서 제외) +#[cfg(any(target_os = "windows", test))] +pub(super) fn windows_wheel_sample(button_flags: u16, button_data: u16) -> Option { + (button_flags & (RI_MOUSE_WHEEL | RI_MOUSE_HWHEEL) != 0).then_some( + WheelSample::WindowsRawMouse { + button_flags, + button_data, + }, + ) +} + +/// HID 유효 배율. Feature 없음·비정상 값은 HID 초기 배율 1, 음수는 방향 반전 배율 +#[cfg(any(target_os = "macos", test))] +fn effective_multiplier(feature: Option) -> f64 { + let Some(feature) = feature else { + return 1.0; + }; + if feature.logical_max <= feature.logical_min { + return 1.0; + } + // 물리 범위 미지정(0..0)이면 논리 범위 사용 + let (physical_min, physical_max) = if feature.physical_min == 0 && feature.physical_max == 0 { + (feature.logical_min, feature.logical_max) + } else { + (feature.physical_min, feature.physical_max) + }; + let exponent = match feature.unit_exponent as i32 { + raw @ 8..=15 => raw - 16, + raw => raw, + }; + let ratio = (feature.value - feature.logical_min) as f64 + / (feature.logical_max - feature.logical_min) as f64; + let multiplier = (ratio * (physical_max - physical_min) as f64 + physical_min as f64) + * 10_f64.powi(exponent); + if multiplier.is_finite() && multiplier != 0.0 { + multiplier + } else { + 1.0 + } +} + +/// 원본 표본 → (축, 물리 방향 기준 units). 세로 +는 사용자 반대쪽(위), 가로 +는 오른쪽 +fn normalize(sample: WheelSample) -> Option<(WheelAxis, f64)> { + let (axis, units) = match sample { + #[cfg(any(target_os = "windows", test))] + WheelSample::WindowsRawMouse { + button_flags, + button_data, + } => { + let units = f64::from(button_data as i16); + if button_flags & RI_MOUSE_WHEEL != 0 { + (WheelAxis::Vertical, units) + } else if button_flags & RI_MOUSE_HWHEEL != 0 { + (WheelAxis::Horizontal, units) + } else { + return None; + } + } + #[cfg(any(target_os = "macos", test))] + WheelSample::MacHidValue { + usage_page, + usage, + value, + multiplier, + } => { + let axis = match (usage_page, usage) { + (HID_PAGE_GENERIC_DESKTOP, HID_USAGE_WHEEL) => WheelAxis::Vertical, + (HID_PAGE_CONSUMER, HID_USAGE_AC_PAN) => WheelAxis::Horizontal, + _ => return None, + }; + let units = value as f64 * WHEEL_NOTCH_UNITS / effective_multiplier(multiplier); + (axis, units) + } + }; + (units != 0.0).then_some((axis, units)) +} + +#[derive(Default)] +struct AxisState { + /// 칸 판정 전 signed 잔여량 + residual: f64, + /// 누적 목표 위치 (소수 보존) + target: f64, + /// 마지막으로 발행한 정수 위치 + emitted: i64, + last_emit: Option, +} + +#[derive(Default)] +pub(super) struct WheelEngine { + pressed: [Option; 4], + hold_tracker: HoldTracker, + axes: [AxisState; 2], + /// 마지막 에지의 논리 시각 (단조 증가 보정 기준) + last_edge: Option, +} + +impl WheelEngine { + pub(super) fn on_input( + &mut self, + captured: InputCapture, + sample: WheelSample, + ) -> Vec { + let mut outputs = self.on_tick(captured.instant); + let Some((axis, units)) = normalize(sample) else { + return outputs; + }; + let captured = self.monotonic(captured); + + let state = &mut self.axes[axis.index()]; + state.residual += units; + let notches = ((state.residual.abs() + WHEEL_UNIT_EPSILON) / WHEEL_NOTCH_UNITS).floor(); + let positive = state.residual > 0.0; + state.residual -= notches * WHEEL_NOTCH_UNITS * state.residual.signum(); + state.target += units; + + let direction = WheelDirection::from_axis(axis, positive); + for _ in 0..notches as u64 { + self.pulse(direction, captured, &mut outputs); + } + + let state = &self.axes[axis.index()]; + let throttle_passed = state.last_emit.is_none_or(|last| { + captured.instant.saturating_duration_since(last) + >= Duration::from_millis(AXIS_THROTTLE_MS) + }); + if throttle_passed { + self.flush_axis(axis, captured.instant, &mut outputs); + } + outputs + } + + pub(super) fn on_tick(&mut self, now: Instant) -> Vec { + let mut outputs = Vec::new(); + let mut expired: Vec<(Instant, WheelDirection)> = WheelDirection::ALL + .into_iter() + .filter_map(|direction| { + let deadline = self.release_deadline(direction)?; + (deadline <= now).then_some((deadline, direction)) + }) + .collect(); + expired.sort_by_key(|(deadline, direction)| (*deadline, direction.index())); + for (_, direction) in expired { + let at = self.tap_release_at(direction); + self.release(direction, at, &mut outputs); + } + + for axis in [WheelAxis::Vertical, WheelAxis::Horizontal] { + if self + .axis_flush_deadline(axis) + .is_some_and(|deadline| deadline <= now) + { + self.flush_axis(axis, now, &mut outputs); + } + } + outputs + } + + pub(super) fn next_deadline(&self) -> Option { + let releases = WheelDirection::ALL + .into_iter() + .filter_map(|direction| self.release_deadline(direction)); + let flushes = [WheelAxis::Vertical, WheelAxis::Horizontal] + .into_iter() + .filter_map(|axis| self.axis_flush_deadline(axis)); + releases.chain(flushes).min() + } + + /// 입력 종료: 눌린 탭 해제, 남은 축 이동 발행 + pub(super) fn shutdown(&mut self, now: InputCapture) -> Vec { + let mut outputs = Vec::new(); + let now = self.monotonic(now); + for direction in WheelDirection::ALL { + if self.pressed[direction.index()].is_some() { + self.release(direction, now, &mut outputs); + } + } + for axis in [WheelAxis::Vertical, WheelAxis::Horizontal] { + self.flush_axis(axis, now.instant, &mut outputs); + } + outputs + } + + fn monotonic(&mut self, captured: InputCapture) -> InputCapture { + let at = match self.last_edge { + Some(last) if captured.instant < last.instant => last, + _ => captured, + }; + self.last_edge = Some(at); + at + } + + fn release_deadline(&self, direction: WheelDirection) -> Option { + self.pressed[direction.index()] + .map(|down| down.instant + Duration::from_millis(WHEEL_TAP_MS)) + } + + fn tap_release_at(&self, direction: WheelDirection) -> InputCapture { + let down = self.pressed[direction.index()].expect("released tap must be pressed"); + InputCapture { + instant: down.instant + Duration::from_millis(WHEEL_TAP_MS), + input_ts_ms: down.input_ts_ms.map(|ts| ts + WHEEL_TAP_MS as f64), + } + } + + /// 미발행 이동의 trailing flush 시한. 첫 이동은 on_input에서 즉시 발행하므로 last_emit 기준만 + fn axis_flush_deadline(&self, axis: WheelAxis) -> Option { + let state = &self.axes[axis.index()]; + if state.target.round() as i64 == state.emitted { + return None; + } + state + .last_emit + .map(|last| last + Duration::from_millis(AXIS_THROTTLE_MS)) + } + + fn pulse( + &mut self, + direction: WheelDirection, + at: InputCapture, + outputs: &mut Vec, + ) { + for held in [direction.opposite(), direction] { + if self.pressed[held.index()].is_some() { + self.release(held, at, outputs); + } + } + let labels = + self.hold_tracker + .press(direction, at.instant, vec![direction.label().to_string()]); + self.pressed[direction.index()] = Some(at); + outputs.push(hook_output(direction, labels, HookKeyState::Down, None, at)); + } + + fn release( + &mut self, + direction: WheelDirection, + at: InputCapture, + outputs: &mut Vec, + ) { + self.pressed[direction.index()] = None; + let release = + self.hold_tracker + .release(direction, at.instant, vec![direction.label().to_string()]); + if self.last_edge.is_none_or(|last| last.instant < at.instant) { + self.last_edge = Some(at); + } + outputs.push(hook_output( + direction, + release.labels, + HookKeyState::Up, + release.hold_duration_ms, + at, + )); + } + + /// 이동 전 기준값 → full/4 이하 단계로 이동값 발행 + fn flush_axis(&mut self, axis: WheelAxis, now: Instant, outputs: &mut Vec) { + let state = &mut self.axes[axis.index()]; + let target = state.target.round() as i64; + let mut remaining = target - state.emitted; + if remaining == 0 { + return; + } + outputs.push(axis_output(axis, state.emitted)); + while remaining != 0 { + let step = remaining.clamp(-WHEEL_AXIS_MAX_STEP, WHEEL_AXIS_MAX_STEP); + state.emitted += step; + remaining -= step; + outputs.push(axis_output(axis, state.emitted)); + } + state.last_emit = Some(now); + } +} + +fn hook_output( + direction: WheelDirection, + labels: Vec, + state: HookKeyState, + hold_duration_ms: Option, + at: InputCapture, +) -> DaemonOutput { + DaemonOutput::Hook(HookMessage { + device: InputDeviceKind::Mouse, + labels, + state, + physical_id: Some(direction.physical_id().to_string()), + vk_code: None, + scan_code: None, + flags: None, + hold_duration_ms, + input_ts_ms: at.input_ts_ms, + }) +} + +fn axis_output(axis: WheelAxis, position: i64) -> DaemonOutput { + DaemonOutput::Axis(HidAxisMessage { + axis_id: axis.axis_id().to_string(), + value: position.rem_euclid(i64::from(WHEEL_AXIS_FULL)) as u32, + full: WHEEL_AXIS_FULL, + }) +} + +/// 어댑터 → 엔진 스레드 입력 채널 +#[derive(Clone)] +pub(super) struct WheelSender(Sender<(InputCapture, WheelSample)>); + +impl WheelSender { + pub(super) fn send(&self, captured: InputCapture, sample: WheelSample) { + if self.0.send((captured, sample)).is_err() { + eprintln!("keyboard daemon wheel channel closed"); + std::process::exit(1); + } + } +} + +/// 데몬당 하나. 입력 채널이 모두 닫히면 남은 탭을 해제하고 종료 +pub(super) fn spawn_wheel_engine(output: OutputSender) -> io::Result { + let (sender, receiver) = mpsc::channel::<(InputCapture, WheelSample)>(); + thread::Builder::new() + .name("keyboard-wheel-engine".into()) + .spawn(move || { + let mut engine = WheelEngine::default(); + loop { + let received = match engine.next_deadline() { + Some(deadline) => { + receiver.recv_timeout(deadline.saturating_duration_since(Instant::now())) + } + None => receiver.recv().map_err(|_| RecvTimeoutError::Disconnected), + }; + let (outputs, closed) = match received { + Ok((captured, sample)) => (engine.on_input(captured, sample), false), + Err(RecvTimeoutError::Timeout) => (engine.on_tick(Instant::now()), false), + Err(RecvTimeoutError::Disconnected) => { + (engine.shutdown(InputCapture::now()), true) + } + }; + for item in outputs { + output.send(item); + } + if closed { + break; + } + } + })?; + Ok(WheelSender(sender)) +} + +#[cfg(test)] +mod tests { + use std::{ + sync::mpsc, + time::{Duration, Instant}, + }; + + use super::{ + effective_multiplier, normalize, spawn_wheel_engine, windows_wheel_sample, + HidResolutionMultiplier, WheelAxis, WheelEngine, WheelSample, HID_PAGE_CONSUMER, + HID_PAGE_GENERIC_DESKTOP, HID_USAGE_AC_PAN, HID_USAGE_WHEEL, RI_MOUSE_HWHEEL, + RI_MOUSE_WHEEL, WHEEL_AXIS_FULL, WHEEL_AXIS_MAX_STEP, + }; + use crate::ipc::{HookKeyState, InputDeviceKind}; + use crate::keyboard::daemon::{DaemonOutput, InputCapture, OutputSender}; + + #[derive(Debug, PartialEq)] + enum Out { + Down(String), + Up(String, f64), + Axis(String, u32), + } + + fn down(label: &str) -> Out { + Out::Down(label.to_string()) + } + + fn up(label: &str, hold_ms: f64) -> Out { + Out::Up(label.to_string(), hold_ms) + } + + fn axis(axis_id: &str, value: u32) -> Out { + Out::Axis(axis_id.to_string(), value) + } + + fn simplify(outputs: Vec) -> Vec { + outputs + .into_iter() + .map(|output| match output { + DaemonOutput::Hook(message) => { + assert_eq!(message.device, InputDeviceKind::Mouse); + assert_eq!(message.labels.len(), 1); + let label = message.labels[0].clone(); + match message.state { + HookKeyState::Down => { + assert_eq!(message.hold_duration_ms, None); + Out::Down(label) + } + HookKeyState::Up => Out::Up(label, message.hold_duration_ms.unwrap()), + } + } + DaemonOutput::Axis(message) => { + assert_eq!(message.full, WHEEL_AXIS_FULL); + Out::Axis(message.axis_id, message.value) + } + DaemonOutput::Command(command) => panic!("unexpected command {command:?}"), + }) + .collect() + } + + fn keys_only(outputs: Vec) -> Vec { + simplify(outputs) + .into_iter() + .filter(|out| !matches!(out, Out::Axis(..))) + .collect() + } + + fn axis_only(outputs: Vec) -> Vec { + simplify(outputs) + .into_iter() + .filter(|out| matches!(out, Out::Axis(..))) + .collect() + } + + struct Clock { + base: Instant, + } + + impl Clock { + fn new() -> Self { + Self { + base: Instant::now(), + } + } + + fn at(&self, ms: u64) -> InputCapture { + InputCapture { + instant: self.base + Duration::from_millis(ms), + input_ts_ms: Some(1_000.0 + ms as f64), + } + } + + fn instant(&self, ms: u64) -> Instant { + self.base + Duration::from_millis(ms) + } + } + + fn vertical(units: i16) -> WheelSample { + WheelSample::WindowsRawMouse { + button_flags: RI_MOUSE_WHEEL, + button_data: units as u16, + } + } + + fn horizontal(units: i16) -> WheelSample { + WheelSample::WindowsRawMouse { + button_flags: RI_MOUSE_HWHEEL, + button_data: units as u16, + } + } + + fn mac_wheel(value: i64, multiplier: Option) -> WheelSample { + WheelSample::MacHidValue { + usage_page: HID_PAGE_GENERIC_DESKTOP, + usage: HID_USAGE_WHEEL, + value, + multiplier, + } + } + + fn feature(value: i64, physical_max: i64) -> HidResolutionMultiplier { + HidResolutionMultiplier { + value, + logical_min: 0, + logical_max: 1, + physical_min: 1, + physical_max, + unit_exponent: 0, + } + } + + #[test] + fn windows_wheel_flags_map_to_signed_axis_units() { + assert_eq!(normalize(vertical(120)), Some((WheelAxis::Vertical, 120.0))); + assert_eq!( + normalize(vertical(-120)), + Some((WheelAxis::Vertical, -120.0)) + ); + assert_eq!( + normalize(horizontal(240)), + Some((WheelAxis::Horizontal, 240.0)) + ); + assert_eq!( + normalize(horizontal(-15)), + Some((WheelAxis::Horizontal, -15.0)) + ); + // 버튼만 있는 보고서는 휠 아님 + assert_eq!( + normalize(WheelSample::WindowsRawMouse { + button_flags: 0x0001, + button_data: 0, + }), + None + ); + } + + #[test] + fn windows_sample_is_created_only_for_wheel_reports() { + assert!(windows_wheel_sample(0x0001, 0).is_none()); + assert!(windows_wheel_sample(0, 0).is_none()); + assert!(windows_wheel_sample(RI_MOUSE_WHEEL, 120).is_some()); + assert!(windows_wheel_sample(RI_MOUSE_HWHEEL | 0x0001, 120).is_some()); + } + + #[test] + fn mac_hid_wheel_and_pan_map_to_notch_units() { + assert_eq!( + normalize(mac_wheel(1, None)), + Some((WheelAxis::Vertical, 120.0)) + ); + assert_eq!( + normalize(mac_wheel(-2, None)), + Some((WheelAxis::Vertical, -240.0)) + ); + assert_eq!( + normalize(WheelSample::MacHidValue { + usage_page: HID_PAGE_CONSUMER, + usage: HID_USAGE_AC_PAN, + value: 1, + multiplier: None, + }), + Some((WheelAxis::Horizontal, 120.0)) + ); + assert_eq!(normalize(mac_wheel(0, None)), None); + // X축 등 다른 요소는 무시 + assert_eq!( + normalize(WheelSample::MacHidValue { + usage_page: HID_PAGE_GENERIC_DESKTOP, + usage: 0x30, + value: 5, + multiplier: None, + }), + None + ); + } + + #[test] + fn resolution_multiplier_follows_current_feature_value() { + assert_eq!(effective_multiplier(None), 1.0); + // 초기값(논리 최소)은 물리 최소 배율 + assert_eq!(effective_multiplier(Some(feature(0, 8))), 1.0); + assert_eq!(effective_multiplier(Some(feature(1, 8))), 8.0); + assert_eq!(effective_multiplier(Some(feature(1, 16))), 16.0); + // 물리 범위 미지정이면 논리 범위 + assert_eq!( + effective_multiplier(Some(HidResolutionMultiplier { + value: 4, + logical_min: 1, + logical_max: 8, + physical_min: 0, + physical_max: 0, + unit_exponent: 0, + })), + 4.0 + ); + // 4비트 음수 지수 (0xF = -1) + assert_eq!( + effective_multiplier(Some(HidResolutionMultiplier { + value: 1, + logical_min: 0, + logical_max: 1, + physical_min: 10, + physical_max: 80, + unit_exponent: 0xF, + })), + 8.0 + ); + // 음수 배율은 방향 반전 (HUT Resolution Multiplier) + assert_eq!( + effective_multiplier(Some(HidResolutionMultiplier { + value: 0, + logical_min: 0, + logical_max: 1, + physical_min: -8, + physical_max: -1, + unit_exponent: 0, + })), + -8.0 + ); + // 비정상 범위는 기본 배율 + assert_eq!( + effective_multiplier(Some(HidResolutionMultiplier { + value: 1, + logical_min: 1, + logical_max: 1, + physical_min: 1, + physical_max: 8, + unit_exponent: 0, + })), + 1.0 + ); + } + + #[test] + fn fractional_high_resolution_counts_keep_exact_notches() { + let clock = Clock::new(); + let mut engine = WheelEngine::default(); + let multiplier = Some(feature(1, 16)); + + let mut downs = 0; + for ms in 0..32 { + downs += keys_only(engine.on_input(clock.at(ms * 100), mac_wheel(1, multiplier))) + .iter() + .filter(|out| matches!(out, Out::Down(_))) + .count(); + } + assert_eq!(downs, 2); + } + + #[test] + fn negative_multiplier_inverts_direction_and_scales_notches() { + let clock = Clock::new(); + let mut engine = WheelEngine::default(); + let inverted = Some(HidResolutionMultiplier { + value: 0, + logical_min: 0, + logical_max: 1, + physical_min: -8, + physical_max: -1, + unit_exponent: 0, + }); + + assert_eq!( + keys_only(engine.on_input(clock.at(0), mac_wheel(-8, inverted))), + vec![down("WHEELUP")] + ); + } + + #[test] + fn one_notch_is_a_tap_released_after_tap_duration() { + let clock = Clock::new(); + let mut engine = WheelEngine::default(); + + assert_eq!( + keys_only(engine.on_input(clock.at(0), vertical(120))), + vec![down("WHEELUP")] + ); + assert_eq!(engine.next_deadline(), Some(clock.instant(50))); + assert!(keys_only(engine.on_tick(clock.instant(49))).is_empty()); + assert_eq!( + keys_only(engine.on_tick(clock.instant(50))), + vec![up("WHEELUP", 50.0)] + ); + assert_eq!(engine.next_deadline(), None); + } + + #[test] + fn deadline_up_uses_logical_edge_timestamp() { + let clock = Clock::new(); + let mut engine = WheelEngine::default(); + engine.on_input(clock.at(0), vertical(120)); + + let outputs = engine.on_tick(clock.instant(80)); + let up = outputs + .iter() + .find_map(|output| match output { + DaemonOutput::Hook(message) if message.state == HookKeyState::Up => Some(message), + _ => None, + }) + .unwrap(); + assert_eq!(up.input_ts_ms, Some(1_050.0)); + assert_eq!(up.hold_duration_ms, Some(50.0)); + assert_eq!(up.physical_id.as_deref(), Some("mouse:wheel:up")); + } + + #[test] + fn high_resolution_deltas_accumulate_to_one_notch() { + let clock = Clock::new(); + let mut engine = WheelEngine::default(); + + for ms in 0..3 { + assert!(keys_only(engine.on_input(clock.at(ms), vertical(30))).is_empty()); + } + assert_eq!( + keys_only(engine.on_input(clock.at(3), vertical(30))), + vec![down("WHEELUP")] + ); + } + + #[test] + fn residual_survives_long_pause() { + let clock = Clock::new(); + let mut engine = WheelEngine::default(); + + assert!(keys_only(engine.on_input(clock.at(0), vertical(60))).is_empty()); + assert_eq!( + keys_only(engine.on_input(clock.at(2_000), vertical(60))), + vec![down("WHEELUP")] + ); + } + + #[test] + fn residual_is_signed_across_direction_changes() { + let clock = Clock::new(); + let mut engine = WheelEngine::default(); + + assert!(keys_only(engine.on_input(clock.at(0), vertical(60))).is_empty()); + assert!(keys_only(engine.on_input(clock.at(10), vertical(-60))).is_empty()); + assert!(keys_only(engine.on_input(clock.at(20), vertical(-60))).is_empty()); + assert_eq!( + keys_only(engine.on_input(clock.at(30), vertical(-60))), + vec![down("WHEELDOWN")] + ); + } + + #[test] + fn multi_notch_report_keeps_every_notch_with_up_before_down() { + let clock = Clock::new(); + let mut engine = WheelEngine::default(); + + assert_eq!( + keys_only(engine.on_input(clock.at(0), vertical(-360))), + vec![ + down("WHEELDOWN"), + up("WHEELDOWN", 0.0), + down("WHEELDOWN"), + up("WHEELDOWN", 0.0), + down("WHEELDOWN"), + ] + ); + } + + #[test] + fn next_notch_before_deadline_releases_then_presses() { + let clock = Clock::new(); + let mut engine = WheelEngine::default(); + engine.on_input(clock.at(0), vertical(120)); + + assert_eq!( + keys_only(engine.on_input(clock.at(10), vertical(120))), + vec![up("WHEELUP", 10.0), down("WHEELUP")] + ); + assert_eq!(engine.next_deadline(), Some(clock.instant(12))); + engine.on_tick(clock.instant(12)); + assert_eq!(engine.next_deadline(), Some(clock.instant(60))); + } + + #[test] + fn expired_tap_is_released_before_late_input() { + let clock = Clock::new(); + let mut engine = WheelEngine::default(); + engine.on_input(clock.at(0), vertical(120)); + + // 시한 tick 없이 다음 입력이 와도 시한 Up이 먼저 + assert_eq!( + keys_only(engine.on_input(clock.at(80), vertical(120))), + vec![up("WHEELUP", 50.0), down("WHEELUP")] + ); + } + + #[test] + fn stale_capture_time_does_not_run_backwards() { + let clock = Clock::new(); + let mut engine = WheelEngine::default(); + engine.on_input(clock.at(0), vertical(120)); + engine.on_tick(clock.instant(50)); + + // 시한 Up(50ms) 뒤에 더 이른 캡처 시각의 입력이 도착 + let outputs = engine.on_input(clock.at(40), vertical(120)); + let down = outputs + .iter() + .find_map(|output| match output { + DaemonOutput::Hook(message) if message.state == HookKeyState::Down => Some(message), + _ => None, + }) + .unwrap(); + assert_eq!(down.input_ts_ms, Some(1_050.0)); + } + + #[test] + fn reversing_releases_opposite_direction_first() { + let clock = Clock::new(); + let mut engine = WheelEngine::default(); + engine.on_input(clock.at(0), vertical(120)); + + assert_eq!( + keys_only(engine.on_input(clock.at(20), vertical(-120))), + vec![up("WHEELUP", 20.0), down("WHEELDOWN")] + ); + } + + #[test] + fn horizontal_axis_uses_left_and_right_labels() { + let clock = Clock::new(); + let mut engine = WheelEngine::default(); + + assert_eq!( + keys_only(engine.on_input(clock.at(0), horizontal(120))), + vec![down("WHEELRIGHT")] + ); + assert_eq!( + keys_only(engine.on_input(clock.at(100), horizontal(-120))), + vec![up("WHEELRIGHT", 50.0), down("WHEELLEFT")] + ); + } + + #[test] + fn shutdown_releases_pressed_taps() { + let clock = Clock::new(); + let mut engine = WheelEngine::default(); + engine.on_input(clock.at(0), vertical(120)); + engine.on_input(clock.at(1), horizontal(-120)); + + assert_eq!( + keys_only(engine.shutdown(clock.at(5))), + vec![up("WHEELUP", 5.0), up("WHEELLEFT", 4.0)] + ); + assert_eq!(engine.next_deadline(), None); + } + + #[test] + fn axis_batch_sends_baseline_before_moved_value() { + let clock = Clock::new(); + let mut engine = WheelEngine::default(); + + assert_eq!( + axis_only(engine.on_input(clock.at(0), vertical(120))), + vec![axis("WHEEL:VERTICAL", 0), axis("WHEEL:VERTICAL", 120)] + ); + assert_eq!( + axis_only(engine.on_input(clock.at(1_000), vertical(-240))), + vec![axis("WHEEL:VERTICAL", 120), axis("WHEEL:VERTICAL", 2_760)] + ); + } + + #[test] + fn axis_is_throttled_with_trailing_flush() { + let clock = Clock::new(); + let mut engine = WheelEngine::default(); + engine.on_input(clock.at(0), vertical(120)); + + assert!(axis_only(engine.on_input(clock.at(5), vertical(120))).is_empty()); + assert_eq!(engine.next_deadline(), Some(clock.instant(12))); + assert_eq!( + axis_only(engine.on_tick(clock.instant(12))), + vec![axis("WHEEL:VERTICAL", 120), axis("WHEEL:VERTICAL", 240)] + ); + } + + // axisEventBus.ts의 wrap 최단경로 복원 공식 + fn shortest_delta(last: u32, value: u32, full: u32) -> i64 { + let (last, value, full) = (i64::from(last), i64::from(value), i64::from(full)); + let half = full / 2; + ((value - last + half) % full + full) % full - half + } + + #[test] + fn large_axis_moves_are_split_to_stay_recoverable() { + let clock = Clock::new(); + + for units in [3_000_i16, -7_000] { + let mut engine = WheelEngine::default(); + let values: Vec = axis_only(engine.on_input(clock.at(0), vertical(units))) + .into_iter() + .map(|out| match out { + Out::Axis(_, value) => value, + other => panic!("{other:?}"), + }) + .collect(); + let recovered: i64 = values + .windows(2) + .map(|pair| { + let delta = shortest_delta(pair[0], pair[1], WHEEL_AXIS_FULL); + assert!(delta.abs() <= WHEEL_AXIS_MAX_STEP); + delta + }) + .sum(); + assert_eq!(recovered, i64::from(units)); + } + } + + #[test] + fn worker_emits_tap_and_release_through_output_channel() { + let (sender, receiver) = mpsc::channel(); + let wheel = spawn_wheel_engine(OutputSender(sender)).unwrap(); + wheel.send(InputCapture::now(), vertical(120)); + + let mut keys = Vec::new(); + while keys.len() < 2 { + match receiver.recv_timeout(Duration::from_secs(1)).unwrap() { + DaemonOutput::Hook(message) => keys.push(message.state), + DaemonOutput::Axis(_) => {} + DaemonOutput::Command(command) => panic!("unexpected command {command:?}"), + } + } + assert_eq!(keys, vec![HookKeyState::Down, HookKeyState::Up]); + } + + #[test] + fn worker_releases_pressed_tap_when_input_closes() { + let (sender, receiver) = mpsc::channel(); + let wheel = spawn_wheel_engine(OutputSender(sender)).unwrap(); + wheel.send(InputCapture::now(), vertical(-120)); + drop(wheel); + + let states: Vec = receiver + .iter() + .filter_map(|output| match output { + DaemonOutput::Hook(message) => Some(message.state), + _ => None, + }) + .collect(); + assert_eq!(states, vec![HookKeyState::Down, HookKeyState::Up]); + } +} diff --git a/src-tauri/src/keyboard/daemon/windows.rs b/src-tauri/src/keyboard/daemon/windows.rs index 436c96412..d6de3b0d1 100644 --- a/src-tauri/src/keyboard/daemon/windows.rs +++ b/src-tauri/src/keyboard/daemon/windows.rs @@ -213,6 +213,7 @@ pub(super) fn run_raw_input() -> Result<()> { Err(_) => Box::new(std::io::stdout()), }; let output = super::start_output_writer(sink)?; + let wheel = super::wheel::spawn_wheel_engine(output.clone())?; // 글로벌 단축키 상태 추적기 let hotkeys = super::load_hotkeys_from_env(); @@ -233,9 +234,6 @@ pub(super) fn run_raw_input() -> Result<()> { const RI_MOUSE_BUTTON_4_UP: u16 = 0x0080; const RI_MOUSE_BUTTON_5_DOWN: u16 = 0x0100; const RI_MOUSE_BUTTON_5_UP: u16 = 0x0200; - // 휠 상수 — 완전성을 위해 유지하나 미사용 (휠 이벤트 비활성화) - const _RI_MOUSE_WHEEL: u16 = 0x0400; - const _RI_MOUSE_HWHEEL: u16 = 0x0800; unsafe extern "system" fn wndproc( hwnd: HWND, @@ -542,6 +540,12 @@ pub(super) fn run_raw_input() -> Result<()> { t if t == RIM_TYPEMOUSE.0 => { let mouse = raw.data.mouse; let button_flags = mouse.Anonymous.Anonymous.usButtonFlags; + if let Some(sample) = super::wheel::windows_wheel_sample( + button_flags, + mouse.Anonymous.Anonymous.usButtonData, + ) { + wheel.send(captured, sample); + } let mut events: Vec<(u8, String, HookKeyState)> = Vec::new(); let mut push = |button: u8, label: &str, state: HookKeyState| { diff --git a/src-tauri/src/keyboard/daemon/windows_hid.rs b/src-tauri/src/keyboard/daemon/windows_hid.rs index 6ea60fd76..e8a89b4c9 100644 --- a/src-tauri/src/keyboard/daemon/windows_hid.rs +++ b/src-tauri/src/keyboard/daemon/windows_hid.rs @@ -24,9 +24,6 @@ use windows::Win32::UI::Input::{ use crate::ipc::{HidAxisMessage, HookKeyState, HookMessage, InputDeviceKind}; -/// 축 값 전송 최소 간격(ms) — 고빈도 노브 입력이 파이프/입력 스레드를 막지 않도록. -const AXIS_THROTTLE_MS: u64 = 12; - /// 디바이스별 정적 정보 (preparsed data + caps 캐시) struct DeviceCaps { preparsed: Vec, @@ -247,7 +244,7 @@ impl HidProcessor { for (page, usage, value, full) in axis_values { let th = state.axis.entry((page, usage)).or_default(); let changed = !th.sent || th.last_value != value; - if changed && now.saturating_sub(th.last_emit_ms) >= AXIS_THROTTLE_MS { + if changed && now.saturating_sub(th.last_emit_ms) >= super::AXIS_THROTTLE_MS { th.last_value = value; th.last_emit_ms = now; th.sent = true; From 38fd839bade3526275e40f9494f3641c11ff0490 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Wed, 7 Oct 2026 20:14:11 +0900 Subject: [PATCH 2/4] =?UTF-8?q?feat:=20=ED=9C=A0=20=ED=82=A4=20=EB=9D=BC?= =?UTF-8?q?=EB=B2=A8=EA=B3=BC=20=EB=85=B8=EB=B8=8C=20=ED=9C=A0=20=EC=B6=95?= =?UTF-8?q?=20=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../single/SingleKnobPanel.test.tsx | 16 +++++++++++++++ .../single/SingleKnobPanel.tsx | 14 ++++++++++--- src/renderer/hooks/useKeySlotCapture.ts | 6 ++++++ src/renderer/locales/en.json | 2 ++ src/renderer/locales/ko.json | 2 ++ src/renderer/locales/ru.json | 2 ++ src/renderer/locales/zh-Hant.json | 2 ++ src/renderer/locales/zh-cn.json | 2 ++ src/renderer/utils/input/KeyMaps.ts | 20 +++++++++++++++++++ 9 files changed, 63 insertions(+), 3 deletions(-) diff --git a/src/renderer/components/main/Grid/PropertiesPanel/single/SingleKnobPanel.test.tsx b/src/renderer/components/main/Grid/PropertiesPanel/single/SingleKnobPanel.test.tsx index bc24923c7..211e2b461 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/single/SingleKnobPanel.test.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/single/SingleKnobPanel.test.tsx @@ -102,6 +102,22 @@ describe('SingleKnobPanel 대상 전환 세션 정리', () => { }); }; + it('휠 가상 축은 로케일 이름, HID 축은 접두어를 뗀 ID로 표시한다', () => { + const labelOf = (axisId: string) => + container.querySelector(`button[title="${axisId}"]`)?.textContent; + + render({ ...knobPosition, axisId: 'WHEEL:VERTICAL' }); + expect(labelOf('WHEEL:VERTICAL')).toBe( + 'propertiesPanel.knobAxisWheelVertical', + ); + render({ ...knobPosition, axisId: 'WHEEL:HORIZONTAL' }); + expect(labelOf('WHEEL:HORIZONTAL')).toBe( + 'propertiesPanel.knobAxisWheelHorizontal', + ); + render({ ...knobPosition, axisId: 'HIDA:1ccf:101c:1:48' }); + expect(labelOf('HIDA:1ccf:101c:1:48')).toBe('1ccf:101c:1:48'); + }); + it('stable capture는 selection과 index가 바뀌어도 시작 ID에 완료한다', () => { act(() => captureButton().click()); render({ diff --git a/src/renderer/components/main/Grid/PropertiesPanel/single/SingleKnobPanel.tsx b/src/renderer/components/main/Grid/PropertiesPanel/single/SingleKnobPanel.tsx index a15095bab..6ee6ef005 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/single/SingleKnobPanel.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/single/SingleKnobPanel.tsx @@ -180,9 +180,17 @@ export const SingleKnobPanel: React.FC = ({ }; const knobTitle = singleKnobPosition.layerName || 'Knob'; - const axisLabel = singleKnobPosition.axisId - ? singleKnobPosition.axisId.replace(/^HIDA:/, '') - : t('propertiesPanel.knobAxisUnset') || '미지정'; + const axisLabel = (() => { + const { axisId } = singleKnobPosition; + if (!axisId) return t('propertiesPanel.knobAxisUnset') || '미지정'; + if (axisId === 'WHEEL:VERTICAL') { + return t('propertiesPanel.knobAxisWheelVertical'); + } + if (axisId === 'WHEEL:HORIZONTAL') { + return t('propertiesPanel.knobAxisWheelHorizontal'); + } + return axisId.replace(/^HIDA:/, ''); + })(); // 그림자·이미지 피커의 캔버스 상태 프리뷰 대상 (색 피커는 세션 훅이 발행) const knobPreviewAnchor: EditStateAnchor | null = diff --git a/src/renderer/hooks/useKeySlotCapture.ts b/src/renderer/hooks/useKeySlotCapture.ts index b0422fb2e..266b3e7dc 100644 --- a/src/renderer/hooks/useKeySlotCapture.ts +++ b/src/renderer/hooks/useKeySlotCapture.ts @@ -97,6 +97,11 @@ export function useKeySlotCapture({ window.addEventListener('keyup', blockKeyboardEvents, true); window.addEventListener('keypress', blockKeyboardEvents, true); window.addEventListener('mousedown', blockMouseEvents, true); + // 휠 할당 중 패널 스크롤 방지 + window.addEventListener('wheel', blockMouseEvents, { + capture: true, + passive: false, + }); window.addEventListener('contextmenu', blockContextMenu, true); return () => { @@ -104,6 +109,7 @@ export function useKeySlotCapture({ window.removeEventListener('keyup', blockKeyboardEvents, true); window.removeEventListener('keypress', blockKeyboardEvents, true); window.removeEventListener('mousedown', blockMouseEvents, true); + window.removeEventListener('wheel', blockMouseEvents, true); window.removeEventListener('contextmenu', blockContextMenu, true); }; }, [isListening, escapeCancels]); diff --git a/src/renderer/locales/en.json b/src/renderer/locales/en.json index 24f679df8..c3baddea8 100644 --- a/src/renderer/locales/en.json +++ b/src/renderer/locales/en.json @@ -565,6 +565,8 @@ "graphShowAverageLine": "Show Average Line", "knobAxis": "Knob Mapping", "knobAxisUnset": "Unset", + "knobAxisWheelVertical": "Mouse Wheel", + "knobAxisWheelHorizontal": "Mouse Horizontal Wheel", "knobCapture": "Click to set", "knobCapturing": "Turn the knob", "knobSensitivity": "Sensitivity", diff --git a/src/renderer/locales/ko.json b/src/renderer/locales/ko.json index 1869a2076..868ef0bbc 100644 --- a/src/renderer/locales/ko.json +++ b/src/renderer/locales/ko.json @@ -565,6 +565,8 @@ "graphShowAverageLine": "평균선 표시", "knobAxis": "노브 매핑", "knobAxisUnset": "미지정", + "knobAxisWheelVertical": "마우스 휠", + "knobAxisWheelHorizontal": "마우스 가로 휠", "knobCapture": "클릭하여 설정", "knobCapturing": "노브를 돌리세요", "knobSensitivity": "민감도", diff --git a/src/renderer/locales/ru.json b/src/renderer/locales/ru.json index 8e15941e6..ba36a31a6 100644 --- a/src/renderer/locales/ru.json +++ b/src/renderer/locales/ru.json @@ -570,6 +570,8 @@ "clickToSet": "Нажмите", "knobAxis": "Назначение ручки", "knobAxisUnset": "Не задано", + "knobAxisWheelVertical": "Колесо мыши", + "knobAxisWheelHorizontal": "Горизонтальное колесо мыши", "knobCapture": "Нажмите", "knobCapturing": "Поверните ручку", "knobSensitivity": "Чувствительность", diff --git a/src/renderer/locales/zh-Hant.json b/src/renderer/locales/zh-Hant.json index 5c4e4834f..10e190cf6 100644 --- a/src/renderer/locales/zh-Hant.json +++ b/src/renderer/locales/zh-Hant.json @@ -570,6 +570,8 @@ "clickToSet": "單擊設定", "knobAxis": "旋鈕對應", "knobAxisUnset": "未設定", + "knobAxisWheelVertical": "滑鼠滾輪", + "knobAxisWheelHorizontal": "滑鼠水平滾輪", "knobCapture": "單擊設定", "knobCapturing": "轉動旋鈕", "knobSensitivity": "靈敏度", diff --git a/src/renderer/locales/zh-cn.json b/src/renderer/locales/zh-cn.json index 1a9f279c3..57049c423 100644 --- a/src/renderer/locales/zh-cn.json +++ b/src/renderer/locales/zh-cn.json @@ -570,6 +570,8 @@ "clickToSet": "点击设置", "knobAxis": "旋钮映射", "knobAxisUnset": "未设置", + "knobAxisWheelVertical": "鼠标滚轮", + "knobAxisWheelHorizontal": "鼠标水平滚轮", "knobCapture": "点击设置", "knobCapturing": "转动旋钮", "knobSensitivity": "灵敏度", diff --git a/src/renderer/utils/input/KeyMaps.ts b/src/renderer/utils/input/KeyMaps.ts index 6e693788c..c18aad0b3 100644 --- a/src/renderer/utils/input/KeyMaps.ts +++ b/src/renderer/utils/input/KeyMaps.ts @@ -225,6 +225,26 @@ const extraGlobalKeyMap: Record = { }, MOUSE4: { browserKey: 'Mouse4', globalKey: 'MOUSE4', displayName: 'Mouse 4' }, MOUSE5: { browserKey: 'Mouse5', globalKey: 'MOUSE5', displayName: 'Mouse 5' }, + WHEELUP: { + browserKey: 'WheelUp', + globalKey: 'WHEELUP', + displayName: 'Wheel Up', + }, + WHEELDOWN: { + browserKey: 'WheelDown', + globalKey: 'WHEELDOWN', + displayName: 'Wheel Down', + }, + WHEELLEFT: { + browserKey: 'WheelLeft', + globalKey: 'WHEELLEFT', + displayName: 'Wheel Left', + }, + WHEELRIGHT: { + browserKey: 'WheelRight', + globalKey: 'WHEELRIGHT', + displayName: 'Wheel Right', + }, GP_A: { browserKey: 'GamepadA', globalKey: 'GP_A', displayName: 'Pad A' }, GP_B: { browserKey: 'GamepadB', globalKey: 'GP_B', displayName: 'Pad B' }, GP_X: { browserKey: 'GamepadX', globalKey: 'GP_X', displayName: 'Pad X' }, From 01c9495238397081e86c73789ac9ee1c54f1c931 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Wed, 7 Oct 2026 20:14:11 +0900 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20=EA=B0=99=EC=9D=80=20=EC=8B=9C?= =?UTF-8?q?=EA=B0=81=20=EC=97=B0=EC=86=8D=20=EB=85=B8=ED=8A=B8=20id=20?= =?UTF-8?q?=EC=B6=A9=EB=8F=8C=EA=B3=BC=20=EC=B6=95=20=EA=B8=B0=EC=A4=80?= =?UTF-8?q?=EA=B0=92=20=EC=9E=AC=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hooks/overlay/useNoteSystem.test.tsx | 26 +++++++++ src/renderer/hooks/overlay/useNoteSystem.ts | 5 +- .../overlay/useOverlayKeyStateRuntime.ts | 11 +++- src/renderer/utils/input/axisEventBus.test.ts | 57 +++++++++++++++++++ src/renderer/utils/input/axisEventBus.ts | 5 ++ .../overlay/App.keyDelayTimers.test.tsx | 2 +- .../windows/overlay/App.nativeWindow.test.tsx | 2 +- .../overlay/App.spritePreview.test.tsx | 2 +- src/renderer/windows/overlay/App.test.tsx | 23 +++++++- 9 files changed, 127 insertions(+), 6 deletions(-) create mode 100644 src/renderer/utils/input/axisEventBus.test.ts diff --git a/src/renderer/hooks/overlay/useNoteSystem.test.tsx b/src/renderer/hooks/overlay/useNoteSystem.test.tsx index 4e9ac3b68..24e93e785 100644 --- a/src/renderer/hooks/overlay/useNoteSystem.test.tsx +++ b/src/renderer/hooks/overlay/useNoteSystem.test.tsx @@ -368,6 +368,32 @@ describe('useNoteSystem 길이 연속성', () => { // holdDurationMs와 무관하게 도착(보정) 시각으로 종료 expect(note!.endTime).toBe(60); }); + + it('같은 키·같은 시작 시각의 연속 노트도 서로 다른 id로 각자 종료한다', async () => { + await render({ ...DELAY_SETTINGS, delayedNoteEnabled: false }); + + // 휠 한 보고서에 두 칸: Down·Up·Down이 같은 시각 + result.handleKeyDown('W', { displayTime: 10, physTime: 10 }); + result.handleKeyUp('W', { + displayTime: 10, + physTime: 10, + holdDurationMs: 0, + }); + result.handleKeyDown('W', { displayTime: 10, physTime: 10 }); + await advance(50); + result.handleKeyUp('W', { + displayTime: 60, + physTime: 60, + holdDurationMs: 50, + }); + + const first = noteOf('W', 0)!; + const second = noteOf('W', 1)!; + expect(first.id).not.toBe(second.id); + expect(first.endTime).toBe(10); + expect(second.startTime).toBe(10); + expect(second.endTime).toBe(60); + }); }); describe('useNoteSystem 반환 API 안정성 (#111)', () => { diff --git a/src/renderer/hooks/overlay/useNoteSystem.ts b/src/renderer/hooks/overlay/useNoteSystem.ts index f43c76006..2385ca508 100644 --- a/src/renderer/hooks/overlay/useNoteSystem.ts +++ b/src/renderer/hooks/overlay/useNoteSystem.ts @@ -189,6 +189,8 @@ export function useNoteSystem({ const subscribers = useRef>(new Set()); const notePoolRef = useRef([]); const noteLookupRef = useRef>(new Map()); + // 같은 키·같은 시작 시각의 연속 노트(휠 다중 칸)도 id가 겹치지 않도록 + const noteSeqRef = useRef(0); const noteBufferRef = useRef(createNoteBuffer()); const finalizeTimersRef = useRef>>( new Map(), @@ -432,7 +434,8 @@ export function useNoteSystem({ const createNote = (keyName: string, startTimeOverride?: number): string => { const startTime = startTimeOverride ?? performance.now(); - const noteId = `${keyName}_${startTime}`; + noteSeqRef.current += 1; + const noteId = `${keyName}_${startTime}_${noteSeqRef.current}`; const currentNotes = notesRef.current; let keyNotes = currentNotes[keyName]; if (!keyNotes) { diff --git a/src/renderer/hooks/overlay/useOverlayKeyStateRuntime.ts b/src/renderer/hooks/overlay/useOverlayKeyStateRuntime.ts index 98acf88a2..1e9d2b6ec 100644 --- a/src/renderer/hooks/overlay/useOverlayKeyStateRuntime.ts +++ b/src/renderer/hooks/overlay/useOverlayKeyStateRuntime.ts @@ -182,9 +182,16 @@ export const useOverlayKeyStateRuntime = ({ }; // HID 축 이벤트 버스 초기화 (input:axis 구독 → axisSignals 누적) - import('@utils/input/axisEventBus').then(({ axisEventBus }) => { + const axisEventBusModule = import('@utils/input/axisEventBus'); + void axisEventBusModule.then(({ axisEventBus }) => { axisEventBus.initialize(); }); + // 휠 가상 축은 데몬 재시작 시 0부터, OBS 유실 뒤 값은 이전 값과 이어지지 않음 + const resetAxisBaselines = () => { + void axisEventBusModule.then(({ axisEventBus }) => { + axisEventBus.resetBaselines(); + }); + }; let hydrationCancelled = false; @@ -337,6 +344,7 @@ export const useOverlayKeyStateRuntime = ({ // OBS Lagged/재연결 스냅샷은 유실된 keys:state를 개별 복구하지 못하므로 대조로 정리 const unsubscribeResync = obsApi.onResync(() => { + resetAxisBaselines(); void reconcileWithBootstrap(); }); @@ -344,6 +352,7 @@ export const useOverlayKeyStateRuntime = ({ const unsubscribeKeysReset = window.api.keys.onKeysReset(() => { // 진행 중인 대조의 낡은 스냅샷이 리셋 이후 상태를 덮지 못하게 무효화 reconcileGenerationRef.current += 1; + resetAxisBaselines(); const { finalizeAllActive } = keyEventContextRef.current; finalizeAllActive(); cancelKeyDelayTimers( diff --git a/src/renderer/utils/input/axisEventBus.test.ts b/src/renderer/utils/input/axisEventBus.test.ts new file mode 100644 index 000000000..f7b910d37 --- /dev/null +++ b/src/renderer/utils/input/axisEventBus.test.ts @@ -0,0 +1,57 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { getAxisSignal } from '@stores/signals/axisSignals'; +import { axisEventBus, type AxisPayload } from './axisEventBus'; + +const mocks = vi.hoisted(() => ({ + handler: null as null | ((event: { payload: AxisPayload }) => void), +})); + +vi.mock('@tauri-apps/api/event', () => ({ + listen: vi.fn( + async ( + _event: string, + handler: (event: { payload: AxisPayload }) => void, + ) => { + mocks.handler = handler; + return () => {}; + }, + ), +})); + +const FULL = 2880; + +const emit = (axisId: string, value: number) => { + mocks.handler?.({ payload: { axisId, value, full: FULL } }); +}; + +describe('axisEventBus 기준값', () => { + beforeEach(async () => { + await axisEventBus.initialize(); + axisEventBus.resetBaselines(); + }); + + it('첫 값은 기준으로만 쓰고 이후 wrap 최단경로 델타를 누적한다', () => { + const axis = getAxisSignal('WHEEL:TEST-A'); + const start = axis.value; + + emit('WHEEL:TEST-A', 0); + expect(axis.value).toBe(start); + emit('WHEEL:TEST-A', 120); + emit('WHEEL:TEST-A', FULL - 120); + expect(axis.value - start).toBeCloseTo(-120 / FULL); + }); + + it('기준값 폐기 뒤 첫 값은 이전 값과 이어 계산하지 않는다', () => { + const axis = getAxisSignal('WHEEL:TEST-B'); + emit('WHEEL:TEST-B', 1200); + const before = axis.value; + + // 데몬 재시작으로 0부터 다시 시작한 축 + axisEventBus.resetBaselines(); + emit('WHEEL:TEST-B', 0); + expect(axis.value).toBe(before); + emit('WHEEL:TEST-B', 120); + expect(axis.value - before).toBeCloseTo(120 / FULL); + }); +}); diff --git a/src/renderer/utils/input/axisEventBus.ts b/src/renderer/utils/input/axisEventBus.ts index be2d6f25c..63a0e80f2 100644 --- a/src/renderer/utils/input/axisEventBus.ts +++ b/src/renderer/utils/input/axisEventBus.ts @@ -63,6 +63,11 @@ class AxisEventBus { }); } + /** 축별 직전 값 폐기 - 송신 측이 재시작·유실되면 다음 값부터 새 기준 */ + resetBaselines() { + this.lastValue.clear(); + } + /** raw 축 페이로드 구독 (매핑 UI의 축 캡처용) */ subscribe(listener: AxisEventListener): () => void { this.listeners.add(listener); diff --git a/src/renderer/windows/overlay/App.keyDelayTimers.test.tsx b/src/renderer/windows/overlay/App.keyDelayTimers.test.tsx index dd0ea9553..bb09b12bc 100644 --- a/src/renderer/windows/overlay/App.keyDelayTimers.test.tsx +++ b/src/renderer/windows/overlay/App.keyDelayTimers.test.tsx @@ -95,7 +95,7 @@ vi.mock('@hooks/shared/useLayoutComputation', () => ({ }), })); vi.mock('@utils/input/axisEventBus', () => ({ - axisEventBus: { initialize: vi.fn() }, + axisEventBus: { initialize: vi.fn(), resetBaselines: vi.fn() }, })); vi.mock('@utils/input/keyEventBus', () => ({ keyEventBus: { diff --git a/src/renderer/windows/overlay/App.nativeWindow.test.tsx b/src/renderer/windows/overlay/App.nativeWindow.test.tsx index 93defd181..3beb13b7d 100644 --- a/src/renderer/windows/overlay/App.nativeWindow.test.tsx +++ b/src/renderer/windows/overlay/App.nativeWindow.test.tsx @@ -146,7 +146,7 @@ vi.mock('@hooks/overlay/useOverlayHitRegions', () => ({ })); vi.mock('@components/shared/OverlayScene', () => ({ default: () => null })); vi.mock('@utils/input/axisEventBus', () => ({ - axisEventBus: { initialize: vi.fn() }, + axisEventBus: { initialize: vi.fn(), resetBaselines: vi.fn() }, })); vi.mock('@utils/input/keyEventBus', () => ({ keyEventBus: { diff --git a/src/renderer/windows/overlay/App.spritePreview.test.tsx b/src/renderer/windows/overlay/App.spritePreview.test.tsx index 5f65bf4d4..f17ce51d1 100644 --- a/src/renderer/windows/overlay/App.spritePreview.test.tsx +++ b/src/renderer/windows/overlay/App.spritePreview.test.tsx @@ -113,7 +113,7 @@ vi.mock('@hooks/shared/useLayoutComputation', () => ({ }, })); vi.mock('@utils/input/axisEventBus', () => ({ - axisEventBus: { initialize: vi.fn() }, + axisEventBus: { initialize: vi.fn(), resetBaselines: vi.fn() }, })); vi.mock('@utils/input/keyEventBus', () => ({ keyEventBus: { diff --git a/src/renderer/windows/overlay/App.test.tsx b/src/renderer/windows/overlay/App.test.tsx index f427e3765..f233fe62d 100644 --- a/src/renderer/windows/overlay/App.test.tsx +++ b/src/renderer/windows/overlay/App.test.tsx @@ -13,6 +13,7 @@ globalThis.IS_REACT_ACT_ENVIRONMENT = true; const mocks = vi.hoisted(() => ({ axisInitialize: vi.fn(), + axisResetBaselines: vi.fn(), bootstrap: vi.fn(), keyInitialize: vi.fn(), keyEventListener: null as null | ((payload: unknown) => void), @@ -156,7 +157,10 @@ vi.mock('@hooks/shared/useLayoutComputation', () => ({ }), })); vi.mock('@utils/input/axisEventBus', () => ({ - axisEventBus: { initialize: mocks.axisInitialize }, + axisEventBus: { + initialize: mocks.axisInitialize, + resetBaselines: mocks.axisResetBaselines, + }, })); vi.mock('@utils/input/keyEventBus', () => ({ keyEventBus: { @@ -248,6 +252,7 @@ const makeApiMock = () => const resetSharedMocks = () => { mocks.axisInitialize.mockReset(); + mocks.axisResetBaselines.mockReset(); mocks.bootstrap.mockReset(); mocks.bootstrap.mockResolvedValue({ activeKeys: [] }); mocks.keyInitialize.mockReset(); @@ -635,6 +640,22 @@ describe('note timing payload and loss recovery', () => { expect(getKeySignal('KeyJ').value).toBe(true); }); + it('keys:reset과 obs:resync 수신 시 축 기준값을 버린다', async () => { + mocks.bootstrap.mockResolvedValue({ activeKeys: [] }); + + await act(async () => { + mocks.keysResetListener?.({ reason: 'hook_restart' }); + }); + await flushAsync(); + expect(mocks.axisResetBaselines).toHaveBeenCalledTimes(1); + + await act(async () => { + mocks.resyncListener?.(); + }); + await flushAsync(); + expect(mocks.axisResetBaselines).toHaveBeenCalledTimes(2); + }); + it('obs:resync 수신 시 모드 삼중 일치에서만 스냅샷 대조를 실행한다', async () => { mocks.bootstrap.mockResolvedValue({ activeKeys: ['KeyJ'], From d8c34940c9065d7440fff6a4e02278c66499efff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Wed, 7 Oct 2026 20:14:11 +0900 Subject: [PATCH 4/4] =?UTF-8?q?docs:=20=ED=9C=A0=20=EB=9D=BC=EB=B2=A8?= =?UTF-8?q?=EA=B3=BC=20=ED=9C=A0=20=EC=B6=95=20=EC=95=88=EB=82=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/content/en/api-reference/keys/page.mdx | 9 ++++++- docs/content/en/api-reference/knobs/page.mdx | 24 +++++++++++++------ docs/content/ko/api-reference/keys/page.mdx | 8 ++++++- docs/content/ko/api-reference/knobs/page.mdx | 25 +++++++++++++------- 4 files changed, 49 insertions(+), 17 deletions(-) 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; // 회전 방향 반전 };