← All repositories

listless

A simple list app for Apple platforms

git clone https://code.inqk.net/listless.git


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

MListlessMac/Helpers/AppCommands.swift | 10++++++++--
MListlessMac/Helpers/ClickableTextField.swift | 69++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------
MListlessMac/ListlessMacApp.swift | 14+++++++++-----
MListlessMac/Views/TaskListView.swift | 55+++++++++++++++++++++++++++++--------------------------
MListlessMac/Views/TaskRowView.swift | 1+

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)