listless

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

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)