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