← All repositories

listless

A simple list app for Apple platforms

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


commit 4ec01363a6ab1627d3a8c04dfd3cdc3a52f60098
parent 62e3b387afa076f761f8d2e69ca1c95d1f19d94b
Author: Michael Camilleri <[email protected]>
Date:   Thu,  5 Mar 2026 03:58:55 +0900

Fix keyboard navigation bugs in iOS version

This commit waves a white flag and gives up trying to use SwiftUI for
certain keyboard navigation elements. Instead, it uses the kludge of a
UIViewRepresentible object to intercept certain keys so that they can
be handled appropriately.

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

Diffstat

MAGENTS.md | 3++-
MListless.xcodeproj/project.pbxproj | 4++++
AListlessiOS/Helpers/KeyCommandBridge.swift | 90+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MListlessiOS/Views/TaskListView.swift | 26++++++++++++--------------

4 files changed, 108 insertions(+), 15 deletions(-)

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

@@ -140,13 +140,14 @@
 - **Drag-and-drop reordering**: Both platforms maintain `visualOrder` during drag for live preview and commit to Core Data via `store.moveTask()` on release; keyboard navigation uses `displayActiveTasks` (visual order during drag, data order otherwise)
   - **macOS**: `onDrag` + `dropDestination` with `isTargeted` callbacks; three-zone overlay on each row (1/6 top, 2/3 middle, 1/6 bottom) using `.layoutPriority()` for proportional sizing; top zone always inserts before, bottom always after, middle uses smart direction logic; VStack catch-all `dropDestination` handles the actual drop; ScrollView-level `.onDrop` catch-all clears drag state when a drag is cancelled without a successful drop; 1×1 transparent drag preview; visual lift (scale + shadow + zIndex) triggers on hold (0.4s) via `liftedTaskID` before the drag session begins — `LongPressGesture` in `TaskRowDragGesture` fires `onLift`, an `NSEvent.addLocalMonitorForEvents(matching: .leftMouseUp)` monitor clears the lift on release without drag; once `.onDrag` fires, `draggedTaskID` takes over and `liftedTaskID` is cleared; `isRowLifted()` combines both states for the visual modifiers; note that `leftMouseUp` does not fire during system drag sessions (macOS enters a modal event loop), so cancelled-drag cleanup relies on the ScrollView catch-all `.onDrop`; all of this lives directly in the macOS `body` in `ListlessMac/Views/TaskListView.swift` and `ListlessMac/Helpers/TaskRowDragGesture.swift`
   - **iOS**: `LongPressGesture(minimumDuration: 0.4).sequenced(before: DragGesture(...))` via `.simultaneousGesture()`; dragged row stays in-place with scale/shadow/zIndex lift (no overlay or ghost spacer); row frames tracked via `.onGeometryChange` (global coords); neighbour-swap when finger crosses 20% of row height past its own edge; `visualOrder` updated with spring animation; `handleIOSDragChanged` and `commitIOSDrag` live in `ListlessiOS/Extensions/TaskListView+Drag.swift`; `didStartDrag()` (sets `isDragging`, triggers haptic) is defined on the iOS struct in `ListlessiOS/Views/TaskListView.swift` with an empty no-op counterpart on the macOS struct
