Commit 81f64216be
Verified · cmc
Layout: unified · split
Sources/OrgstarMobile/MobileEditor.swift +91 −1
| @@ -17,6 +17,35 @@ final class LockedRanges: @unchecked Sendable { | ||
| 17 | 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. | |
| 21 | final 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 | 49 | /// The iOS editor: a TextKit 2 text view that is the open file's live state, as the Mac's |
| 21 | 50 | /// `OrgEditor` is. Typing and undo go through the text view and into the document; commands |
| 22 | 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 | 73 | init(document: DocumentState, editable: Bool) { |
| 45 | 74 | self.document = document |
| 46 | textView = UITextView(usingTextLayoutManager: true) | |
| 75 | let keyed = KeyedTextView(usingTextLayoutManager: true) | |
| 76 | textView = keyed | |
| 47 | 77 | super.init() |
| 78 | keyed.keyHandler = { [weak self] key in self?.handleKey(key) ?? false } | |
| 48 | 79 | textView.isEditable = editable |
| 49 | 80 | textView.autocorrectionType = .no |
| 50 | 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 | 265 | func editContext() -> EditContext { |
| 176 | 266 | EditContext(revision: document.revision, text: document.text, tree: document.tree, selection: selection, hidden: hidden.all, options: options, knownTags: knownTags) |
| 177 | 267 | } |