diff --git a/CHANGELOG.md b/CHANGELOG.md index 97b63038..d30e7c2b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,11 @@ +# Unreleased +## Changed +- Punctuation/OEM keys (the key left of 1, `-`, `=`, `[`, `]`, `\`, `;`, `'`, `,`, `.`, `/`) are now stored by their physical position instead of the character they type, and resolved through the active keyboard layout. Non-US layouts get the key they actually pressed. +- Hold mode now allows the hotkey and the auto-press key to be the same key, so you can hold a key to spam that same key. Toggle mode still blocks it since that really would feed back on itself. +- Keyboard auto-press now holds the key for at least 20ms when the duty cycle is off (only for intervals of 50ms or longer). Games sample the keyboard once per frame and silently ignored the old instant press. +## Fixed +- Fixed keys that produce a non-ASCII character (§, ö, ä, ü, ç, ...) being rejected as both a hotkey and an auto-press key. + # v3.9.1 - 21.07.2026 (d.m.y) ## Fixed - Fixed Presets not saving the clicker activation hotkey. diff --git a/src-tauri/src/engine/worker.rs b/src-tauri/src/engine/worker.rs index 48155c26..89aceb35 100644 --- a/src-tauri/src/engine/worker.rs +++ b/src-tauri/src/engine/worker.rs @@ -276,8 +276,15 @@ pub fn start_clicker_inner(app: &AppHandle) -> AppResult { let settings = state.settings.lock().unwrap_or_else(poisoned_inner).clone(); let config = build_config(&settings)?; - // Prevent feedback loop: keyboard key must not match a modifier-free hotkey - if config.input_type == crate::engine::InputType::Keyboard && config.key_code > 0 { + // Prevent feedback loop: keyboard key must not match a modifier-free hotkey. + // Hold mode is exempt while the low-level hooks run: press state then comes + // from hardware events only, so auto-pressing the held key cannot retrigger + // or self-cancel the hotkey (e.g. hold § to spam § into a game macro). + let allow_same_key = settings.mode == "Hold" && crate::hotkeys::hooks_active(); + if !allow_same_key + && config.input_type == crate::engine::InputType::Keyboard + && config.key_code > 0 + { let hotkey_binding = state .registered_hotkey .lock() @@ -697,9 +704,19 @@ impl ClickerContext { config.duty }; let cycle_ms = (config.interval_secs * 1000.0).max(1.0) as u32; - let hold_ms = + let mut hold_ms = ((config.interval_secs * duty.max(0.0) / 100.0 * 1000.0) as u32).min(cycle_ms); + // Games sample the keyboard once per frame, so a key that goes down and + // up within the same frame is never observed. With the duty cycle off the + // hold is 0 ms, which desktop apps still register but games drop. Give + // slow keyboard cadences a floor above one 60 Hz frame; fast cadences keep + // hold 0 so the batched SendInput fast path stays available. + const MIN_GAME_VISIBLE_HOLD_MS: u32 = 20; + if is_keyboard && hold_ms == 0 && cycle_ms >= 50 { + hold_ms = MIN_GAME_VISIBLE_HOLD_MS.min(cycle_ms / 2); + } + Self { is_keyboard, down_flag, diff --git a/src-tauri/src/hotkeys.rs b/src-tauri/src/hotkeys.rs index 687876b1..976fa1d2 100644 --- a/src-tauri/src/hotkeys.rs +++ b/src-tauri/src/hotkeys.rs @@ -118,6 +118,10 @@ pub fn parse_hotkey_binding(hotkey: &str) -> AppResult { pub fn parse_hotkey_main_key(token: &str, original_hotkey: &str) -> AppResult<(i32, String)> { let lower = token.trim().to_ascii_lowercase(); + if let Some(binding) = parse_physical_code_token(&lower) { + return Ok(binding); + } + if let Some(binding) = parse_named_key_token(&lower) { return Ok(binding); } @@ -156,6 +160,13 @@ pub fn parse_hotkey_main_key(token: &str, original_hotkey: &str) -> AppResult<(i } } + // Layout-specific single characters (§, ö, ä, ü, ç, ...) reach here when the + // capture only produced a printable key with no usable code. Ask the active + // layout which VK produces that character. + if let Some(vk) = vk_from_layout_char(&lower) { + return Ok((vk, lower)); + } + Err(AppError::Hotkey(format!( "Couldn't recognize '{token}' as a valid key in '{original_hotkey}'" ))) @@ -189,6 +200,14 @@ fn physical_key_state() -> &'static [AtomicBool; 256] { .get_or_init(|| Box::leak(Box::new(std::array::from_fn(|_| AtomicBool::new(false))))) } +/// True once the low-level keyboard/mouse hooks are installed. While they are, +/// hotkey state comes from real hardware events only (injected input carries +/// `AUTOCLICKER_EXTRA_INFO` and is skipped), so the clicker cannot retrigger +/// its own hotkey. +pub fn hooks_active() -> bool { + HOOKS_ACTIVE.load(Ordering::Relaxed) +} + fn is_physical_vk_down(vk: i32) -> bool { if !(0..256).contains(&vk) { return false; @@ -555,6 +574,54 @@ fn binding(vk: i32, token: &str) -> (i32, String) { (vk, token.to_string()) } +/// Physical (scancode-addressed) keys whose VK differs per keyboard layout. +/// Returns `(set-1 scancode, US-layout fallback VK, canonical token)`. +fn physical_code_scancode(token: &str) -> Option<(u32, i32, &'static str)> { + match token { + "backquote" | "grave" | "section" => Some((0x29, VK_OEM_3 as i32, "Backquote")), + "minus" => Some((0x0C, VK_OEM_MINUS as i32, "Minus")), + "equal" => Some((0x0D, VK_OEM_PLUS as i32, "Equal")), + "bracketleft" => Some((0x1A, VK_OEM_4 as i32, "BracketLeft")), + "bracketright" => Some((0x1B, VK_OEM_6 as i32, "BracketRight")), + "backslash" => Some((0x2B, VK_OEM_5 as i32, "Backslash")), + "semicolon" => Some((0x27, VK_OEM_1 as i32, "Semicolon")), + "quote" => Some((0x28, VK_OEM_7 as i32, "Quote")), + "comma" => Some((0x33, VK_OEM_COMMA as i32, "Comma")), + "period" => Some((0x34, VK_OEM_PERIOD as i32, "Period")), + "slash" => Some((0x35, VK_OEM_2 as i32, "Slash")), + "intlro" => Some((0x73, VK_OEM_102 as i32, "IntlRo")), + "intlyen" => Some((0x7D, VK_OEM_5 as i32, "IntlYen")), + _ => None, + } +} + +fn parse_physical_code_token(token: &str) -> Option<(i32, String)> { + let (scancode, fallback_vk, canonical) = physical_code_scancode(token)?; + let mapped = unsafe { MapVirtualKeyW(scancode, MAPVK_VSC_TO_VK_EX) } as i32; + let vk = if mapped == 0 { fallback_vk } else { mapped }; + Some(binding(vk, canonical)) +} + +fn vk_from_layout_char(token: &str) -> Option { + let mut chars = token.chars(); + let ch = chars.next()?; + if chars.next().is_some() { + return None; + } + + let scan = unsafe { VkKeyScanW(ch as u16) }; + if scan == -1 { + return None; + } + + let vk = (scan & 0xFF) as i32; + if vk > 0 { + Some(vk) + } else { + None + } +} + fn parse_named_key_token(token: &str) -> Option<(i32, String)> { match token { "<" | ">" | "intlbackslash" | "oem102" | "nonusbackslash" => { @@ -675,7 +742,37 @@ fn parse_function_key_token(token: &str) -> Option<(i32, String)> { #[cfg(test)] mod tests { - use super::{format_hotkey_binding, modifiers_match, parse_hotkey_binding}; + use super::{ + format_hotkey_binding, modifiers_match, parse_hotkey_binding, vk_from_layout_char, + }; + + #[test] + fn physical_code_tokens_round_trip() { + for token in [ + "Backquote", + "Minus", + "Equal", + "BracketLeft", + "BracketRight", + "Backslash", + "Semicolon", + "Quote", + "Comma", + "Period", + "Slash", + ] { + let binding = parse_hotkey_binding(token).expect("code token should parse"); + assert_eq!(binding.key_token, token); + assert!(binding.main_vk > 0, "{token} resolved to no VK"); + assert_eq!(format_hotkey_binding(&binding), token); + } + } + + #[test] + fn layout_chars_resolve_to_vk() { + assert_eq!(vk_from_layout_char("a"), Some(0x41)); + assert_eq!(vk_from_layout_char("ab"), None); + } #[test] fn numpad_tokens_round_trip() { diff --git a/src/components/panels/SimplePanel.tsx b/src/components/panels/SimplePanel.tsx index 988757cf..498fff4d 100644 --- a/src/components/panels/SimplePanel.tsx +++ b/src/components/panels/SimplePanel.tsx @@ -152,6 +152,7 @@ function SimplePanel({ settings, update }: SimplePanelProps) { settings.hotkey, settings.keyboardKey, keyboardKeyCaseIsUpper, + settings.mode, ); const hotkeyConflicts = hasConflict ? ["Auto-press key"] : []; const autoPressKeyConflicts = hasConflict ? ["Hotkey"] : []; diff --git a/src/components/panels/advanced/sections/ClickerTypeSection.tsx b/src/components/panels/advanced/sections/ClickerTypeSection.tsx index 5eb70386..19790e96 100644 --- a/src/components/panels/advanced/sections/ClickerTypeSection.tsx +++ b/src/components/panels/advanced/sections/ClickerTypeSection.tsx @@ -48,6 +48,7 @@ export default function ClickerTypeSection({ settings, update }: Props) { settings.hotkey, settings.keyboardKey, keyboardKeyCaseIsUpper, + settings.mode, ); const autoPressKeyConflicts = hasConflict ? ["Hotkey"] : []; diff --git a/src/components/panels/advanced/sections/HotkeySection.tsx b/src/components/panels/advanced/sections/HotkeySection.tsx index e15f09e5..f9c70290 100644 --- a/src/components/panels/advanced/sections/HotkeySection.tsx +++ b/src/components/panels/advanced/sections/HotkeySection.tsx @@ -20,6 +20,7 @@ export default function HotkeySection({ settings, update }: Props) { settings.hotkey, settings.keyboardKey, settings.keyboardKeyCase === "upper", + settings.mode, ); const hotkeyConflicts = hasConflict ? ["Auto-press key"] : []; diff --git a/src/hotkeys.ts b/src/hotkeys.ts index a9cf3285..3b6efc45 100644 --- a/src/hotkeys.ts +++ b/src/hotkeys.ts @@ -60,6 +60,34 @@ const SHIFTED_SYMBOL_BASE_MAP: Record = { ">": "<", }; +// Punctuation/OEM keys whose produced character depends on the keyboard layout +// (e.g. Backquote is "`" on US but "§" on FI/SE). Stored by physical code so the +// binding follows the key, not the character; the backend resolves the VK via +// the active layout. Values are the US-layout fallback label. +const PHYSICAL_CODE_FALLBACK_LABELS: Record = { + Backquote: "`", + Minus: "-", + Equal: "=", + BracketLeft: "[", + BracketRight: "]", + Backslash: "\\", + Semicolon: ";", + Quote: "'", + Comma: ",", + Period: ".", + Slash: "/", + IntlBackslash: "<", + IntlRo: "\\", + IntlYen: "\\", +}; + +const PHYSICAL_CODE_BY_LOWER: Record = Object.fromEntries( + Object.keys(PHYSICAL_CODE_FALLBACK_LABELS).map((code) => [ + code.toLowerCase(), + code, + ]), +); + const NUMPAD_CODE_MAP: Record = { Numpad0: "numpad0", Numpad1: "numpad1", @@ -363,8 +391,8 @@ function mainKeyFromCode( key: string, location?: number, ): string | null { - if (code === "IntlBackslash") { - return "IntlBackslash"; + if (PHYSICAL_CODE_FALLBACK_LABELS[code]) { + return code; } if (/^Key[A-Z]$/.test(code)) { @@ -426,8 +454,12 @@ function displayTokenFromStoredValue( const trimmed = token.trim(); if (!trimmed) return trimmed; - if (trimmed === "IntlBackslash") { - return layoutMap?.get("IntlBackslash") ?? "<"; + const physicalCode = PHYSICAL_CODE_BY_LOWER[trimmed.toLowerCase()]; + if (physicalCode) { + return ( + layoutMap?.get(physicalCode) ?? + PHYSICAL_CODE_FALLBACK_LABELS[physicalCode] + ); } if (/^Key[A-Z]$/.test(trimmed)) { @@ -511,8 +543,9 @@ function normalizeStoredMainKey( const trimmed = token.trim(); if (!trimmed) return trimmed; - if (trimmed === "IntlBackslash") { - return "IntlBackslash"; + const physicalCode = PHYSICAL_CODE_BY_LOWER[trimmed.toLowerCase()]; + if (physicalCode) { + return physicalCode; } if (/^Key[A-Z]$/.test(trimmed)) { @@ -693,8 +726,12 @@ export function conflictsWithAutoPressKey( hotkey: string, keyboardKey: string, keyboardKeyCaseIsUpper: boolean, + mode?: string, ): boolean { if (!hotkey || !keyboardKey) return false; + // Hold mode intentionally supports "hold a key to spam that same key": the + // backend tracks the hotkey from hardware events only, so there is no loop. + if (mode === "Hold") return false; const mainKey = hotkeyMainKey(hotkey); const modifiers = hotkeyModifiers(hotkey); const kbKey = keyboardKey.toLowerCase();