custom interactivity hotkey

This commit is contained in:
2026-03-21 22:28:14 +08:00
parent 8f4aab3d87
commit a72430e65f
6 changed files with 796 additions and 10 deletions

View File

@@ -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<String>,
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<SceneInteractivityModifier>,
#[serde(default)]
pub key: Option<SceneInteractivityKey>,
}
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<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
#[derive(Deserialize)]
struct SceneInteractivityHotkeyV2 {
#[serde(default)]
modifiers: Vec<SceneInteractivityModifier>,
#[serde(default)]
key: Option<SceneInteractivityKey>,
}
#[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)]

View File

@@ -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(),
}
}

View File

@@ -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<AtomicBool> {
.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<SceneInteractivityModifier> {
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() {