From aa9d1f54a1fe31a4c5c16ac47fedb651dc59cbfa Mon Sep 17 00:00:00 2001 From: Wind-Explorer Date: Sun, 22 Mar 2026 02:10:10 +0800 Subject: [PATCH] accelerators sservice --- src-tauri/src/services/accelerators.rs | 307 +++++++++++++++ src-tauri/src/services/client_config/mod.rs | 219 ++--------- src-tauri/src/services/client_config/store.rs | 8 +- src-tauri/src/services/mod.rs | 1 + src-tauri/src/services/scene/interactivity.rs | 143 +------ src/lib/bindings.ts | 9 +- src/lib/utils/accelerators.ts | 212 +++++++++++ src/routes/app-menu/tabs/preferences.svelte | 352 ++++-------------- src/routes/client-config/+page.svelte | 32 +- 9 files changed, 642 insertions(+), 641 deletions(-) create mode 100644 src-tauri/src/services/accelerators.rs create mode 100644 src/lib/utils/accelerators.ts diff --git a/src-tauri/src/services/accelerators.rs b/src-tauri/src/services/accelerators.rs new file mode 100644 index 0000000..537c1cf --- /dev/null +++ b/src-tauri/src/services/accelerators.rs @@ -0,0 +1,307 @@ +use device_query::Keycode; +use serde::{Deserialize, Serialize}; +use specta::Type; + +#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Type)] +#[serde(rename_all = "snake_case")] +pub enum AcceleratorAction { + SceneInteractivity, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Type)] +pub struct KeyboardAccelerator { + #[serde(default)] + pub modifiers: Vec, + #[serde(default)] + pub key: Option, +} + +impl KeyboardAccelerator { + pub fn normalized(mut self) -> Self { + self.modifiers.sort_unstable(); + self.modifiers.dedup(); + + if self.modifiers.is_empty() { + return Self::default(); + } + + self + } +} + +#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Type)] +#[serde(rename_all = "snake_case")] +pub enum AcceleratorModifier { + Cmd, + Alt, + Ctrl, + Shift, +} + +impl Default for KeyboardAccelerator { + fn default() -> Self { + #[cfg(target_os = "macos")] + { + Self { + modifiers: vec![AcceleratorModifier::Cmd], + key: None, + } + } + + #[cfg(not(target_os = "macos"))] + { + Self { + modifiers: vec![AcceleratorModifier::Alt], + key: None, + } + } + } +} + +pub fn default_accelerator_for_action(action: AcceleratorAction) -> KeyboardAccelerator { + match action { + AcceleratorAction::SceneInteractivity => KeyboardAccelerator::default(), + } +} + +pub fn default_accelerators() -> std::collections::BTreeMap +{ + let mut map = std::collections::BTreeMap::new(); + map.insert( + AcceleratorAction::SceneInteractivity, + default_accelerator_for_action(AcceleratorAction::SceneInteractivity), + ); + map +} + +pub fn normalize_accelerators( + mut accelerators: std::collections::BTreeMap, +) -> std::collections::BTreeMap { + for value in accelerators.values_mut() { + *value = value.clone().normalized(); + } + + for action in [AcceleratorAction::SceneInteractivity] { + accelerators + .entry(action) + .or_insert_with(|| default_accelerator_for_action(action)); + } + + accelerators +} + +#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Type)] +#[serde(rename_all = "snake_case")] +pub enum AcceleratorKey { + A, + B, + C, + D, + E, + F, + G, + H, + I, + J, + K, + L, + M, + N, + O, + P, + Q, + R, + S, + T, + U, + V, + W, + X, + Y, + Z, + Num0, + Num1, + Num2, + Num3, + Num4, + Num5, + Num6, + Num7, + Num8, + Num9, + F1, + F2, + F3, + F4, + F5, + F6, + F7, + F8, + F9, + F10, + F11, + F12, + Enter, + Space, + Escape, + Tab, + Backspace, + Delete, + Insert, + Home, + End, + PageUp, + PageDown, + ArrowUp, + ArrowDown, + ArrowLeft, + ArrowRight, + Minus, + Equal, + LeftBracket, + RightBracket, + BackSlash, + Semicolon, + Apostrophe, + Comma, + Dot, + Slash, + Grave, +} + +fn contains_any(keys: &[Keycode], candidates: &[Keycode]) -> bool { + candidates.iter().any(|candidate| keys.contains(candidate)) +} + +fn has_modifier(keys: &[Keycode], modifier: AcceleratorModifier) -> bool { + match modifier { + AcceleratorModifier::Cmd => contains_any( + keys, + &[ + Keycode::Command, + Keycode::RCommand, + Keycode::LMeta, + Keycode::RMeta, + ], + ), + AcceleratorModifier::Alt => contains_any( + keys, + &[ + Keycode::LAlt, + Keycode::RAlt, + Keycode::LOption, + Keycode::ROption, + ], + ), + AcceleratorModifier::Ctrl => contains_any(keys, &[Keycode::LControl, Keycode::RControl]), + AcceleratorModifier::Shift => contains_any(keys, &[Keycode::LShift, Keycode::RShift]), + } +} + +fn keycodes_for(key: AcceleratorKey) -> &'static [Keycode] { + match key { + AcceleratorKey::A => &[Keycode::A], + AcceleratorKey::B => &[Keycode::B], + AcceleratorKey::C => &[Keycode::C], + AcceleratorKey::D => &[Keycode::D], + AcceleratorKey::E => &[Keycode::E], + AcceleratorKey::F => &[Keycode::F], + AcceleratorKey::G => &[Keycode::G], + AcceleratorKey::H => &[Keycode::H], + AcceleratorKey::I => &[Keycode::I], + AcceleratorKey::J => &[Keycode::J], + AcceleratorKey::K => &[Keycode::K], + AcceleratorKey::L => &[Keycode::L], + AcceleratorKey::M => &[Keycode::M], + AcceleratorKey::N => &[Keycode::N], + AcceleratorKey::O => &[Keycode::O], + AcceleratorKey::P => &[Keycode::P], + AcceleratorKey::Q => &[Keycode::Q], + AcceleratorKey::R => &[Keycode::R], + AcceleratorKey::S => &[Keycode::S], + AcceleratorKey::T => &[Keycode::T], + AcceleratorKey::U => &[Keycode::U], + AcceleratorKey::V => &[Keycode::V], + AcceleratorKey::W => &[Keycode::W], + AcceleratorKey::X => &[Keycode::X], + AcceleratorKey::Y => &[Keycode::Y], + AcceleratorKey::Z => &[Keycode::Z], + AcceleratorKey::Num0 => &[Keycode::Key0], + AcceleratorKey::Num1 => &[Keycode::Key1], + AcceleratorKey::Num2 => &[Keycode::Key2], + AcceleratorKey::Num3 => &[Keycode::Key3], + AcceleratorKey::Num4 => &[Keycode::Key4], + AcceleratorKey::Num5 => &[Keycode::Key5], + AcceleratorKey::Num6 => &[Keycode::Key6], + AcceleratorKey::Num7 => &[Keycode::Key7], + AcceleratorKey::Num8 => &[Keycode::Key8], + AcceleratorKey::Num9 => &[Keycode::Key9], + AcceleratorKey::F1 => &[Keycode::F1], + AcceleratorKey::F2 => &[Keycode::F2], + AcceleratorKey::F3 => &[Keycode::F3], + AcceleratorKey::F4 => &[Keycode::F4], + AcceleratorKey::F5 => &[Keycode::F5], + AcceleratorKey::F6 => &[Keycode::F6], + AcceleratorKey::F7 => &[Keycode::F7], + AcceleratorKey::F8 => &[Keycode::F8], + AcceleratorKey::F9 => &[Keycode::F9], + AcceleratorKey::F10 => &[Keycode::F10], + AcceleratorKey::F11 => &[Keycode::F11], + AcceleratorKey::F12 => &[Keycode::F12], + AcceleratorKey::Enter => &[Keycode::Enter, Keycode::NumpadEnter], + AcceleratorKey::Space => &[Keycode::Space], + AcceleratorKey::Escape => &[Keycode::Escape], + AcceleratorKey::Tab => &[Keycode::Tab], + AcceleratorKey::Backspace => &[Keycode::Backspace], + AcceleratorKey::Delete => &[Keycode::Delete], + AcceleratorKey::Insert => &[Keycode::Insert], + AcceleratorKey::Home => &[Keycode::Home], + AcceleratorKey::End => &[Keycode::End], + AcceleratorKey::PageUp => &[Keycode::PageUp], + AcceleratorKey::PageDown => &[Keycode::PageDown], + AcceleratorKey::ArrowUp => &[Keycode::Up], + AcceleratorKey::ArrowDown => &[Keycode::Down], + AcceleratorKey::ArrowLeft => &[Keycode::Left], + AcceleratorKey::ArrowRight => &[Keycode::Right], + AcceleratorKey::Minus => &[Keycode::Minus], + AcceleratorKey::Equal => &[Keycode::Equal], + AcceleratorKey::LeftBracket => &[Keycode::LeftBracket], + AcceleratorKey::RightBracket => &[Keycode::RightBracket], + AcceleratorKey::BackSlash => &[Keycode::BackSlash], + AcceleratorKey::Semicolon => &[Keycode::Semicolon], + AcceleratorKey::Apostrophe => &[Keycode::Apostrophe], + AcceleratorKey::Comma => &[Keycode::Comma], + AcceleratorKey::Dot => &[Keycode::Dot], + AcceleratorKey::Slash => &[Keycode::Slash], + AcceleratorKey::Grave => &[Keycode::Grave], + } +} + +fn has_key(keys: &[Keycode], key: AcceleratorKey) -> bool { + contains_any(keys, keycodes_for(key)) +} + +fn pressed_modifiers(keys: &[Keycode]) -> Vec { + let mut modifiers = Vec::new(); + + for modifier in [ + AcceleratorModifier::Cmd, + AcceleratorModifier::Alt, + AcceleratorModifier::Ctrl, + AcceleratorModifier::Shift, + ] { + if has_modifier(keys, modifier) { + modifiers.push(modifier); + } + } + + modifiers +} + +pub fn is_accelerator_active(keys: &[Keycode], accelerator: &KeyboardAccelerator) -> bool { + if pressed_modifiers(keys) != accelerator.modifiers { + return false; + } + + accelerator.key.map_or(true, |key| has_key(keys, key)) +} diff --git a/src-tauri/src/services/client_config/mod.rs b/src-tauri/src/services/client_config/mod.rs index ce3f649..27f3e46 100644 --- a/src-tauri/src/services/client_config/mod.rs +++ b/src-tauri/src/services/client_config/mod.rs @@ -1,6 +1,8 @@ mod store; mod window; +use std::collections::BTreeMap; + use serde::{Deserialize, Deserializer, Serialize}; use specta::Type; use thiserror::Error; @@ -8,216 +10,65 @@ use thiserror::Error; pub use store::{load_app_config, save_app_config}; pub use window::open_config_window; -#[derive(Default, Serialize, Deserialize, Clone, Debug, Type)] +pub use crate::services::accelerators::{ + default_accelerator_for_action, default_accelerators, normalize_accelerators, + AcceleratorAction, KeyboardAccelerator, +}; + +#[derive(Serialize, Clone, Debug, Type)] pub struct AppConfig { pub api_base_url: Option, pub debug_mode: bool, #[serde(default)] - pub scene_interactivity_hotkey: SceneInteractivityHotkey, + pub accelerators: BTreeMap, } -#[derive(Serialize, Clone, Debug, PartialEq, Eq, Type)] -pub struct SceneInteractivityHotkey { - #[serde(default)] - pub modifiers: Vec, - #[serde(default)] - pub key: Option, -} - -impl SceneInteractivityHotkey { +impl AppConfig { pub fn normalized(mut self) -> Self { - self.modifiers.sort_unstable(); - self.modifiers.dedup(); - - if self.modifiers.is_empty() { - return Self::default(); - } - + self.accelerators = normalize_accelerators(self.accelerators); self } - fn from_legacy(value: LegacySceneInteractivityHotkey) -> Self { - let modifiers = match value { - LegacySceneInteractivityHotkey::CmdAlt => { - vec![ - SceneInteractivityModifier::Cmd, - SceneInteractivityModifier::Alt, - ] - } - LegacySceneInteractivityHotkey::CmdCtrl => { - vec![ - SceneInteractivityModifier::Cmd, - SceneInteractivityModifier::Ctrl, - ] - } - LegacySceneInteractivityHotkey::AltCtrl => { - vec![ - SceneInteractivityModifier::Alt, - SceneInteractivityModifier::Ctrl, - ] - } - LegacySceneInteractivityHotkey::Cmd => vec![SceneInteractivityModifier::Cmd], - LegacySceneInteractivityHotkey::Alt => vec![SceneInteractivityModifier::Alt], - LegacySceneInteractivityHotkey::Ctrl => vec![SceneInteractivityModifier::Ctrl], - }; + pub fn accelerator_for(&self, action: AcceleratorAction) -> KeyboardAccelerator { + self.accelerators + .get(&action) + .cloned() + .unwrap_or_else(|| default_accelerator_for_action(action)) + } +} +impl Default for AppConfig { + fn default() -> Self { Self { - modifiers, - key: None, + api_base_url: None, + debug_mode: false, + accelerators: default_accelerators(), } } } -impl<'de> Deserialize<'de> for SceneInteractivityHotkey { +impl<'de> Deserialize<'de> for AppConfig { fn deserialize(deserializer: D) -> Result where D: Deserializer<'de>, { #[derive(Deserialize)] - struct SceneInteractivityHotkeyV2 { + struct AppConfigSerde { + api_base_url: Option, #[serde(default)] - modifiers: Vec, + debug_mode: bool, #[serde(default)] - key: Option, + accelerators: BTreeMap, } - #[derive(Deserialize)] - #[serde(untagged)] - enum SceneInteractivityHotkeySerde { - V2(SceneInteractivityHotkeyV2), - Legacy(LegacySceneInteractivityHotkey), - } - - let value = SceneInteractivityHotkeySerde::deserialize(deserializer)?; - let normalized = match value { - SceneInteractivityHotkeySerde::V2(v2) => Self { - modifiers: v2.modifiers, - key: v2.key, - } - .normalized(), - SceneInteractivityHotkeySerde::Legacy(legacy) => Self::from_legacy(legacy).normalized(), - }; - - Ok(normalized) - } -} - -#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Type)] -#[serde(rename_all = "snake_case")] -pub enum SceneInteractivityModifier { - Cmd, - Alt, - Ctrl, - Shift, -} - -#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Type)] -#[serde(rename_all = "snake_case")] -pub enum SceneInteractivityKey { - A, - B, - C, - D, - E, - F, - G, - H, - I, - J, - K, - L, - M, - N, - O, - P, - Q, - R, - S, - T, - U, - V, - W, - X, - Y, - Z, - Num0, - Num1, - Num2, - Num3, - Num4, - Num5, - Num6, - Num7, - Num8, - Num9, - F1, - F2, - F3, - F4, - F5, - F6, - F7, - F8, - F9, - F10, - F11, - F12, - Enter, - Space, - Escape, - Tab, - Backspace, - Delete, - Insert, - Home, - End, - PageUp, - PageDown, - ArrowUp, - ArrowDown, - ArrowLeft, - ArrowRight, - Minus, - Equal, - LeftBracket, - RightBracket, - BackSlash, - Semicolon, - Apostrophe, - Comma, - Dot, - Slash, - Grave, -} - -#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -enum LegacySceneInteractivityHotkey { - CmdAlt, - CmdCtrl, - AltCtrl, - Cmd, - Alt, - Ctrl, -} - -impl Default for SceneInteractivityHotkey { - fn default() -> Self { - #[cfg(target_os = "macos")] - { - Self { - modifiers: vec![SceneInteractivityModifier::Cmd], - key: None, - } - } - - #[cfg(not(target_os = "macos"))] - { - Self { - modifiers: vec![SceneInteractivityModifier::Alt], - key: None, - } + let value = AppConfigSerde::deserialize(deserializer)?; + + Ok(Self { + api_base_url: value.api_base_url, + debug_mode: value.debug_mode, + accelerators: value.accelerators, } + .normalized()) } } diff --git a/src-tauri/src/services/client_config/store.rs b/src-tauri/src/services/client_config/store.rs index 537e135..69497cd 100644 --- a/src-tauri/src/services/client_config/store.rs +++ b/src-tauri/src/services/client_config/store.rs @@ -6,7 +6,7 @@ use url::Url; use crate::get_app_handle; -use super::{AppConfig, ClientConfigError, SceneInteractivityHotkey}; +use super::{AppConfig, ClientConfigError}; const CONFIG_FILENAME: &str = "client_config.json"; const DEFAULT_API_BASE_URL: &str = "https://api.friendolls.adamcv.com"; @@ -48,16 +48,14 @@ fn sanitize(mut config: AppConfig) -> AppConfig { .or_else(|| Some(DEFAULT_API_BASE_URL.to_string())) .map(|v| strip_trailing_slash(&v)); - config.scene_interactivity_hotkey = config.scene_interactivity_hotkey.normalized(); - - config + config.normalized() } pub fn default_app_config() -> AppConfig { AppConfig { api_base_url: Some(DEFAULT_API_BASE_URL.to_string()), debug_mode: false, - scene_interactivity_hotkey: SceneInteractivityHotkey::default(), + ..AppConfig::default() } } diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index 5aae99f..7ac78eb 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -2,6 +2,7 @@ pub mod app_data; pub mod app_events; pub mod app_menu; pub mod app_update; +pub mod accelerators; pub mod auth; pub mod client_config; pub mod cursor; diff --git a/src-tauri/src/services/scene/interactivity.rs b/src-tauri/src/services/scene/interactivity.rs index b59cc94..51ab3f6 100644 --- a/src-tauri/src/services/scene/interactivity.rs +++ b/src-tauri/src/services/scene/interactivity.rs @@ -2,7 +2,7 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use std::thread; -use device_query::{DeviceQuery, DeviceState, Keycode}; +use device_query::{DeviceQuery, DeviceState}; use once_cell::sync::OnceCell; use tauri::Manager; use tauri_specta::Event as _; @@ -11,10 +11,8 @@ use tracing::{error, info, warn}; use crate::{ get_app_handle, lock_r, services::{ - app_events::SceneInteractiveChanged, - client_config::{ - SceneInteractivityHotkey, SceneInteractivityKey, SceneInteractivityModifier, - }, + accelerators::is_accelerator_active, app_events::SceneInteractiveChanged, + client_config::AcceleratorAction, }, state::FDOLL, }; @@ -38,135 +36,6 @@ fn scene_interactive_state() -> Arc { .clone() } -fn has_modifier(keys: &[Keycode], modifier: SceneInteractivityModifier) -> bool { - match modifier { - SceneInteractivityModifier::Cmd => { - keys.contains(&Keycode::Command) - || keys.contains(&Keycode::RCommand) - || keys.contains(&Keycode::LMeta) - || keys.contains(&Keycode::RMeta) - } - SceneInteractivityModifier::Alt => { - keys.contains(&Keycode::LAlt) - || keys.contains(&Keycode::RAlt) - || keys.contains(&Keycode::LOption) - || keys.contains(&Keycode::ROption) - } - SceneInteractivityModifier::Ctrl => { - keys.contains(&Keycode::LControl) || keys.contains(&Keycode::RControl) - } - SceneInteractivityModifier::Shift => { - keys.contains(&Keycode::LShift) || keys.contains(&Keycode::RShift) - } - } -} - -fn has_key(keys: &[Keycode], key: SceneInteractivityKey) -> bool { - match key { - SceneInteractivityKey::A => keys.contains(&Keycode::A), - SceneInteractivityKey::B => keys.contains(&Keycode::B), - SceneInteractivityKey::C => keys.contains(&Keycode::C), - SceneInteractivityKey::D => keys.contains(&Keycode::D), - SceneInteractivityKey::E => keys.contains(&Keycode::E), - SceneInteractivityKey::F => keys.contains(&Keycode::F), - SceneInteractivityKey::G => keys.contains(&Keycode::G), - SceneInteractivityKey::H => keys.contains(&Keycode::H), - SceneInteractivityKey::I => keys.contains(&Keycode::I), - SceneInteractivityKey::J => keys.contains(&Keycode::J), - SceneInteractivityKey::K => keys.contains(&Keycode::K), - SceneInteractivityKey::L => keys.contains(&Keycode::L), - SceneInteractivityKey::M => keys.contains(&Keycode::M), - SceneInteractivityKey::N => keys.contains(&Keycode::N), - SceneInteractivityKey::O => keys.contains(&Keycode::O), - SceneInteractivityKey::P => keys.contains(&Keycode::P), - SceneInteractivityKey::Q => keys.contains(&Keycode::Q), - SceneInteractivityKey::R => keys.contains(&Keycode::R), - SceneInteractivityKey::S => keys.contains(&Keycode::S), - SceneInteractivityKey::T => keys.contains(&Keycode::T), - SceneInteractivityKey::U => keys.contains(&Keycode::U), - SceneInteractivityKey::V => keys.contains(&Keycode::V), - SceneInteractivityKey::W => keys.contains(&Keycode::W), - SceneInteractivityKey::X => keys.contains(&Keycode::X), - SceneInteractivityKey::Y => keys.contains(&Keycode::Y), - SceneInteractivityKey::Z => keys.contains(&Keycode::Z), - SceneInteractivityKey::Num0 => keys.contains(&Keycode::Key0), - SceneInteractivityKey::Num1 => keys.contains(&Keycode::Key1), - SceneInteractivityKey::Num2 => keys.contains(&Keycode::Key2), - SceneInteractivityKey::Num3 => keys.contains(&Keycode::Key3), - SceneInteractivityKey::Num4 => keys.contains(&Keycode::Key4), - SceneInteractivityKey::Num5 => keys.contains(&Keycode::Key5), - SceneInteractivityKey::Num6 => keys.contains(&Keycode::Key6), - SceneInteractivityKey::Num7 => keys.contains(&Keycode::Key7), - SceneInteractivityKey::Num8 => keys.contains(&Keycode::Key8), - SceneInteractivityKey::Num9 => keys.contains(&Keycode::Key9), - SceneInteractivityKey::F1 => keys.contains(&Keycode::F1), - SceneInteractivityKey::F2 => keys.contains(&Keycode::F2), - SceneInteractivityKey::F3 => keys.contains(&Keycode::F3), - SceneInteractivityKey::F4 => keys.contains(&Keycode::F4), - SceneInteractivityKey::F5 => keys.contains(&Keycode::F5), - SceneInteractivityKey::F6 => keys.contains(&Keycode::F6), - SceneInteractivityKey::F7 => keys.contains(&Keycode::F7), - SceneInteractivityKey::F8 => keys.contains(&Keycode::F8), - SceneInteractivityKey::F9 => keys.contains(&Keycode::F9), - SceneInteractivityKey::F10 => keys.contains(&Keycode::F10), - SceneInteractivityKey::F11 => keys.contains(&Keycode::F11), - SceneInteractivityKey::F12 => keys.contains(&Keycode::F12), - SceneInteractivityKey::Enter => { - keys.contains(&Keycode::Enter) || keys.contains(&Keycode::NumpadEnter) - } - SceneInteractivityKey::Space => keys.contains(&Keycode::Space), - SceneInteractivityKey::Escape => keys.contains(&Keycode::Escape), - SceneInteractivityKey::Tab => keys.contains(&Keycode::Tab), - SceneInteractivityKey::Backspace => keys.contains(&Keycode::Backspace), - SceneInteractivityKey::Delete => keys.contains(&Keycode::Delete), - SceneInteractivityKey::Insert => keys.contains(&Keycode::Insert), - SceneInteractivityKey::Home => keys.contains(&Keycode::Home), - SceneInteractivityKey::End => keys.contains(&Keycode::End), - SceneInteractivityKey::PageUp => keys.contains(&Keycode::PageUp), - SceneInteractivityKey::PageDown => keys.contains(&Keycode::PageDown), - SceneInteractivityKey::ArrowUp => keys.contains(&Keycode::Up), - SceneInteractivityKey::ArrowDown => keys.contains(&Keycode::Down), - SceneInteractivityKey::ArrowLeft => keys.contains(&Keycode::Left), - SceneInteractivityKey::ArrowRight => keys.contains(&Keycode::Right), - SceneInteractivityKey::Minus => keys.contains(&Keycode::Minus), - SceneInteractivityKey::Equal => keys.contains(&Keycode::Equal), - SceneInteractivityKey::LeftBracket => keys.contains(&Keycode::LeftBracket), - SceneInteractivityKey::RightBracket => keys.contains(&Keycode::RightBracket), - SceneInteractivityKey::BackSlash => keys.contains(&Keycode::BackSlash), - SceneInteractivityKey::Semicolon => keys.contains(&Keycode::Semicolon), - SceneInteractivityKey::Apostrophe => keys.contains(&Keycode::Apostrophe), - SceneInteractivityKey::Comma => keys.contains(&Keycode::Comma), - SceneInteractivityKey::Dot => keys.contains(&Keycode::Dot), - SceneInteractivityKey::Slash => keys.contains(&Keycode::Slash), - SceneInteractivityKey::Grave => keys.contains(&Keycode::Grave), - } -} - -fn pressed_modifiers(keys: &[Keycode]) -> Vec { - let mut modifiers = Vec::new(); - - for modifier in [ - SceneInteractivityModifier::Cmd, - SceneInteractivityModifier::Alt, - SceneInteractivityModifier::Ctrl, - SceneInteractivityModifier::Shift, - ] { - if has_modifier(keys, modifier) { - modifiers.push(modifier); - } - } - - modifiers -} - -fn is_hotkey_active(keys: &[Keycode], hotkey: SceneInteractivityHotkey) -> bool { - if pressed_modifiers(keys) != hotkey.modifiers { - return false; - } - - hotkey.key.map_or(true, |key| has_key(keys, key)) -} - pub(crate) fn start_scene_modifier_listener() { MODIFIER_LISTENER_INIT.get_or_init(|| { let state = scene_interactive_state(); @@ -206,9 +75,11 @@ pub(crate) fn start_scene_modifier_listener() { let keys = device_state.get_keys(); let hotkey = { let guard = lock_r!(FDOLL); - guard.app_config.scene_interactivity_hotkey.clone() + guard + .app_config + .accelerator_for(AcceleratorAction::SceneInteractivity) }; - let keys_interactive = is_hotkey_active(&keys, hotkey); + let keys_interactive = is_accelerator_active(&keys, &hotkey); let menus_open = { if let Ok(menus) = get_open_pet_menus().lock() { diff --git a/src/lib/bindings.ts b/src/lib/bindings.ts index e5443b3..d2bde0b 100644 --- a/src/lib/bindings.ts +++ b/src/lib/bindings.ts @@ -174,8 +174,11 @@ userStatusChanged: "user-status-changed" /** user-defined types **/ +export type AcceleratorAction = "scene_interactivity" +export type AcceleratorKey = "a" | "b" | "c" | "d" | "e" | "f" | "g" | "h" | "i" | "j" | "k" | "l" | "m" | "n" | "o" | "p" | "q" | "r" | "s" | "t" | "u" | "v" | "w" | "x" | "y" | "z" | "num_0" | "num_1" | "num_2" | "num_3" | "num_4" | "num_5" | "num_6" | "num_7" | "num_8" | "num_9" | "f1" | "f2" | "f3" | "f4" | "f5" | "f6" | "f7" | "f8" | "f9" | "f10" | "f11" | "f12" | "enter" | "space" | "escape" | "tab" | "backspace" | "delete" | "insert" | "home" | "end" | "page_up" | "page_down" | "arrow_up" | "arrow_down" | "arrow_left" | "arrow_right" | "minus" | "equal" | "left_bracket" | "right_bracket" | "back_slash" | "semicolon" | "apostrophe" | "comma" | "dot" | "slash" | "grave" +export type AcceleratorModifier = "cmd" | "alt" | "ctrl" | "shift" export type ActiveDollSpriteChanged = string | null -export type AppConfig = { api_base_url: string | null; debug_mode: boolean; scene_interactivity_hotkey?: SceneInteractivityHotkey } +export type AppConfig = { api_base_url: string | null; debug_mode: boolean; accelerators?: Partial<{ [key in AcceleratorAction]: KeyboardAccelerator }> } export type AppDataRefreshed = UserData export type AuthFlowStatus = "started" | "succeeded" | "failed" | "cancelled" export type AuthFlowUpdated = AuthFlowUpdatedPayload @@ -212,13 +215,11 @@ export type InteractionDeliveryFailed = InteractionDeliveryFailedDto export type InteractionDeliveryFailedDto = { recipientUserId: string; reason: string } export type InteractionPayloadDto = { senderUserId: string; senderName: string; content: string; type: string; timestamp: string } export type InteractionReceived = InteractionPayloadDto +export type KeyboardAccelerator = { modifiers?: AcceleratorModifier[]; key?: AcceleratorKey | null } export type ModuleMetadata = { id: string; name: string; version: string; description: string | null } export type PresenceStatus = { title: string | null; subtitle: string | null; graphicsB64: string | null } export type SceneData = { display: DisplayData; grid_size: number } export type SceneInteractiveChanged = boolean -export type SceneInteractivityHotkey = { modifiers?: SceneInteractivityModifier[]; key?: SceneInteractivityKey | null } -export type SceneInteractivityKey = "a" | "b" | "c" | "d" | "e" | "f" | "g" | "h" | "i" | "j" | "k" | "l" | "m" | "n" | "o" | "p" | "q" | "r" | "s" | "t" | "u" | "v" | "w" | "x" | "y" | "z" | "num_0" | "num_1" | "num_2" | "num_3" | "num_4" | "num_5" | "num_6" | "num_7" | "num_8" | "num_9" | "f1" | "f2" | "f3" | "f4" | "f5" | "f6" | "f7" | "f8" | "f9" | "f10" | "f11" | "f12" | "enter" | "space" | "escape" | "tab" | "backspace" | "delete" | "insert" | "home" | "end" | "page_up" | "page_down" | "arrow_up" | "arrow_down" | "arrow_left" | "arrow_right" | "minus" | "equal" | "left_bracket" | "right_bracket" | "back_slash" | "semicolon" | "apostrophe" | "comma" | "dot" | "slash" | "grave" -export type SceneInteractivityModifier = "cmd" | "alt" | "ctrl" | "shift" export type SendFriendRequestDto = { receiverId: string } export type SendInteractionDto = { recipientUserId: string; content: string; type: string } export type SetInteractionOverlay = boolean diff --git a/src/lib/utils/accelerators.ts b/src/lib/utils/accelerators.ts new file mode 100644 index 0000000..3fb3177 --- /dev/null +++ b/src/lib/utils/accelerators.ts @@ -0,0 +1,212 @@ +import type { + AcceleratorAction, + AcceleratorKey, + AcceleratorModifier, + AppConfig, + KeyboardAccelerator, +} from "$lib/bindings"; + +export const SCENE_INTERACTIVITY_ACTION: AcceleratorAction = + "scene_interactivity"; + +const MODIFIER_PRIORITY: Record = { + cmd: 0, + alt: 1, + ctrl: 2, + shift: 3, +}; + +export const MODIFIER_LABELS: Record = { + cmd: "Cmd", + alt: "Alt", + ctrl: "Ctrl", + shift: "Shift", +}; + +const SPECIAL_KEY_LABELS: Partial> = { + enter: "Enter", + space: "Space", + escape: "Escape", + tab: "Tab", + backspace: "Backspace", + delete: "Delete", + insert: "Insert", + home: "Home", + end: "End", + page_up: "Page Up", + page_down: "Page Down", + arrow_up: "Arrow Up", + arrow_down: "Arrow Down", + arrow_left: "Arrow Left", + arrow_right: "Arrow Right", + minus: "-", + equal: "=", + left_bracket: "[", + right_bracket: "]", + back_slash: "\\", + semicolon: ";", + apostrophe: "'", + comma: ",", + dot: ".", + slash: "/", + grave: "`", +}; + +const SPECIAL_CODE_TO_KEY: Record = { + Enter: "enter", + Space: "space", + Escape: "escape", + Tab: "tab", + Backspace: "backspace", + Delete: "delete", + Insert: "insert", + Home: "home", + End: "end", + PageUp: "page_up", + PageDown: "page_down", + ArrowUp: "arrow_up", + ArrowDown: "arrow_down", + ArrowLeft: "arrow_left", + ArrowRight: "arrow_right", + Minus: "minus", + Equal: "equal", + BracketLeft: "left_bracket", + BracketRight: "right_bracket", + Backslash: "back_slash", + Semicolon: "semicolon", + Quote: "apostrophe", + Comma: "comma", + Period: "dot", + Slash: "slash", + Backquote: "grave", +}; + +const FUNCTION_KEY_MIN = 1; +const FUNCTION_KEY_MAX = 12; + +const toLetterKey = (code: string): AcceleratorKey | null => { + if (!code.startsWith("Key") || code.length !== 4) return null; + + const letter = code[3].toLowerCase(); + if (letter < "a" || letter > "z") return null; + + return letter as AcceleratorKey; +}; + +const toDigitKey = (code: string): AcceleratorKey | null => { + if (!code.startsWith("Digit") || code.length !== 6) return null; + + const digit = code[5]; + if (digit < "0" || digit > "9") return null; + + return `num_${digit}` as AcceleratorKey; +}; + +const toFunctionKey = (code: string): AcceleratorKey | null => { + if (!code.startsWith("F")) return null; + + const parsed = Number.parseInt(code.slice(1), 10); + if (Number.isNaN(parsed)) return null; + if (parsed < FUNCTION_KEY_MIN || parsed > FUNCTION_KEY_MAX) return null; + + return `f${parsed}` as AcceleratorKey; +}; + +const toPatternKey = (code: string): AcceleratorKey | null => { + return toLetterKey(code) ?? toDigitKey(code) ?? toFunctionKey(code); +}; + +const toPatternLabel = (key: AcceleratorKey): string | null => { + if (key.length === 1) { + return key.toUpperCase(); + } + + if (key.startsWith("num_")) { + return key.slice(4); + } + + if (key.startsWith("f")) { + const parsed = Number.parseInt(key.slice(1), 10); + if (Number.isNaN(parsed)) return null; + if (parsed < FUNCTION_KEY_MIN || parsed > FUNCTION_KEY_MAX) return null; + return `F${parsed}`; + } + + return null; +}; + +export const keyFromKeyboardCode = (code: string): AcceleratorKey | null => { + return toPatternKey(code) ?? SPECIAL_CODE_TO_KEY[code] ?? null; +}; + +export const labelForAcceleratorKey = (key: AcceleratorKey): string => { + return toPatternLabel(key) ?? SPECIAL_KEY_LABELS[key] ?? key; +}; + +export const MODIFIER_CODES = new Set([ + "MetaLeft", + "MetaRight", + "AltLeft", + "AltRight", + "ControlLeft", + "ControlRight", + "ShiftLeft", + "ShiftRight", +]); + +export const normalizeAccelerator = ( + accelerator: KeyboardAccelerator, +): KeyboardAccelerator => { + const uniqueModifiers = [...new Set(accelerator.modifiers ?? [])].sort( + (a, b) => MODIFIER_PRIORITY[a] - MODIFIER_PRIORITY[b], + ); + + return { + modifiers: uniqueModifiers, + key: accelerator.key ?? null, + }; +}; + +export const formatAcceleratorLabel = (accelerator: KeyboardAccelerator): string => { + const modifiers = (accelerator.modifiers ?? []).map( + (modifier) => MODIFIER_LABELS[modifier], + ); + const key = accelerator.key ? labelForAcceleratorKey(accelerator.key) : null; + return key ? [...modifiers, key].join(" + ") : modifiers.join(" + "); +}; + +export const getModifiersFromEvent = ( + event: KeyboardEvent, +): AcceleratorModifier[] => { + const modifiers: AcceleratorModifier[] = []; + if (event.metaKey) modifiers.push("cmd"); + if (event.altKey) modifiers.push("alt"); + if (event.ctrlKey) modifiers.push("ctrl"); + if (event.shiftKey) modifiers.push("shift"); + return modifiers; +}; + +export const acceleratorsEqual = ( + left: KeyboardAccelerator, + right: KeyboardAccelerator, +): boolean => { + const normalizedLeft = normalizeAccelerator(left); + const normalizedRight = normalizeAccelerator(right); + + if (normalizedLeft.key !== normalizedRight.key) return false; + + const leftModifiers = normalizedLeft.modifiers ?? []; + const rightModifiers = normalizedRight.modifiers ?? []; + + if (leftModifiers.length !== rightModifiers.length) return false; + + return leftModifiers.every((modifier, index) => modifier === rightModifiers[index]); +}; + +export const getAcceleratorForAction = ( + config: AppConfig, + action: AcceleratorAction, +): KeyboardAccelerator | null => { + const accelerator = config.accelerators?.[action]; + return accelerator ? normalizeAccelerator(accelerator) : null; +}; diff --git a/src/routes/app-menu/tabs/preferences.svelte b/src/routes/app-menu/tabs/preferences.svelte index d5a5b02..54e2118 100644 --- a/src/routes/app-menu/tabs/preferences.svelte +++ b/src/routes/app-menu/tabs/preferences.svelte @@ -3,263 +3,41 @@ import { commands, type AppConfig, - type SceneInteractivityHotkey, - type SceneInteractivityKey, - type SceneInteractivityModifier, + type KeyboardAccelerator, } from "$lib/bindings"; + import { + MODIFIER_CODES, + SCENE_INTERACTIVITY_ACTION, + acceleratorsEqual, + formatAcceleratorLabel, + getAcceleratorForAction, + getModifiersFromEvent, + keyFromKeyboardCode, + normalizeAccelerator, + } from "$lib/utils/accelerators"; import { appData } from "../../../events/app-data"; import Power from "../../../assets/icons/power.svelte"; - const DEFAULT_HOTKEY: SceneInteractivityHotkey = { - modifiers: ["alt"], - key: null, - }; - - const MODIFIER_LABELS: Record = { - cmd: "Cmd", - alt: "Alt", - ctrl: "Ctrl", - shift: "Shift", - }; - - const KEY_LABELS: Record = { - a: "A", - b: "B", - c: "C", - d: "D", - e: "E", - f: "F", - g: "G", - h: "H", - i: "I", - j: "J", - k: "K", - l: "L", - m: "M", - n: "N", - o: "O", - p: "P", - q: "Q", - r: "R", - s: "S", - t: "T", - u: "U", - v: "V", - w: "W", - x: "X", - y: "Y", - z: "Z", - num_0: "0", - num_1: "1", - num_2: "2", - num_3: "3", - num_4: "4", - num_5: "5", - num_6: "6", - num_7: "7", - num_8: "8", - num_9: "9", - f1: "F1", - f2: "F2", - f3: "F3", - f4: "F4", - f5: "F5", - f6: "F6", - f7: "F7", - f8: "F8", - f9: "F9", - f10: "F10", - f11: "F11", - f12: "F12", - enter: "Enter", - space: "Space", - escape: "Escape", - tab: "Tab", - backspace: "Backspace", - delete: "Delete", - insert: "Insert", - home: "Home", - end: "End", - page_up: "Page Up", - page_down: "Page Down", - arrow_up: "Arrow Up", - arrow_down: "Arrow Down", - arrow_left: "Arrow Left", - arrow_right: "Arrow Right", - minus: "-", - equal: "=", - left_bracket: "[", - right_bracket: "]", - back_slash: "\\", - semicolon: ";", - apostrophe: "'", - comma: ",", - dot: ".", - slash: "/", - grave: "`", - }; - - const CODE_TO_KEY: Partial> = { - KeyA: "a", - KeyB: "b", - KeyC: "c", - KeyD: "d", - KeyE: "e", - KeyF: "f", - KeyG: "g", - KeyH: "h", - KeyI: "i", - KeyJ: "j", - KeyK: "k", - KeyL: "l", - KeyM: "m", - KeyN: "n", - KeyO: "o", - KeyP: "p", - KeyQ: "q", - KeyR: "r", - KeyS: "s", - KeyT: "t", - KeyU: "u", - KeyV: "v", - KeyW: "w", - KeyX: "x", - KeyY: "y", - KeyZ: "z", - Digit0: "num_0", - Digit1: "num_1", - Digit2: "num_2", - Digit3: "num_3", - Digit4: "num_4", - Digit5: "num_5", - Digit6: "num_6", - Digit7: "num_7", - Digit8: "num_8", - Digit9: "num_9", - F1: "f1", - F2: "f2", - F3: "f3", - F4: "f4", - F5: "f5", - F6: "f6", - F7: "f7", - F8: "f8", - F9: "f9", - F10: "f10", - F11: "f11", - F12: "f12", - Enter: "enter", - Space: "space", - Escape: "escape", - Tab: "tab", - Backspace: "backspace", - Delete: "delete", - Insert: "insert", - Home: "home", - End: "end", - PageUp: "page_up", - PageDown: "page_down", - ArrowUp: "arrow_up", - ArrowDown: "arrow_down", - ArrowLeft: "arrow_left", - ArrowRight: "arrow_right", - Minus: "minus", - Equal: "equal", - BracketLeft: "left_bracket", - BracketRight: "right_bracket", - Backslash: "back_slash", - Semicolon: "semicolon", - Quote: "apostrophe", - Comma: "comma", - Period: "dot", - Slash: "slash", - Backquote: "grave", - }; - - const MODIFIER_CODES = new Set([ - "MetaLeft", - "MetaRight", - "AltLeft", - "AltRight", - "ControlLeft", - "ControlRight", - "ShiftLeft", - "ShiftRight", - ]); - - const normalizeHotkey = ( - hotkey: SceneInteractivityHotkey | null | undefined, - ): SceneInteractivityHotkey => { - if (!hotkey) return { ...DEFAULT_HOTKEY }; - - const uniqueModifiers = [...new Set(hotkey.modifiers ?? [])].sort(); - if (uniqueModifiers.length === 0 && !hotkey.key) { - return { ...DEFAULT_HOTKEY }; - } - - return { - modifiers: uniqueModifiers, - key: hotkey.key ?? null, - }; - }; - - const formatHotkeyLabel = (hotkey: SceneInteractivityHotkey): string => { - const modifiers = (hotkey.modifiers ?? []).map( - (modifier) => MODIFIER_LABELS[modifier], - ); - const key = hotkey.key ? KEY_LABELS[hotkey.key] : null; - const parts = key ? [...modifiers, key] : modifiers; - return parts.join(" + "); - }; - - const getModifiersFromEvent = ( - event: KeyboardEvent, - ): SceneInteractivityModifier[] => { - const modifiers: SceneInteractivityModifier[] = []; - if (event.metaKey) modifiers.push("cmd"); - if (event.altKey) modifiers.push("alt"); - if (event.ctrlKey) modifiers.push("ctrl"); - if (event.shiftKey) modifiers.push("shift"); - return modifiers; - }; - - const hotkeysEqual = ( - a: SceneInteractivityHotkey | null | undefined, - b: SceneInteractivityHotkey | null | undefined, - ): boolean => { - const left = normalizeHotkey(a); - const right = normalizeHotkey(b); - - if (left.key !== right.key) return false; - if ((left.modifiers?.length ?? 0) !== (right.modifiers?.length ?? 0)) { - return false; - } - - return (left.modifiers ?? []).every( - (modifier, index) => modifier === (right.modifiers ?? [])[index], - ); - }; - let signingOut = false; let appConfig: AppConfig | null = null; - let hotkey = { ...DEFAULT_HOTKEY }; + let accelerator: KeyboardAccelerator | null = null; let captureMode = false; let capturePreviewLabel = ""; - let hotkeyInput: HTMLInputElement | null = null; - let hotkeyLabel = ""; - let hotkeyError = ""; - let hotkeySuccess = ""; - let hotkeyDirty = false; - let hotkeySaving = false; + let acceleratorInput: HTMLInputElement | null = null; + let acceleratorLabel = ""; + let acceleratorError = ""; + let acceleratorSuccess = ""; + let acceleratorDirty = false; + let acceleratorSaving = false; const loadConfig = async () => { try { const config = await commands.getClientConfig(); appConfig = config; - hotkey = normalizeHotkey(config.scene_interactivity_hotkey); + accelerator = getAcceleratorForAction(config, SCENE_INTERACTIVITY_ACTION); } catch (error) { console.error("Failed to load client config", error); - hotkeyError = "Failed to load current hotkey settings."; + acceleratorError = "Failed to load current accelerator settings."; } }; @@ -285,41 +63,44 @@ const beginCapture = () => { captureMode = true; capturePreviewLabel = ""; - hotkeyError = ""; - hotkeySuccess = ""; - setTimeout(() => hotkeyInput?.focus(), 0); + acceleratorError = ""; + acceleratorSuccess = ""; + setTimeout(() => acceleratorInput?.focus(), 0); }; const stopCapture = () => { captureMode = false; capturePreviewLabel = ""; - saveHotkey(); + saveAccelerator(); }; - const saveHotkey = async () => { - if (!appConfig || hotkeySaving) return; - hotkeySaving = true; - hotkeyError = ""; - hotkeySuccess = ""; + const saveAccelerator = async () => { + if (!appConfig || !accelerator || acceleratorSaving) return; + acceleratorSaving = true; + acceleratorError = ""; + acceleratorSuccess = ""; try { const nextConfig: AppConfig = { ...appConfig, - scene_interactivity_hotkey: hotkey, + accelerators: { + ...(appConfig.accelerators ?? {}), + [SCENE_INTERACTIVITY_ACTION]: accelerator, + }, }; await commands.saveClientConfig(nextConfig); appConfig = nextConfig; - hotkeySuccess = "Hotkey saved."; + acceleratorSuccess = "Accelerator saved."; } catch (error) { - console.error("Failed to save hotkey", error); - hotkeyError = "Failed to save hotkey."; + console.error("Failed to save accelerator", error); + acceleratorError = "Failed to save accelerator."; } finally { - hotkeySaving = false; + acceleratorSaving = false; } }; - const onHotkeyCapture = async (event: KeyboardEvent) => { - if (!captureMode || hotkeySaving) return; + const onAcceleratorCapture = async (event: KeyboardEvent) => { + if (!captureMode || acceleratorSaving) return; event.preventDefault(); event.stopPropagation(); @@ -330,47 +111,52 @@ } const modifiers = getModifiersFromEvent(event); - const key = CODE_TO_KEY[event.code] ?? null; + const key = keyFromKeyboardCode(event.code); const modifierOnlyPress = MODIFIER_CODES.has(event.code); if (modifiers.length === 0) { capturePreviewLabel = ""; - hotkeyError = "Hotkey must include at least one modifier key."; + acceleratorError = "Accelerator must include at least one modifier key."; return; } if (modifierOnlyPress) { - const nextHotkey = normalizeHotkey({ + const nextAccelerator = normalizeAccelerator({ modifiers, key: null, }); - hotkey = nextHotkey; - capturePreviewLabel = formatHotkeyLabel(nextHotkey); - hotkeySuccess = ""; + accelerator = nextAccelerator; + capturePreviewLabel = formatAcceleratorLabel(nextAccelerator); + acceleratorSuccess = ""; return; } if (!key) { - hotkeyError = "That key is not supported for hotkey combos yet."; + acceleratorError = "That key is not supported for accelerator combos yet."; return; } - const nextHotkey = normalizeHotkey({ modifiers, key }); - hotkey = nextHotkey; - capturePreviewLabel = formatHotkeyLabel(nextHotkey); - hotkeySuccess = ""; + const nextAccelerator = normalizeAccelerator({ modifiers, key }); + accelerator = nextAccelerator; + capturePreviewLabel = formatAcceleratorLabel(nextAccelerator); + acceleratorSuccess = ""; }; - $: hotkeyLabel = formatHotkeyLabel(hotkey); - $: hotkeyDirty = appConfig - ? !hotkeysEqual(hotkey, appConfig.scene_interactivity_hotkey) + $: acceleratorLabel = accelerator ? formatAcceleratorLabel(accelerator) : ""; + $: acceleratorDirty = appConfig + ? accelerator && appConfig.accelerators?.[SCENE_INTERACTIVITY_ACTION] + ? !acceleratorsEqual( + accelerator, + appConfig.accelerators?.[SCENE_INTERACTIVITY_ACTION], + ) + : false : false; onMount(() => { loadConfig(); const handleKeydown = (event: KeyboardEvent) => { - void onHotkeyCapture(event); + void onAcceleratorCapture(event); }; window.addEventListener("keydown", handleKeydown, true); @@ -385,31 +171,31 @@

{$appData?.user?.name}'s preferences