From a72430e65fe453ac2c473143b058a5aebe5ff63d Mon Sep 17 00:00:00 2001 From: Wind-Explorer Date: Sat, 21 Mar 2026 22:28:14 +0800 Subject: [PATCH] custom interactivity hotkey --- src-tauri/src/services/client_config/mod.rs | 209 ++++++++- src-tauri/src/services/client_config/store.rs | 5 +- src-tauri/src/services/scene/interactivity.rs | 150 ++++++- src/lib/bindings.ts | 5 +- src/routes/app-menu/tabs/preferences.svelte | 402 +++++++++++++++++- src/routes/client-config/+page.svelte | 35 +- 6 files changed, 796 insertions(+), 10 deletions(-) diff --git a/src-tauri/src/services/client_config/mod.rs b/src-tauri/src/services/client_config/mod.rs index 099dd4e..ce3f649 100644 --- a/src-tauri/src/services/client_config/mod.rs +++ b/src-tauri/src/services/client_config/mod.rs @@ -1,7 +1,7 @@ mod store; mod window; -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize}; use specta::Type; use thiserror::Error; @@ -12,6 +12,213 @@ pub use window::open_config_window; pub struct AppConfig { pub api_base_url: Option, pub debug_mode: bool, + #[serde(default)] + pub scene_interactivity_hotkey: SceneInteractivityHotkey, +} + +#[derive(Serialize, Clone, Debug, PartialEq, Eq, Type)] +pub struct SceneInteractivityHotkey { + #[serde(default)] + pub modifiers: Vec, + #[serde(default)] + pub key: Option, +} + +impl SceneInteractivityHotkey { + pub fn normalized(mut self) -> Self { + self.modifiers.sort_unstable(); + self.modifiers.dedup(); + + if self.modifiers.is_empty() { + return Self::default(); + } + + 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], + }; + + Self { + modifiers, + key: None, + } + } +} + +impl<'de> Deserialize<'de> for SceneInteractivityHotkey { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + struct SceneInteractivityHotkeyV2 { + #[serde(default)] + modifiers: Vec, + #[serde(default)] + key: Option, + } + + #[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, + } + } + } } #[derive(Debug, Error)] diff --git a/src-tauri/src/services/client_config/store.rs b/src-tauri/src/services/client_config/store.rs index 07c2ceb..537e135 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}; +use super::{AppConfig, ClientConfigError, SceneInteractivityHotkey}; const CONFIG_FILENAME: &str = "client_config.json"; const DEFAULT_API_BASE_URL: &str = "https://api.friendolls.adamcv.com"; @@ -48,6 +48,8 @@ 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 } @@ -55,6 +57,7 @@ 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(), } } diff --git a/src-tauri/src/services/scene/interactivity.rs b/src-tauri/src/services/scene/interactivity.rs index a3cd671..b59cc94 100644 --- a/src-tauri/src/services/scene/interactivity.rs +++ b/src-tauri/src/services/scene/interactivity.rs @@ -8,7 +8,16 @@ use tauri::Manager; use tauri_specta::Event as _; use tracing::{error, info, warn}; -use crate::{get_app_handle, services::app_events::SceneInteractiveChanged}; +use crate::{ + get_app_handle, lock_r, + services::{ + app_events::SceneInteractiveChanged, + client_config::{ + SceneInteractivityHotkey, SceneInteractivityKey, SceneInteractivityModifier, + }, + }, + state::FDOLL, +}; use super::windows::SCENE_WINDOW_LABEL; @@ -29,6 +38,135 @@ 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(); @@ -51,7 +189,7 @@ pub(crate) fn start_scene_modifier_listener() { MessageDialogBuilder::new( app_handle.dialog().clone(), "Missing Permissions", - "Friendolls needs Accessibility permissions to detect the Alt key for interactivity. Please grant permissions in System Settings -> Privacy & Security -> Accessibility and restart the app.", + "Friendolls needs Accessibility permissions to detect your scene interactivity hotkey. Please grant permissions in System Settings -> Privacy & Security -> Accessibility and restart the app.", ) .kind(MessageDialogKind::Warning) .show(|_| {}); @@ -66,9 +204,11 @@ pub(crate) fn start_scene_modifier_listener() { loop { let keys = device_state.get_keys(); - let keys_interactive = - (keys.contains(&Keycode::LAlt) || keys.contains(&Keycode::RAlt)) - || keys.contains(&Keycode::Command); + let hotkey = { + let guard = lock_r!(FDOLL); + guard.app_config.scene_interactivity_hotkey.clone() + }; + let keys_interactive = is_hotkey_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 e25ce1d..e5443b3 100644 --- a/src/lib/bindings.ts +++ b/src/lib/bindings.ts @@ -175,7 +175,7 @@ userStatusChanged: "user-status-changed" /** user-defined types **/ export type ActiveDollSpriteChanged = string | null -export type AppConfig = { api_base_url: string | null; debug_mode: boolean } +export type AppConfig = { api_base_url: string | null; debug_mode: boolean; scene_interactivity_hotkey?: SceneInteractivityHotkey } export type AppDataRefreshed = UserData export type AuthFlowStatus = "started" | "succeeded" | "failed" | "cancelled" export type AuthFlowUpdated = AuthFlowUpdatedPayload @@ -216,6 +216,9 @@ export type ModuleMetadata = { id: string; name: string; version: string; descri 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/routes/app-menu/tabs/preferences.svelte b/src/routes/app-menu/tabs/preferences.svelte index d665141..d5a5b02 100644 --- a/src/routes/app-menu/tabs/preferences.svelte +++ b/src/routes/app-menu/tabs/preferences.svelte @@ -1,9 +1,267 @@

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

+
{#if errorMessage}