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..7eb85c0e 100644 --- a/Sources/DefiDaemon/DaemonFocus.swift +++ b/Sources/DefiDaemon/DaemonFocus.swift @@ -42,8 +42,10 @@ extension Daemon { let generation = commandGeneration let session = desktopSessionGeneration let previousWindowID = lastPointerWindowID + let delayMS = config.input.focusFollowsMouseDelayMS pointerHitTestTask = Task { [weak self] in - guard let self, !Task.isCancelled else { return } + // Each motion cancels the previous task, so focus waits for the pointer to rest. + 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/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/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/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 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)