← All repositories

listless

A simple list app for Apple platforms

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


ListlessiOS/Helpers/KeyCommandBridge.swift (6351B)

1import SwiftUI
2import UIKit
3
4/// UIViewRepresentable that captures keyboard input via UIKeyCommand when
5/// SwiftUI's `.focusable()` / `@FocusState` system fails to accept
6/// programmatic focus (known iPadOS limitation with hardware keyboards).
7/// On iPhone, where `@FocusState` works normally, `isActive` stays false
8/// and the bridge remains inert.
9///
10/// Also serves as the first responder for menu item actions defined by
11/// `buildMenu(with:)` in the app delegate. Action methods dispatch to
12/// `IOSMenuCoordinator`; `validate(_:)` enables/disables items based on
13/// coordinator state.
14struct KeyCommandBridge: UIViewRepresentable {
15    let isActive: Bool
16    let onUp: () -> Void
17    let onDown: () -> Void
18    let onSpace: () -> Void
19    let onReturn: () -> Void
20    let onDelete: () -> Void
21    let onHome: () -> Void
22    let onEnd: () -> Void
23    let onPageUp: () -> Void
24    let onPageDown: () -> Void
25
26    func makeUIView(context: Context) -> KeyCaptureView {
27        let view = KeyCaptureView()
28        view.onUp = onUp
29        view.onDown = onDown
30        view.onSpace = onSpace
31        view.onReturn = onReturn
32        view.onDelete = onDelete
33        view.onHome = onHome
34        view.onEnd = onEnd
35        view.onPageUp = onPageUp
36        view.onPageDown = onPageDown
37        view.isActive = isActive
38        return view
39    }
40
41    func updateUIView(_ view: KeyCaptureView, context: Context) {
42        view.onUp = onUp
43        view.onDown = onDown
44        view.onSpace = onSpace
45        view.onReturn = onReturn
46        view.onDelete = onDelete
47        view.onHome = onHome
48        view.onEnd = onEnd
49        view.onPageUp = onPageUp
50        view.onPageDown = onPageDown
51        view.isActive = isActive
52
53        if isActive && !view.isFirstResponder {
54            DispatchQueue.main.async { [weak view] in
55                guard let view, view.isActive else { return }
56                view.becomeFirstResponder()
57            }
58        }
59    }
60
61    final class KeyCaptureView: UIView, IOSMenuActions {
62        var isActive = false
63        var onUp: (() -> Void)?
64        var onDown: (() -> Void)?
65        var onSpace: (() -> Void)?
66        var onReturn: (() -> Void)?
67        var onDelete: (() -> Void)?
68        var onHome: (() -> Void)?
69        var onEnd: (() -> Void)?
70        var onPageUp: (() -> Void)?
71        var onPageDown: (() -> Void)?
72
73        override var canBecomeFirstResponder: Bool { true }
74
75        // MARK: - Plain key commands (no modifiers)
76
77        override var keyCommands: [UIKeyCommand]? {
78            guard isActive else { return nil }
79            return [
80                UIKeyCommand.inputUpArrow,
81                UIKeyCommand.inputDownArrow,
82                " ",
83                "\r",
84                "\u{8}",
85                UIKeyCommand.inputHome,
86                UIKeyCommand.inputEnd,
87                UIKeyCommand.inputPageUp,
88                UIKeyCommand.inputPageDown,
89            ].map { input in
90                let cmd = UIKeyCommand(
91                    input: input,
92                    modifierFlags: [],
93                    action: #selector(handleKeyCommand(_:))
94                )
95                cmd.wantsPriorityOverSystemBehavior = true
96                return cmd
97            }
98        }
99
100        @objc private func handleKeyCommand(_ sender: UIKeyCommand) {
101            switch sender.input {
102            case UIKeyCommand.inputUpArrow:
103                onUp?()
104            case UIKeyCommand.inputDownArrow:
105                onDown?()
106            case " ":
107                onSpace?()
108            case "\r":
109                onReturn?()
110            case "\u{8}":
111                onDelete?()
112            case UIKeyCommand.inputHome:
113                onHome?()
114            case UIKeyCommand.inputEnd:
115                onEnd?()
116            case UIKeyCommand.inputPageUp:
117                onPageUp?()
118            case UIKeyCommand.inputPageDown:
119                onPageDown?()
120            default:
121                break
122            }
123        }
124
125        // MARK: - Menu item actions (from buildMenu via responder chain)
126
127        @objc func handleNewItem() {
128            IOSMenuCoordinator.shared.newItem?()
129        }
130
131        @objc func handleDeleteItem() {
132            IOSMenuCoordinator.shared.deleteItem?()
133        }
134
135        @objc func handleMoveUp() {
136            IOSMenuCoordinator.shared.moveUp?()
137        }
138
139        @objc func handleMoveDown() {
140            IOSMenuCoordinator.shared.moveDown?()
141        }
142
143        @objc func handleMarkCompleted() {
144            IOSMenuCoordinator.shared.markCompleted?()
145        }
146
147        @objc func handleNavigatePageUp() {
148            IOSMenuCoordinator.shared.navigatePageUp?()
149        }
150
151        @objc func handleNavigatePageDown() {
152            IOSMenuCoordinator.shared.navigatePageDown?()
153        }
154
155        @objc func handleNavigateToFirst() {
156            IOSMenuCoordinator.shared.navigateToFirst?()
157        }
158
159        @objc func handleNavigateToLast() {
160            IOSMenuCoordinator.shared.navigateToLast?()
161        }
162
163        // MARK: - Menu validation
164
165        override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
166            switch action {
167            case IOSMenuSelectors.newItem:
168                return isActive
169            case IOSMenuSelectors.deleteItem:
170                return isActive && IOSMenuCoordinator.shared.canDelete
171            case IOSMenuSelectors.moveUp:
172                return isActive && IOSMenuCoordinator.shared.canMoveUp
173            case IOSMenuSelectors.moveDown:
174                return isActive && IOSMenuCoordinator.shared.canMoveDown
175            case IOSMenuSelectors.markCompleted:
176                return isActive && IOSMenuCoordinator.shared.canMarkCompleted
177            case IOSMenuSelectors.navigatePageUp,
178                IOSMenuSelectors.navigatePageDown,
179                IOSMenuSelectors.navigateToFirst,
180                IOSMenuSelectors.navigateToLast:
181                return isActive
182            default:
183                return super.canPerformAction(action, withSender: sender)
184            }
185        }
186
187        override func validate(_ command: UICommand) {
188            super.validate(command)
189            if command.action == IOSMenuSelectors.markCompleted {
190                command.title = IOSMenuCoordinator.shared.markCompletedTitle
191            }
192        }
193    }
194}