Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
23 changes: 20 additions & 3 deletions src-tauri/src/engine/worker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -276,8 +276,15 @@ pub fn start_clicker_inner(app: &AppHandle) -> AppResult<ClickerStatusPayload> {
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()
Expand Down Expand Up @@ -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,
Expand Down
99 changes: 98 additions & 1 deletion src-tauri/src/hotkeys.rs
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,10 @@ pub fn parse_hotkey_binding(hotkey: &str) -> AppResult<HotkeyBinding> {
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);
}
Expand Down Expand Up @@ -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}'"
)))
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<i32> {
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" => {
Expand Down Expand Up @@ -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() {
Expand Down
1 change: 1 addition & 0 deletions src/components/panels/SimplePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"] : [];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export default function ClickerTypeSection({ settings, update }: Props) {
settings.hotkey,
settings.keyboardKey,
keyboardKeyCaseIsUpper,
settings.mode,
);
const autoPressKeyConflicts = hasConflict ? ["Hotkey"] : [];

Expand Down
1 change: 1 addition & 0 deletions src/components/panels/advanced/sections/HotkeySection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"] : [];

Expand Down
49 changes: 43 additions & 6 deletions src/hotkeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,34 @@ const SHIFTED_SYMBOL_BASE_MAP: Record<string, string> = {
">": "<",
};

// 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<string, string> = {
Backquote: "`",
Minus: "-",
Equal: "=",
BracketLeft: "[",
BracketRight: "]",
Backslash: "\\",
Semicolon: ";",
Quote: "'",
Comma: ",",
Period: ".",
Slash: "/",
IntlBackslash: "<",
IntlRo: "\\",
IntlYen: "\\",
};

const PHYSICAL_CODE_BY_LOWER: Record<string, string> = Object.fromEntries(
Object.keys(PHYSICAL_CODE_FALLBACK_LABELS).map((code) => [
code.toLowerCase(),
code,
]),
);

const NUMPAD_CODE_MAP: Record<string, string> = {
Numpad0: "numpad0",
Numpad1: "numpad1",
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -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();
Expand Down