← All repositories

listless

A simple list app for Apple platforms

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


commit bdbc6373eada02ce6e974616a953bdbd87812836
parent adc15e8d9b9154b1a202fbc9b26224f61d671d90
Author: Michael Camilleri <[email protected]>
Date:   Sun, 15 Mar 2026 21:20:08 +0900

Fix focus bug in macOS version

Prior to this commit, there was a bug where SwiftUI and AppKit would
conflict about which view had focus. This commit takes a bit of a
fragile approach and has a global flag that can be set to prevent rows
from accepting focus.

Co-Authored-By: Claude 4.6 Opus <[email protected]>

Diffstat

MAGENTS.md | 1+
MListlessMac/Helpers/ClickableTextField.swift | 26++++++++++++++++++++++++--
MListlessMac/Helpers/TaskRowDragGesture.swift | 2+-
MListlessMac/Views/TaskListView.swift | 7+++++++

4 files changed, 33 insertions(+), 3 deletions(-)

diff --git a/AGENTS.md b/AGENTS.md

@@ -117,6 +117,7 @@ Listless is a to-do list app for Apple platforms. It is intended to run on iPhon
 - **Focus management**: Single `@FocusState` enum (`FocusField` in `TaskListTypes.swift`) with `.task(UUID)` and `.scrollView` cases. Never use multiple @FocusState variables for related focus. Keyboard handlers return `.ignored` when wrong focus state.
   - **iOS focus cleanup**: On iOS, always dismiss focus by setting `focusedField = nil`, never directly to `.scrollView`. The `onChange(of: focusedFieldBinding)` handler intercepts `nil` to run cleanup (e.g. `deleteIfEmpty` for empty tasks) before redirecting to `.scrollView`. Skipping `nil` desyncs SwiftUI focus state from UIKit first responder and can cause crashes.
   - **Pending focus**: Set both `pendingFocus` and `focusedField` in `createNewTask()`. Do NOT resolve in `.onAppear` (race conditions). Clear `pendingFocus` in `startEditing()`. Guard `deleteIfEmpty()` against `pendingFocus` matches.
+  - **macOS key-view loop guard**: When Return/Escape ends editing via `makeFirstResponder(nil)`, SwiftUI processes the `@FocusState` change asynchronously. During reconciliation it can traverse the view hierarchy and make the first `NSTextField` become first responder, overriding the `.scrollView` assignment. `ClickableNSTextField.blockKeyViewLoop` is set to `true` before `makeFirstResponder(nil)` and cleared in the outer `onChange(of: focusedFieldBinding)` handler — keeping all non-editing text fields from accepting first responder until SwiftUI has finished processing. Do not clear this flag synchronously in `doCommandBy`; the whole point is that it outlives that call.
 - **Text editing**: iOS uses `TappableTextField` (UIViewRepresentable wrapping `UITextView`); macOS uses `ClickableTextField` (NSViewRepresentable wrapping `NSTextField`). Both use delegate/coordinator patterns to bridge to SwiftUI. Key gotcha: `onEditingChanged` callbacks from UIKit may arrive during a SwiftUI update pass — defer via `DispatchQueue.main.async`.
 - **Drag-and-drop**: Both platforms maintain `visualOrder` during drag and commit via `store.moveTask()` on release. macOS uses `onDrag` + `dropDestination`; iOS uses `LongPressGesture.sequenced(before: DragGesture)` via `.simultaneousGesture()`.
 - **Keyboard navigation**: macOS uses dictionary-based keybindings in `KeyboardNavigationModifier.swift`. iOS uses `KeyCommandBridge` (UIViewRepresentable) because iPadOS `@FocusState` silently fails with hardware keyboards — the iOS `body` does **not** use `.focusable()`, `.focused(equals: .scrollView)`, or `.keyboardNavigation()`.

diff --git a/ListlessMac/Helpers/ClickableTextField.swift b/ListlessMac/Helpers/ClickableTextField.swift

@@ -5,6 +5,24 @@ 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
+
+    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
+            }
+        }
+        return super.acceptsFirstResponder
+    }
+
     override func becomeFirstResponder() -> Bool {
         let result = super.becomeFirstResponder()
         if result, let event = NSApp.currentEvent, event.type == .leftMouseDown {
@@ -191,14 +209,18 @@ 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
+                // 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.
                 editEndReason = .returnKey
+                ClickableNSTextField.blockKeyViewLoop = true
                 control.window?.makeFirstResponder(nil)
                 return true  // Prevent newline insertion
             }
             if commandSelector == #selector(NSResponder.cancelOperation(_:)) {
-                // Escape key pressed
+                // Escape key pressed — same blocking strategy as Return.
                 editEndReason = .escape
+                ClickableNSTextField.blockKeyViewLoop = true
                 control.window?.makeFirstResponder(nil)
                 return true
             }

diff --git a/ListlessMac/Helpers/TaskRowDragGesture.swift b/ListlessMac/Helpers/TaskRowDragGesture.swift

@@ -108,7 +108,7 @@ struct TaskRowDragGesture: ViewModifier {
 // MARK: - Drag Source
 
 @MainActor
-class DragSourceManager: NSObject, @preconcurrency NSDraggingSource {
+class DragSourceManager: NSObject, NSDraggingSource {
     weak var sourceView: NSView?
     var isActive = false
     var onDragEnd: (() -> Void)?

diff --git a/ListlessMac/Views/TaskListView.swift b/ListlessMac/Views/TaskListView.swift

@@ -421,6 +421,13 @@ struct TaskListView: View, TaskListViewProtocol {
             updateMenuCoordinator()
         }
         .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
             fState.focusedField = newValue
             handleFocusChange(from: oldValue, to: newValue)