← All repositories

listless

A simple list app for Apple platforms

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


commit 6f3fb4de51ee5d8019d508c61a5eb72dddf596fa
parent e9d82013903eed2ccfe26366715179c9c65c0eab
Author: Michael Camilleri <[email protected]>
Date:   Thu,  5 Mar 2026 05:07:03 +0900

Use workaround for menu items in iOS

At the risk of an overly broad generalisation, SwiftUI's Commands API
doesn't work reliably. This commit follows a similar approach on iOS to
how menus are handled in the macOS version of Listless.

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

Diffstat

MAGENTS.md | 18++++++++++++++----
MListlessiOS/Helpers/AppCommands.swift | 90+++++++++++++++++++++++++++++++------------------------------------------------
MListlessiOS/Helpers/KeyCommandBridge.swift | 50+++++++++++++++++++++++++++++++++++++++++++++++++-
MListlessiOS/ListlessiOSApp.swift | 62+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
MListlessiOS/Views/TaskListView.swift | 61++++++++++++++++++++++++++++++++++++++++++++++---------------

5 files changed, 203 insertions(+), 78 deletions(-)

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

@@ -78,15 +78,25 @@
   - SwiftUI's declarative patterns proved more maintainable for this app
 - **Switching to AppKit** (if needed): Update `project.yml` to exclude SwiftUI views and include `ListlessMac/AppKit/`, then run `xcodegen generate`
 
-## macOS Menu Customisation
-- **Do not use SwiftUI's `Commands` API** — broken on macOS 15: dividers don't render and `CommandGroup(replacing:)` causes Window menu jitter.
+## Menu Customisation (both platforms)
+- **Do not use SwiftUI's `Commands` API** — broken on macOS 15 (dividers don't render, `CommandGroup(replacing:)` causes Window menu jitter) and on iPadOS (arrow key glyphs render as "?", `@FocusedValue` propagation fails when a UIKit view is first responder).
+- **Both platforms use native menu APIs** with a shared coordinator pattern: action closures + enabled-state booleans updated by `TaskListView.updateMenuCoordinator()`.
+- **Adding a new key binding** requires four touch points per platform: (1) coordinator — add action closure + enabled flag, (2) selector/action protocol — add the `@objc` method, (3) menu definition — register the key command in the right menu, (4) `updateMenuCoordinator()` — wire up the action and enabled state.
+
+### macOS
 - **Menus are fully AppKit-owned** in `AppDelegate.installMainMenu()` (`ListlessMac/ListlessMacApp.swift`), assigned directly via `NSApp.mainMenu`.
 - **No runtime menu patching**: do not use `NSMenu.didAddItemNotification`/tag-guard patch logic for command setup in the current architecture.
-- **`MenuCoordinator`** (`ListlessMac/Helpers/AppCommands.swift`) bridges SwiftUI state to AppKit: action closures + enabled-state booleans updated by `TaskListView.updateMenuCoordinator()`. Enabled state is surfaced via `NSMenuItemValidation.validateMenuItem` on `AppDelegate`.
-- **Command shortcuts are canonical in AppKit menus** (e.g. New Task, Move Up/Down, Mark Completed, Delete). Avoid duplicating those command shortcuts in `TaskListView.keyboardNavigation(...)`.
+- **`MenuCoordinator`** (`ListlessMac/Helpers/AppCommands.swift`) bridges SwiftUI state to AppKit. Enabled state is surfaced via `NSMenuItemValidation.validateMenuItem` on `AppDelegate`.
+- **Command shortcuts are canonical in AppKit menus** (e.g. New Item, Move Up/Down, Mark Completed, Delete). Avoid duplicating those command shortcuts in `TaskListView.keyboardNavigation(...)`.
 - **Selector style**: prefer typed `#selector(...)` where available; use `MenuSelectors` constants for string-based selectors that lack typed Swift symbols.
 - **Window menu**: keep explicit baseline items in `installMainMenu()` and let `NSApp.windowsMenu` provide system-managed dynamic window list behavior.
 
