diff --git a/docs/content/en/api-reference/keys/page.mdx b/docs/content/en/api-reference/keys/page.mdx index b9612c98e..6f8372e26 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/en/api-reference/settings/page.mdx b/docs/content/en/api-reference/settings/page.mdx index b252bbae0..cb4fc8d34 100644 --- a/docs/content/en/api-reference/settings/page.mdx +++ b/docs/content/en/api-reference/settings/page.mdx @@ -17,6 +17,8 @@ Returns the complete current settings object. interface SettingsState { hardwareAcceleration: boolean; alwaysOnTop: boolean; + // App UI theme. 'system' follows the OS appearance + uiTheme: 'system' | 'light' | 'dark'; overlayLocked: boolean; noteEffect: boolean; noteSettings: NoteSettings; diff --git a/docs/content/ko/api-reference/keys/page.mdx b/docs/content/ko/api-reference/keys/page.mdx index 297c708df..673070a70 100644 --- a/docs/content/ko/api-reference/keys/page.mdx +++ b/docs/content/ko/api-reference/keys/page.mdx @@ -433,7 +433,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" } @@ -449,6 +449,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/docs/content/ko/api-reference/settings/page.mdx b/docs/content/ko/api-reference/settings/page.mdx index 99cc7134a..85445bad6 100644 --- a/docs/content/ko/api-reference/settings/page.mdx +++ b/docs/content/ko/api-reference/settings/page.mdx @@ -17,6 +17,8 @@ description: 앱 설정 조회, 변경, 구독 API interface SettingsState { hardwareAcceleration: boolean; alwaysOnTop: boolean; + // 앱 UI 테마. "system"은 OS 설정을 따른다 + uiTheme: "system" | "light" | "dark"; overlayLocked: boolean; noteEffect: boolean; noteSettings: NoteSettings; diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index fac6e23e4..e4c6f8e3b 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 12ebd6e3f..b7adb90cb 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/app.rs b/src-tauri/src/app.rs index e36a7c90a..f7c4d3262 100644 --- a/src-tauri/src/app.rs +++ b/src-tauri/src/app.rs @@ -618,6 +618,11 @@ fn apply_main_window_configuration( log::debug!("failed to hide main window before configuration: {err}"); } + let preference = app.state::().store.snapshot().ui_theme; + if let Err(err) = state::window::window_theme::apply_main_window_theme(&window, preference) { + log::warn!("failed to synchronize main window theme: {err:#}"); + } + if cfg!(target_os = "windows") { if let Err(err) = window.set_decorations(false) { log::warn!("failed to disable decorations: {err}"); diff --git a/src-tauri/src/commands/keys/keys.rs b/src-tauri/src/commands/keys/keys.rs index c1d33fbb8..58ece237f 100644 --- a/src-tauri/src/commands/keys/keys.rs +++ b/src-tauri/src/commands/keys/keys.rs @@ -704,7 +704,8 @@ fn keys_reset_all_inner( &stat_positions, &app_data_dir, ); - let settings_diff = apply_patch_to_store(store, &settings_patch); + let settings_diff = apply_patch_to_store(store, &settings_patch) + .map_err(crate::services::settings::settings_patch_validation_error)?; Ok((settings_diff, cleared_tab_css_ids)) }, )?; diff --git a/src-tauri/src/commands/layout/settings.rs b/src-tauri/src/commands/layout/settings.rs index f88382859..914a09eda 100644 --- a/src-tauri/src/commands/layout/settings.rs +++ b/src-tauri/src/commands/layout/settings.rs @@ -36,9 +36,8 @@ pub async fn settings_update( state .store .commit_history_overlap_mutation_with_admission(admission, |store| { - Ok(crate::services::settings::apply_patch_to_store( - store, &patch, - )) + crate::services::settings::apply_patch_to_store(store, &patch) + .map_err(crate::services::settings::settings_patch_validation_error) })?; if let Some(previous) = previous.as_ref() { state.resync_global_css_watcher(previous, &state.store.snapshot()); diff --git a/src-tauri/src/commands/preset/load.rs b/src-tauri/src/commands/preset/load.rs index f0b596701..94247b8bc 100644 --- a/src-tauri/src/commands/preset/load.rs +++ b/src-tauri/src/commands/preset/load.rs @@ -326,7 +326,8 @@ fn preset_load_from_path( } rekey_full_preset_elements(store); crate::state::migration::clear_dangling_group_ids(store); - let diff = apply_patch_to_store(store, &settings_patch); + let diff = apply_patch_to_store(store, &settings_patch) + .map_err(crate::services::settings::settings_patch_validation_error)?; Ok(( diff, previous_tab_css_overrides, @@ -638,7 +639,9 @@ fn preset_load_tab_from_path( ..SettingsPatchInput::default() }, ) - }); + }) + .transpose() + .map_err(crate::services::settings::settings_patch_validation_error)?; crate::state::migration::clear_dangling_group_ids(store); Ok(( settings_diff, diff --git a/src-tauri/src/commands/preset/load/tests.rs b/src-tauri/src/commands/preset/load/tests.rs index d3a4a6b2b..590ddf75c 100644 --- a/src-tauri/src/commands/preset/load/tests.rs +++ b/src-tauri/src/commands/preset/load/tests.rs @@ -156,6 +156,7 @@ fn full_preset_settings_patch_preserves_custom_css_history() { }]; let mut store = AppStoreData { custom_css_history: history.clone(), + ui_theme: crate::models::UiTheme::Light, ..AppStoreData::default() }; let mut preset = PresetFile { @@ -167,6 +168,8 @@ fn full_preset_settings_patch_preserves_custom_css_history() { ..PresetFile::default() }; let resolved = resolve_full_preset_settings(&mut preset, &store); + let preset_wire = serde_json::to_value(&preset).expect("preset 직렬화"); + assert!(preset_wire.get("uiTheme").is_none()); let patch = SettingsPatchInput { use_custom_css: Some(resolved.use_custom_css), custom_css: Some(CustomCssPatch { @@ -176,9 +179,10 @@ fn full_preset_settings_patch_preserves_custom_css_history() { ..SettingsPatchInput::default() }; - apply_patch_to_store(&mut store, &patch); + apply_patch_to_store(&mut store, &patch).expect("preset 설정 적용"); assert_eq!(store.custom_css_history, history); + assert_eq!(store.ui_theme, crate::models::UiTheme::Light); } #[test] 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 b6973d32d..1c3e378db 100644 --- a/src-tauri/src/keyboard/daemon/windows.rs +++ b/src-tauri/src/keyboard/daemon/windows.rs @@ -235,6 +235,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(); @@ -255,9 +256,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, @@ -564,6 +562,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; diff --git a/src-tauri/src/models/mod.rs b/src-tauri/src/models/mod.rs index fc6676050..ef5ba8af8 100644 --- a/src-tauri/src/models/mod.rs +++ b/src-tauri/src/models/mod.rs @@ -678,6 +678,8 @@ pub type LayerGroups = HashMap>; pub struct AppStoreData { pub hardware_acceleration: bool, pub always_on_top: bool, + #[serde(default)] + pub ui_theme: UiTheme, pub overlay_locked: bool, #[serde(default)] pub overlay_visible: bool, @@ -786,6 +788,7 @@ impl Default for AppStoreData { Self { hardware_acceleration: true, always_on_top: true, + ui_theme: UiTheme::default(), overlay_locked: false, overlay_visible: false, note_effect: false, @@ -850,6 +853,7 @@ impl AppStoreData { SettingsState { hardware_acceleration: self.hardware_acceleration, always_on_top: self.always_on_top, + ui_theme: self.ui_theme, overlay_locked: self.overlay_locked, note_effect: self.note_effect, note_settings: self.note_settings.clone(), diff --git a/src-tauri/src/models/settings.rs b/src-tauri/src/models/settings.rs index 08a7d8f2d..22277fb67 100644 --- a/src-tauri/src/models/settings.rs +++ b/src-tauri/src/models/settings.rs @@ -1,10 +1,44 @@ use super::*; +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "lowercase")] +pub enum UiTheme { + #[default] + System, + Light, + Dark, +} + +impl TryFrom<&str> for UiTheme { + type Error = (); + + fn try_from(value: &str) -> Result { + match value { + "system" => Ok(Self::System), + "light" => Ok(Self::Light), + "dark" => Ok(Self::Dark), + _ => Err(()), + } + } +} + +impl UiTheme { + pub(crate) fn as_tauri_theme(self) -> Option { + match self { + Self::System => None, + Self::Light => Some(tauri::Theme::Light), + Self::Dark => Some(tauri::Theme::Dark), + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct SettingsState { pub hardware_acceleration: bool, pub always_on_top: bool, + #[serde(default)] + pub ui_theme: UiTheme, pub overlay_locked: bool, pub note_effect: bool, #[serde(default)] @@ -73,6 +107,7 @@ pub struct NoteSettingsPatch { pub struct SettingsPatchInput { pub hardware_acceleration: Option, pub always_on_top: Option, + pub ui_theme: Option, pub overlay_locked: Option, pub note_effect: Option, pub note_settings: Option, @@ -134,6 +169,7 @@ impl SettingsDiff { [ p.hardware_acceleration.is_some(), p.always_on_top.is_some(), + p.ui_theme.is_some(), p.overlay_locked.is_some(), p.note_effect.is_some(), p.note_settings.is_some(), @@ -169,6 +205,8 @@ pub struct SettingsPatch { #[serde(skip_serializing_if = "Option::is_none")] pub always_on_top: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub ui_theme: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub overlay_locked: Option, #[serde(skip_serializing_if = "Option::is_none")] pub note_effect: Option, diff --git a/src-tauri/src/services/settings.rs b/src-tauri/src/services/settings.rs index abe79bd91..640dfc5fe 100644 --- a/src-tauri/src/services/settings.rs +++ b/src-tauri/src/services/settings.rs @@ -5,9 +5,9 @@ use anyhow::{Context, Result}; use crate::models::{ AppStoreData, CustomCss, CustomCssPatch, CustomJs, CustomJsPatch, FontType, NoteSettings, NoteSettingsPatch, SettingsDiff, SettingsPatch, SettingsPatchInput, SettingsState, - ShortcutsState, + ShortcutsState, UiTheme, }; -use crate::state::AppStore; +use crate::{errors::EditorCommitError, state::AppStore}; #[derive(Clone)] pub struct SettingsService { @@ -28,7 +28,7 @@ impl SettingsService { self.store.update(|state| { diff = Some(apply_patch_to_store(state, &patch)); })?; - diff.context("settings patch did not produce a diff") + diff.context("settings patch did not produce a diff")? } } @@ -36,16 +36,21 @@ pub(crate) fn settings_from_store(store: &AppStoreData) -> SettingsState { store.settings_state() } +pub(crate) fn settings_patch_validation_error(error: anyhow::Error) -> EditorCommitError { + EditorCommitError::validation("INVALID_SETTINGS_PATCH", error.to_string()) +} + pub(crate) fn apply_patch_to_store( store: &mut AppStoreData, patch: &SettingsPatchInput, -) -> SettingsDiff { +) -> Result { let current = settings_from_store(store); - let normalized = normalize_patch(patch, ¤t); + let normalized = normalize_patch(patch, ¤t)?; let next = apply_changes(current, &normalized); store.hardware_acceleration = next.hardware_acceleration; store.always_on_top = next.always_on_top; + store.ui_theme = next.ui_theme; store.overlay_locked = next.overlay_locked; store.note_effect = next.note_effect; store.note_settings = next.note_settings.clone(); @@ -67,13 +72,13 @@ pub(crate) fn apply_patch_to_store( store.shortcuts = next.shortcuts.clone(); store.obs_mode_enabled = next.obs_mode_enabled; - SettingsDiff { + Ok(SettingsDiff { changed: normalized, full: Some(next), - } + }) } -fn normalize_patch(patch: &SettingsPatchInput, current: &SettingsState) -> SettingsPatch { +fn normalize_patch(patch: &SettingsPatchInput, current: &SettingsState) -> Result { let mut normalized = SettingsPatch::default(); if let Some(value) = patch.hardware_acceleration { normalized.hardware_acceleration = Some(value); @@ -81,6 +86,12 @@ fn normalize_patch(patch: &SettingsPatchInput, current: &SettingsState) -> Setti if let Some(value) = patch.always_on_top { normalized.always_on_top = Some(value); } + if let Some(value) = patch.ui_theme.as_deref() { + normalized.ui_theme = Some( + UiTheme::try_from(value) + .map_err(|()| anyhow::anyhow!("invalid uiTheme value: {value}"))?, + ); + } if let Some(value) = patch.overlay_locked { normalized.overlay_locked = Some(value); } @@ -195,7 +206,7 @@ fn normalize_patch(patch: &SettingsPatchInput, current: &SettingsState) -> Setti if let Some(value) = patch.obs_mode_enabled { normalized.obs_mode_enabled = Some(value); } - normalized + Ok(normalized) } fn apply_changes(mut current: SettingsState, patch: &SettingsPatch) -> SettingsState { @@ -205,6 +216,9 @@ fn apply_changes(mut current: SettingsState, patch: &SettingsPatch) -> SettingsS if let Some(value) = patch.always_on_top { current.always_on_top = value; } + if let Some(value) = patch.ui_theme { + current.ui_theme = value; + } if let Some(value) = patch.overlay_locked { current.overlay_locked = value; } @@ -350,6 +364,7 @@ mod tests { assert!(d.hardware_acceleration); assert!(d.always_on_top); + assert_eq!(d.ui_theme, UiTheme::System); assert!(!d.overlay_locked); assert!(!d.note_effect); if cfg!(target_os = "macos") { @@ -499,6 +514,7 @@ mod tests { let expected_a = SettingsState { hardware_acceleration: false, always_on_top: false, + ui_theme: UiTheme::Light, overlay_locked: true, note_effect: true, note_settings: expected_note.clone(), @@ -531,6 +547,7 @@ mod tests { let patch_a = SettingsPatchInput { hardware_acceleration: Some(false), always_on_top: Some(false), + ui_theme: Some("light".to_string()), overlay_locked: Some(true), note_effect: Some(true), note_settings: Some(NoteSettingsPatch { @@ -579,13 +596,14 @@ mod tests { }; let mut store = AppStoreData::default(); - let diff_a = apply_patch_to_store(&mut store, &patch_a); + let diff_a = apply_patch_to_store(&mut store, &patch_a).expect("첫 patch 적용"); assert_eq!(diff_a.full.expect("full snapshot 반환"), expected_a); assert_eq!(settings_from_store(&store), expected_a); // 같은 기본값을 공유하는 bool 쌍이 서로 반대가 되도록 절반만 반전 let patch_b = SettingsPatchInput { always_on_top: Some(true), + ui_theme: Some("dark".to_string()), note_effect: Some(false), developer_mode_enabled: Some(false), auto_update_enabled: Some(true), @@ -595,6 +613,7 @@ mod tests { }; let expected_b = SettingsState { always_on_top: true, + ui_theme: UiTheme::Dark, note_effect: false, developer_mode_enabled: false, auto_update_enabled: true, @@ -603,8 +622,8 @@ mod tests { ..expected_a }; - let diff_b = apply_patch_to_store(&mut store, &patch_b); - assert_eq!(diff_b.changed_count(), 6); + let diff_b = apply_patch_to_store(&mut store, &patch_b).expect("두 번째 patch 적용"); + assert_eq!(diff_b.changed_count(), 7); assert_eq!(diff_b.full.expect("full snapshot 반환"), expected_b); assert_eq!(settings_from_store(&store), expected_b); // 교차 매핑 검출 핵심 쌍, use_custom_css와 use_custom_js가 서로 반대 @@ -619,7 +638,8 @@ mod tests { let from_empty_store = settings_from_store(&AppStoreData::default()); let mut store = AppStoreData::default(); - let diff = apply_patch_to_store(&mut store, &SettingsPatchInput::default()); + let diff = apply_patch_to_store(&mut store, &SettingsPatchInput::default()) + .expect("빈 patch 적용"); assert_eq!(diff.changed_count(), 0); let from_empty_patch = diff.full.expect("빈 patch도 full snapshot 반환"); @@ -628,4 +648,37 @@ mod tests { // 빈 patch가 store 기본값을 변경하지 않음 assert_eq!(settings_from_store(&store), from_default); } + + #[test] + fn unknown_ui_theme_is_rejected_without_mutating_store() { + let mut store = AppStoreData::default(); + let before = store.clone(); + let patch = SettingsPatchInput { + ui_theme: Some("neon".to_string()), + ..SettingsPatchInput::default() + }; + + let error = apply_patch_to_store(&mut store, &patch).expect_err("미지 테마 거절"); + + assert!(error.to_string().contains("invalid uiTheme value: neon")); + assert_eq!(store, before); + } + + #[test] + fn ui_theme_uses_camel_case_in_settings_payloads() { + let mut store = AppStoreData::default(); + let diff = apply_patch_to_store( + &mut store, + &SettingsPatchInput { + ui_theme: Some("light".to_string()), + ..SettingsPatchInput::default() + }, + ) + .expect("테마 patch 적용"); + + let wire = serde_json::to_value(diff).expect("설정 diff 직렬화"); + assert_eq!(wire["changed"]["uiTheme"], "light"); + assert_eq!(wire["full"]["uiTheme"], "light"); + assert!(wire["changed"].get("ui_theme").is_none()); + } } diff --git a/src-tauri/src/state/app_state.rs b/src-tauri/src/state/app_state.rs index 3be9c97e5..d0a209dda 100644 --- a/src-tauri/src/state/app_state.rs +++ b/src-tauri/src/state/app_state.rs @@ -64,7 +64,7 @@ use keyboard_runtime::{ KEYBOARD_RECOVERY_DELAYS_MS, }; use lifecycle_runtime::{ - attach_main_window_close_handler, dispatch_remove_tray_icon, execute_frontend_lifecycle, + attach_main_window_event_handler, dispatch_remove_tray_icon, execute_frontend_lifecycle, remove_tray_icon, tray_menu_labels, }; #[cfg(target_os = "macos")] @@ -128,7 +128,7 @@ use crate::{ HistoryStatus, KeyCounterSettings, KeyCounters, KeyMappings, KeyPositions, KeySlot, KeySoundOutputBackendPersist, OverlayBounds, OverlayResizeAnchor, PanelBounds, SettingsDiff, SettingsState, StoredOverlayBounds, StoredOverlayNativePosition, - TabCssOverrides, + TabCssOverrides, UiTheme, }, services::{ css_watcher::CssWatcher, @@ -939,7 +939,7 @@ impl AppState { fn attach_main_window_handlers(&self, app: &AppHandle) { let overlay_force_close = self.overlay_force_close.clone(); if let Some(window) = app.get_webview_window("main") { - attach_main_window_close_handler(window, overlay_force_close, app.clone()); + attach_main_window_event_handler(window, overlay_force_close, app.clone()); return; } @@ -948,7 +948,7 @@ impl AppState { thread::spawn(move || { for _ in 0..15 { if let Some(window) = app_handle.get_webview_window("main") { - attach_main_window_close_handler( + attach_main_window_event_handler( window, overlay_force_close.clone(), app_handle.clone(), diff --git a/src-tauri/src/state/app_state/lifecycle_runtime.rs b/src-tauri/src/state/app_state/lifecycle_runtime.rs index 3cfeb2818..9d0f75b32 100644 --- a/src-tauri/src/state/app_state/lifecycle_runtime.rs +++ b/src-tauri/src/state/app_state/lifecycle_runtime.rs @@ -1,6 +1,6 @@ use super::*; -pub(super) fn attach_main_window_close_handler( +pub(super) fn attach_main_window_event_handler( window: WebviewWindow, overlay_force_close: Arc, app_handle: AppHandle, @@ -13,8 +13,18 @@ pub(super) fn attach_main_window_close_handler( } let main_window = window.clone(); - window.on_window_event(move |event| { - if let WindowEvent::CloseRequested { api, .. } = event { + window.on_window_event(move |event| match event { + WindowEvent::ThemeChanged(theme) + if app_handle.state::().store.snapshot().ui_theme == UiTheme::System => + { + if let Err(err) = crate::state::window::window_theme::apply_main_window_background( + &main_window, + *theme, + ) { + log::warn!("failed to follow system theme for main window: {err:#}"); + } + } + WindowEvent::CloseRequested { api, .. } => { if overlay_force_close.load(Ordering::SeqCst) { return; } @@ -38,6 +48,7 @@ pub(super) fn attach_main_window_close_handler( api.prevent_close(); state.request_frontend_shutdown(app_handle.clone()); } + _ => {} }); } diff --git a/src-tauri/src/state/app_state/overlay_runtime.rs b/src-tauri/src/state/app_state/overlay_runtime.rs index 88e019a42..1cf01a889 100644 --- a/src-tauri/src/state/app_state/overlay_runtime.rs +++ b/src-tauri/src/state/app_state/overlay_runtime.rs @@ -909,6 +909,22 @@ impl AppState { // 오버레이가 보이는 상태일 때만 설정 적용 let is_visible = *self.overlay_visible.read(); + // 메인·분리 패널만 앱 테마를 따른다. 오버레이는 사용자 콘텐츠 창이라 제외 + if let Some(value) = diff.changed.ui_theme { + if let Some(window) = app.get_webview_window("main") { + if let Err(err) = + crate::state::window::window_theme::apply_main_window_theme(&window, value) + { + log::warn!("failed to synchronize main window theme: {err:#}"); + } + } + if let Some(window) = app.get_webview_window(PANEL_LABEL) { + if let Err(err) = window.set_theme(value.as_tauri_theme()) { + log::warn!("failed to apply panel window theme: {err}"); + } + } + } + if let Some(value) = diff.changed.always_on_top { if is_visible { if let Some(window) = app.get_webview_window(OVERLAY_LABEL) { diff --git a/src-tauri/src/state/app_state/panel_runtime.rs b/src-tauri/src/state/app_state/panel_runtime.rs index 2dc36c4cb..d21da6699 100644 --- a/src-tauri/src/state/app_state/panel_runtime.rs +++ b/src-tauri/src/state/app_state/panel_runtime.rs @@ -440,6 +440,11 @@ impl AppState { let snapshot = self.store.snapshot(); let stored_bounds = snapshot.panel_bounds; let layout = resolve_panel_window_layout(stored_bounds, main_rect, monitors, None); + let native_theme = app + .get_webview_window("main") + .and_then(|window| window.theme().ok()); + let resolved_theme = + crate::state::window::window_theme::resolve_theme(snapshot.ui_theme, native_theme); // about:blank는 runtime-wry가 초기 네비게이션을 건너뛴다 - opener가 문서를 채우고, // WebView2의 "NewWindow는 네비게이션 전이어야 함" 요건도 이걸로 맞는다. @@ -470,6 +475,7 @@ impl AppState { // (유틸리티 패널 관례 - 버튼이 첫 클릭에 바로 동작) .accept_first_mouse(true) .visible(false) + .theme(snapshot.ui_theme.as_tauri_theme()) .inner_size(PANEL_WIDTH, layout.height) .min_inner_size(PANEL_WIDTH, layout.min_height) .max_inner_size(PANEL_WIDTH, layout.max_height) @@ -479,8 +485,9 @@ impl AppState { // 이미 내비게이션이 시작된 뒤라 늦다. 실제 색은 렌더러가 토큰을 읽어 덮는다 #[cfg(target_os = "windows")] { - builder = - builder.background_color(crate::state::window::windows_window_corners::SEED_FILL); + builder = builder.background_color( + crate::state::window::windows_window_corners::seed_fill(resolved_theme), + ); } #[cfg(not(target_os = "windows"))] @@ -514,7 +521,7 @@ impl AppState { } } - self.configure_panel_window(&window, app, layout.max_height); + self.configure_panel_window(&window, app, layout.max_height, resolved_theme); Ok(window) } @@ -523,6 +530,7 @@ impl AppState { window: &WebviewWindow, app: &AppHandle, initial_max_height: f64, + _resolved_theme: tauri::Theme, ) { // 웹 콘텐츠가 그리는 라운딩은 리사이즈 프레임을 못 따라옴 - 실루엣은 컴포지터가 소유 #[cfg(target_os = "macos")] @@ -530,7 +538,7 @@ impl AppState { // Windows는 DWM이 이미 자기 반경으로 자르고 있어 웹 라운딩과 어긋난다 - 반경 지정이 // 불가능하므로 실루엣을 DWM에 넘기고 웹은 사각으로 채운다 (메인 창과 같은 처리) #[cfg(target_os = "windows")] - crate::state::window::windows_window_corners::apply_initial_chrome(window); + crate::state::window::windows_window_corners::apply_initial_chrome(window, _resolved_theme); let bounds_session = self .panel_bounds_persistence diff --git a/src-tauri/src/state/store/history_restore.rs b/src-tauri/src/state/store/history_restore.rs index d31db5a71..0f0439465 100644 --- a/src-tauri/src/state/store/history_restore.rs +++ b/src-tauri/src/state/store/history_restore.rs @@ -584,7 +584,8 @@ impl AppStore { let settings_diff = crate::services::settings::apply_patch_to_store( &mut scratch, &preset_history_settings_patch(&target.settings), - ); + ) + .map_err(crate::services::settings::settings_patch_validation_error)?; crate::state::migration::canonicalize_gradient_pairs(&mut scratch); crate::state::migration::canonicalize_image_modes(&mut scratch); crate::state::migration::normalize_sprite_triggers(&mut scratch); diff --git a/src-tauri/src/state/store/tests.rs b/src-tauri/src/state/store/tests.rs index 4c011df3b..ba159217c 100644 --- a/src-tauri/src/state/store/tests.rs +++ b/src-tauri/src/state/store/tests.rs @@ -26,7 +26,7 @@ use crate::{ PluginInstancesReconcileRequest, PluginPoint, ReactiveSpritePosition, SavedPluginInstance, SettingsPatchInput, SlotMatch, SoundLibraryEntry, SoundSource, SpriteActivation, SpriteAnchor, SpriteImageMetrics, SpritePose, SpriteReferenceNaturalSize, SpriteTransform, - StatPosition, StatType, TabCss, TabNoteSettings, EDITOR_COMMIT_SCHEMA_VERSION_V2, + StatPosition, StatType, TabCss, TabNoteSettings, UiTheme, EDITOR_COMMIT_SCHEMA_VERSION_V2, EDITOR_OPS_VERSION, EDITOR_SCHEMA_VERSION, }, services::{css_watcher::commit_css_reload, settings::apply_patch_to_store}, @@ -13621,7 +13621,8 @@ fn unrecorded_preset_overlap_mutations_clear_redo_only_when_overlap_changes() { background_color: Some(format!("#{:06x}", index + 1)), ..SettingsPatchInput::default() }, - ); + ) + .expect("설정 patch 적용"); } Ok(()) }) @@ -13639,6 +13640,62 @@ fn unrecorded_preset_overlap_mutations_clear_redo_only_when_overlap_changes() { let _ = std::fs::remove_dir_all(dir); } +#[test] +fn ui_theme_change_does_not_invalidate_editor_history() { + let dir = test_directory("ui-theme-history-exclusion-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let initial_dx = store.editor_get().document.key_positions["4key"][0].dx; + + store + .commit_editor_document(editor_request( + store.editor_get().revision, + uuid::Uuid::new_v4().to_string(), + position_patch(&store, initial_dx + 1.0), + )) + .unwrap(); + let operation_id = uuid::Uuid::new_v4().to_string(); + let gate = store.history_gate(); + let barrier = gate.close(&operation_id).unwrap(); + let current_counters = store.snapshot().key_counters; + store + .apply_history_operation( + HistoryDirection::Undo, + &operation_id, + ¤t_counters, + || {}, + ) + .unwrap(); + drop(barrier); + let before = store.history_status(); + assert!(before.can_redo); + + let transaction = store + .commit_history_overlap_mutation(|data| { + apply_patch_to_store( + data, + &SettingsPatchInput { + ui_theme: Some("light".to_string()), + ..SettingsPatchInput::default() + }, + ) + .map_err(crate::services::settings::settings_patch_validation_error) + }) + .unwrap(); + + assert!(transaction.history_status.is_none()); + assert_eq!(store.snapshot().ui_theme, UiTheme::Light); + let after = store.history_status(); + assert_eq!(after.history_revision, before.history_revision); + assert_eq!(after.history_epoch, before.history_epoch); + assert_eq!(after.can_undo, before.can_undo); + assert_eq!(after.can_redo, before.can_redo); + assert_eq!(after.truncated, before.truncated); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + #[test] fn unrecorded_legacy_editor_mutations_invalidate_stale_undo_and_redo() { let dir = test_directory("legacy-editor-history-invalidation-test"); @@ -16527,7 +16584,8 @@ fn preset_style_layout_and_settings_commit_in_one_writer_revision() { background_color: Some("#123456".to_string()), ..SettingsPatchInput::default() }, - ); + ) + .expect("설정 patch 적용"); Ok(()) }, ) @@ -16553,7 +16611,8 @@ fn preset_style_layout_and_settings_commit_in_one_writer_revision() { background_color: Some("#FFFFFF".to_string()), ..SettingsPatchInput::default() }, - ); + ) + .expect("설정 patch 적용"); Ok(()) }, ); @@ -17010,6 +17069,22 @@ fn panel_detached_missing_field_defaults_to_docked() { let _ = std::fs::remove_dir_all(dir); } +#[test] +fn ui_theme_missing_field_defaults_to_system() { + let dir = test_directory("ui-theme-default-test"); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("store.json"); + let mut fixture = serde_json::to_value(AppStoreData::default()).unwrap(); + assert!(fixture.as_object_mut().unwrap().remove("uiTheme").is_some()); + std::fs::write(&path, serde_json::to_vec_pretty(&fixture).unwrap()).unwrap(); + + let store = AppStore::initialize_in_dir(&dir).unwrap(); + assert_eq!(store.snapshot().ui_theme, UiTheme::System); + store.flush_and_shutdown().unwrap(); + drop(store); + let _ = std::fs::remove_dir_all(dir); +} + #[test] fn regular_update_commits_pending_deferred_state() { let dir = test_directory("store-deferred-regular-update-test"); diff --git a/src-tauri/src/state/window/mod.rs b/src-tauri/src/state/window/mod.rs index 8f6691257..aacb7f7d3 100644 --- a/src-tauri/src/state/window/mod.rs +++ b/src-tauri/src/state/window/mod.rs @@ -5,6 +5,7 @@ pub(crate) mod macos_termination; #[cfg(target_os = "macos")] pub(crate) mod macos_window_corners; pub(crate) mod panel_drag; +pub(crate) mod window_theme; #[cfg(target_os = "windows")] pub(crate) mod windows_window_corners; diff --git a/src-tauri/src/state/window/window_theme.rs b/src-tauri/src/state/window/window_theme.rs new file mode 100644 index 000000000..ab0dc3b51 --- /dev/null +++ b/src-tauri/src/state/window/window_theme.rs @@ -0,0 +1,74 @@ +use anyhow::{Context, Result}; +use tauri::{window::Color, Theme, WebviewWindow}; + +use crate::models::UiTheme; + +// tokens/dark.css, tokens/light.css의 --ui-bg-app과 같은 값 +const MAIN_DARK_BACKGROUND: Color = Color(0x13, 0x13, 0x15, 0xFF); +const MAIN_LIGHT_BACKGROUND: Color = Color(0xCC, 0xD3, 0xDC, 0xFF); + +pub(crate) fn resolve_theme(preference: UiTheme, native_theme: Option) -> Theme { + match preference { + UiTheme::Light => Theme::Light, + UiTheme::Dark => Theme::Dark, + UiTheme::System => native_theme.unwrap_or(Theme::Dark), + } +} + +fn main_background_color(theme: Theme) -> Color { + match theme { + Theme::Light => MAIN_LIGHT_BACKGROUND, + Theme::Dark => MAIN_DARK_BACKGROUND, + _ => MAIN_DARK_BACKGROUND, + } +} + +pub(crate) fn apply_main_window_background(window: &WebviewWindow, theme: Theme) -> Result<()> { + window + .set_background_color(Some(main_background_color(theme))) + .context("failed to apply main window background color") +} + +pub(crate) fn apply_main_window_theme(window: &WebviewWindow, preference: UiTheme) -> Result<()> { + window + .set_theme(preference.as_tauri_theme()) + .context("failed to apply main window theme")?; + + // System 전환 직후의 실제 테마를 읽어 네이티브 표면과 같은 색을 사용 + let native_theme = (preference == UiTheme::System) + .then(|| window.theme().ok()) + .flatten(); + apply_main_window_background(window, resolve_theme(preference, native_theme)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn explicit_theme_ignores_native_theme() { + assert_eq!( + resolve_theme(UiTheme::Light, Some(Theme::Dark)), + Theme::Light + ); + assert_eq!( + resolve_theme(UiTheme::Dark, Some(Theme::Light)), + Theme::Dark + ); + } + + #[test] + fn system_theme_uses_native_theme_with_dark_fallback() { + assert_eq!( + resolve_theme(UiTheme::System, Some(Theme::Light)), + Theme::Light + ); + assert_eq!(resolve_theme(UiTheme::System, None), Theme::Dark); + } + + #[test] + fn background_color_matches_resolved_theme() { + assert_eq!(main_background_color(Theme::Light), MAIN_LIGHT_BACKGROUND); + assert_eq!(main_background_color(Theme::Dark), MAIN_DARK_BACKGROUND); + } +} diff --git a/src-tauri/src/state/window/windows_window_corners.rs b/src-tauri/src/state/window/windows_window_corners.rs index 581550110..bf589b813 100644 --- a/src-tauri/src/state/window/windows_window_corners.rs +++ b/src-tauri/src/state/window/windows_window_corners.rs @@ -1,7 +1,7 @@ // lib와 bin의 독립 state 모듈 컴파일 #![allow(dead_code)] -use tauri::{window::Color, WebviewWindow}; +use tauri::{window::Color, Theme, WebviewWindow}; use windows::Win32::Foundation::COLORREF; use windows::Win32::Graphics::Dwm::{ DwmSetWindowAttribute, DWMWA_BORDER_COLOR, DWMWA_WINDOW_CORNER_PREFERENCE, DWMWCP_ROUND, @@ -24,27 +24,49 @@ use windows::Win32::Graphics::Dwm::{ // 창이 불투명이라 웹이 라운딩해도 모서리 바깥이 같은 색으로 남아 사각으로 보이므로, // 반경만 살리면 각진 면 위에 둥근 링만 떠서 어긋난다. 라인은 웹이 사각 링으로 메운다. -// 첫 페인트 전 구간을 메우는 씨앗값 - tokens.css의 --ui-bg-panel-detached와 같은 값을 유지한다. +// 첫 페인트 전 구간을 메우는 씨앗값 - tokens/dark.css, tokens/light.css의 +// --ui-bg-panel-detached와 같은 값을 유지한다. // 창 빌더에 실려 WebView2 컨트롤러 생성 옵션으로 들어가므로 첫 프레임부터 유효하고, // 실제 색은 렌더러가 토큰을 읽어 apply_surface_chrome으로 덮는다 -pub(crate) const SEED_FILL: Color = Color(0x1C, 0x1C, 0x1E, 0xFF); +const DARK_SEED_FILL: Color = Color(0x1C, 0x1C, 0x1E, 0xFF); +const LIGHT_SEED_FILL: Color = Color(0xEE, 0xF3, 0xF7, 0xFF); -// tokens.css의 --ui-line과 같은 값. DWM 기본 보더는 시스템 색이라 훨씬 진해서, 렌더러가 +// 테마 토큰 파일의 --ui-line과 같은 값. DWM 기본 보더는 시스템 색이라 훨씬 진해서, 렌더러가 // 토큰을 읽어 덮는 순간 선 굵기가 바뀐 것처럼 보인다 - 창 생성 시점에 미리 맞춰 전환을 없앤다 -const SEED_LINE: [f64; 4] = [1.0, 1.0, 1.0, 0.1]; +const DARK_SEED_LINE: [f64; 4] = [1.0, 1.0, 1.0, 0.1]; +// 라이트 --ui-line은 순흑이 아니라 살짝 남색으로 기운 잉크다. +// 무채색 위 순흑 저알파는 때가 탄 것처럼 보인다 +const LIGHT_SEED_LINE: [f64; 4] = [0.062745, 0.094118, 0.156863, 0.12]; + +pub(crate) fn seed_fill(theme: Theme) -> Color { + match theme { + Theme::Light => LIGHT_SEED_FILL, + Theme::Dark => DARK_SEED_FILL, + _ => DARK_SEED_FILL, + } +} + +fn seed_line(theme: Theme) -> [f64; 4] { + match theme { + Theme::Light => LIGHT_SEED_LINE, + Theme::Dark => DARK_SEED_LINE, + _ => DARK_SEED_LINE, + } +} -fn seed_fill_components() -> [f64; 4] { +fn seed_fill_components(theme: Theme) -> [f64; 4] { + let fill = seed_fill(theme); [ - f64::from(SEED_FILL.0) / 255.0, - f64::from(SEED_FILL.1) / 255.0, - f64::from(SEED_FILL.2) / 255.0, + f64::from(fill.0) / 255.0, + f64::from(fill.1) / 255.0, + f64::from(fill.2) / 255.0, 1.0, ] } // 창 생성 시점 - 결과를 기다릴 소비자가 없어 fire-and-forget. // Win11의 기본값이 이미 라운드지만, 창 종류에 따라 달라지는 휴리스틱에 기대지 않는다 -pub(crate) fn apply_initial_chrome(window: &WebviewWindow) { +pub(crate) fn apply_initial_chrome(window: &WebviewWindow, theme: Theme) { match set_corner_preference(window) { Ok(()) => log::info!( "[window-corners] applied native corner preference to '{}'", @@ -56,8 +78,10 @@ pub(crate) fn apply_initial_chrome(window: &WebviewWindow) { ), } - if let Err(reason) = set_border_color(window, composite_over(SEED_LINE, seed_fill_components())) - { + if let Err(reason) = set_border_color( + window, + composite_over(seed_line(theme), seed_fill_components(theme)), + ) { log::debug!( "[window-corners] skipped seed border color for '{}': {reason}", window.label() @@ -170,7 +194,8 @@ fn to_colorref(rgb: [f64; 3]) -> COLORREF { #[cfg(test)] mod tests { - use super::{composite_over, to_colorref}; + use super::{composite_over, seed_fill_components, seed_line, to_colorref}; + use tauri::Theme; #[test] fn opaque_line_replaces_the_fill() { @@ -190,15 +215,20 @@ mod tests { // --ui-line rgba(255,255,255,0.1)을 --ui-bg-panel-detached #1c1c1e 위에 올린 값 #[test] - fn translucent_line_blends_toward_the_fill() { - let blended = composite_over( - [1.0, 1.0, 1.0, 0.1], - [28.0 / 255.0, 28.0 / 255.0, 30.0 / 255.0, 1.0], - ); + fn dark_translucent_line_blends_toward_the_fill() { + let blended = composite_over(seed_line(Theme::Dark), seed_fill_components(Theme::Dark)); assert_eq!(super::channel(blended[0]), 51); assert_eq!(super::channel(blended[2]), 53); } + // --ui-line rgba(16,24,40,0.12)을 --ui-bg-panel-detached #eef3f7 위에 올린 값 + #[test] + fn light_translucent_line_blends_toward_the_fill() { + let blended = composite_over(seed_line(Theme::Light), seed_fill_components(Theme::Light)); + assert_eq!(super::channel(blended[0]), 211); + assert_eq!(super::channel(blended[2]), 222); + } + #[test] fn colorref_packs_blue_into_the_high_byte() { assert_eq!(to_colorref([1.0, 0.0, 0.0]).0, 0x0000_00FF); diff --git a/src/renderer/__tests__/rendering/digitPopAnimationContract.test.ts b/src/renderer/__tests__/rendering/digitPopAnimationContract.test.ts index b25387233..78cff6ce9 100644 --- a/src/renderer/__tests__/rendering/digitPopAnimationContract.test.ts +++ b/src/renderer/__tests__/rendering/digitPopAnimationContract.test.ts @@ -7,7 +7,7 @@ const mainCss = readFileSync( 'utf8', ); const tokensCss = readFileSync( - resolve(process.cwd(), 'src/renderer/styles/tokens.css'), + resolve(process.cwd(), 'src/renderer/styles/tokens/base.css'), 'utf8', ); diff --git a/src/renderer/__tests__/rendering/glassCanvasSurfaceContract.test.ts b/src/renderer/__tests__/rendering/glassCanvasSurfaceContract.test.ts index 54349b327..58edd6a6f 100644 --- a/src/renderer/__tests__/rendering/glassCanvasSurfaceContract.test.ts +++ b/src/renderer/__tests__/rendering/glassCanvasSurfaceContract.test.ts @@ -2,27 +2,43 @@ import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { describe, expect, it } from 'vitest'; -const tokensCss = readFileSync( - resolve(process.cwd(), 'src/renderer/styles/tokens.css'), - 'utf8', -); -const mainCss = readFileSync( - resolve(process.cwd(), 'src/renderer/styles/main.css'), - 'utf8', -); -const globalCss = readFileSync( - resolve(process.cwd(), 'src/renderer/styles/global.css'), - 'utf8', -); -const tailwindConfig = readFileSync( - resolve(process.cwd(), 'tailwind.config.js'), - 'utf8', -); +const read = (path: string) => + readFileSync(resolve(process.cwd(), path), 'utf8'); + +const tokensCss = read('src/renderer/styles/tokens.css'); +const baseCss = read('src/renderer/styles/tokens/base.css'); +const darkCss = read('src/renderer/styles/tokens/dark.css'); +const lightCss = read('src/renderer/styles/tokens/light.css'); +const mainCss = read('src/renderer/styles/main.css'); +const globalCss = read('src/renderer/styles/global.css'); +const tailwindConfig = read('tailwind.config.js'); + +const themes: [string, string][] = [ + ['dark', darkCss], + ['light', lightCss], +]; + +const declared = (css: string, token: string): string | null => { + const match = css.match(new RegExp(`${token}:\\s*([^;]+);`)); + return match ? match[1].trim() : null; +}; describe('canvas 위 glass 표면 계약', () => { - it('일반 glass와 분리된 밝기·채도 clamp를 가진다', () => { - expect(tokensCss).toMatch(/--ui-glass-canvas-backdrop-dim:\s*0\.62/); - expect(tokensCss).toMatch(/--ui-glass-canvas-backdrop-sat:\s*0\.35/); + it('일반 glass와 분리된 밝기·대비·채도 clamp를 가진다', () => { + // 캔버스는 사용자가 만든 임의 밝기라 일반 표면과 다른 세기가 필요하다. + // 값이 같아지면 분리해 둔 이유가 사라지므로 다르다는 것 자체를 계약으로 고정 + for (const [name, css] of themes) { + for (const dial of ['dim', 'contrast', 'sat']) { + const base = declared(css, `--ui-glass-backdrop-${dial}`); + const canvas = declared(css, `--ui-glass-canvas-backdrop-${dial}`); + expect(base, `${name} ${dial}`).not.toBeNull(); + expect(canvas, `${name} canvas ${dial}`).not.toBeNull(); + } + expect( + declared(css, '--ui-glass-canvas-backdrop-sat'), + `${name} canvas sat`, + ).not.toBe(declared(css, '--ui-glass-backdrop-sat')); + } expect(mainCss).toContain( ':where(.backdrop-glass-canvas).backdrop-glass {', ); @@ -31,12 +47,30 @@ describe('canvas 위 glass 표면 계약', () => { ); }); + it('필터 체인이 세 다이얼을 모두 소비한다', () => { + // 토큰만 늘리고 체인에 안 넣으면 라이트의 범위 압축이 조용히 죽는다 + const filters: string[] = mainCss.match(/backdrop-filter:[^;]+;/g) ?? []; + const clamped = filters.filter((rule) => rule.includes('brightness(')); + expect(clamped.length).toBeGreaterThan(0); + for (const rule of clamped) { + expect(rule).toContain('contrast('); + expect(rule).toContain('saturate('); + } + }); + // Windows 투명 효과는 접근성이 아니라 개인 설정 토글이고 배터리 절약 모드가 임의로 끈다 it('OS 투명도 축소 설정으로 글래스를 강등하지 않는다', () => { // 결정 근거 주석은 쿼리 이름을 그대로 적어두므로 규칙만 남기고 검사 const rulesOnly = (css: string) => css.replace(/\/\*[\s\S]*?\*\//g, ''); - // 메인 창이 로드하는 세 시트 전부 - for (const css of [mainCss, tokensCss, globalCss]) { + // 메인 창이 로드하는 시트 전부 + for (const css of [ + mainCss, + tokensCss, + baseCss, + darkCss, + lightCss, + globalCss, + ]) { expect(rulesOnly(css)).not.toContain('prefers-reduced-transparency'); } }); @@ -55,12 +89,29 @@ describe('canvas 위 glass 표면 계약', () => { } }); - it('popup 전용 짧은 shadow가 theme에 연결된다', () => { - expect(tokensCss).toMatch( - /--ui-shadow-popup:[\s\S]*?0 3px 10px rgba\(0, 0, 0, 0\.28\)/, - ); + it('popup 전용 짧은 shadow가 두 테마에 모두 연결된다', () => { + for (const [name, css] of themes) { + const popup = declared(css, '--ui-shadow-popup'); + const modal = declared(css, '--ui-shadow-3'); + expect(popup, `${name} popup`).not.toBeNull(); + // 팝업은 모달의 넓은 번짐을 재사용하지 않는다 + expect(popup, `${name} popup`).not.toBe(modal); + } expect(tailwindConfig).toContain( "'elevation-popup': 'var(--ui-shadow-popup)'", ); }); + + it('루미노시티 레이어의 목표 휘도가 glass 틴트와 같은 색이다', () => { + // 틴트만 올리고 여기를 두면 캡이 휘도를 도로 끌어당긴다 + for (const [name, css] of themes) { + const tint = declared(css, '--ui-glass-backdrop-lum-tint'); + const glass = declared(css, '--ui-glass'); + expect(tint, `${name} lum tint`).toMatch(/^#[0-9a-f]{6}$/); + const [r, g, b] = [1, 3, 5].map((i) => + parseInt((tint as string).slice(i, i + 2), 16), + ); + expect(glass, `${name} glass`).toContain(`rgba(${r}, ${g}, ${b},`); + } + }); }); diff --git a/src/renderer/__tests__/rendering/gooeySpringResilience.test.tsx b/src/renderer/__tests__/rendering/gooeySpringResilience.test.tsx index 2def81ed6..866e4b146 100644 --- a/src/renderer/__tests__/rendering/gooeySpringResilience.test.tsx +++ b/src/renderer/__tests__/rendering/gooeySpringResilience.test.tsx @@ -118,7 +118,7 @@ describe('GooeyThumb 스프링 복원력', () => { expect(body?.closest('svg')).not.toBeNull(); expect(body?.getAttribute('filter')).toBeNull(); expect(body?.getAttribute('shape-rendering')).toBe('geometricPrecision'); - expect(body?.getAttribute('stroke')).toBe('#fff'); + expect(body?.getAttribute('stroke')).toBe('var(--ui-handle-fill)'); expect(shadow?.getAttribute('filter')).toMatch(/^url\(#goo-shadow-/); expect(host.querySelector('feGaussianBlur')).toBeNull(); expect(host.querySelector('feColorMatrix')).toBeNull(); diff --git a/src/renderer/__tests__/rendering/themeTokenParityContract.test.ts b/src/renderer/__tests__/rendering/themeTokenParityContract.test.ts new file mode 100644 index 000000000..7a413159a --- /dev/null +++ b/src/renderer/__tests__/rendering/themeTokenParityContract.test.ts @@ -0,0 +1,176 @@ +import { globSync, readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +const read = (name: string) => + readFileSync(resolve(process.cwd(), 'src/renderer/styles', name), 'utf8'); + +const barrelCss = read('tokens.css'); +const baseCss = read('tokens/base.css'); +const darkCss = read('tokens/dark.css'); +const lightCss = read('tokens/light.css'); +const tailwindConfig = readFileSync( + resolve(process.cwd(), 'tailwind.config.js'), + 'utf8', +); + +const stripComments = (css: string) => css.replace(/\/\*[\s\S]*?\*\//g, ''); + +// 선언만 뽑는다. var(--x) 참조는 콜론이 없어 걸리지 않는다 +const declaredKeys = (css: string): string[] => + Array.from(stripComments(css).matchAll(/(--[\w-]+)\s*:/g)).map((m) => m[1]); + +const darkKeys = declaredKeys(darkCss); +const lightKeys = declaredKeys(lightCss); + +const tokenHex = (css: string, key: string): string => { + const match = stripComments(css).match( + new RegExp(`${key}\\s*:\\s*(#[0-9a-fA-F]{6})\\b`), + ); + if (!match) throw new Error(`${key} hex token not found`); + return match[1]; +}; + +const relativeLuminance = (hex: string): number => { + const channels = hex + .slice(1) + .match(/.{2}/g)! + .map((channel) => Number.parseInt(channel, 16) / 255) + .map((channel) => + channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4, + ); + return channels[0] * 0.2126 + channels[1] * 0.7152 + channels[2] * 0.0722; +}; + +const contrastRatio = (foreground: string, background: string): number => { + const foregroundLuminance = relativeLuminance(foreground); + const backgroundLuminance = relativeLuminance(background); + return ( + (Math.max(foregroundLuminance, backgroundLuminance) + 0.05) / + (Math.min(foregroundLuminance, backgroundLuminance) + 0.05) + ); +}; + +const labLightness = (hex: string): number => { + const luminance = relativeLuminance(hex); + const threshold = 216 / 24389; + const transformed = + luminance > threshold + ? Math.cbrt(luminance) + : (24389 / 27 / 116) * luminance + 16 / 116; + return 116 * transformed - 16; +}; + +describe('테마 토큰 파리티 계약', () => { + it('다크와 라이트가 같은 키를 같은 순서로 선언한다', () => { + // 순서까지 묶어 두면 두 파일을 나란히 놓고 읽을 수 있다 + expect(lightKeys).toEqual(darkKeys); + }); + + it('키가 중복 선언되지 않는다', () => { + expect(new Set(darkKeys).size).toBe(darkKeys.length); + expect(new Set(lightKeys).size).toBe(lightKeys.length); + }); + + it('테마 파일은 :root 폴백을 두지 않는다', () => { + // 다크가 bare :root에 남으면 라이트에서 빠진 토큰이 조용히 다크로 새어나간다. + // :where()로 감싸는 이유는 사용자 CSS의 :root 재정의와 특이도를 맞추기 위함 + expect(stripComments(darkCss)).toContain( + ":root:where([data-theme='dark']) {", + ); + expect(stripComments(lightCss)).toContain( + ":root:where([data-theme='light']) {", + ); + expect(stripComments(darkCss)).not.toMatch(/(^|\})\s*:root\s*\{/); + expect(stripComments(lightCss)).not.toMatch(/(^|\})\s*:root\s*\{/); + }); + + it('base는 색을 소유하지 않는다', () => { + const body = stripComments(baseCss); + expect(body).not.toMatch(/#[0-9a-fA-F]{3,8}\b/); + expect(body).not.toMatch(/\b(rgba?|hsla?|color-mix|oklch)\(/); + expect(body).not.toMatch(/:\s*(white|black)\b/); + }); + + it('테마 파일은 구조 토큰을 소유하지 않는다', () => { + const structural = /(radius|duration|ease|stagger|scale|^--z-)/; + expect(darkKeys.filter((key) => structural.test(key))).toEqual([]); + expect(lightKeys.filter((key) => structural.test(key))).toEqual([]); + }); + + it('배럴이 세 파일을 순서대로 로드한다', () => { + const order = Array.from( + barrelCss.matchAll(/@import\s+'\.\/tokens\/([\w-]+)\.css'/g), + ).map((m) => m[1]); + expect(order).toEqual(['base', 'dark', 'light']); + }); + + it('tailwind가 참조하는 토큰이 양쪽 테마에 모두 있다', () => { + const referenced = new Set( + Array.from(tailwindConfig.matchAll(/var\((--ui-[\w-]+)\)/g)).map( + (m) => m[1], + ), + ); + const baseKeys = new Set(declaredKeys(baseCss)); + const missing = Array.from(referenced).filter( + (key) => + !baseKeys.has(key) && + !(darkKeys.includes(key) && lightKeys.includes(key)), + ); + expect(missing).toEqual([]); + }); + + it('소스가 참조하는 토큰이 양쪽 테마에 모두 있다', () => { + // 한쪽 테마에만 값이 있거나 아무 데도 없는 토큰을 쓰기 시작한 코드를 막는다. + // 사용자 콘텐츠 변수(--dmn-*, --key-*, --counter-* 등)는 앱 테마 대상이 아니다 + const known = new Set([ + ...declaredKeys(baseCss), + ...darkKeys.filter((key) => lightKeys.includes(key)), + ]); + const sources = [ + ...globSync('src/renderer/**/*.{ts,tsx}'), + ...globSync('src/renderer/styles/*.css'), + ].filter((file) => !file.includes('__tests__') && !file.includes('.test.')); + + const missing = new Map(); + for (const file of sources) { + const body = readFileSync(resolve(process.cwd(), file), 'utf8'); + for (const match of body.matchAll(/var\((--ui-[\w-]+)/g)) { + const key = match[1]; + if (known.has(key) || missing.has(key)) continue; + missing.set(key, file); + } + } + expect(Object.fromEntries(missing)).toEqual({}); + }); + + it('라이트 저강도 읽기 텍스트가 앱 바닥과 패널에서 AA 대비를 지킨다', () => { + for (const foreground of ['--ui-fg-muted', '--ui-fg-caption']) { + for (const surface of [ + '--ui-bg-app', + '--ui-bg-panel', + '--ui-bg-panel-detached', + ]) { + expect( + contrastRatio( + tokenHex(lightCss, foreground), + tokenHex(lightCss, surface), + ), + ).toBeGreaterThanOrEqual(4.5); + } + } + }); + + it('라이트 muted, caption, 장식 faint의 명도 단계를 분리한다', () => { + const muted = tokenHex(lightCss, '--ui-fg-muted'); + const caption = tokenHex(lightCss, '--ui-fg-caption'); + const faint = tokenHex(lightCss, '--ui-fg-faint'); + const mutedLightness = labLightness(muted); + const captionLightness = labLightness(caption); + const faintLightness = labLightness(faint); + + expect(captionLightness).toBeGreaterThan(mutedLightness); + expect(faintLightness).toBeGreaterThan(captionLightness); + expect(faintLightness - mutedLightness).toBeGreaterThanOrEqual(8); + }); +}); diff --git a/src/renderer/__tests__/rendering/zIndexLayerContract.test.ts b/src/renderer/__tests__/rendering/zIndexLayerContract.test.ts index 1b6cee234..fa91505b3 100644 --- a/src/renderer/__tests__/rendering/zIndexLayerContract.test.ts +++ b/src/renderer/__tests__/rendering/zIndexLayerContract.test.ts @@ -5,7 +5,7 @@ import { describe, expect, it } from 'vitest'; const readSource = (path: string) => readFileSync(resolve(process.cwd(), path), 'utf8'); -const TOKENS = readSource('src/renderer/styles/tokens.css'); +const TOKENS = readSource('src/renderer/styles/tokens/base.css'); const tokenValue = (name: string): number => { const match = TOKENS.match(new RegExp(`--${name}:\\s*(-?\\d+);`)); @@ -92,7 +92,7 @@ describe('z-index 층 계약', () => { }); // 컴포넌트 안에서만 겨루는 작은 값(z-10 등)은 계약 밖이다. - // 사다리 구간을 맨숫자로 쓰면 tokens.css가 단일 소스가 아니게 된다 + // 사다리 구간을 맨숫자로 쓰면 tokens/base.css가 단일 소스가 아니게 된다 it('크롬 사다리 구간을 맨숫자로 쓰지 않는다', () => { const offenders = collectSources('src/renderer').filter((path) => { const source = stripComments(readSource(path)); diff --git a/src/renderer/assets/mp4/light/always-on-top.mp4 b/src/renderer/assets/mp4/light/always-on-top.mp4 new file mode 100644 index 000000000..2257d95bd Binary files /dev/null and b/src/renderer/assets/mp4/light/always-on-top.mp4 differ diff --git a/src/renderer/assets/mp4/light/custom-css.mp4 b/src/renderer/assets/mp4/light/custom-css.mp4 new file mode 100644 index 000000000..c901d3a6f Binary files /dev/null and b/src/renderer/assets/mp4/light/custom-css.mp4 differ diff --git a/src/renderer/assets/mp4/light/custom-js.mp4 b/src/renderer/assets/mp4/light/custom-js.mp4 new file mode 100644 index 000000000..3aa9c3eb5 Binary files /dev/null and b/src/renderer/assets/mp4/light/custom-js.mp4 differ diff --git a/src/renderer/assets/mp4/light/key-counter.mp4 b/src/renderer/assets/mp4/light/key-counter.mp4 new file mode 100644 index 000000000..7509e5131 Binary files /dev/null and b/src/renderer/assets/mp4/light/key-counter.mp4 differ diff --git a/src/renderer/assets/mp4/light/note-effect.mp4 b/src/renderer/assets/mp4/light/note-effect.mp4 new file mode 100644 index 000000000..5161aec70 Binary files /dev/null and b/src/renderer/assets/mp4/light/note-effect.mp4 differ diff --git a/src/renderer/assets/mp4/light/obs-mode.mp4 b/src/renderer/assets/mp4/light/obs-mode.mp4 new file mode 100644 index 000000000..b999dfff6 Binary files /dev/null and b/src/renderer/assets/mp4/light/obs-mode.mp4 differ diff --git a/src/renderer/assets/mp4/light/overlay-lock.mp4 b/src/renderer/assets/mp4/light/overlay-lock.mp4 new file mode 100644 index 000000000..14ea19821 Binary files /dev/null and b/src/renderer/assets/mp4/light/overlay-lock.mp4 differ diff --git a/src/renderer/assets/mp4/light/resize-anchor.mp4 b/src/renderer/assets/mp4/light/resize-anchor.mp4 new file mode 100644 index 000000000..9b388dffc Binary files /dev/null and b/src/renderer/assets/mp4/light/resize-anchor.mp4 differ diff --git a/src/renderer/assets/mp4/light/tray-enabled.mp4 b/src/renderer/assets/mp4/light/tray-enabled.mp4 new file mode 100644 index 000000000..a24eab349 Binary files /dev/null and b/src/renderer/assets/mp4/light/tray-enabled.mp4 differ diff --git a/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchPanelHeader.test.tsx b/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchPanelHeader.test.tsx index da70b8591..02a25f318 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchPanelHeader.test.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchPanelHeader.test.tsx @@ -75,7 +75,7 @@ describe('BatchPanelHeader 이름 변경 계약', () => { expect(labels[0]?.textContent).toBe('propertiesPanel.multiSelection'); expect(labels[0]?.className).toBe('text-fg text-label leading-none'); expect(labels[1]?.textContent).toBe('(4)'); - expect(labels[1]?.className).toBe('text-fg-faint text-body'); + expect(labels[1]?.className).toBe('text-fg-caption text-body'); expect(container.querySelector('button')).toBeNull(); }); diff --git a/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchPanelHeader.tsx b/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchPanelHeader.tsx index d9108de7c..6c05ab47e 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchPanelHeader.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchPanelHeader.tsx @@ -53,7 +53,7 @@ const BatchPanelHeader = ({ )} {!selectedGroupInfo && ( - ({totalCount}) + ({totalCount}) )} diff --git a/src/renderer/components/main/Grid/PropertiesPanel/layer/LayerTabContent.tsx b/src/renderer/components/main/Grid/PropertiesPanel/layer/LayerTabContent.tsx index a80b027b2..b6610aec4 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/layer/LayerTabContent.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/layer/LayerTabContent.tsx @@ -504,7 +504,7 @@ const LayerTabContent: React.FC = ({ > {layerItems.length === 0 ? (
-

+

{t('propertiesPanel.noLayers') || 'No layers'}

diff --git a/src/renderer/components/main/Grid/PropertiesPanel/navigation/panelChrome.test.ts b/src/renderer/components/main/Grid/PropertiesPanel/navigation/panelChrome.test.ts index 301472a9d..69459d177 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/navigation/panelChrome.test.ts +++ b/src/renderer/components/main/Grid/PropertiesPanel/navigation/panelChrome.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; +import { MINIMAP_SURFACE_CLASS } from '@components/main/Grid/core/minimapChrome'; import { PANEL_HEADER_CLASS, PANEL_HEADER_HEIGHT, @@ -23,7 +24,14 @@ describe('panelChrome', () => { // 칩은 opacity로 등퇴장한다 - 라이브 블러가 붙으면 WKWebView에서 점멸 it('접힘 토글 칩은 라이브 블러 없는 솔리드 재질이다', () => { - expect(SIDE_PANEL_CHIP_MATERIAL_CLASS).toContain('bg-glass-panel-solid'); + expect(SIDE_PANEL_CHIP_MATERIAL_CLASS).toContain('-solid'); expect(SIDE_PANEL_CHIP_MATERIAL_CLASS).not.toContain('backdrop-glass'); }); + + // 칩이 보이는 건 패널이 닫혀 있을 때뿐이고, 그때는 미니맵과 같은 티어의 + // 캔버스 부유 칩이다. 패널 계열 재질로 되돌리면 라이트에서 두 칩이 8 L* 벌어진다 + it('접힘 토글 칩은 미니맵과 같은 캔버스 크롬 계열을 쓴다', () => { + expect(SIDE_PANEL_CHIP_MATERIAL_CLASS).toContain('bg-glass-dim'); + expect(MINIMAP_SURFACE_CLASS).toContain('bg-glass-dim'); + }); }); diff --git a/src/renderer/components/main/Grid/PropertiesPanel/navigation/panelChrome.ts b/src/renderer/components/main/Grid/PropertiesPanel/navigation/panelChrome.ts index f679aea55..471349803 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/navigation/panelChrome.ts +++ b/src/renderer/components/main/Grid/PropertiesPanel/navigation/panelChrome.ts @@ -9,8 +9,12 @@ export const SIDE_PANEL_MATERIAL_CLASS = // 접힘 토글 칩 - opacity로 등퇴장하므로 라이브 블러 금지 // (블러+opacity 조합은 WKWebView에서 블러 레이어가 점멸하고, 페이드를 래퍼로 // 옮겨도 opacity < 1인 조상이 backdrop root가 되어 같은 증상 - Modal.tsx 참조). -// 프레임과 같은 L*에 서도록 상주 크롬 솔리드 토큰을 쓴다 -export const SIDE_PANEL_CHIP_MATERIAL_CLASS = 'bg-glass-panel-solid'; +// 재질은 패널 계열이 아니라 캔버스 상주 크롬이다 - 칩이 보이는 건 패널이 닫혀 있을 +// 때뿐이고, 그때는 미니맵과 같은 티어의 부유 칩이라 같은 면으로 읽혀야 한다. +// 열릴 때 프레임과 L*가 어긋나는 구간이 생기지만 그건 페이드 200ms 안이고, +// 상시 보이는 상태가 우선한다. +// -solid는 라이브 dim과 같은 L*에 서도록 맞춰 둔 솔리드 쌍둥이다 +export const SIDE_PANEL_CHIP_MATERIAL_CLASS = 'bg-glass-dim-solid'; export const SIDE_PANEL_FRAME_CLASS = `absolute right-0 top-0 bottom-0 w-[240px] ${SIDE_PANEL_MATERIAL_CLASS} shadow-elevation-panel z-[var(--z-chrome-panel)]`; diff --git a/src/renderer/components/main/Grid/PropertiesPanel/plugin/PluginSettingsForm.tsx b/src/renderer/components/main/Grid/PropertiesPanel/plugin/PluginSettingsForm.tsx index 5aea1251a..63c857815 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/plugin/PluginSettingsForm.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/plugin/PluginSettingsForm.tsx @@ -75,7 +75,7 @@ const PluginSettingsForm = ({ ); if (!sections.some((section) => section.renderVisible)) { return ( -

+

{t('propertiesPanel.pluginNoSettings') || '설정할 항목이 없습니다.'}

); @@ -230,7 +230,7 @@ const PluginSettingsForm = ({ className="flex flex-col gap-[6px]" > {section.label && ( -

+

{sectionLabel}

)} diff --git a/src/renderer/components/main/Grid/PropertiesPanel/plugin/PluginSettingsPanelView.tsx b/src/renderer/components/main/Grid/PropertiesPanel/plugin/PluginSettingsPanelView.tsx index a9e69a7c6..359c00772 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/plugin/PluginSettingsPanelView.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/plugin/PluginSettingsPanelView.tsx @@ -76,7 +76,7 @@ const PluginSettingsPanelView: React.FC = ({ {t('propertiesPanel.pluginSettings') || '플러그인 설정'} {pluginSettingsPanel.pluginId} @@ -103,7 +103,7 @@ const PluginSettingsPanelView: React.FC = ({ ) : (
-

+

{t('propertiesPanel.pluginNoSettings') || '설정할 항목이 없습니다.'}

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/components/main/Grid/PropertiesPanel/single/SingleMappingSection.tsx b/src/renderer/components/main/Grid/PropertiesPanel/single/SingleMappingSection.tsx index 0cc55c0a7..790de0762 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/single/SingleMappingSection.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/single/SingleMappingSection.tsx @@ -139,7 +139,7 @@ const SingleMappingSection = ({ {!(slotListening && !slotPickerOpen) && slotParts.extra && ( // case 피처: +를 숫자 중심에 맞춘 글리프로 치환. // tracking은 +와 숫자 사이 0.25px 확보용, 끝 글자 뒤 여분은 -mr로 상쇄 (배지는 한 자리 전제) - + {slotParts.extra} )} diff --git a/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSelectionPanel.tsx b/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSelectionPanel.tsx index d523b33eb..0c207c11b 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSelectionPanel.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSelectionPanel.tsx @@ -227,7 +227,7 @@ export const PluginSelectionPanel: React.FC = ({
-

{noticeText}

+

{noticeText}

); @@ -264,13 +264,13 @@ export const PluginSelectionPanel: React.FC = ({ /> )} {!hasSinglePluginSelection && ( -

+

{t('propertiesPanel.pluginMultiSelection') || '플러그인 요소는 한 번에 하나만 편집할 수 있습니다.'}

)} {hasSinglePluginSelection && showModalHint && ( -

+

{t('propertiesPanel.pluginModalHint') || '이 플러그인은 설정 모달을 사용합니다. 요소를 클릭해 설정하세요.'}

diff --git a/src/renderer/components/main/Grid/PropertiesPanel/single/SpritePoseEditorPopup.tsx b/src/renderer/components/main/Grid/PropertiesPanel/single/SpritePoseEditorPopup.tsx index 8e384e2ad..c230f937c 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/single/SpritePoseEditorPopup.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/single/SpritePoseEditorPopup.tsx @@ -274,7 +274,7 @@ const SpritePoseEditorPopup: React.FC = ({ {/* 담당 키 - 미리보기·변환 아래가 정위치, 신규 상태도 카드가 작아 한눈에 보인다 */} {poseControls.keyOptions.length === 0 && deadTriggers.length === 0 ? ( -

+

{t('propertiesPanel.spriteNoKeys') || '이 모드에 키 요소가 없습니다'}

diff --git a/src/renderer/components/main/Grid/PropertiesPanelHost.tsx b/src/renderer/components/main/Grid/PropertiesPanelHost.tsx index dabb53c38..45ae5b3b8 100644 --- a/src/renderer/components/main/Grid/PropertiesPanelHost.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanelHost.tsx @@ -8,6 +8,7 @@ import { usePanelHeaderDrag } from '@hooks/panel/usePanelHeaderDrag'; import { usePointerFocusGuard } from '@hooks/ui/usePointerFocusGuard'; import { restoreLenisScroll } from '@hooks/useLenis'; import { panelWindowApi } from '@api/modules/window/panelWindowApi'; +import { subscribeResolvedTheme } from '@utils/theme/applyTheme'; import { flushFocusedEditor } from '@src/renderer/editor/runtime/lifecycle/lifecycleEditorFlush'; import { isHistoryEditorFlushLocked } from '@src/renderer/editor/runtime/lifecycle/historyEditorFlushLock'; import { @@ -396,8 +397,15 @@ const PropertiesPanelHost = ({ subtree: true, characterData: true, }); + // 테마 전환은 html 속성만 바꾼다 - head 옵저버에 안 걸려 네이티브 가장자리가 + // 옛 색으로 고착된다. 속성이 바뀐 뒤 토큰이 새 값이므로 다음 프레임에 읽는다 + const unsubscribeTheme = subscribeResolvedTheme(() => { + cancelAnimationFrame(scheduled); + scheduled = requestAnimationFrame(sync); + }); return () => { observer.disconnect(); + unsubscribeTheme(); cancelAnimationFrame(scheduled); }; }, [childWindow]); @@ -409,7 +417,7 @@ const PropertiesPanelHost = ({ {dockHint && (