-- **Keyboard navigation system**: Dictionary-based keybindings in `Listless/Helpers/KeyboardNavigationModifier.swift`
+- **Keyboard navigation system (macOS)**: Dictionary-based keybindings in `Listless/Helpers/KeyboardNavigationModifier.swift`
   - `ShortcutKey` struct combines `KeyEquivalent` + `EventModifiers` for flexible shortcuts
   - Manual Hashable conformance required (EventModifiers doesn't auto-conform)
   - Key normalization: backspace character `\u{7F}` → `.delete` for consistent matching
   - Modifier normalization: strips `.function` and `.numericPad` (system modifiers that come with arrow keys)
   - Only user-intentional modifiers (.command, .shift, .option, .control) are matched
   - Supports modifier-based shortcuts like `ShortcutKey(key: "n", modifiers: .command)` for future ⌘N
+- **Keyboard navigation system (iOS)**: `KeyCommandBridge` (`ListlessiOS/Helpers/KeyCommandBridge.swift`) — a `UIViewRepresentable` wrapping a `UIView` that becomes first responder and registers `UIKeyCommand` entries (Up, Down, Space, Return, Delete) with `wantsPriorityOverSystemBehavior = true`. This bypasses iPadOS's `@FocusState` limitation where programmatic focus assignment to a `.focusable()` view silently fails with a hardware keyboard, causing `.onKeyPress` to never fire. The bridge is active when not editing a task (`focusedFieldBinding` is not `.task(...)`); `fState.focusedField` is set to `.scrollView` directly in `onAppear` so navigation function guards pass. The iOS `TaskListView` body does **not** use `.focusable()`, `.focused($focusedFieldBinding, equals: .scrollView)`, or `.keyboardNavigation()` — those remain on macOS only.
 - Avoid `Spacer` inside `ScrollView` (causes unwanted scrollbar when content fits viewport).
 - **Escape hatches** (`GeometryReader`, `PreferenceKey` size reporting, `UIViewRepresentable`/`NSViewRepresentable`): only reach for these after exhausting SwiftUI-native alternatives. They add complexity, can cause layout loops, and make state flow harder to follow. If you find yourself about to use one, first try `.frame`, `.overlay`, `.background`, `alignmentGuide`, or a different view decomposition.
 

diff --git a/Listless.xcodeproj/project.pbxproj b/Listless.xcodeproj/project.pbxproj

@@ -12,6 +12,7 @@
 		0ACA67F6578EFF181EE5C9A7 /* TaskItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBB8A3BEB346267B30B4675F /* TaskItem.swift */; };
 		0F12D56A528FCBF8A67864CB /* TappableTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6ECE0E961F87BA32FA87BF90 /* TappableTextField.swift */; };
 		11AA75BE98CFBE44AEAB7100 /* Media.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 9404C09EE1A4D91DFF338464 /* Media.xcassets */; };
+		12E43D0CD9124037022E3C38 /* KeyCommandBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8B5E429B253183F887C5FD6 /* KeyCommandBridge.swift */; };
 		1477B460E3DEFA3FDB7DA65B /* TaskListTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F9754B32B2FD5AF8552BC85 /* TaskListTypes.swift */; };
 		172F2DD978988E207610055F /* KeyboardNavigationModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4669414A460FC0758D5B49A8 /* KeyboardNavigationModifier.swift */; };
 		182D9FB61E3A3650D7D83D2A /* TaskRowSwipeGesture.swift in Sources */ = {isa = PBXBuildFile; fileRef = 067D666DF0AF1C53404ECF7C /* TaskRowSwipeGesture.swift */; };
@@ -147,6 +148,7 @@
 		E2174C43654733E9D4023157 /* PlatformTextFieldWidthModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlatformTextFieldWidthModifier.swift; sourceTree = "<group>"; };
 		E51B8129962E5CC78ECDDC2B /* TaskListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskListView.swift; sourceTree = "<group>"; };
 		E8448C5778F75F52719114AF /* PlatformTextFieldWidthModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlatformTextFieldWidthModifier.swift; sourceTree = "<group>"; };
+		E8B5E429B253183F887C5FD6 /* KeyCommandBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyCommandBridge.swift; sourceTree = "<group>"; };
 		E9288507CE6023425D1DE724 /* SyncDiagnosticsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncDiagnosticsView.swift; sourceTree = "<group>"; };
 		F1E998119283F784B9ADEE28 /* AppColors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppColors.swift; sourceTree = "<group>"; };
 		F416DD868A4C044F0D64F8D0 /* TaskRowDragGesture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskRowDragGesture.swift; sourceTree = "<group>"; };
@@ -161,6 +163,7 @@
 				F1E998119283F784B9ADEE28 /* AppColors.swift */,
 				46C46FC97E6DB6FF81AC5C22 /* AppCommands.swift */,
 				B7588879D0FA1C2A8BCEF14F /* HoverCursorModifier.swift */,
+				E8B5E429B253183F887C5FD6 /* KeyCommandBridge.swift */,
 				FA5D58EC1FBAA96E83A79445 /* PlatformScrollIndicatorsModifier.swift */,
 				E2174C43654733E9D4023157 /* PlatformTextFieldWidthModifier.swift */,
 				6ECE0E961F87BA32FA87BF90 /* TappableTextField.swift */,
@@ -526,6 +529,7 @@
 				CAB42FAA253E2B347AB0594B /* CloudKitErrorClassifier.swift in Sources */,
 				889DCB2BB3C01DDA281EA81A /* CloudKitSyncMonitor.swift in Sources */,
 				E60F81C7B930AACE67746759 /* HoverCursorModifier.swift in Sources */,
+				12E43D0CD9124037022E3C38 /* KeyCommandBridge.swift in Sources */,
 				19699EC4FF57EF0D636B65E3 /* KeyValueSyncBridge.swift in Sources */,
 				F6587B84ECC6BFE92A5FB493 /* KeyboardNavigationModifier.swift in Sources */,
 				5B60B409CE4BA668DB30A65D /* Listless.xcdatamodeld in Sources */,