+### iOS (iPad)
+- **Menus use UIKit's `buildMenu(with:)`** in `IOSAppDelegate` (`ListlessiOS/ListlessiOSApp.swift`), inserting `UIKeyCommand` items into standard `.file` and `.edit` menus so the iPad keyboard shortcut overlay groups them correctly.
+- **`IOSMenuCoordinator`** (`ListlessiOS/Helpers/AppCommands.swift`) bridges SwiftUI state to UIKit. `IOSMenuActions` protocol declares `@objc` action selectors; `KeyCaptureView` (the first responder in `KeyCommandBridge`) conforms and dispatches to the coordinator.
+- **Conditional availability**: `KeyCaptureView.canPerformAction(_:withSender:)` checks coordinator enabled flags; commands are greyed out in the overlay when conditions aren't met (e.g. Move Up disabled when selected task is first).
+- **Plain (unmodified) key commands** (Up, Down, Space, Return, Delete) are still handled by `KeyCommandBridge`'s `keyCommands` property with `wantsPriorityOverSystemBehavior = true`. Command-modified shortcuts go through `buildMenu` → responder chain → `KeyCaptureView` action methods.
+
 ## iOS Implementation Notes
 - **Platform-specific inits**: `ListlessiOS/Views/TaskRowView.swift` and `ListlessMac/Views/TaskRowView.swift` have diverged — iOS takes `isDragging: Binding<Bool>` while macOS does not. This is fine because each platform has its own `TaskListView.swift` in its platform-specific `Views/` directory, so identical call-site signatures are not required. When adding new parameters, update both `TaskRowView` inits and both `TaskListView` bodies.
 - **Swipe gesture**: Pure SwiftUI — `DragGesture(minimumDistance: 10, coordinateSpace: .local)` applied via `.simultaneousGesture()` for scroll coexistence. Direction discrimination happens in `handleDragChanged`: `abs(horizontalTranslation) > verticalTranslation + 10` must hold before any swipe offset is applied, so vertical scrolls pass through without activating the swipe. Haptic feedback uses `@State var hapticTrigger` toggled in `triggerAction` with a `.sensoryFeedback(.impact(weight: .medium), trigger: hapticTrigger)` modifier on the ZStack.

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

@@ -1,68 +1,48 @@
-import SwiftUI
+import UIKit
 
-// MARK: - Task Actions
+// MARK: - Menu Coordinator
 
