From 0c2fbc458134091f5a6b8480cd2bfb60b35b6ffb Mon Sep 17 00:00:00 2001 From: Quentin Eude Date: Mon, 12 Oct 2026 17:56:54 +0200 Subject: [PATCH 1/2] feat(input): add pointer-focus delay configuration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New config option focus_follows_mouse_delay_ms (0–1000 ms, default 50) - Pointer must rest for configured delay before focus follows - Applies only when focus_follows_mouse is enabled - Add UI slider control and search target in settings - Add validation and decoding tests --- CONFIGURATION.md | 2 ++ Sources/DefiConfig/Config.swift | 3 +++ Sources/DefiConfig/ConfigModels.swift | 7 +++++++ Sources/DefiDaemon/DaemonFocus.swift | 3 +++ Sources/DefiMacOS/DefiSettingsView.swift | 8 ++++++++ Sources/DefiMacOS/SettingsSearch.swift | 2 ++ Tests/DefiConfigTests/InputConfigTests.swift | 11 +++++++++++ 7 files changed, 36 insertions(+) diff --git a/CONFIGURATION.md b/CONFIGURATION.md index 4bfcedb3..1d6fb808 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -114,6 +114,7 @@ Controls focus transfer between managed windows and pointer. [input] focus_follows_mouse = false focus_follows_mouse_max_scroll_amount = 0.0 +focus_follows_mouse_delay_ms = 50 mouse_follows_focus = false ``` @@ -121,6 +122,7 @@ mouse_follows_focus = false | --- | --- | --- | --- | | `focus_follows_mouse` | `false` | boolean | Focuses managed window under physical pointer and scrolls strip minimally when needed. | | `focus_follows_mouse_max_scroll_amount` | `0` | number from `0` to `1` | Maximum scroll as fraction of monitor width. `0` accepts only targets needing no scroll; `0.1` permits up to 10%. | +| `focus_follows_mouse_delay_ms` | `50` | integer from `0` to `1000` | Milliseconds the pointer must rest before focus follows it. `0` focuses immediately; the delay applies only when `focus_follows_mouse` is enabled. | | `mouse_follows_focus` | `false` | boolean | Warps pointer to focused window center after keyboard focus changes, unless pointer is already inside it. | Bare `focus_follows_mouse = true` does not scroll. diff --git a/Sources/DefiConfig/Config.swift b/Sources/DefiConfig/Config.swift index 7d0852ad..7b2d193f 100644 --- a/Sources/DefiConfig/Config.swift +++ b/Sources/DefiConfig/Config.swift @@ -107,6 +107,9 @@ public struct Config: Equatable, Sendable { ) } } + guard (0...1_000).contains(input.focusFollowsMouseDelayMS) else { + throw ConfigError.invalidValue("input.focus_follows_mouse_delay_ms") + } guard (0.05...1).contains(layout.defaultColumnWidth) else { throw ConfigError.invalidValue("layout.default_column_width") } diff --git a/Sources/DefiConfig/ConfigModels.swift b/Sources/DefiConfig/ConfigModels.swift index d5254ef0..9fa6963c 100644 --- a/Sources/DefiConfig/ConfigModels.swift +++ b/Sources/DefiConfig/ConfigModels.swift @@ -5,15 +5,18 @@ import TOMLDecoder public struct InputConfig: Codable, Equatable, Sendable { public var focusFollowsMouse: Bool public var focusFollowsMouseMaxScrollAmount: Double? + public var focusFollowsMouseDelayMS: Int public var mouseFollowsFocus: Bool public init( focusFollowsMouse: Bool = false, focusFollowsMouseMaxScrollAmount: Double? = 0, + focusFollowsMouseDelayMS: Int = 50, mouseFollowsFocus: Bool = false ) { self.focusFollowsMouse = focusFollowsMouse self.focusFollowsMouseMaxScrollAmount = focusFollowsMouseMaxScrollAmount + self.focusFollowsMouseDelayMS = focusFollowsMouseDelayMS self.mouseFollowsFocus = mouseFollowsFocus } @@ -21,6 +24,7 @@ public struct InputConfig: Codable, Equatable, Sendable { case focusFollowsMouse = "focus_follows_mouse" case focusFollowsMouseMaxScrollAmount = "focus_follows_mouse_max_scroll_amount" + case focusFollowsMouseDelayMS = "focus_follows_mouse_delay_ms" case mouseFollowsFocus = "mouse_follows_focus" } @@ -34,6 +38,9 @@ public struct InputConfig: Codable, Equatable, Sendable { Double.self, forKey: .focusFollowsMouseMaxScrollAmount ) ?? defaults.focusFollowsMouseMaxScrollAmount + focusFollowsMouseDelayMS = + try values.decodeIfPresent(Int.self, forKey: .focusFollowsMouseDelayMS) + ?? defaults.focusFollowsMouseDelayMS mouseFollowsFocus = try values.decodeIfPresent(Bool.self, forKey: .mouseFollowsFocus) ?? defaults.mouseFollowsFocus } diff --git a/Sources/DefiDaemon/DaemonFocus.swift b/Sources/DefiDaemon/DaemonFocus.swift index b54648ee..05cb815f 100644 --- a/Sources/DefiDaemon/DaemonFocus.swift +++ b/Sources/DefiDaemon/DaemonFocus.swift @@ -42,7 +42,10 @@ extension Daemon { let generation = commandGeneration let session = desktopSessionGeneration let previousWindowID = lastPointerWindowID + let delayMS = config.input.focusFollowsMouseDelayMS pointerHitTestTask = Task { [weak self] in + // Each motion cancels the previous task, so focus waits for the pointer to rest. + if delayMS > 0 { try? await Task.sleep(for: .milliseconds(delayMS)) } guard let self, !Task.isCancelled else { return } let hit = await platform.managedWindowID( at: invocation.location, rawWindowID: invocation.windowID, retaining: previousWindowID diff --git a/Sources/DefiMacOS/DefiSettingsView.swift b/Sources/DefiMacOS/DefiSettingsView.swift index d0310c4c..7484144e 100644 --- a/Sources/DefiMacOS/DefiSettingsView.swift +++ b/Sources/DefiMacOS/DefiSettingsView.swift @@ -481,6 +481,14 @@ private struct InputSettingsView: View { .settingsSearchTarget(.focusFollowsPointer) Toggle(SettingsOption.pointerFollowsFocus.title, isOn: boolBinding("input", "mouse_follows_focus", model.config.input.mouseFollowsFocus)) .settingsSearchTarget(.pointerFollowsFocus) + SettingsNumberRow( + title: SettingsOption.pointerFocusDelay.title, + value: Double(model.config.input.focusFollowsMouseDelayMS), + range: 0...1_000, step: 10, unit: "ms", + onChange: { model.set(table: "input", key: "focus_follows_mouse_delay_ms", value: String(Int($0.rounded()))) } + ) + .settingsSearchTarget(.pointerFocusDelay) + .disabled(!model.config.input.focusFollowsMouse) } Section("Advanced", isExpanded: $advancedExpanded) { SettingsNumberRow( diff --git a/Sources/DefiMacOS/SettingsSearch.swift b/Sources/DefiMacOS/SettingsSearch.swift index ae4d16da..ebe32e44 100644 --- a/Sources/DefiMacOS/SettingsSearch.swift +++ b/Sources/DefiMacOS/SettingsSearch.swift @@ -39,6 +39,7 @@ enum SettingsOption: String, CaseIterable, Sendable { case focusFollowsPointer case pointerFollowsFocus case pointerFocusScroll + case pointerFocusDelay case defaultModifier case modifierAliases case customShortcut @@ -86,6 +87,7 @@ enum SettingsOption: String, CaseIterable, Sendable { case .focusFollowsPointer: (.input, "Focus follows pointer", "input.focus_follows_mouse mouse", false) case .pointerFollowsFocus: (.input, "Move pointer to keyboard focus", "input.mouse_follows_focus mouse", false) case .pointerFocusScroll: (.input, "Maximum pointer-focus scroll", "input.focus_follows_mouse_max_scroll_amount mouse", true) + case .pointerFocusDelay: (.input, "Pointer-focus delay", "input.focus_follows_mouse_delay_ms mouse", false) case .defaultModifier: (.input, "Default key modifier", "default_key_modifier keyboard", true) case .modifierAliases: (.input, "Modifier aliases", "modifier_combinations alias modifiers", true) case .customShortcut: (.input, "Add Custom Shortcut…", "custom command keybinding keyboard binding", true) diff --git a/Tests/DefiConfigTests/InputConfigTests.swift b/Tests/DefiConfigTests/InputConfigTests.swift index 35632156..a4115a9d 100644 --- a/Tests/DefiConfigTests/InputConfigTests.swift +++ b/Tests/DefiConfigTests/InputConfigTests.swift @@ -30,6 +30,17 @@ struct InputConfigTests { #expect(config.input.mouseFollowsFocus) } + @Test + func focusFollowsMouseDelayDecodesAndRejectsOutOfRange() throws { + let config = try Config.decode( + Data("[input]\nfocus_follows_mouse_delay_ms = 50\n".utf8) + ) + #expect(config.input.focusFollowsMouseDelayMS == 50) + #expect(throws: ConfigError.invalidValue("input.focus_follows_mouse_delay_ms")) { + try Config.decode(Data("[input]\nfocus_follows_mouse_delay_ms = -1\n".utf8)) + } + } + @Test(arguments: [-0.1, 1.1]) func focusFollowsMouseScrollLimitRejectsOutOfRangeValues( value: Double From 314c5cfa2bfca591dc79cbb49aa63dc1f07ca206 Mon Sep 17 00:00:00 2001 From: Quentin Eude Date: Mon, 12 Oct 2026 18:04:12 +0200 Subject: [PATCH 2/2] fix: address PR review feedback (#130) Extract the pointer-focus rest delay into a testable helper and cover zero delay, elapsed delay, and cancellation by newer motion. --- Sources/DefiDaemon/DaemonFocus.swift | 3 +-- Sources/DefiRuntime/FocusSelection.swift | 7 ++++++ .../DefiRuntimeTests/PointerFocusTests.swift | 23 +++++++++++++++++++ 3 files changed, 31 insertions(+), 2 deletions(-) diff --git a/Sources/DefiDaemon/DaemonFocus.swift b/Sources/DefiDaemon/DaemonFocus.swift index 05cb815f..7eb85c0e 100644 --- a/Sources/DefiDaemon/DaemonFocus.swift +++ b/Sources/DefiDaemon/DaemonFocus.swift @@ -45,8 +45,7 @@ extension Daemon { let delayMS = config.input.focusFollowsMouseDelayMS pointerHitTestTask = Task { [weak self] in // Each motion cancels the previous task, so focus waits for the pointer to rest. - if delayMS > 0 { try? await Task.sleep(for: .milliseconds(delayMS)) } - guard let self, !Task.isCancelled else { return } + guard await pointerFocusRestDelayElapsed(milliseconds: delayMS), let self else { return } let hit = await platform.managedWindowID( at: invocation.location, rawWindowID: invocation.windowID, retaining: previousWindowID ) diff --git a/Sources/DefiRuntime/FocusSelection.swift b/Sources/DefiRuntime/FocusSelection.swift index 85e1c8a8..1e5dfdbc 100644 --- a/Sources/DefiRuntime/FocusSelection.swift +++ b/Sources/DefiRuntime/FocusSelection.swift @@ -355,6 +355,13 @@ public func pointerFocusRetryIsCurrent( && latestUserInputTimestamp <= pointerTimestamp } +/// Waits for the pointer-focus rest delay; `false` means the wait was cancelled by newer motion. +public func pointerFocusRestDelayElapsed(milliseconds: Int) async -> Bool { + guard milliseconds > 0 else { return !Task.isCancelled } + do { try await Task.sleep(for: .milliseconds(milliseconds)) } catch { return false } + return true +} + public func pointerFocusIntentIsCurrent( pointerTimestamp: TimeInterval, latestUserInputTimestamp: TimeInterval, diff --git a/Tests/DefiRuntimeTests/PointerFocusTests.swift b/Tests/DefiRuntimeTests/PointerFocusTests.swift index 3d961aac..6ba9ffd0 100644 --- a/Tests/DefiRuntimeTests/PointerFocusTests.swift +++ b/Tests/DefiRuntimeTests/PointerFocusTests.swift @@ -5,6 +5,29 @@ import DefiRuntime import Testing struct PointerFocusTests { + @Test + func restDelayOfZeroSkipsTheWait() async { + let clock = ContinuousClock() + let start = clock.now + #expect(await pointerFocusRestDelayElapsed(milliseconds: 0)) + #expect(start.duration(to: clock.now) < .milliseconds(100)) + } + + @Test + func restDelayWaitsBeforeCompleting() async { + let clock = ContinuousClock() + let start = clock.now + #expect(await pointerFocusRestDelayElapsed(milliseconds: 50)) + #expect(start.duration(to: clock.now) >= .milliseconds(50)) + } + + @Test + func newerMotionCancelsThePendingWait() async { + let wait = Task { await pointerFocusRestDelayElapsed(milliseconds: 5_000) } + wait.cancel() + #expect(await wait.value == false) + } + @Test func pointerFocusRecoveryTargetsLogicalWindowAfterUnmanagedExit() { let logicalWindowID = WindowID(rawValue: 1)