diff --git a/README.md b/README.md index 63f1d9c..a368804 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,12 @@ Regular wheel mode: cargo run --manifest-path renewed_driver/Cargo.toml -- --mode wheel ``` +Multitouch timing can be tuned without recompiling: + +```bash +cargo run --manifest-path renewed_driver/Cargo.toml -- --mode multitouch --mt-idle-ms 100 --mt-idle-max-ms 1500 --mt-settle-ms 80 +``` + The process still needs access to the Surface Dial event device and `/dev/uinput`, just like the original daemon. diff --git a/doc/behavior.md b/doc/behavior.md index 13d3220..494ea74 100644 --- a/doc/behavior.md +++ b/doc/behavior.md @@ -19,14 +19,35 @@ surface-dial-renewed --mode wheel Normal Dial rotation emits a synthetic two-finger touchpad scroll through `Surface Dial Renewed Touchpad`. -The driver starts a virtual two-finger contact on the first rotation event, -moves both virtual fingers vertically, and ends the touch gesture after no -rotation has arrived for 2 seconds. +The driver starts a virtual two-finger contact on the first rotation event and +moves both virtual fingers vertically. Gesture release is dynamic: -The 2 second hold is intentional. Very slow physical Dial movement can emit -sparse `REL_DIAL` events. If the virtual fingers are lifted too quickly, libinput -can treat those sparse movements as many tiny gestures and ignore them. Holding -the contact makes slow and precise movement accumulate into one gesture. +- normal and fast rotation releases after about `100ms` +- sparse precision movement can stretch release up to `1500ms` + +The `100ms` minimum matches the old driver's multitouch control. The timeout is +based on the time gap between rotation events, not the raw delta size. That is +important because the Dial can emit `delta=1` even during normal fast scrolling. +If the virtual fingers are lifted too quickly during sparse movement, libinput +can treat those sparse events as many tiny gestures and ignore them. If the hold +is too long, page-edge overscroll animations can remain stretched for too long +before bouncing back. + +The driver keeps the last rotation timestamp even after a virtual touch gesture +ends. This matters because sparse precision events can arrive after the 100ms +gesture has already been released. The next event still sees the previous event +gap and can stretch the new gesture timeout. A gap of `2500ms` or more is +treated as a real pause and resets back to the `100ms` minimum. + +Timing is configurable: + +```bash +surface-dial-renewed --mt-idle-ms 100 --mt-idle-max-ms 1500 --mt-settle-ms 80 +``` + +`--mt-idle-ms` controls the fast-scroll minimum release timeout. +`--mt-idle-max-ms` controls the slow/precision maximum release timeout. +`--mt-settle-ms` controls the short stationary frame before the fingers lift. ## Sensitivity Selector diff --git a/doc/installation-service.md b/doc/installation-service.md index 7794413..5d2efd1 100644 --- a/doc/installation-service.md +++ b/doc/installation-service.md @@ -52,6 +52,25 @@ The service runs: It restarts on failure with a 2 second delay. +To tune multitouch timing in the service, edit: + +```text +~/.config/systemd/user/surface-dial-renewed.service +``` + +Example: + +```text +ExecStart=%h/.cargo/bin/surface-dial-renewed --mode multitouch --mt-idle-ms 100 --mt-idle-max-ms 1500 --mt-settle-ms 80 +``` + +Then reload and restart: + +```bash +systemctl --user daemon-reload +systemctl --user restart surface-dial-renewed.service +``` + Useful commands: ```bash diff --git a/install/README.md b/install/README.md index 0e547e9..ff76518 100644 --- a/install/README.md +++ b/install/README.md @@ -56,6 +56,27 @@ systemctl --user status surface-dial-renewed.service journalctl --user -u surface-dial-renewed.service -f ``` +## Tune Multitouch Timing + +Edit: + +```bash +~/.config/systemd/user/surface-dial-renewed.service +``` + +Example: + +```text +ExecStart=%h/.cargo/bin/surface-dial-renewed --mode multitouch --mt-idle-ms 100 --mt-idle-max-ms 1500 --mt-settle-ms 80 +``` + +Apply changes: + +```bash +systemctl --user daemon-reload +systemctl --user restart surface-dial-renewed.service +``` + ## Uninstall Automatic uninstall from the repository root: diff --git a/src/main.rs b/src/main.rs index 56d8444..5757d17 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,7 +5,7 @@ mod notify; mod sensitivity; mod virtual_input; -use std::time::Duration; +use std::time::{Duration, Instant}; use dial::{DialEvent, DialEventKind, DialReader}; use error::Result; @@ -14,8 +14,12 @@ use notify::SensitivityNotifier; use sensitivity::Sensitivity; use virtual_input::{VirtualInput, WheelDirection}; -const MULTITOUCH_IDLE_TIMEOUT: Duration = Duration::from_millis(2_000); -const MULTITOUCH_SETTLE_TIMEOUT: Duration = Duration::from_millis(80); +const DEFAULT_MULTITOUCH_IDLE_TIMEOUT: Duration = Duration::from_millis(100); +const DEFAULT_MULTITOUCH_MAX_IDLE_TIMEOUT: Duration = Duration::from_millis(1_500); +const DEFAULT_MULTITOUCH_SETTLE_TIMEOUT: Duration = Duration::from_millis(80); +const DYNAMIC_IDLE_FAST_EVENT_MS: u128 = 80; +const DYNAMIC_IDLE_SLOW_EVENT_MS: u128 = 450; +const DYNAMIC_IDLE_RESET_MS: u128 = 2_500; const SENSITIVITY_STEP_UNITS: i32 = 48; #[derive(Debug, Clone, Copy, Eq, PartialEq)] @@ -24,6 +28,25 @@ enum OutputMode { Wheel, } +#[derive(Debug, Clone, Copy)] +struct DriverConfig { + mode: OutputMode, + multitouch_idle_timeout: Duration, + multitouch_max_idle_timeout: Duration, + multitouch_settle_timeout: Duration, +} + +impl Default for DriverConfig { + fn default() -> Self { + Self { + mode: OutputMode::Multitouch, + multitouch_idle_timeout: DEFAULT_MULTITOUCH_IDLE_TIMEOUT, + multitouch_max_idle_timeout: DEFAULT_MULTITOUCH_MAX_IDLE_TIMEOUT, + multitouch_settle_timeout: DEFAULT_MULTITOUCH_SETTLE_TIMEOUT, + } + } +} + #[derive(Debug)] struct DriverState { mode: OutputMode, @@ -36,6 +59,8 @@ struct DriverState { mt_active: bool, mt_position: i32, mt_remainder_milli: i32, + mt_current_idle_timeout: Duration, + mt_last_rotation_at: Option, } impl DriverState { @@ -49,6 +74,8 @@ impl DriverState { mt_active: false, mt_position: 0, mt_remainder_milli: 0, + mt_current_idle_timeout: DEFAULT_MULTITOUCH_IDLE_TIMEOUT, + mt_last_rotation_at: None, } } } @@ -61,8 +88,8 @@ fn main() { } fn run() -> Result<()> { - let mode = parse_mode(std::env::args().skip(1)); - let mut state = DriverState::new(mode); + let config = parse_config(std::env::args().skip(1)); + let mut state = DriverState::new(config.mode); // Input reading runs on a worker thread; output devices stay owned by the // main thread to avoid sharing uinput handles across threads. let mut dial = DialReader::spawn(Duration::from_millis(750))?; @@ -70,25 +97,38 @@ fn run() -> Result<()> { let mut notifier = SensitivityNotifier::new(); let mut haptics = Haptics::new(); - eprintln!("surface-dial-renewed: running in {mode:?} mode"); + eprintln!( + "surface-dial-renewed: running in {:?} mode, mt_idle={}ms, mt_idle_max={}ms, mt_settle={}ms", + config.mode, + config.multitouch_idle_timeout.as_millis(), + config.multitouch_max_idle_timeout.as_millis(), + config.multitouch_settle_timeout.as_millis() + ); loop { // While a synthetic two-finger gesture is active, recv with a timeout. // Timeout means "the user stopped rotating", so the virtual fingers can - // be lifted. The 2s delay is deliberate: very slow Dial movement emits - // sparse REL_DIAL events, and libinput needs one continuous gesture to - // recognize those as precise scroll movement. + // be lifted. The timeout is dynamic: normal/fast scrolling uses the old + // driver's 100ms release behavior, while sparse precision movement can + // stretch toward the configured maximum. The last rotation timestamp is + // kept across gesture endings so slow events are not all treated as + // unrelated first events. let timeout = if state.mt_active { - Some(MULTITOUCH_IDLE_TIMEOUT) + Some(state.mt_current_idle_timeout) } else { None }; match dial.next_event(timeout)? { - Some(event) => { - handle_event(event, &mut state, &mut output, &mut notifier, &mut haptics)? - } - None => end_multitouch_if_active(&mut state, &mut output)?, + Some(event) => handle_event( + event, + &mut state, + &mut output, + &mut notifier, + &mut haptics, + &config, + )?, + None => end_multitouch_if_active(&mut state, &mut output, &config)?, } } } @@ -99,6 +139,7 @@ fn handle_event( output: &mut VirtualInput, notifier: &mut SensitivityNotifier, haptics: &mut Haptics, + config: &DriverConfig, ) -> Result<()> { match event.kind { DialEventKind::Connected => { @@ -108,7 +149,7 @@ fn handle_event( haptics.reconnect(); } DialEventKind::Disconnected => { - end_multitouch_if_active(state, output)?; + end_multitouch_if_active(state, output, config)?; haptics.disconnect(); eprintln!("surface-dial-renewed: dial disconnected"); } @@ -133,13 +174,13 @@ fn handle_event( // Never scroll while the sensitivity selector is active. End a // pending touchpad gesture before showing the selector so the // desktop does not see overlapping scroll and adjustment input. - end_multitouch_if_active(state, output)?; + end_multitouch_if_active(state, output, config)?; adjust_sensitivity(delta, state, notifier, haptics)?; return Ok(()); } match state.mode { - OutputMode::Multitouch => multitouch_scroll(delta, state, output)?, + OutputMode::Multitouch => multitouch_scroll(delta, state, output, config)?, OutputMode::Wheel => wheel_scroll(delta, state, output)?, } } @@ -178,7 +219,14 @@ fn adjust_sensitivity( Ok(()) } -fn multitouch_scroll(delta: i32, state: &mut DriverState, output: &mut VirtualInput) -> Result<()> { +fn multitouch_scroll( + delta: i32, + state: &mut DriverState, + output: &mut VirtualInput, + config: &DriverConfig, +) -> Result<()> { + update_multitouch_idle_timeout(delta, state, config); + if !state.mt_active { state.mt_position = 0; output.multitouch_start()?; @@ -216,6 +264,38 @@ fn multitouch_scroll(delta: i32, state: &mut DriverState, output: &mut VirtualIn Ok(()) } +fn update_multitouch_idle_timeout(_delta: i32, state: &mut DriverState, config: &DriverConfig) { + let now = Instant::now(); + let event_gap = state + .mt_last_rotation_at + .map(|last| now.saturating_duration_since(last)); + state.mt_last_rotation_at = Some(now); + + let Some(event_gap) = event_gap else { + state.mt_current_idle_timeout = config.multitouch_idle_timeout; + return; + }; + + let gap_ms = event_gap.as_millis(); + state.mt_current_idle_timeout = if gap_ms >= DYNAMIC_IDLE_RESET_MS { + // A genuine pause should not make the first new scroll linger. + config.multitouch_idle_timeout + } else if gap_ms >= DYNAMIC_IDLE_SLOW_EVENT_MS { + config.multitouch_max_idle_timeout + } else if gap_ms <= DYNAMIC_IDLE_FAST_EVENT_MS { + config.multitouch_idle_timeout + } else { + let fast = DYNAMIC_IDLE_FAST_EVENT_MS; + let slow = DYNAMIC_IDLE_SLOW_EVENT_MS; + let span = slow.saturating_sub(fast).max(1); + let progress = gap_ms.saturating_sub(fast).min(span); + let min_ms = config.multitouch_idle_timeout.as_millis(); + let max_ms = config.multitouch_max_idle_timeout.as_millis().max(min_ms); + let dynamic_ms = min_ms + ((max_ms - min_ms) * progress) / span; + Duration::from_millis(dynamic_ms as u64) + }; +} + fn wheel_scroll(delta: i32, state: &DriverState, output: &mut VirtualInput) -> Result<()> { let ticks = delta.abs().saturating_mul(state.sensitivity.wheel_ticks()); let direction = if delta > 0 { @@ -231,36 +311,72 @@ fn wheel_scroll(delta: i32, state: &DriverState, output: &mut VirtualInput) -> R Ok(()) } -fn end_multitouch_if_active(state: &mut DriverState, output: &mut VirtualInput) -> Result<()> { +fn end_multitouch_if_active( + state: &mut DriverState, + output: &mut VirtualInput, + config: &DriverConfig, +) -> Result<()> { if state.mt_active { // Emit one stationary frame and wait briefly before lifting fingers. // This made libinput less likely to interpret the final delta as a // touchpad flick/coast. output.multitouch_settle(state.mt_position)?; - std::thread::sleep(MULTITOUCH_SETTLE_TIMEOUT); + std::thread::sleep(config.multitouch_settle_timeout); output.multitouch_end()?; state.mt_active = false; state.mt_position = 0; state.mt_remainder_milli = 0; + state.mt_current_idle_timeout = config.multitouch_idle_timeout; } Ok(()) } -fn parse_mode(args: impl Iterator) -> OutputMode { - let mut mode = OutputMode::Multitouch; +fn parse_config(args: impl Iterator) -> DriverConfig { + let mut config = DriverConfig::default(); let mut previous = String::new(); for arg in args { if previous == "--mode" { - mode = parse_mode_value(&arg); + config.mode = parse_mode_value(&arg); + } else if previous == "--mt-idle-ms" { + config.multitouch_idle_timeout = Duration::from_millis(parse_ms(&arg, "mt-idle-ms")); + } else if previous == "--mt-idle-max-ms" { + config.multitouch_max_idle_timeout = + Duration::from_millis(parse_ms(&arg, "mt-idle-max-ms")); + } else if previous == "--mt-settle-ms" { + config.multitouch_settle_timeout = + Duration::from_millis(parse_ms(&arg, "mt-settle-ms")); } else if let Some(value) = arg.strip_prefix("--mode=") { - mode = parse_mode_value(value); + config.mode = parse_mode_value(value); + } else if let Some(value) = arg.strip_prefix("--mt-idle-ms=") { + config.multitouch_idle_timeout = Duration::from_millis(parse_ms(value, "mt-idle-ms")); + } else if let Some(value) = arg.strip_prefix("--mt-idle-max-ms=") { + config.multitouch_max_idle_timeout = + Duration::from_millis(parse_ms(value, "mt-idle-max-ms")); + } else if let Some(value) = arg.strip_prefix("--mt-settle-ms=") { + config.multitouch_settle_timeout = + Duration::from_millis(parse_ms(value, "mt-settle-ms")); } previous = arg; } - mode + config +} + +fn parse_ms(value: &str, name: &str) -> u64 { + match value.parse::() { + Ok(ms) => ms, + Err(_) => { + eprintln!("surface-dial-renewed: invalid --{name} '{value}', using default"); + match name { + "mt-idle-ms" => DEFAULT_MULTITOUCH_IDLE_TIMEOUT.as_millis() as u64, + "mt-idle-max-ms" => DEFAULT_MULTITOUCH_MAX_IDLE_TIMEOUT.as_millis() as u64, + "mt-settle-ms" => DEFAULT_MULTITOUCH_SETTLE_TIMEOUT.as_millis() as u64, + _ => 0, + } + } + } } fn parse_mode_value(value: &str) -> OutputMode {