-struct TaskActions {
+/// Bridges SwiftUI view state to UIKit menu items, mirroring the macOS
+/// `MenuCoordinator` pattern. `TaskListView.updateMenuCoordinator()` keeps
+/// actions and enabled flags current; `KeyCaptureView` dispatches actions
+/// and validates commands via the responder chain.
+@MainActor
+final class IOSMenuCoordinator {
+    static let shared = IOSMenuCoordinator()
+    private init() {}
+
+    // Actions — set by TaskListView on each relevant state change.
     var newTask: (() -> Void)?
     var deleteTask: (() -> Void)?
     var moveUp: (() -> Void)?
     var moveDown: (() -> Void)?
     var markCompleted: (() -> Void)?
-}
-
-// MARK: - Focused Value Key
 
-struct TaskActionsKey: FocusedValueKey {
-    typealias Value = TaskActions
+    // Enabled state — read by KeyCaptureView in validate(_:).
+    var canDelete = false
+    var canMoveUp = false
+    var canMoveDown = false
+    var canMarkCompleted = false
 }
 
-extension FocusedValues {
-    var taskActions: TaskActions? {
-        get { self[TaskActionsKey.self] }
-        set { self[TaskActionsKey.self] = newValue }
-    }
-}
-
-// MARK: - Commands
-
-struct TaskCommands: Commands {
-    @FocusedValue(\.taskActions) var actions
+// MARK: - Menu Selectors
 
-    var body: some Commands {
-        CommandGroup(after: .newItem) {
-            Button("New Item") {
-                actions?.newTask?()
-            }
-            .keyboardShortcut("n")
-            .disabled(actions?.newTask == nil)
-        }
-
-        CommandGroup(replacing: .textEditing) {
-            Button("Delete") {
-                actions?.deleteTask?()
-            }
-            .keyboardShortcut(.delete, modifiers: .command)
-            .disabled(actions?.deleteTask == nil)
-
-            Divider()
-
-            Button("Move Up") {
-                actions?.moveUp?()
-            }
-            .keyboardShortcut(.upArrow, modifiers: .command)
-            .disabled(actions?.moveUp == nil)
-
-            Button("Move Down") {
-                actions?.moveDown?()
-            }
-            .keyboardShortcut(.downArrow, modifiers: .command)
-            .disabled(actions?.moveDown == nil)
+/// Selectors for menu item actions routed through the responder chain.
+/// `KeyCaptureView` (first responder) implements these as `@objc` methods.
+enum IOSMenuSelectors {
+    static let newTask = #selector(IOSMenuActions.handleNewTask)
+    static let deleteTask = #selector(IOSMenuActions.handleDeleteTask)
+    static let moveUp = #selector(IOSMenuActions.handleMoveUp)
+    static let moveDown = #selector(IOSMenuActions.handleMoveDown)
+    static let markCompleted = #selector(IOSMenuActions.handleMarkCompleted)
+}
 
-            Button("Mark as Complete") {
-                actions?.markCompleted?()
-            }
-            .keyboardShortcut(.space, modifiers: .command)
-            .disabled(actions?.markCompleted == nil)
-        }
-    }
+/// Protocol declaring the `@objc` action methods so selectors can be
+/// referenced at compile time. `KeyCaptureView` conforms to this.
+@MainActor @objc protocol IOSMenuActions {
+    func handleNewTask()
+    func handleDeleteTask()
+    func handleMoveUp()
+    func handleMoveDown()
+    func handleMarkCompleted()
 }

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

@@ -6,6 +6,11 @@ import UIKit
 /// programmatic focus (known iPadOS limitation with hardware keyboards).
 /// On iPhone, where `@FocusState` works normally, `isActive` stays false
 /// and the bridge remains inert.
+///
+/// Also serves as the first responder for menu item actions defined by
+/// `buildMenu(with:)` in the app delegate. Action methods dispatch to
+/// `IOSMenuCoordinator`; `validate(_:)` enables/disables items based on
+/// coordinator state.
 struct KeyCommandBridge: UIViewRepresentable {
     let isActive: Bool
     let onUp: () -> Void
@@ -41,7 +46,7 @@ struct KeyCommandBridge: UIViewRepresentable {
         }
     }
 
-    final class KeyCaptureView: UIView {
+    final class KeyCaptureView: UIView, IOSMenuActions {
         var isActive = false
         var onUp: (() -> Void)?
         var onDown: (() -> Void)?
@@ -51,6 +56,8 @@ struct KeyCommandBridge: UIViewRepresentable {
 
         override var canBecomeFirstResponder: Bool { true }
 
+        // MARK: - Plain key commands (no modifiers)
+
         override var keyCommands: [UIKeyCommand]? {
             guard isActive else { return nil }
             return [
@@ -86,5 +93,46 @@ struct KeyCommandBridge: UIViewRepresentable {
                 break
             }
         }
+
+        // MARK: - Menu item actions (from buildMenu via responder chain)
+
+        @objc func handleNewTask() {
+            IOSMenuCoordinator.shared.newTask?()
+        }
+
+        @objc func handleDeleteTask() {
+            IOSMenuCoordinator.shared.deleteTask?()
+        }
+
+        @objc func handleMoveUp() {
+            IOSMenuCoordinator.shared.moveUp?()
+        }
+
+        @objc func handleMoveDown() {
+            IOSMenuCoordinator.shared.moveDown?()
+        }
+
+        @objc func handleMarkCompleted() {
+            IOSMenuCoordinator.shared.markCompleted?()
+        }
+
+        // MARK: - Menu validation
+
+        override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
+            switch action {
+            case IOSMenuSelectors.newTask:
+                return isActive
+            case IOSMenuSelectors.deleteTask:
+                return isActive && IOSMenuCoordinator.shared.canDelete
+            case IOSMenuSelectors.moveUp:
+                return isActive && IOSMenuCoordinator.shared.canMoveUp
+            case IOSMenuSelectors.moveDown:
+                return isActive && IOSMenuCoordinator.shared.canMoveDown
+            case IOSMenuSelectors.markCompleted:
+                return isActive && IOSMenuCoordinator.shared.canMarkCompleted
+            default:
+                return super.canPerformAction(action, withSender: sender)
+            }
+        }
     }
 }

diff --git a/ListlessiOS/ListlessiOSApp.swift b/ListlessiOS/ListlessiOSApp.swift

@@ -1,7 +1,66 @@
 import SwiftUI
 
+// MARK: - App Delegate
+
+class IOSAppDelegate: UIResponder, UIApplicationDelegate {
+    override func buildMenu(with builder: UIMenuBuilder) {
+        guard builder.system == .main else {
+            super.buildMenu(with: builder)
+            return
+        }
+
+        // File menu — New Item (⌘N)
+        let newItem = UIKeyCommand(
+            title: "New Item",
+            action: IOSMenuSelectors.newTask,
+            input: "n",
+            modifierFlags: .command
+        )
+        builder.insertChild(
+            UIMenu(title: "", options: .displayInline, children: [newItem]),
+            atStartOfMenu: .file
+        )
+
+        // Edit menu — Move Up (⌘↑), Move Down (⌘↓), Delete (⌘⌫),
+        //              Mark as Complete (⌘Space)
+        let moveUp = UIKeyCommand(
+            title: "Move Up",
+            action: IOSMenuSelectors.moveUp,
+            input: UIKeyCommand.inputUpArrow,
+            modifierFlags: .command
+        )
+        let moveDown = UIKeyCommand(
+            title: "Move Down",
+            action: IOSMenuSelectors.moveDown,
+            input: UIKeyCommand.inputDownArrow,
+            modifierFlags: .command
+        )
+        let delete = UIKeyCommand(
+            title: "Delete",
+            action: IOSMenuSelectors.deleteTask,
+            input: "\u{8}",
+            modifierFlags: .command
+        )
+        let markComplete = UIKeyCommand(
+            title: "Mark as Complete",
+            action: IOSMenuSelectors.markCompleted,
+            input: " ",
+            modifierFlags: .command
+        )
+        builder.insertChild(
+            UIMenu(title: "", options: .displayInline, children: [
+                moveUp, moveDown, delete, markComplete,
+            ]),
+            atEndOfMenu: .edit
+        )
+    }
+}
+
+// MARK: - App
+
 @main
 struct ListlessiOSApp: App {
+    @UIApplicationDelegateAdaptor(IOSAppDelegate.self) var appDelegate
     @AppStorage("appearanceMode") private var appearanceMode = 0
     private let persistenceController: PersistenceController
     private let keyValueSyncBridge = KeyValueSyncBridge(keys: ["headingText"])
@@ -42,8 +101,5 @@ struct ListlessiOSApp: App {
                         .frame(height: 0)
                 }
         }
-        .commands {
-            TaskCommands()
-        }
     }
 }

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

@@ -118,23 +118,53 @@ struct TaskListView: View, TaskListViewProtocol {
         )
     }
 
