iOS: Emacs keys on a hardware keyboard !113

merged merged by cmc on 2026-10-06 23:36 UTC · krz/orgstar:ios-keys into main

1 file changed, +91 −1

Layout: unified · split

Sources/OrgstarMobile/MobileEditor.swift +91 −1
@@ -17,6 +17,35 @@ final class LockedRanges: @unchecked Sendable {
17 func containing(_ offset: Int) -> Range<Int>? { lock.withLock { ranges.first { $0.contains(offset) } } } 17 func containing(_ offset: Int) -> Range<Int>? { lock.withLock { ranges.first { $0.contains(offset) } } }
18} 18}
19 19
20/// A text view that offers hardware-keyboard presses to the editor's keymap first.
21final class KeyedTextView: UITextView {
22 var keyHandler: ((UIKey) -> Bool)?
23 /// Keys the editor took, whose release the text system doesn't see. Optional: made by
24 /// UIKit's `init(usingTextLayoutManager:)`, the view skips Swift's default values.
25 private var handled: Set<UIKeyboardHIDUsage>?
26
27 override func pressesBegan(_ presses: Set<UIPress>, with event: UIPressesEvent?) {
28 var rest: Set<UIPress> = []
29 for press in presses {
30 if let key = press.key, keyHandler?(key) == true {
31 handled = (handled ?? []).union([key.keyCode])
32 } else {
33 rest.insert(press)
34 }
35 }
36 if !rest.isEmpty { super.pressesBegan(rest, with: event) }
37 }
38
39 override func pressesEnded(_ presses: Set<UIPress>, with event: UIPressesEvent?) {
40 let rest = presses.filter { press in
41 guard let code = press.key?.keyCode, handled?.contains(code) == true else { return true }
42 handled?.remove(code)
43 return false
44 }
45 if !rest.isEmpty { super.pressesEnded(rest, with: event) }
46 }
47}
48
20/// The iOS editor: a TextKit 2 text view that is the open file's live state, as the Mac's 49/// The iOS editor: a TextKit 2 text view that is the open file's live state, as the Mac's
21/// `OrgEditor` is. Typing and undo go through the text view and into the document; commands 50/// `OrgEditor` is. Typing and undo go through the text view and into the document; commands
22/// run on the document and apply their edits as one undo step; headings, drawers and blocks 51/// run on the document and apply their edits as one undo step; headings, drawers and blocks
@@ -43,8 +72,10 @@ final class MobileEditor: NSObject, DocumentBuffer {
43 72
44 init(document: DocumentState, editable: Bool) { 73 init(document: DocumentState, editable: Bool) {
45 self.document = document 74 self.document = document
46 textView = UITextView(usingTextLayoutManager: true) 75 let keyed = KeyedTextView(usingTextLayoutManager: true)
76 textView = keyed
47 super.init() 77 super.init()
78 keyed.keyHandler = { [weak self] key in self?.handleKey(key) ?? false }
48 textView.isEditable = editable 79 textView.isEditable = editable
49 textView.autocorrectionType = .no 80 textView.autocorrectionType = .no
50 textView.autocapitalizationType = .none 81 textView.autocapitalizationType = .none
@@ -172,6 +203,65 @@ final class MobileEditor: NSObject, DocumentBuffer {
172 } 203 }
173 } 204 }
174 205
206 // MARK: - Hardware keyboard
207
208 private lazy var dispatcher = KeyDispatcher(keymap: keymap)
209
210 private static let named: [UIKeyboardHIDUsage: String] = [
211 .keyboardTab: "TAB", .keyboardReturnOrEnter: "RET", .keypadEnter: "RET", .keyboardSpacebar: "SPC",
212 .keyboardEscape: "ESC", .keyboardDeleteOrBackspace: "DEL", .keyboardDeleteForward: "<delete>",
213 .keyboardLeftArrow: "<left>", .keyboardRightArrow: "<right>", .keyboardUpArrow: "<up>", .keyboardDownArrow: "<down>",
214 .keyboardHome: "<home>", .keyboardEnd: "<end>", .keyboardPageUp: "<prior>", .keyboardPageDown: "<next>",
215 ]
216
217 /// A key press as a chord; Option is Meta, as `option-as-meta` makes it on the Mac.
218 static func chord(_ key: UIKey) -> KeyChord? {
219 var modifiers: KeyChord.Modifiers = []
220 let flags = key.modifierFlags
221 if flags.contains(.control) { modifiers.insert(.control) }
222 if flags.contains(.alternate) { modifiers.insert(.meta) }
223 if flags.contains(.command) { modifiers.insert(.command) }
224 if let name = named[key.keyCode] {
225 if flags.contains(.shift) { modifiers.insert(.shift) }
226 return KeyChord(name, modifiers)
227 }
228 var characters = key.charactersIgnoringModifiers
229 if flags.contains(.shift), characters.count == 1, characters.lowercased() == characters { characters = characters.uppercased() }
230 guard characters.count == 1 else { return nil }
231 return KeyChord(characters, modifiers)
232 }
233
234 /// The Emacs keymap's commands on an iPad keyboard. Keys it doesn't bind, Command shortcuts
235 /// and editing keys (C-a, C-e, C-k...) are the text system's.
236 func handleKey(_ key: UIKey) -> Bool {
237 guard let chord = Self.chord(key), !chord.modifiers.contains(.command) else { return false }
238 // Option alone types characters unless it begins a binding.
239 if chord.modifiers == [.meta], dispatcher.pending.isEmpty,
240 keymap.candidates(for: [chord]).isEmpty, !keymap.isPrefix([chord]) { return false }
241 switch dispatcher.feed(chord) {
242 case .pending(let keys):
243 onMessage?(KeySequence.format(keys) + "-")
244 return true
245 case .cancelled:
246 onMessage?("Quit")
247 return true
248 case .undefined(let keys):
249 onMessage?("\(KeySequence.format(keys)) is undefined")
250 return true
251 case .unbound:
252 return false
253 case .complete(let keys, let candidates):
254 let context = editContext()
255 let runnable = candidates.first { binding in
256 (binding.when.map { KeyContext.holds($0, in: context) } ?? true) && applies(binding.command, in: context)
257 }
258 guard let binding = runnable else { return keys.count > 1 }
259 if binding.command.hasPrefix("edit."), !["edit.undo", "edit.redo"].contains(binding.command) { return keys.count > 1 }
260 run(binding.command)
261 return true
262 }
263 }
264
175 func editContext() -> EditContext { 265 func editContext() -> EditContext {
176 EditContext(revision: document.revision, text: document.text, tree: document.tree, selection: selection, hidden: hidden.all, options: options, knownTags: knownTags) 266 EditContext(revision: document.revision, text: document.text, tree: document.tree, selection: selection, hidden: hidden.all, options: options, knownTags: knownTags)
177 } 267 }