From b1c035354894e8f2ea7492e13d41378200a10da8 Mon Sep 17 00:00:00 2001 From: Maurice Date: Sat, 7 Nov 2026 14:42:48 +0100 Subject: [PATCH] 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. --- client/ios/App/App/AppDelegate.swift | 11 +++ client/ios/App/App/Info.plist | 2 + .../App/App/TrekBridgeViewController.swift | 81 +++++++++++++++++++ client/ios/App/App/TrekShellPlugin.swift | 19 ++++- 4 files changed, 112 insertions(+), 1 deletion(-) diff --git a/client/ios/App/App/AppDelegate.swift b/client/ios/App/App/AppDelegate.swift index 6fd58372c..ce1e849c6 100644 --- a/client/ios/App/App/AppDelegate.swift +++ b/client/ios/App/App/AppDelegate.swift @@ -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 { diff --git a/client/ios/App/App/Info.plist b/client/ios/App/App/Info.plist index db57f50f3..ba7090150 100644 --- a/client/ios/App/App/Info.plist +++ b/client/ios/App/App/Info.plist @@ -160,6 +160,8 @@ UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight + UIApplicationSupportsIndirectInputEvents + UIViewControllerBasedStatusBarAppearance diff --git a/client/ios/App/App/TrekBridgeViewController.swift b/client/ios/App/App/TrekBridgeViewController.swift index 07070021e..2c37e7982 100644 --- a/client/ios/App/App/TrekBridgeViewController.swift +++ b/client/ios/App/App/TrekBridgeViewController.swift @@ -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. diff --git a/client/ios/App/App/TrekShellPlugin.swift b/client/ios/App/App/TrekShellPlugin.swift index 2a5f7a06d..534288545 100644 --- a/client/ios/App/App/TrekShellPlugin.swift +++ b/client/ios/App/App/TrekShellPlugin.swift @@ -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