| 1 | import ExpoModulesCore |
| 2 | import UIKit |
| 3 | |
| 4 | public final class T3KeyboardCommandsModule: Module { |
| 5 | public func definition() -> ModuleDefinition { |
| 6 | Name("T3KeyboardCommands") |
| 7 | |
| 8 | View(T3KeyboardCommandsView.self) { |
| 9 | Prop("enabledCommands") { (view: T3KeyboardCommandsView, commands: [String]) in |
| 10 | view.setEnabledCommands(commands) |
| 11 | } |
| 12 | Events("onCommand") |
| 13 | } |
| 14 | } |
| 15 | } |
| 16 | |
| 17 | public final class T3KeyboardCommandsView: ExpoView { |
| 18 | let onCommand = EventDispatcher() |
| 19 | private var enabledCommands = Set<String>() |
| 20 | private let shortcutInputView = UIView(frame: .zero) |
| 21 | |
| 22 | public override var canBecomeFirstResponder: Bool { true } |
| 23 | |
| 24 | // This responder handles hardware shortcuts, so reclaiming it must not show a software keyboard. |
| 25 | public override var inputView: UIView? { shortcutInputView } |
| 26 | |
| 27 | public override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { |
| 28 | if action == #selector(openCommandPalette) || action == #selector(paletteNext) || action == #selector(palettePrevious) || action == #selector(paletteDismiss), |
| 29 | let input = window?.t3FirstResponder as? UITextInput, |
| 30 | input.markedTextRange != nil { |
| 31 | return false |
| 32 | } |
| 33 | return super.canPerformAction(action, withSender: sender) |
| 34 | } |
| 35 | |
| 36 | public override var keyCommands: [UIKeyCommand]? { |
| 37 | let isPad = UIDevice.current.userInterfaceIdiom == .pad |
| 38 | var commands = [ |
| 39 | enabledCommand("newTask", input: "n", modifiers: .command, action: #selector(newTask), title: "New Task"), |
| 40 | enabledCommand("focusSearch", input: "f", modifiers: .command, action: #selector(focusSearch), title: "Find"), |
| 41 | isPad |
| 42 | ? enabledCommand("commandPalette", input: "k", modifiers: .command, action: #selector(openCommandPalette), title: "Command Palette") |
| 43 | : enabledCommand("focusSearch", input: "k", modifiers: .command, action: #selector(focusSearch), title: "Focus Search"), |
| 44 | enabledCommand("paletteNext", input: UIKeyCommand.inputDownArrow, modifiers: [], action: #selector(paletteNext), title: "Next Result"), |
| 45 | enabledCommand("palettePrevious", input: UIKeyCommand.inputUpArrow, modifiers: [], action: #selector(palettePrevious), title: "Previous Result"), |
| 46 | enabledCommand("paletteDismiss", input: UIKeyCommand.inputEscape, modifiers: [], action: #selector(paletteDismiss), title: "Close Command Palette"), |
| 47 | enabledCommand("back", input: "[", modifiers: .command, action: #selector(goBack), title: "Back"), |
| 48 | enabledCommand("files", input: "f", modifiers: [.command, .shift], action: #selector(openFiles), title: "Open Files"), |
| 49 | enabledCommand("terminal", input: "t", modifiers: [.command, .shift], action: #selector(openTerminal), title: "Open Terminal"), |
| 50 | enabledCommand("review", input: "r", modifiers: [.command, .shift], action: #selector(openReview), title: "Open Review"), |
| 51 | enabledCommand( |
| 52 | "copyThreadReference", |
| 53 | input: "c", |
| 54 | modifiers: [.command, .shift], |
| 55 | action: #selector(copyThreadReference), |
| 56 | title: "Copy PR Link or Thread ID" |
| 57 | ), |
| 58 | enabledCommand("toggleSidebar", input: "\\", modifiers: .command, action: #selector(handleToggleSidebar), title: "Toggle Sidebar"), |
| 59 | enabledCommand("cycleHost", input: "h", modifiers: [.command, .shift], action: #selector(cycleHost), title: "Next Machine"), |
| 60 | ].compactMap { $0 } |
| 61 | if isPad { |
| 62 | commands += (1...9).compactMap { index in |
| 63 | enabledCommand( |
| 64 | "thread.jump.\(index)", |
| 65 | input: String(index), |
| 66 | modifiers: .command, |
| 67 | action: #selector(jumpToThread(_:)), |
| 68 | title: "Go to Thread \(index)" |
| 69 | ) |
| 70 | } |
| 71 | } |
| 72 | return commands |
| 73 | } |
| 74 | |
| 75 | func setEnabledCommands(_ commands: [String]) { |
| 76 | enabledCommands = Set(commands) |
| 77 | if isFirstResponder { |
| 78 | resignFirstResponder() |
| 79 | } |
| 80 | reclaimFirstResponderIfAvailable() |
| 81 | } |
| 82 | |
| 83 | private func enabledCommand( |
| 84 | _ identifier: String, |
| 85 | input: String, |
| 86 | modifiers: UIKeyModifierFlags, |
| 87 | action: Selector, |
| 88 | title: String |
| 89 | ) -> UIKeyCommand? { |
| 90 | guard enabledCommands.contains(identifier) else { return nil } |
| 91 | return command(input, modifiers: modifiers, action: action, title: title) |
| 92 | } |
| 93 | |
| 94 | public required init(appContext: AppContext? = nil) { |
| 95 | super.init(appContext: appContext) |
| 96 | inputAssistantItem.leadingBarButtonGroups = [] |
| 97 | inputAssistantItem.trailingBarButtonGroups = [] |
| 98 | NotificationCenter.default.addObserver( |
| 99 | self, |
| 100 | selector: #selector(reclaimFirstResponderIfAvailable), |
| 101 | name: UITextView.textDidEndEditingNotification, |
| 102 | object: nil |
| 103 | ) |
| 104 | NotificationCenter.default.addObserver( |
| 105 | self, |
| 106 | selector: #selector(reclaimFirstResponderIfAvailable), |
| 107 | name: UITextField.textDidEndEditingNotification, |
| 108 | object: nil |
| 109 | ) |
| 110 | NotificationCenter.default.addObserver( |
| 111 | self, |
| 112 | selector: #selector(reclaimFirstResponderIfAvailable), |
| 113 | name: UIApplication.didBecomeActiveNotification, |
| 114 | object: nil |
| 115 | ) |
| 116 | } |
| 117 | |
| 118 | deinit { |
| 119 | NotificationCenter.default.removeObserver(self) |
| 120 | } |
| 121 | |
| 122 | public override func didMoveToWindow() { |
| 123 | super.didMoveToWindow() |
| 124 | reclaimFirstResponderIfAvailable() |
| 125 | } |
| 126 | |
| 127 | public override func layoutSubviews() { |
| 128 | super.layoutSubviews() |
| 129 | reclaimFirstResponderIfAvailable() |
| 130 | } |
| 131 | |
| 132 | private func command( |
| 133 | _ input: String, |
| 134 | modifiers: UIKeyModifierFlags, |
| 135 | action: Selector, |
| 136 | title: String |
| 137 | ) -> UIKeyCommand { |
| 138 | let command = UIKeyCommand(input: input, modifierFlags: modifiers, action: action) |
| 139 | command.discoverabilityTitle = title |
| 140 | command.wantsPriorityOverSystemBehavior = true |
| 141 | return command |
| 142 | } |
| 143 | |
| 144 | @objc private func newTask() { emit("newTask") } |
| 145 | @objc private func openCommandPalette() { emit("commandPalette") } |
| 146 | @objc private func paletteNext() { emit("paletteNext") } |
| 147 | @objc private func palettePrevious() { emit("palettePrevious") } |
| 148 | @objc private func paletteDismiss() { emit("paletteDismiss") } |
| 149 | @objc private func jumpToThread(_ sender: UIKeyCommand) { |
| 150 | guard let input = sender.input else { return } |
| 151 | emit("thread.jump.\(input)") |
| 152 | } |
| 153 | @objc private func focusSearch() { emit("focusSearch") } |
| 154 | @objc private func goBack() { emit("back") } |
| 155 | @objc private func openFiles() { emit("files") } |
| 156 | @objc private func openTerminal() { emit("terminal") } |
| 157 | @objc private func openReview() { emit("review") } |
| 158 | @objc private func copyThreadReference() { emit("copyThreadReference") } |
| 159 | @objc private func handleToggleSidebar() { emit("toggleSidebar") } |
| 160 | @objc private func cycleHost() { emit("cycleHost") } |
| 161 | |
| 162 | private func emit(_ command: String) { |
| 163 | onCommand(["command": command]) |
| 164 | } |
| 165 | |
| 166 | @objc private func reclaimFirstResponderIfAvailable() { |
| 167 | DispatchQueue.main.async { [weak self] in |
| 168 | guard let self, self.window?.t3FirstResponder == nil else { return } |
| 169 | self.becomeFirstResponder() |
| 170 | } |
| 171 | } |
| 172 | } |
| 173 | |
| 174 | private extension UIView { |
| 175 | var t3FirstResponder: UIResponder? { |
| 176 | if isFirstResponder { return self } |
| 177 | for subview in subviews { |
| 178 | if let responder = subview.t3FirstResponder { return responder } |
| 179 | } |
| 180 | return nil |
| 181 | } |
| 182 | } |