diff --git a/ListlessiOS/Helpers/KeyCommandBridge.swift b/ListlessiOS/Helpers/KeyCommandBridge.swift

@@ -0,0 +1,90 @@
+import SwiftUI
+import UIKit
+
+/// UIViewRepresentable that captures keyboard input via UIKeyCommand when
+/// SwiftUI's `.focusable()` / `@FocusState` system fails to accept
+/// programmatic focus (known iPadOS limitation with hardware keyboards).
+/// On iPhone, where `@FocusState` works normally, `isActive` stays false
+/// and the bridge remains inert.
+struct KeyCommandBridge: UIViewRepresentable {
+    let isActive: Bool
+    let onUp: () -> Void
+    let onDown: () -> Void
+    let onSpace: () -> Void
+    let onReturn: () -> Void
+    let onDelete: () -> Void
+
+    func makeUIView(context: Context) -> KeyCaptureView {
+        let view = KeyCaptureView()
+        view.onUp = onUp
+        view.onDown = onDown
+        view.onSpace = onSpace
+        view.onReturn = onReturn
+        view.onDelete = onDelete
+        view.isActive = isActive
+        return view
+    }
+
+    func updateUIView(_ view: KeyCaptureView, context: Context) {
+        view.onUp = onUp
+        view.onDown = onDown
+        view.onSpace = onSpace
+        view.onReturn = onReturn
+        view.onDelete = onDelete
+        view.isActive = isActive
+
+        if isActive && !view.isFirstResponder {
+            DispatchQueue.main.async { [weak view] in
+                guard let view, view.isActive else { return }
+                view.becomeFirstResponder()
+            }
+        }
+    }
+
+    final class KeyCaptureView: UIView {
+        var isActive = false
+        var onUp: (() -> Void)?
+        var onDown: (() -> Void)?
+        var onSpace: (() -> Void)?
+        var onReturn: (() -> Void)?
+        var onDelete: (() -> Void)?
+
+        override var canBecomeFirstResponder: Bool { true }
+
+        override var keyCommands: [UIKeyCommand]? {
+            guard isActive else { return nil }
+            return [
+                UIKeyCommand.inputUpArrow,
+                UIKeyCommand.inputDownArrow,
+                " ",
+                "\r",
+                "\u{8}",
+            ].map { input in
+                let cmd = UIKeyCommand(
+                    input: input,
+                    modifierFlags: [],
+                    action: #selector(handleKeyCommand(_:))
+                )
+                cmd.wantsPriorityOverSystemBehavior = true
+                return cmd
+            }
+        }
+
+        @objc private func handleKeyCommand(_ sender: UIKeyCommand) {
+            switch sender.input {
+            case UIKeyCommand.inputUpArrow:
+                onUp?()
+            case UIKeyCommand.inputDownArrow:
+                onDown?()
+            case " ":
+                onSpace?()
+            case "\r":
+                onReturn?()
+            case "\u{8}":
+                onDelete?()
+            default:
+                break
+            }
+        }
+    }
+}

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

@@ -164,22 +164,20 @@ struct TaskListView: View, TaskListViewProtocol {
             .onTapGesture {
                 handleBackgroundTap()
             }
-            .focusable()
-            .focused($focusedFieldBinding, equals: .scrollView)
-            .focusEffectDisabled()
             .accessibilityIdentifier("task-list-scrollview")
-            .keyboardNavigation([
-                ShortcutKey(key: .upArrow): navigateUp,
-                ShortcutKey(key: .downArrow): navigateDown,
-                ShortcutKey(key: .space): toggleSelectedTask,
-                ShortcutKey(key: .return): focusSelectedTask,
-                ShortcutKey(key: .delete): deleteSelectedTask,
-            ])
+            .background {
+                let isEditing = if case .task = focusedFieldBinding { true } else { false }
+                KeyCommandBridge(
+                    isActive: !isEditing,
+                    onUp: { _ = navigateUp() },
+                    onDown: { _ = navigateDown() },
+                    onSpace: { _ = toggleSelectedTask() },
+                    onReturn: { _ = focusSelectedTask() },
+                    onDelete: { _ = deleteSelectedTask() }
+                )
+            }
             .onAppear {
-                if focusedFieldBinding == nil {
-                    focusedFieldBinding = .scrollView
-                }
-                fState.focusedField = focusedFieldBinding
+                fState.focusedField = .scrollView
             }
             .onChange(of: undoManager, initial: true) { _, newValue in
                 managedObjectContext.undoManager = newValue