| 1 | import SwiftUI |
| 2 | import UIKit |
| 3 | import LChatCore |
| 4 | |
| 5 | // The composer's text input. We drop to a UITextView (via a representable) |
| 6 | // instead of SwiftUI's TextField because we need to intercept Return *before* it |
| 7 | // inserts a newline: `.onKeyPress` sits above the field and the focused text |
| 8 | // input swallows Return first, so a SwiftUI-level handler can't make Return send. |
| 9 | // UITextView's `shouldChangeTextIn` delegate gates every edit — the one place the |
| 10 | // interception reliably wins. |
| 11 | // |
| 12 | // `sendOnReturn` follows the CLAUDE.md platform split: on the Mac a bare Return |
| 13 | // sends and Shift+Return inserts a newline (matching the web composer on |
| 14 | // desktop); on touch, Return always inserts a newline and the send button sends. |
| 15 | // |
| 16 | // Rich marks (#890 stage 2): bold/italic/code/strike style IN the box — ⌘B/⌘I |
| 17 | // (and the selection Format menu), ⌘E/⌘⇧X, and format-as-you-type (**bold** |
| 18 | // turns bold on the closing marker; Backspace right after restores the |
| 19 | // literal). The contract with the rest of the app: |
| 20 | // - The `text` binding is the DISPLAY string (exactly what's in the box, no |
| 21 | // markers) — the typeahead/commands space, unchanged from the plain days. |
| 22 | // - The wire form is read at send via ComposerFieldController.serializedMarkdown() |
| 23 | // (the LChatCore serializer twin, vector-pinned against the web). |
| 24 | // - Style truth is the .composerStyle attribute; fonts derive from it. |
| 25 | // - External binding writes mean "set literal plain text" (edit-load shows |
| 26 | // markers, exactly as today) — programmatic mid-draft edits (typeahead |
| 27 | // splices) go through the controller so styling and caret survive. |
| 28 | struct ComposerField: UIViewRepresentable { |
| 29 | @Binding var text: String |
| 30 | @Binding var focused: Bool |
| 31 | // Caret position as a UTF-16 offset (UIKit's native unit) — drives the |
| 32 | // typeahead, which needs to know which token the caret sits in. |
| 33 | var caret: Binding<Int>? = nil |
| 34 | var placeholder: String = "" |
| 35 | var sendOnReturn: Bool |
| 36 | // Format-as-you-type (the closing-marker rules). OFF still leaves the |
| 37 | // shortcuts working — markers you type stay literal and render in the feed. |
| 38 | var formatAsYouType: Bool = true |
| 39 | var controller: ComposerFieldController? = nil |
| 40 | var onSend: () -> Void |
| 41 | // A pasted image (the composer's "paste to upload") — data is JPEG. |
| 42 | var onPasteImage: ((Data) -> Void)? = nil |
| 43 | // Hardware-keyboard ↑ with an empty draft: edit your last message. |
| 44 | var onEditLast: (() -> Void)? = nil |
| 45 | // Hardware-keyboard Escape (iPad): the composer's peel ladder — typeahead, |
| 46 | // command panel, an armed edit/reply, the docked thread pane. Returns |
| 47 | // whether the press was consumed; unconsumed falls through to UIKit. |
| 48 | var onEscape: (() -> Bool)? = nil |
| 49 | |
| 50 | func makeCoordinator() -> Coordinator { Coordinator(self) } |
| 51 | |
| 52 | func makeUIView(context: Context) -> GrowingTextView { |
| 53 | let tv = GrowingTextView() |
| 54 | tv.onPasteImage = onPasteImage |
| 55 | tv.onEditLast = onEditLast |
| 56 | tv.onEscape = onEscape |
| 57 | tv.delegate = context.coordinator |
| 58 | tv.font = tv.baseBodyFont |
| 59 | // Live-track the system Text Size (Dynamic Type) — preferredFont only |
| 60 | // sizes at creation without this (styled runs re-derive in |
| 61 | // traitCollectionDidChange). |
| 62 | tv.adjustsFontForContentSizeCategory = true |
| 63 | tv.backgroundColor = .clear |
| 64 | tv.textContainerInset = .zero |
| 65 | tv.textContainer.lineFragmentPadding = 0 |
| 66 | tv.isScrollEnabled = false |
| 67 | // UIKit's standard rich-editing hooks: routes ⌘B/⌘I and the selection |
| 68 | // Format menu to toggleBoldface/toggleItalics, which we intercept. |
| 69 | tv.allowsEditingTextAttributes = true |
| 70 | tv.typingAttributes = tv.baseAttributes |
| 71 | controller?.view = tv |
| 72 | return tv |
| 73 | } |
| 74 | |
| 75 | func updateUIView(_ tv: GrowingTextView, context: Context) { |
| 76 | context.coordinator.parent = self |
| 77 | controller?.view = tv |
| 78 | tv.formatAsYouType = formatAsYouType |
| 79 | // The key/paste hooks follow the latest values — a bar whose |
| 80 | // attachment sources change (a bot joining flips a default-command |
| 81 | // thread) must not keep the closures it was made with. |
| 82 | tv.onPasteImage = onPasteImage |
| 83 | tv.onEditLast = onEditLast |
| 84 | tv.onEscape = onEscape |
| 85 | // A plain string compare — user edits round-trip exactly, so only |
| 86 | // EXTERNAL sets (edit-load, clear after send, pushback) land here, |
| 87 | // and those are literal plain text by contract. |
| 88 | if tv.text != text { tv.setPlainText(text) } |
| 89 | tv.placeholderLabel.text = placeholder |
| 90 | tv.placeholderLabel.isHidden = !text.isEmpty |
| 91 | // Drive first responder from the focus binding — async so we never mutate |
| 92 | // UIKit responder state in the middle of a SwiftUI update. Comparing to the |
| 93 | // live isFirstResponder (not the previous binding value) means a re-assert |
| 94 | // to `true` after a send-button tap still re-grabs focus. |
| 95 | if focused != tv.isFirstResponder { |
| 96 | DispatchQueue.main.async { |
| 97 | if focused { tv.becomeFirstResponder() } else { tv.resignFirstResponder() } |
| 98 | } |
| 99 | } |
| 100 | } |
| 101 | |
| 102 | // Grow with the text up to six lines, then let it scroll. |
| 103 | func sizeThatFits(_ proposal: ProposedViewSize, uiView: GrowingTextView, context: Context) -> CGSize? { |
| 104 | let width = proposal.width ?? uiView.bounds.width |
| 105 | guard width > 0, width.isFinite else { return nil } |
| 106 | let fit = uiView.sizeThatFits(CGSize(width: width, height: .greatestFiniteMagnitude)).height |
| 107 | let scroll = fit > uiView.capHeight |
| 108 | if uiView.isScrollEnabled != scroll { |
| 109 | DispatchQueue.main.async { uiView.isScrollEnabled = scroll } |
| 110 | } |
| 111 | return CGSize(width: width, height: ceil(min(fit, uiView.capHeight))) |
| 112 | } |
| 113 | |
| 114 | final class Coordinator: NSObject, UITextViewDelegate { |
| 115 | var parent: ComposerField |
| 116 | init(_ parent: ComposerField) { self.parent = parent } |
| 117 | |
| 118 | func textViewDidChange(_ tv: UITextView) { |
| 119 | guard let tv = tv as? GrowingTextView else { return } |
| 120 | tv.editCounter &+= 1 |
| 121 | // Format-as-you-type runs before the binding sync so what the |
| 122 | // binding sees is the post-rule text. Guarded against IME |
| 123 | // composition and reentrancy inside. |
| 124 | if parent.formatAsYouType { tv.maybeApplyInlineRule() } |
| 125 | // Kept SYNCHRONOUS on purpose. This fires only for user-driven edits |
| 126 | // (a programmatic `tv.text` set does NOT call it), so it never runs |
| 127 | // inside a SwiftUI view update — no "modifying state during update". |
| 128 | // And the text write MUST stay sync: deferring it would open a |
| 129 | // runloop window where tv.text and the binding disagree, and an |
| 130 | // unrelated re-render landing there (socket/presence churn) would let |
| 131 | // updateUIView clobber the just-typed character. |
| 132 | parent.text = tv.text |
| 133 | parent.caret?.wrappedValue = tv.selectedRange.location |
| 134 | tv.placeholderLabel.isHidden = !tv.text.isEmpty |
| 135 | } |
| 136 | |
| 137 | func textViewDidChangeSelection(_ tv: UITextView) { |
| 138 | // This DOES fire synchronously when updateUIView sets `tv.text` (a |
| 139 | // programmatic text change moves the selection), so writing the caret |
| 140 | // binding here lands mid-view-update — undefined behaviour |
| 141 | // ("Modifying state during view update"). Defer it to the next |
| 142 | // runloop, the same async idiom the first-responder wiring uses. |
| 143 | let caret = tv.selectedRange.location |
| 144 | DispatchQueue.main.async { self.parent.caret?.wrappedValue = caret } |
| 145 | } |
| 146 | |
| 147 | func textView(_ tv: UITextView, shouldChangeTextIn range: NSRange, |
| 148 | replacementText replacement: String) -> Bool { |
| 149 | guard let growing = tv as? GrowingTextView else { return true } |
| 150 | // Backspace immediately after a format rule fired restores the |
| 151 | // literal text instead of deleting styled content (the web's |
| 152 | // undoInputRule-on-Backspace parity). |
| 153 | if replacement.isEmpty, range.length > 0, |
| 154 | growing.ruleUndoStamp == growing.editCounter, tv.undoManager?.canUndo == true { |
| 155 | tv.undoManager?.undo() |
| 156 | return false |
| 157 | } |
| 158 | if replacement == "\n" { |
| 159 | // Desktop: Shift+Return falls through as a newline; a bare |
| 160 | // Return sends. Either way we swallow the keystroke so no |
| 161 | // stray newline lands in the field. |
| 162 | if parent.sendOnReturn, !growing.shiftHeld { |
| 163 | parent.onSend() |
| 164 | return false |
| 165 | } |
| 166 | // A TYPED newline carries plain style — the marked-break |
| 167 | // contract (**a**\n**b** on the wire) — while the typing style |
| 168 | // is restored after, so continuing to type stays bold, like |
| 169 | // the web after Shift+Enter. |
| 170 | growing.insertPlainNewline() |
| 171 | return false |
| 172 | } |
| 173 | // Arm the format-as-you-type check: rules fire on single typed |
| 174 | // characters only (never pastes, never IME composition). |
| 175 | growing.pendingRuleCheck = |
| 176 | range.length == 0 && replacement.count == 1 && tv.markedTextRange == nil |
| 177 | return true |
| 178 | } |
| 179 | |
| 180 | func textViewDidBeginEditing(_ tv: UITextView) { |
| 181 | if !parent.focused { parent.focused = true } |
| 182 | } |
| 183 | func textViewDidEndEditing(_ tv: UITextView) { |
| 184 | if parent.focused { parent.focused = false } |
| 185 | } |
| 186 | } |
| 187 | } |
| 188 | |
| 189 | // Programmatic access to the live field for edits that must not disturb |
| 190 | // styling or the caret: typeahead splices go through insertText (undo, |
| 191 | // delegate flow and caret placement all behave), never a binding rewrite. |
| 192 | // serializedMarkdown() is the wire form read at send — the getMarkdown to |
| 193 | // the binding's getText, exactly the web facade's split. |
| 194 | @MainActor |
| 195 | final class ComposerFieldController { |
| 196 | weak var view: GrowingTextView? |
| 197 | |
| 198 | func insert(_ text: String, replacingUTF16 range: NSRange?) { |
| 199 | guard let tv = view else { return } |
| 200 | if let range { tv.selectedRange = range } // typing attrs follow the position |
| 201 | tv.insertText(text) |
| 202 | } |
| 203 | |
| 204 | func serializedMarkdown(escapeLiterals: Bool = false) -> String { |
| 205 | guard let tv = view else { return "" } |
| 206 | return composerMarkdown(of: tv.attributedText ?? NSAttributedString(), escapeLiterals: escapeLiterals) |
| 207 | } |
| 208 | |
| 209 | func focus() { |
| 210 | view?.becomeFirstResponder() |
| 211 | } |
| 212 | } |
| 213 | |
| 214 | // A UITextView that reports a placeholder, caps its growth at six lines, |
| 215 | // tracks Shift for the Return split, and owns the rich-mark machinery: |
| 216 | // .composerStyle is the truth, fonts derive from it. |
| 217 | final class GrowingTextView: UITextView { |
| 218 | let placeholderLabel = UILabel() |
| 219 | private(set) var shiftHeld = false |
| 220 | var formatAsYouType = true |
| 221 | // Pasting an image stages it as an upload instead of inserting nothing — |
| 222 | // text pastes are untouched, and a mixed clipboard does both. |
| 223 | var onPasteImage: ((Data) -> Void)? |
| 224 | // Hardware-keyboard ↑ with an empty draft (the desktop edit convention). |
| 225 | var onEditLast: (() -> Void)? |
| 226 | // Hardware-keyboard Escape — the container's peel ladder; true = consumed. |
| 227 | var onEscape: (() -> Bool)? |
| 228 | // Format-as-you-type bookkeeping (see Coordinator + maybeApplyInlineRule). |
| 229 | var pendingRuleCheck = false |
| 230 | var editCounter: UInt64 = 0 |
| 231 | var ruleUndoStamp: UInt64 = .max |
| 232 | private var applyingRule = false |
| 233 | |
| 234 | // The unstyled body font every run's rendering derives from. Never read |
| 235 | // `font` for metrics — after styled content lands it reports the run at |
| 236 | // the caret (a bold/mono base would warp the cap and placeholder). |
| 237 | private(set) var baseBodyFont: UIFont = .preferredFont(forTextStyle: .body) |
| 238 | |
| 239 | var baseAttributes: [NSAttributedString.Key: Any] { |
| 240 | [.font: baseBodyFont, .foregroundColor: UIColor.label] |
| 241 | } |
| 242 | |
| 243 | // Rendering attributes derived from the semantic style — the ONLY place |
| 244 | // style becomes fonts. Code is monospaced on a quiet fill (the web |
| 245 | // composer's chip, at field scale); strike is a strikethrough line. |
| 246 | func renderingAttributes(for style: InlineStyle) -> [NSAttributedString.Key: Any] { |
| 247 | var attrs = baseAttributes |
| 248 | guard !style.isPlain else { return attrs } |
| 249 | attrs[.composerStyle] = ComposerStyleAttr(style) |
| 250 | if style.code { |
| 251 | attrs[.font] = UIFont.monospacedSystemFont(ofSize: baseBodyFont.pointSize * 0.92, weight: .regular) |
| 252 | attrs[.backgroundColor] = UIColor.secondarySystemFill |
| 253 | return attrs |
| 254 | } |
| 255 | var traits: UIFontDescriptor.SymbolicTraits = [] |
| 256 | if style.bold { traits.insert(.traitBold) } |
| 257 | if style.italic { traits.insert(.traitItalic) } |
| 258 | if !traits.isEmpty, let d = baseBodyFont.fontDescriptor.withSymbolicTraits(traits) { |
| 259 | attrs[.font] = UIFont(descriptor: d, size: baseBodyFont.pointSize) |
| 260 | } |
| 261 | if style.strike { attrs[.strikethroughStyle] = NSUnderlineStyle.single.rawValue } |
| 262 | return attrs |
| 263 | } |
| 264 | |
| 265 | private func styleAtCaret() -> InlineStyle { |
| 266 | (typingAttributes[.composerStyle] as? ComposerStyleAttr)?.style ?? .plain |
| 267 | } |
| 268 | |
| 269 | // ---- external sets + newline contract ---- |
| 270 | |
| 271 | /// An external set (edit-load, clear, pushback): literal plain text, |
| 272 | /// caret at the end, typing style reset, history dropped (a cleared |
| 273 | /// draft mustn't ⌘Z a sent message back). |
| 274 | func setPlainText(_ s: String) { |
| 275 | attributedText = NSAttributedString(string: s, attributes: baseAttributes) |
| 276 | selectedRange = NSRange(location: (s as NSString).length, length: 0) |
| 277 | typingAttributes = baseAttributes |
| 278 | undoManager?.removeAllActions() |
| 279 | placeholderLabel.isHidden = !s.isEmpty |
| 280 | } |
| 281 | |
| 282 | /// A typed newline: the "\n" itself is plain (marked-break contract), the |
| 283 | /// typing style around it survives so the next line continues the mark. |
| 284 | func insertPlainNewline() { |
| 285 | let saved = typingAttributes |
| 286 | typingAttributes = baseAttributes |
| 287 | insertText("\n") |
| 288 | typingAttributes = saved |
| 289 | } |
| 290 | |
| 291 | // ---- mark toggles (⌘B/⌘I via UIKit's standard routing, ⌘E/⌘⇧X ours) ---- |
| 292 | |
| 293 | override var keyCommands: [UIKeyCommand]? { |
| 294 | var commands = super.keyCommands ?? [] |
| 295 | commands.append(UIKeyCommand(input: "e", modifierFlags: .command, |
| 296 | action: #selector(toggleCodeMark))) |
| 297 | let strike = UIKeyCommand(input: "x", modifierFlags: [.command, .shift], |
| 298 | action: #selector(toggleStrikeMark)) |
| 299 | commands.append(strike) |
| 300 | return commands |
| 301 | } |
| 302 | |
| 303 | override func toggleBoldface(_ sender: Any?) { toggle(\.bold) } |
| 304 | override func toggleItalics(_ sender: Any?) { toggle(\.italic) } |
| 305 | @objc private func toggleCodeMark() { toggle(\.code) } |
| 306 | @objc private func toggleStrikeMark() { toggle(\.strike) } |
| 307 | |
| 308 | override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { |
| 309 | // No underline: the wire grammar has no marker for it, and a style |
| 310 | // that silently vanishes on send would be a lie. |
| 311 | if action == #selector(toggleUnderline(_:)) { return false } |
| 312 | if action == #selector(paste(_:)), onPasteImage != nil, |
| 313 | UIPasteboard.general.hasImages { return true } |
| 314 | return super.canPerformAction(action, withSender: sender) |
| 315 | } |
| 316 | |
| 317 | private func flipped(_ style: InlineStyle, _ key: WritableKeyPath<InlineStyle, Bool>, to on: Bool) -> InlineStyle { |
| 318 | var out = style |
| 319 | out[keyPath: key] = on |
| 320 | // Code is exclusive both ways, like the web schema. |
| 321 | if key == \.code, on { out.bold = false; out.italic = false; out.strike = false } |
| 322 | if key != \.code, on { out.code = false } |
| 323 | return out |
| 324 | } |
| 325 | |
| 326 | private func toggle(_ key: WritableKeyPath<InlineStyle, Bool>) { |
| 327 | let range = selectedRange |
| 328 | if range.length == 0 { |
| 329 | // Bare caret: flip the typing style — the next characters wear it. |
| 330 | let style = flipped(styleAtCaret(), key, to: !styleAtCaret()[keyPath: key]) |
| 331 | typingAttributes = renderingAttributes(for: style) |
| 332 | return |
| 333 | } |
| 334 | guard let storage = textStorage as NSTextStorage? else { return } |
| 335 | // Standard toggle semantics: all-styled → remove, else add. |
| 336 | var allStyled = true |
| 337 | storage.enumerateAttribute(.composerStyle, in: range) { value, _, stop in |
| 338 | let style = (value as? ComposerStyleAttr)?.style ?? .plain |
| 339 | if !style[keyPath: key] { allStyled = false; stop.pointee = true } |
| 340 | } |
| 341 | let before = storage.attributedSubstring(from: range) |
| 342 | undoManager?.registerUndo(withTarget: self) { tv in |
| 343 | tv.textStorage.replaceCharacters(in: range, with: before) |
| 344 | tv.delegate?.textViewDidChange?(tv) |
| 345 | } |
| 346 | storage.beginEditing() |
| 347 | storage.enumerateAttribute(.composerStyle, in: range) { value, subRange, _ in |
| 348 | let style = (value as? ComposerStyleAttr)?.style ?? .plain |
| 349 | let flippedStyle = flipped(style, key, to: !allStyled) |
| 350 | storage.setAttributes(renderingAttributes(for: flippedStyle), range: subRange) |
| 351 | } |
| 352 | storage.endEditing() |
| 353 | // Attribute-only edits don't fire textViewDidChange — poke the |
| 354 | // delegate or a send right after ⌘B serializes the pre-toggle draft |
| 355 | // (the attempt-one trap, kept fixed). |
| 356 | delegate?.textViewDidChange?(self) |
| 357 | } |
| 358 | |
| 359 | // ---- format-as-you-type ---- |
| 360 | |
| 361 | // Called from textViewDidChange after a single typed character: if the |
| 362 | // text up to the caret completes an inline rule (**b**, *i*, `c`, ~s~ — |
| 363 | // ComposerRules, drift-gated against the web), swap the marker span for |
| 364 | // the styled content. One Backspace (or ⌘Z) restores the literal. |
| 365 | func maybeApplyInlineRule() { |
| 366 | guard pendingRuleCheck, !applyingRule, markedTextRange == nil else { |
| 367 | pendingRuleCheck = false |
| 368 | return |
| 369 | } |
| 370 | pendingRuleCheck = false |
| 371 | let caret = selectedRange.location |
| 372 | guard selectedRange.length == 0, caret > 0 else { return } |
| 373 | let ns = text as NSString |
| 374 | let lineStart = ns.range(of: "\n", options: .backwards, |
| 375 | range: NSRange(location: 0, length: caret)).upperBound |
| 376 | let lineRange = NSRange(location: lineStart == NSNotFound ? 0 : lineStart, |
| 377 | length: caret - (lineStart == NSNotFound ? 0 : lineStart)) |
| 378 | guard lineRange.length > 0 else { return } |
| 379 | guard let hit = inlineCompletion(lineUpToCaret: ns.substring(with: lineRange)) else { return } |
| 380 | let absolute = NSRange(location: lineRange.location + hit.replaceRange.lowerBound, |
| 381 | length: hit.replaceRange.upperBound - hit.replaceRange.lowerBound) |
| 382 | // Never inside a code run (the web's rangeHasMark(code) guard). |
| 383 | var touchesCode = false |
| 384 | textStorage.enumerateAttribute(.composerStyle, in: absolute) { value, _, stop in |
| 385 | if ((value as? ComposerStyleAttr)?.style.code ?? false) { touchesCode = true; stop.pointee = true } |
| 386 | } |
| 387 | guard !touchesCode else { return } |
| 388 | var style = InlineStyle() |
| 389 | switch hit.mark { |
| 390 | case .bold: style.bold = true |
| 391 | case .italic: style.italic = true |
| 392 | case .code: style.code = true |
| 393 | case .strike: style.strike = true |
| 394 | } |
| 395 | applyingRule = true |
| 396 | let before = textStorage.attributedSubstring(from: absolute) |
| 397 | let beforeCaret = caret |
| 398 | undoManager?.registerUndo(withTarget: self) { tv in |
| 399 | let restored = NSRange(location: absolute.location, length: (hit.content as NSString).length) |
| 400 | tv.textStorage.replaceCharacters(in: restored, with: before) |
| 401 | tv.selectedRange = NSRange(location: beforeCaret, length: 0) |
| 402 | tv.typingAttributes = tv.baseAttributes |
| 403 | tv.delegate?.textViewDidChange?(tv) |
| 404 | } |
| 405 | textStorage.replaceCharacters( |
| 406 | in: absolute, |
| 407 | with: NSAttributedString(string: hit.content, attributes: renderingAttributes(for: style))) |
| 408 | selectedRange = NSRange(location: absolute.location + (hit.content as NSString).length, length: 0) |
| 409 | // The web's removeStoredMark: the next keystroke types unstyled. |
| 410 | typingAttributes = baseAttributes |
| 411 | applyingRule = false |
| 412 | delegate?.textViewDidChange?(self) |
| 413 | // Stamped AFTER the poke — it increments editCounter, and the |
| 414 | // Backspace-undo check compares against the post-poke value. |
| 415 | ruleUndoStamp = editCounter |
| 416 | } |
| 417 | |
| 418 | // ---- paste ---- |
| 419 | |
| 420 | override func paste(_ sender: Any?) { |
| 421 | let pb = UIPasteboard.general |
| 422 | if pb.hasImages, let onPasteImage { |
| 423 | for image in pb.images ?? [] { |
| 424 | if let data = image.jpegData(compressionQuality: 0.92) { onPasteImage(data) } |
| 425 | } |
| 426 | if !pb.hasStrings { return } // image-only paste fully consumed |
| 427 | } |
| 428 | // Text pastes flatten to the caret's typing style (a web page's fonts |
| 429 | // and colors have no wire form), with the web composer's guards: past |
| 430 | // 1MB the paste is refused — attach a file instead. |
| 431 | if let s = pb.string, !s.isEmpty { |
| 432 | if s.utf16.count > 1_000_000 { return } |
| 433 | insertText(s) |
| 434 | return |
| 435 | } |
| 436 | super.paste(sender) |
| 437 | } |
| 438 | |
| 439 | // ---- metrics / chrome (base font, never the caret run's) ---- |
| 440 | |
| 441 | private var lineHeight: CGFloat { baseBodyFont.lineHeight } |
| 442 | var capHeight: CGFloat { ceil(lineHeight * 6) } |
| 443 | |
| 444 | override init(frame: CGRect, textContainer: NSTextContainer?) { |
| 445 | super.init(frame: frame, textContainer: textContainer) |
| 446 | placeholderLabel.font = .preferredFont(forTextStyle: .body) |
| 447 | placeholderLabel.adjustsFontForContentSizeCategory = true |
| 448 | placeholderLabel.textColor = .placeholderText |
| 449 | placeholderLabel.numberOfLines = 1 |
| 450 | addSubview(placeholderLabel) |
| 451 | } |
| 452 | required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } |
| 453 | |
| 454 | override func layoutSubviews() { |
| 455 | super.layoutSubviews() |
| 456 | placeholderLabel.frame = CGRect(x: 0, y: 0, width: bounds.width, height: lineHeight) |
| 457 | } |
| 458 | |
| 459 | // Dynamic Type: refresh the base font and re-derive every styled run from |
| 460 | // its semantic attribute (the reason fonts are never the style truth). |
| 461 | override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { |
| 462 | super.traitCollectionDidChange(previousTraitCollection) |
| 463 | guard previousTraitCollection?.preferredContentSizeCategory != traitCollection.preferredContentSizeCategory else { return } |
| 464 | baseBodyFont = .preferredFont(forTextStyle: .body) |
| 465 | let full = NSRange(location: 0, length: textStorage.length) |
| 466 | textStorage.beginEditing() |
| 467 | textStorage.enumerateAttribute(.composerStyle, in: full) { value, range, _ in |
| 468 | let style = (value as? ComposerStyleAttr)?.style ?? .plain |
| 469 | textStorage.setAttributes(renderingAttributes(for: style), range: range) |
| 470 | } |
| 471 | textStorage.endEditing() |
| 472 | typingAttributes = renderingAttributes(for: styleAtCaret()) |
| 473 | } |
| 474 | |
| 475 | override func pressesBegan(_ presses: Set<UIPress>, with event: UIPressesEvent?) { |
| 476 | if let mods = presses.first?.key?.modifierFlags { shiftHeld = mods.contains(.shift) } |
| 477 | if presses.first?.key?.keyCode == .keyboardUpArrow, text.isEmpty, let onEditLast { |
| 478 | onEditLast() |
| 479 | return |
| 480 | } |
| 481 | // Escape peels the composer's innermost layer (typeahead → command |
| 482 | // panel → armed edit/reply → …); an unconsumed press stays UIKit's. |
| 483 | if presses.first?.key?.keyCode == .keyboardEscape, let onEscape, onEscape() { |
| 484 | return |
| 485 | } |
| 486 | super.pressesBegan(presses, with: event) |
| 487 | } |
| 488 | override func pressesEnded(_ presses: Set<UIPress>, with event: UIPressesEvent?) { |
| 489 | shiftHeld = false |
| 490 | super.pressesEnded(presses, with: event) |
| 491 | } |
| 492 | override func pressesCancelled(_ presses: Set<UIPress>, with event: UIPressesEvent?) { |
| 493 | shiftHeld = false |
| 494 | super.pressesCancelled(presses, with: event) |
| 495 | } |
| 496 | } |
| 497 | |