commit d94ce6c1c56e7e34c9a4a0fb45930af81e4127eb
parent bdbc6373eada02ce6e974616a953bdbd87812836
Author: Michael Camilleri <[email protected]>
Date: Mon, 16 Mar 2026 18:57:03 +0900
Improve the fix for the focus bug in macOS
The previous commit attempted to fix a focus bug in macOS. However,
SwiftUI seems to be broken in reconciling focus when an AppKit view is
located outside the visible area of the window. This commit hacks in a
fix for this.
Co-Authored-By: Claude 4.6 Opus <[email protected]>
Diffstat:
5 files changed, 99 insertions(+), 50 deletions(-)
diff --git a/ListlessMac/Helpers/AppCommands.swift b/ListlessMac/Helpers/AppCommands.swift
@@ -1,9 +1,9 @@
import Foundation
-// Bridges SwiftUI view state to AppKit menu items without using SwiftUI's Commands API.
+// Bridges SwiftUI view state to AppKit without using SwiftUI's Commands API.
// One instance per window; AppDelegate resolves the key window's coordinator at dispatch time.
@MainActor
-final class MenuCoordinator {
+final class WindowCoordinator {
// Actions — set by TaskListView on each relevant state change.
var newTask: (() -> Void)?
@@ -31,4 +31,10 @@ final class MenuCoordinator {
// Dynamic titles — read by AppDelegate in validateMenuItem.
var markCompletedTitle: String = "Mark as Complete"
+
+ // Focus gating — checked by ClickableNSTextField.acceptsFirstResponder
+ // to prevent AppKit's key-view loop from focusing the wrong text field
+ // during SwiftUI reconciliation. When non-nil, only the text field
+ // matching this target may accept first responder.
+ var allowedFocusTarget: FocusField?
}
diff --git a/ListlessMac/Helpers/ClickableTextField.swift b/ListlessMac/Helpers/ClickableTextField.swift
@@ -5,24 +5,48 @@ import SwiftUI
class ClickableNSTextField: NSTextField {
var onBecomeFirstResponder: (() -> Void)?
- /// When true, text fields that are not currently editing refuse first
- /// responder. Set around `makeFirstResponder(nil)` calls triggered by
- /// Return/Escape so that AppKit's key-view loop does not jump focus to
- /// the first text field in the window.
- static var blockKeyViewLoop = false
+ /// The task ID this text field represents, used by the per-window
+ /// `WindowCoordinator.allowedFocusTarget` check.
+ var taskID: UUID?
override var acceptsFirstResponder: Bool {
- if ClickableNSTextField.blockKeyViewLoop && currentEditor() == nil {
- // Allow click-initiated focus even while blocking the key-view loop.
- if let event = NSApp.currentEvent, event.type == .leftMouseDown {
- ClickableNSTextField.blockKeyViewLoop = false
- } else {
- return false
+ // Always allow if this field is already editing.
+ if currentEditor() != nil { return super.acceptsFirstResponder }
+
+ // Always allow click-initiated focus.
+ if let event = NSApp.currentEvent, event.type == .leftMouseDown {
+ return super.acceptsFirstResponder
+ }
+
+ // Check the per-window coordinator for an allowed focus target.
+ if let window,
+ let delegate = NSApp.delegate as? AppDelegate,
+ let coordinator = delegate.coordinator(for: window)
+ {
+ if let allowed = coordinator.allowedFocusTarget {
+ // A specific target is set — only that field may accept.
+ guard let taskID, case .task(let allowedID) = allowed, allowedID == taskID else {
+ return false
+ }
}
}
+
return super.acceptsFirstResponder
}
+ override func viewDidMoveToWindow() {
+ super.viewDidMoveToWindow()
+ guard let window,
+ let taskID,
+ let delegate = NSApp.delegate as? AppDelegate,
+ let coordinator = delegate.coordinator(for: window)
+ else { return }
+ if case .task(let allowedID) = coordinator.allowedFocusTarget, allowedID == taskID {
+ coordinator.allowedFocusTarget = nil
+ window.makeFirstResponder(self)
+ }
+ }
+
override func becomeFirstResponder() -> Bool {
let result = super.becomeFirstResponder()
if result, let event = NSApp.currentEvent, event.type == .leftMouseDown {
@@ -40,10 +64,12 @@ struct ClickableTextField: NSViewRepresentable {
@Binding var text: String
let isCompleted: Bool
let onEditingChanged: (Bool, _ shouldCreateNewTask: Bool) -> Void
+ var taskID: UUID? = nil
var onContentChange: ((String) -> Void)? = nil
func makeNSView(context: Context) -> ClickableNSTextField {
let textField = ClickableNSTextField()
+ textField.taskID = taskID
textField.delegate = context.coordinator
textField.isBordered = false
textField.drawsBackground = false
@@ -209,22 +235,31 @@ struct ClickableTextField: NSViewRepresentable {
// Checker priority inversion warning. This is internal to AppKit's
// first responder machinery, not caused by our callback chain.
if commandSelector == #selector(NSResponder.insertNewline(_:)) {
- // Return key pressed — block the key-view loop until
- // SwiftUI has finished processing the focus change.
- // The flag is cleared in TaskListView's outer onChange.
+ // Return key pressed — set the per-window allowed focus
+ // target to .scrollView so no text field can steal focus
+ // during reconciliation. Cleared in TaskListView's outer
+ // onChange(of: focusedFieldBinding).
editEndReason = .returnKey
- ClickableNSTextField.blockKeyViewLoop = true
+ setAllowedFocusTarget(for: control.window, target: .scrollView)
control.window?.makeFirstResponder(nil)
return true // Prevent newline insertion
}
if commandSelector == #selector(NSResponder.cancelOperation(_:)) {
- // Escape key pressed — same blocking strategy as Return.
+ // Escape key pressed — same strategy as Return.
editEndReason = .escape
- ClickableNSTextField.blockKeyViewLoop = true
+ setAllowedFocusTarget(for: control.window, target: .scrollView)
control.window?.makeFirstResponder(nil)
return true
}
return false
}
+
+ private func setAllowedFocusTarget(for window: NSWindow?, target: FocusField) {
+ guard let window,
+ let delegate = NSApp.delegate as? AppDelegate,
+ let coordinator = delegate.coordinator(for: window)
+ else { return }
+ coordinator.allowedFocusTarget = target
+ }
}
}
diff --git a/ListlessMac/ListlessMacApp.swift b/ListlessMac/ListlessMacApp.swift
@@ -12,13 +12,17 @@ private enum MenuSelectors {
class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation {
private let persistenceController: PersistenceController
private var syncDiagnosticsWindow: NSWindow?
- private let coordinators = NSMapTable<NSWindow, MenuCoordinator>.weakToStrongObjects()
+ private let coordinators = NSMapTable<NSWindow, WindowCoordinator>.weakToStrongObjects()
- private var keyWindowCoordinator: MenuCoordinator? {
+ private var keyWindowCoordinator: WindowCoordinator? {
guard let window = NSApp.keyWindow else { return nil }
return coordinators.object(forKey: window)
}
+ func coordinator(for window: NSWindow) -> WindowCoordinator? {
+ coordinators.object(forKey: window)
+ }
+
override init() {
let isUITesting = ProcessInfo.processInfo.arguments.contains("UI_TESTING")
persistenceController = isUITesting ? PersistenceController(inMemory: true) : .shared
@@ -130,11 +134,11 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation {
private func openNewWindow() {
let defaultContentSize = NSSize(width: 400, height: 350)
- let menuCoordinator = MenuCoordinator()
+ let windowCoordinator = WindowCoordinator()
let rootView = TaskListView(
store: TaskStore(persistenceController: persistenceController),
syncMonitor: persistenceController.syncMonitor,
- menuCoordinator: menuCoordinator
+ windowCoordinator: windowCoordinator
)
.environment(\.managedObjectContext, persistenceController.viewContext)
@@ -152,7 +156,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation {
window.titlebarAppearsTransparent = false
window.isReleasedWhenClosed = false
window.isRestorable = false
- coordinators.setObject(menuCoordinator, forKey: window)
+ coordinators.setObject(windowCoordinator, forKey: window)
let referenceWindow = NSApp.orderedWindows.first { existingWindow in
existingWindow.isVisible && existingWindow.title == "Items"
}
diff --git a/ListlessMac/Views/TaskListView.swift b/ListlessMac/Views/TaskListView.swift
@@ -13,7 +13,7 @@ struct TaskListView: View, TaskListViewProtocol {
@Environment(\.managedObjectContext) var managedObjectContext
let store: TaskStore
- let menuCoordinator: MenuCoordinator
+ let windowCoordinator: WindowCoordinator
@ObservedObject var syncMonitor: CloudKitSyncMonitor
@FetchRequest(
sortDescriptors: [],
@@ -98,7 +98,7 @@ struct TaskListView: View, TaskListViewProtocol {
let selectedIndex: Int?
}
- var menuCoordinatorTrigger: MenuState {
+ var windowCoordinatorTrigger: MenuState {
MenuState(
selectedTaskIDs: fState.selectedTaskIDs,
isScrollViewFocused: focusedField == .scrollView,
@@ -108,8 +108,8 @@ struct TaskListView: View, TaskListViewProtocol {
)
}
- func updateMenuCoordinator() {
- let coord = menuCoordinator
+ func updateWindowCoordinator() {
+ let coord = windowCoordinator
coord.newTask = { createNewTask() }
coord.copySelectedTask = {
guard let taskID = fState.selectedTaskID,
@@ -153,10 +153,10 @@ struct TaskListView: View, TaskListViewProtocol {
coord.canClearCompletedTasks = !completedTasks.isEmpty
}
- init(store: TaskStore, syncMonitor: CloudKitSyncMonitor, menuCoordinator: MenuCoordinator) {
+ init(store: TaskStore, syncMonitor: CloudKitSyncMonitor, windowCoordinator: WindowCoordinator) {
self.store = store
self.syncMonitor = syncMonitor
- self.menuCoordinator = menuCoordinator
+ self.windowCoordinator = windowCoordinator
}
func isRowLifted(_ taskID: UUID) -> Bool {
@@ -299,7 +299,8 @@ struct TaskListView: View, TaskListViewProtocol {
shouldCreateNewTask: shouldCreateNewTask
)
}
- }
+ },
+ taskID: draftAppendRowID
)
.focused(
$focusedFieldBinding,
@@ -418,33 +419,35 @@ struct TaskListView: View, TaskListViewProtocol {
focusedFieldBinding = .scrollView
}
fState.focusedField = focusedFieldBinding
- updateMenuCoordinator()
+ updateWindowCoordinator()
}
.onChange(of: focusedFieldBinding) { oldValue, newValue in
- // Clear the key-view-loop block set by doCommandBy on
- // Return/Escape. Safe because all current endEditing paths
- // target either .scrollView (not an NSTextField) or a new
- // draft view (not yet rendered). If a future path needs to
- // focus an existing text field, it must clear this flag
- // before setting focusedField.
- ClickableNSTextField.blockKeyViewLoop = false
+ // Clear the per-window focus gate once we've landed on
+ // a non-nil value (reconciliation is done). Keep it set
+ // while nil so the redirect below doesn't open a window
+ // for AppKit's key-view loop.
+ if newValue != nil {
+ windowCoordinator.allowedFocusTarget = nil
+ }
fState.focusedField = newValue
handleFocusChange(from: oldValue, to: newValue)
- if newValue == nil {
- if let pending = fState.pendingFocus {
- focusedFieldBinding = pending
- fState.focusedField = pending
- fState.pendingFocus = nil
- } else {
- focusedFieldBinding = .scrollView
- fState.focusedField = .scrollView
- }
+ if let pending = fState.pendingFocus, newValue != pending {
+ // Focus landed somewhere other than the intended
+ // target (or went nil). Set the allowed target so
+ // the text field can claim focus in
+ // viewDidMoveToWindow if it's not yet in the
+ // hierarchy, and redirect immediately in case it is.
+ windowCoordinator.allowedFocusTarget = pending
+ fState.pendingFocus = nil
+ focusedField = pending
+ } else if newValue == nil {
+ focusedField = .scrollView
}
- updateMenuCoordinator()
+ updateWindowCoordinator()
}
- .onChange(of: menuCoordinatorTrigger) { _, _ in updateMenuCoordinator() }
+ .onChange(of: windowCoordinatorTrigger) { _, _ in updateWindowCoordinator() }
.onChange(of: undoManager, initial: true) { _, newValue in
managedObjectContext.undoManager = newValue
}
diff --git a/ListlessMac/Views/TaskRowView.swift b/ListlessMac/Views/TaskRowView.swift
@@ -91,6 +91,7 @@ struct TaskRowView: View {
onEndEdit(taskID, shouldCreateNewTask)
}
},
+ taskID: taskID,
onContentChange: { newTitle in
guard !task.isCompleted else { return }
onTitleChange(task, newTitle)