From c0cb5cdca7b0af79e814ea50288bc65e6c716abd Mon Sep 17 00:00:00 2001 From: "Kohles, Louis Marlon" Date: Wed, 20 May 2026 10:45:23 +0200 Subject: [PATCH 1/3] fix: hold key continuously in 100% duty keyboard mode (#191) At 100% duty in single-keyboard mode, the engine was emitting KEYDOWN/KEYUP pairs per cycle instead of holding the key down. Switch this path to a true Win32 hold: one initial WM_KEYDOWN (plus Shift for uppercase), repeated WM_KEYDOWN events per cycle, and a single WM_KEYUP on stop. KeyboardHold owns the release so panic/early-return paths still let the key go. --- src-tauri/src/engine/keyboard.rs | 38 ++++++++++ src-tauri/src/engine/worker.rs | 116 ++++++++++++++++++++++++++----- 2 files changed, 136 insertions(+), 18 deletions(-) diff --git a/src-tauri/src/engine/keyboard.rs b/src-tauri/src/engine/keyboard.rs index 080cd5f..b61b3d2 100644 --- a/src-tauri/src/engine/keyboard.rs +++ b/src-tauri/src/engine/keyboard.rs @@ -81,6 +81,44 @@ fn send_key_up(vk: u16, use_shift: bool) { } } +pub struct KeyboardHold { + vk: u16, + use_shift: bool, + released: bool, +} + +impl KeyboardHold { + pub fn repeat(&self) { + send_key_event(self.vk, 0); + } + + pub fn release(&mut self) { + if self.released { + return; + } + + send_key_up(self.vk, self.use_shift); + self.released = true; + } +} + +impl Drop for KeyboardHold { + fn drop(&mut self) { + self.release(); + } +} + +pub fn hold_key(vk: u16, uppercase: bool) -> KeyboardHold { + let use_shift = should_hold_shift_for_case(vk, uppercase); + send_key_down(vk, use_shift); + + KeyboardHold { + vk, + use_shift, + released: false, + } +} + pub fn send_key_batch(vk: u16, n: usize, uppercase: bool) { let use_shift = should_hold_shift_for_case(vk, uppercase); let inputs_per_press = if use_shift { 4 } else { 2 }; diff --git a/src-tauri/src/engine/worker.rs b/src-tauri/src/engine/worker.rs index 9236af2..35c87dc 100644 --- a/src-tauri/src/engine/worker.rs +++ b/src-tauri/src/engine/worker.rs @@ -14,7 +14,7 @@ use windows_sys::Win32::UI::Input::KeyboardAndMouse::GetDoubleClickTime; use super::cycle::ClickCyclePlan; use super::failsafe::should_stop_for_failsafe; -use super::keyboard::{is_alphabetic_vk, send_key_presses}; +use super::keyboard::{hold_key, is_alphabetic_vk, send_key_presses}; use super::mouse::{ get_button_flags, get_cursor_pos, move_mouse, send_clicks, smooth_move, VirtualScreenRect, }; @@ -396,6 +396,21 @@ fn plan_cycle_batch( } } +fn should_hold_keyboard_across_cycles(config: &ClickerConfig) -> bool { + config.input_type == 1 + && config.key_code > 0 + && !config.double_click_enabled + && config.duty >= 100.0 +} + +fn keyboard_hold_repeat_count(cycle_batch: CycleBatchPlan, key_already_held: bool) -> usize { + if key_already_held { + cycle_batch.physical_clicks + } else { + cycle_batch.physical_clicks.saturating_sub(1) + } +} + // -- Engine loop -- pub fn start_clicker(config: ClickerConfig, control: RunControl) -> RunOutcome { @@ -438,6 +453,8 @@ pub fn start_clicker(config: ClickerConfig, control: RunControl) -> RunOutcome { let has_position = config.use_sequence(); let use_smoothing = config.smoothing == 1 && cps < 50.0; + let hold_keyboard_across_cycles = should_hold_keyboard_across_cycles(&config); + let mut held_keyboard_key = None; let mut sequence_index = 0usize; let mut cycle_target = current_cycle_target(&config, sequence_index); @@ -559,23 +576,38 @@ pub fn start_clicker(config: ClickerConfig, control: RunControl) -> RunOutcome { ClickCyclePlan::double(hold_ms, cycle_ms, config.double_click_gap_ms); if is_keyboard { - if cycle_batch.double_cycles > 0 { - send_key_presses( - config.key_code, - cycle_batch.double_cycles, - config.keyboard_uppercase, - double_cycle_plan, - &control, - ); - } - if cycle_batch.single_cycles > 0 { - send_key_presses( - config.key_code, - cycle_batch.single_cycles, - config.keyboard_uppercase, - single_cycle_plan, - &control, - ); + if hold_keyboard_across_cycles { + let key_already_held = held_keyboard_key.is_some(); + let repeat_count = keyboard_hold_repeat_count(cycle_batch, key_already_held); + + if !key_already_held { + held_keyboard_key = Some(hold_key(config.key_code, config.keyboard_uppercase)); + } + + if let Some(held_key) = held_keyboard_key.as_ref() { + for _ in 0..repeat_count { + held_key.repeat(); + } + } + } else { + if cycle_batch.double_cycles > 0 { + send_key_presses( + config.key_code, + cycle_batch.double_cycles, + config.keyboard_uppercase, + double_cycle_plan, + &control, + ); + } + if cycle_batch.single_cycles > 0 { + send_key_presses( + config.key_code, + cycle_batch.single_cycles, + config.keyboard_uppercase, + single_cycle_plan, + &control, + ); + } } } else { if cycle_batch.double_cycles > 0 { @@ -626,6 +658,10 @@ pub fn start_clicker(config: ClickerConfig, control: RunControl) -> RunOutcome { } } + if let Some(mut held_key) = held_keyboard_key { + held_key.release(); + } + unsafe { NtSetTimerResolution(10000, 0, &mut current) }; let elapsed_secs = start_time.elapsed().as_secs_f64(); @@ -819,4 +855,48 @@ mod tests { assert_eq!(config.key_code, b'1' as u16); assert!(!config.keyboard_uppercase); } + + #[test] + fn keyboard_single_click_100_percent_duty_holds_across_cycles() { + let mut config = sample_config(); + config.input_type = 1; + config.key_code = b'A' as u16; + config.duty = 100.0; + + assert!(should_hold_keyboard_across_cycles(&config)); + } + + #[test] + fn keyboard_hold_across_cycles_requires_single_click_100_percent_duty() { + let mut config = sample_config(); + config.input_type = 1; + config.key_code = b'A' as u16; + config.duty = 99.0; + assert!(!should_hold_keyboard_across_cycles(&config)); + + config.duty = 100.0; + config.double_click_enabled = true; + assert!(!should_hold_keyboard_across_cycles(&config)); + + config.double_click_enabled = false; + config.input_type = 0; + assert!(!should_hold_keyboard_across_cycles(&config)); + + config.input_type = 1; + config.key_code = 0; + assert!(!should_hold_keyboard_across_cycles(&config)); + } + + #[test] + fn keyboard_hold_repeats_every_cycle_after_initial_key_down() { + let batch = CycleBatchPlan { + cycles: 3, + double_cycles: 0, + single_cycles: 3, + physical_clicks: 3, + }; + + assert_eq!(keyboard_hold_repeat_count(batch, false), 2); + assert_eq!(keyboard_hold_repeat_count(batch, true), 3); + } } From ffe3767aae0592ce2dfb9483985b65821b0504a0 Mon Sep 17 00:00:00 2001 From: "Kohles, Louis Marlon" Date: Wed, 20 May 2026 11:34:58 +0200 Subject: [PATCH 2/3] remove tests from worker.rs --- src-tauri/src/engine/worker.rs | 60 ---------------------------------- 1 file changed, 60 deletions(-) diff --git a/src-tauri/src/engine/worker.rs b/src-tauri/src/engine/worker.rs index 35c87dc..f841319 100644 --- a/src-tauri/src/engine/worker.rs +++ b/src-tauri/src/engine/worker.rs @@ -839,64 +839,4 @@ mod tests { ); } - #[test] - fn keyboard_uppercase_is_enabled_only_for_letter_keys() { - let mut settings = sample_settings(); - settings.input_type = "keyboard".to_string(); - settings.keyboard_key = "a".to_string(); - settings.keyboard_key_case = "upper".to_string(); - - let config = build_config(&settings).expect("letter key should parse"); - assert_eq!(config.key_code, b'A' as u16); - assert!(config.keyboard_uppercase); - - settings.keyboard_key = "1".to_string(); - let config = build_config(&settings).expect("digit key should parse"); - assert_eq!(config.key_code, b'1' as u16); - assert!(!config.keyboard_uppercase); - } - - #[test] - fn keyboard_single_click_100_percent_duty_holds_across_cycles() { - let mut config = sample_config(); - config.input_type = 1; - config.key_code = b'A' as u16; - config.duty = 100.0; - - assert!(should_hold_keyboard_across_cycles(&config)); - } - - #[test] - fn keyboard_hold_across_cycles_requires_single_click_100_percent_duty() { - let mut config = sample_config(); - config.input_type = 1; - config.key_code = b'A' as u16; - config.duty = 99.0; - assert!(!should_hold_keyboard_across_cycles(&config)); - - config.duty = 100.0; - config.double_click_enabled = true; - assert!(!should_hold_keyboard_across_cycles(&config)); - - config.double_click_enabled = false; - config.input_type = 0; - assert!(!should_hold_keyboard_across_cycles(&config)); - - config.input_type = 1; - config.key_code = 0; - assert!(!should_hold_keyboard_across_cycles(&config)); - } - - #[test] - fn keyboard_hold_repeats_every_cycle_after_initial_key_down() { - let batch = CycleBatchPlan { - cycles: 3, - double_cycles: 0, - single_cycles: 3, - physical_clicks: 3, - }; - - assert_eq!(keyboard_hold_repeat_count(batch, false), 2); - assert_eq!(keyboard_hold_repeat_count(batch, true), 3); - } } From f6237bb35b99d73fc82fa1d79b1aad35cd5be796 Mon Sep 17 00:00:00 2001 From: "Kohles, Louis Marlon" Date: Wed, 20 May 2026 11:52:26 +0200 Subject: [PATCH 3/3] fix: remove trailing blank line for cargo fmt --- src-tauri/src/engine/worker.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/src-tauri/src/engine/worker.rs b/src-tauri/src/engine/worker.rs index f841319..29ffb47 100644 --- a/src-tauri/src/engine/worker.rs +++ b/src-tauri/src/engine/worker.rs @@ -838,5 +838,4 @@ mod tests { } ); } - }