listless

A simple list app for Apple platforms
Log | Files | Refs | README | LICENSE

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)