1import ExpoModulesCore
2import UIKit
3
4public 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
17public 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
174private 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}