feat(native): iPad shortcuts in the app menu, pointer input on iPad

The UI's keyboard shortcuts for the screen on show become native key
commands (TrekShell.setKeyCommands, feature keyCommands): they appear in the
overlay a held Command key brings up and in the iPadOS menu bar, named in the
UI's language, and a press is reported back to the page ("keyCommand").
Each screen replaces the list, so only what works there is ever offered.

UIApplicationSupportsIndirectInputEvents turns on the iPad's pointer input
for trackpads and mice.
This commit is contained in:
Maurice
2026-11-07 14:42:48 +01:00
parent c204a25a9c
commit b1c0353548
4 changed files with 112 additions and 1 deletions
+11
View File
@@ -60,6 +60,17 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
// Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:.
}
// MARK: - Hardware keyboard
/// The UI's shortcuts for the screen on show (TrekKeyCommands), in the app's
/// menu: the overlay a held Command key brings up lists them there.
override func buildMenu(with builder: UIMenuBuilder) {
super.buildMenu(with: builder)
guard builder.system == .main, !TrekKeyCommands.current.isEmpty else { return }
let parent: UIMenu.Identifier = builder.menu(for: .application) != nil ? .application : .root
builder.insertChild(TrekKeyCommands.menu(), atStartOfMenu: parent)
}
func application(_ application: UIApplication,
configurationForConnecting connectingSceneSession: UISceneSession,
options: UIScene.ConnectionOptions) -> UISceneConfiguration {
+2
View File
@@ -160,6 +160,8 @@
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UIViewControllerBasedStatusBarAppearance</key>
<true/>
</dict>
@@ -50,6 +50,87 @@ class TrekBridgeViewController: CAPBridgeViewController {
// the page follows the finger and an open sheet closes first.
webView?.allowsBackForwardNavigationGestures = false
}
/// A key command of the UI's (TrekKeyCommands) was pressed. The commands sit
/// in the main menu, which sends their action up the responder chain from
/// the web view, and this controller is the first stop that answers it.
@objc func trekKeyCommand(_ sender: UIKeyCommand) {
guard let id = sender.propertyList as? String else { return }
TrekKeyCommands.onPress?(id)
}
}
// MARK: - Hardware keyboard (TrekShell.setKeyCommands)
/// The UI's keyboard shortcuts as key commands in the app's main menu. There
/// they show in the overlay a held Command key brings up and, on iPadOS 26, in
/// the menu bar, under the names the UI gives them in its language. Each screen
/// replaces the list (mobile/keyboard/MKeyboardHost.tsx), and the menu is
/// rebuilt with it, so only what works here and now is ever offered.
enum TrekKeyCommands {
struct Command {
let id: String
let title: String
let input: String
let modifiers: UIKeyModifierFlags
init?(raw: JSObject) {
guard let id = raw["id"] as? String, let title = raw["title"] as? String,
let input = raw["input"] as? String, !input.isEmpty else { return nil }
var flags: UIKeyModifierFlags = []
for name in raw["modifiers"] as? [String] ?? [] {
switch name {
case "command": flags.insert(.command)
case "shift": flags.insert(.shift)
case "option": flags.insert(.alternate)
case "control": flags.insert(.control)
default: break
}
}
self.id = id
self.title = title
self.input = Command.key(input)
self.modifiers = flags
}
private static func key(_ name: String) -> String {
switch name {
case "left": return UIKeyCommand.inputLeftArrow
case "right": return UIKeyCommand.inputRightArrow
case "up": return UIKeyCommand.inputUpArrow
case "down": return UIKeyCommand.inputDownArrow
case "escape": return UIKeyCommand.inputEscape
default: return name
}
}
}
static let menuIdentifier = UIMenu.Identifier("com.liketrek.trek.shortcuts")
private(set) static var current: [Command] = []
static var onPress: ((String) -> Void)?
static func set(_ commands: [Command]) {
current = commands
UIMenuSystem.main.setNeedsRebuild()
}
/// The commands as one menu, without a title of its own: it joins the app's
/// menu, where iPadOS lists the shortcuts under the app's name.
static func menu() -> UIMenu {
let children = current.map { command -> UIKeyCommand in
let key = UIKeyCommand(
title: command.title,
action: #selector(TrekBridgeViewController.trekKeyCommand(_:)),
input: command.input,
modifierFlags: command.modifiers,
propertyList: command.id
)
// ⌘[ and ⌘, belong to the app here, not to the web view's editing.
key.wantsPriorityOverSystemBehavior = true
return key
}
return UIMenu(title: "", identifier: menuIdentifier, options: .displayInline, children: children)
}
}
/// The remembered server and the offline copy of its start page.
+18 -1
View File
@@ -48,6 +48,7 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
CAPPluginMethod(name: "addToWallet", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "setWatchSnapshot", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "takeWatchActions", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "setKeyCommands", returnType: CAPPluginReturnPromise),
]
/// What this binary offers the server's UI, raised with every native feature
@@ -57,7 +58,7 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
/// The native features by name, so the UI can turn each on by itself.
/// "watch" only where WatchConnectivity is (an iPhone, not an iPad), so the UI never builds a snapshot for nothing.
static var features: [String] {
["capabilities", "edgeBack", "tileCache", "biometricLock", "geolocation", "sharedFiles", "nativePush", "widgets", "liveActivity", "widgetTrips", "storeKit", "shareSheet", "wallet"]
["capabilities", "edgeBack", "tileCache", "biometricLock", "geolocation", "sharedFiles", "nativePush", "widgets", "liveActivity", "widgetTrips", "storeKit", "shareSheet", "wallet", "keyCommands"]
+ (TrekWatchLink.isAvailable ? ["watch"] : [])
}
@@ -72,6 +73,22 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
}
TrekStore.watchUpdates()
TrekWatchLink.shared.onAction = { [weak self] in self?.notifyListeners("watchAction", data: [:]) }
TrekKeyCommands.onPress = { [weak self] id in
self?.notifyListeners("keyCommand", data: ["id": id])
}
}
// MARK: - Hardware keyboard
/// The UI's keyboard shortcuts for the screen it shows (mobile/keyboard),
/// named in its language, as key commands in the app's menu.
@objc func setKeyCommands(_ call: CAPPluginCall) {
let raw = call.getArray("commands", JSObject.self) ?? []
let commands = raw.compactMap { TrekKeyCommands.Command(raw: $0) }
DispatchQueue.main.async {
TrekKeyCommands.set(commands)
call.resolve()
}
}
// MARK: - Widgets and the travel-day Live Activity