-    private var currentTaskActions: TaskActions {
-        let selectedIndex = selectedTaskID.flatMap { id in
-            activeTasks.firstIndex(where: { $0.id == id })
-        }
-        return TaskActions(
-            newTask: { createNewTask() },
-            deleteTask: selectedTaskID != nil
-                ? { _ = deleteSelectedTask() } : nil,
-            moveUp: selectedIndex.map({ $0 > 0 }) == true
-                ? { moveSelectedTaskUp() } : nil,
-            moveDown: selectedIndex.map({ $0 < activeTasks.count - 1 }) == true
-                ? { moveSelectedTaskDown() } : nil,
-            markCompleted: selectedTaskID != nil
-                ? { markSelectedTaskCompleted() } : nil
+    private var selectedIndex: Int? {
+        guard let currentID = selectedTaskID else { return nil }
+        return activeTasks.firstIndex(where: { $0.id == currentID })
+    }
+
+    private var canMoveSelectionUp: Bool {
+        guard focusedField == .scrollView else { return false }
+        guard let index = selectedIndex else { return false }
+        return index > 0
+    }
+
+    private var canMoveSelectionDown: Bool {
+        guard focusedField == .scrollView else { return false }
+        guard let index = selectedIndex else { return false }
+        return index < activeTasks.count - 1
+    }
+
+    private struct MenuState: Equatable {
+        let selectedTaskID: UUID?
+        let isScrollViewFocused: Bool
+        let activeTaskCount: Int
+        let selectedIndex: Int?
+    }
+
+    private var menuCoordinatorTrigger: MenuState {
+        MenuState(
+            selectedTaskID: selectedTaskID,
+            isScrollViewFocused: focusedField == .scrollView,
+            activeTaskCount: activeTasks.count,
+            selectedIndex: selectedIndex
         )
     }
 
+    func updateMenuCoordinator() {
+        let coord = IOSMenuCoordinator.shared
+        coord.newTask = { createNewTask() }
+        coord.deleteTask = { _ = deleteSelectedTask() }
+        coord.moveUp = { moveSelectedTaskUp() }
+        coord.moveDown = { moveSelectedTaskDown() }
+        coord.markCompleted = { markSelectedTaskCompleted() }
+        let inNavMode = focusedField == .scrollView
+        coord.canDelete = selectedTaskID != nil && inNavMode
+        coord.canMoveUp = canMoveSelectionUp
+        coord.canMoveDown = canMoveSelectionDown
+        coord.canMarkCompleted = selectedTaskID != nil && inNavMode
+    }
+
     var vStackSpacing: CGFloat { 12 }
     var pullCreateThreshold: CGFloat { 70 }
     var isCompletelyEmpty: Bool { activeTasks.isEmpty && completedTasks.isEmpty }
@@ -178,7 +208,9 @@ struct TaskListView: View, TaskListViewProtocol {
             }
             .onAppear {
                 fState.focusedField = .scrollView
+                updateMenuCoordinator()
             }
+            .onChange(of: menuCoordinatorTrigger) { _, _ in updateMenuCoordinator() }
             .onChange(of: undoManager, initial: true) { _, newValue in
                 managedObjectContext.undoManager = newValue
             }
@@ -204,7 +236,6 @@ struct TaskListView: View, TaskListViewProtocol {
                     .padding(.trailing, 12)
                 }
             }
-            .focusedSceneValue(\.taskActions, currentTaskActions)
             .sheet(isPresented: isShowingSyncDiagnosticsStateBinding) {
                 NavigationStack {
                     SyncDiagnosticsView(syncMonitor: syncMonitor)