Skip to content
Merged
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
2 changes: 2 additions & 0 deletions CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,13 +114,15 @@ 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
```

| Setting | Default | Values/type | Description |
| --- | --- | --- | --- |
| `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.
Expand Down
3 changes: 3 additions & 0 deletions Sources/DefiConfig/Config.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down
7 changes: 7 additions & 0 deletions Sources/DefiConfig/ConfigModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,22 +5,26 @@ 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
}

enum CodingKeys: String, CodingKey {
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"
}

Expand All @@ -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
}
Expand Down
4 changes: 3 additions & 1 deletion Sources/DefiDaemon/DaemonFocus.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand Down
8 changes: 8 additions & 0 deletions Sources/DefiMacOS/DefiSettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
2 changes: 2 additions & 0 deletions Sources/DefiMacOS/SettingsSearch.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ enum SettingsOption: String, CaseIterable, Sendable {
case focusFollowsPointer
case pointerFollowsFocus
case pointerFocusScroll
case pointerFocusDelay
case defaultModifier
case modifierAliases
case customShortcut
Expand Down Expand Up @@ -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)
Expand Down
7 changes: 7 additions & 0 deletions Sources/DefiRuntime/FocusSelection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
11 changes: 11 additions & 0 deletions Tests/DefiConfigTests/InputConfigTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 23 additions & 0 deletions Tests/DefiRuntimeTests/PointerFocusTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading