import SwiftUI import UIKit import LChatCore // The composer's text input. We drop to a UITextView (via a representable) // instead of SwiftUI's TextField because we need to intercept Return *before* it // inserts a newline: `.onKeyPress` sits above the field and the focused text // input swallows Return first, so a SwiftUI-level handler can't make Return send. // UITextView's `shouldChangeTextIn` delegate gates every edit — the one place the // interception reliably wins. // // `sendOnReturn` follows the CLAUDE.md platform split: on the Mac a bare Return // sends and Shift+Return inserts a newline (matching the web composer on // desktop); on touch, Return always inserts a newline and the send button sends. // // Rich marks (#890 stage 2): bold/italic/code/strike style IN the box — ⌘B/⌘I // (and the selection Format menu), ⌘E/⌘⇧X, and format-as-you-type (**bold** // turns bold on the closing marker; Backspace right after restores the // literal). The contract with the rest of the app: // - The `text` binding is the DISPLAY string (exactly what's in the box, no // markers) — the typeahead/commands space, unchanged from the plain days. // - The wire form is read at send via ComposerFieldController.serializedMarkdown() // (the LChatCore serializer twin, vector-pinned against the web). // - Style truth is the .composerStyle attribute; fonts derive from it. // - External binding writes mean "set literal plain text" (edit-load shows // markers, exactly as today) — programmatic mid-draft edits (typeahead // splices) go through the controller so styling and caret survive. struct ComposerField: UIViewRepresentable { @Binding var text: String @Binding var focused: Bool // Caret position as a UTF-16 offset (UIKit's native unit) — drives the // typeahead, which needs to know which token the caret sits in. var caret: Binding? = nil var placeholder: String = "" var sendOnReturn: Bool // Format-as-you-type (the closing-marker rules). OFF still leaves the // shortcuts working — markers you type stay literal and render in the feed. var formatAsYouType: Bool = true var controller: ComposerFieldController? = nil var onSend: () -> Void // A pasted image (the composer's "paste to upload") — data is JPEG. var onPasteImage: ((Data) -> Void)? = nil // Hardware-keyboard ↑ with an empty draft: edit your last message. var onEditLast: (() -> Void)? = nil // Hardware-keyboard Escape (iPad): the composer's peel ladder — typeahead, // command panel, an armed edit/reply, the docked thread pane. Returns // whether the press was consumed; unconsumed falls through to UIKit. var onEscape: (() -> Bool)? = nil func makeCoordinator() -> Coordinator { Coordinator(self) } func makeUIView(context: Context) -> GrowingTextView { let tv = GrowingTextView() tv.onPasteImage = onPasteImage tv.onEditLast = onEditLast tv.onEscape = onEscape tv.delegate = context.coordinator tv.font = tv.baseBodyFont // Live-track the system Text Size (Dynamic Type) — preferredFont only // sizes at creation without this (styled runs re-derive in // traitCollectionDidChange). tv.adjustsFontForContentSizeCategory = true tv.backgroundColor = .clear tv.textContainerInset = .zero tv.textContainer.lineFragmentPadding = 0 tv.isScrollEnabled = false // UIKit's standard rich-editing hooks: routes ⌘B/⌘I and the selection // Format menu to toggleBoldface/toggleItalics, which we intercept. tv.allowsEditingTextAttributes = true tv.typingAttributes = tv.baseAttributes controller?.view = tv return tv } func updateUIView(_ tv: GrowingTextView, context: Context) { context.coordinator.parent = self controller?.view = tv tv.formatAsYouType = formatAsYouType // The key/paste hooks follow the latest values — a bar whose // attachment sources change (a bot joining flips a default-command // thread) must not keep the closures it was made with. tv.onPasteImage = onPasteImage tv.onEditLast = onEditLast tv.onEscape = onEscape // A plain string compare — user edits round-trip exactly, so only // EXTERNAL sets (edit-load, clear after send, pushback) land here, // and those are literal plain text by contract. if tv.text != text { tv.setPlainText(text) } tv.placeholderLabel.text = placeholder tv.placeholderLabel.isHidden = !text.isEmpty // Drive first responder from the focus binding — async so we never mutate // UIKit responder state in the middle of a SwiftUI update. Comparing to the // live isFirstResponder (not the previous binding value) means a re-assert // to `true` after a send-button tap still re-grabs focus. if focused != tv.isFirstResponder { DispatchQueue.main.async { if focused { tv.becomeFirstResponder() } else { tv.resignFirstResponder() } } } } // Grow with the text up to six lines, then let it scroll. func sizeThatFits(_ proposal: ProposedViewSize, uiView: GrowingTextView, context: Context) -> CGSize? { let width = proposal.width ?? uiView.bounds.width guard width > 0, width.isFinite else { return nil } let fit = uiView.sizeThatFits(CGSize(width: width, height: .greatestFiniteMagnitude)).height let scroll = fit > uiView.capHeight if uiView.isScrollEnabled != scroll { DispatchQueue.main.async { uiView.isScrollEnabled = scroll } } return CGSize(width: width, height: ceil(min(fit, uiView.capHeight))) } final class Coordinator: NSObject, UITextViewDelegate { var parent: ComposerField init(_ parent: ComposerField) { self.parent = parent } func textViewDidChange(_ tv: UITextView) { guard let tv = tv as? GrowingTextView else { return } tv.editCounter &+= 1 // Format-as-you-type runs before the binding sync so what the // binding sees is the post-rule text. Guarded against IME // composition and reentrancy inside. if parent.formatAsYouType { tv.maybeApplyInlineRule() } // Kept SYNCHRONOUS on purpose. This fires only for user-driven edits // (a programmatic `tv.text` set does NOT call it), so it never runs // inside a SwiftUI view update — no "modifying state during update". // And the text write MUST stay sync: deferring it would open a // runloop window where tv.text and the binding disagree, and an // unrelated re-render landing there (socket/presence churn) would let // updateUIView clobber the just-typed character. parent.text = tv.text parent.caret?.wrappedValue = tv.selectedRange.location tv.placeholderLabel.isHidden = !tv.text.isEmpty } func textViewDidChangeSelection(_ tv: UITextView) { // This DOES fire synchronously when updateUIView sets `tv.text` (a // programmatic text change moves the selection), so writing the caret // binding here lands mid-view-update — undefined behaviour // ("Modifying state during view update"). Defer it to the next // runloop, the same async idiom the first-responder wiring uses. let caret = tv.selectedRange.location DispatchQueue.main.async { self.parent.caret?.wrappedValue = caret } } func textView(_ tv: UITextView, shouldChangeTextIn range: NSRange, replacementText replacement: String) -> Bool { guard let growing = tv as? GrowingTextView else { return true } // Backspace immediately after a format rule fired restores the // literal text instead of deleting styled content (the web's // undoInputRule-on-Backspace parity). if replacement.isEmpty, range.length > 0, growing.ruleUndoStamp == growing.editCounter, tv.undoManager?.canUndo == true { tv.undoManager?.undo() return false } if replacement == "\n" { // Desktop: Shift+Return falls through as a newline; a bare // Return sends. Either way we swallow the keystroke so no // stray newline lands in the field. if parent.sendOnReturn, !growing.shiftHeld { parent.onSend() return false } // A TYPED newline carries plain style — the marked-break // contract (**a**\n**b** on the wire) — while the typing style // is restored after, so continuing to type stays bold, like // the web after Shift+Enter. growing.insertPlainNewline() return false } // Arm the format-as-you-type check: rules fire on single typed // characters only (never pastes, never IME composition). growing.pendingRuleCheck = range.length == 0 && replacement.count == 1 && tv.markedTextRange == nil return true } func textViewDidBeginEditing(_ tv: UITextView) { if !parent.focused { parent.focused = true } } func textViewDidEndEditing(_ tv: UITextView) { if parent.focused { parent.focused = false } } } } // Programmatic access to the live field for edits that must not disturb // styling or the caret: typeahead splices go through insertText (undo, // delegate flow and caret placement all behave), never a binding rewrite. // serializedMarkdown() is the wire form read at send — the getMarkdown to // the binding's getText, exactly the web facade's split. @MainActor final class ComposerFieldController { weak var view: GrowingTextView? func insert(_ text: String, replacingUTF16 range: NSRange?) { guard let tv = view else { return } if let range { tv.selectedRange = range } // typing attrs follow the position tv.insertText(text) } func serializedMarkdown(escapeLiterals: Bool = false) -> String { guard let tv = view else { return "" } return composerMarkdown(of: tv.attributedText ?? NSAttributedString(), escapeLiterals: escapeLiterals) } func focus() { view?.becomeFirstResponder() } } // A UITextView that reports a placeholder, caps its growth at six lines, // tracks Shift for the Return split, and owns the rich-mark machinery: // .composerStyle is the truth, fonts derive from it. final class GrowingTextView: UITextView { let placeholderLabel = UILabel() private(set) var shiftHeld = false var formatAsYouType = true // Pasting an image stages it as an upload instead of inserting nothing — // text pastes are untouched, and a mixed clipboard does both. var onPasteImage: ((Data) -> Void)? // Hardware-keyboard ↑ with an empty draft (the desktop edit convention). var onEditLast: (() -> Void)? // Hardware-keyboard Escape — the container's peel ladder; true = consumed. var onEscape: (() -> Bool)? // Format-as-you-type bookkeeping (see Coordinator + maybeApplyInlineRule). var pendingRuleCheck = false var editCounter: UInt64 = 0 var ruleUndoStamp: UInt64 = .max private var applyingRule = false // The unstyled body font every run's rendering derives from. Never read // `font` for metrics — after styled content lands it reports the run at // the caret (a bold/mono base would warp the cap and placeholder). private(set) var baseBodyFont: UIFont = .preferredFont(forTextStyle: .body) var baseAttributes: [NSAttributedString.Key: Any] { [.font: baseBodyFont, .foregroundColor: UIColor.label] } // Rendering attributes derived from the semantic style — the ONLY place // style becomes fonts. Code is monospaced on a quiet fill (the web // composer's chip, at field scale); strike is a strikethrough line. func renderingAttributes(for style: InlineStyle) -> [NSAttributedString.Key: Any] { var attrs = baseAttributes guard !style.isPlain else { return attrs } attrs[.composerStyle] = ComposerStyleAttr(style) if style.code { attrs[.font] = UIFont.monospacedSystemFont(ofSize: baseBodyFont.pointSize * 0.92, weight: .regular) attrs[.backgroundColor] = UIColor.secondarySystemFill return attrs } var traits: UIFontDescriptor.SymbolicTraits = [] if style.bold { traits.insert(.traitBold) } if style.italic { traits.insert(.traitItalic) } if !traits.isEmpty, let d = baseBodyFont.fontDescriptor.withSymbolicTraits(traits) { attrs[.font] = UIFont(descriptor: d, size: baseBodyFont.pointSize) } if style.strike { attrs[.strikethroughStyle] = NSUnderlineStyle.single.rawValue } return attrs } private func styleAtCaret() -> InlineStyle { (typingAttributes[.composerStyle] as? ComposerStyleAttr)?.style ?? .plain } // ---- external sets + newline contract ---- /// An external set (edit-load, clear, pushback): literal plain text, /// caret at the end, typing style reset, history dropped (a cleared /// draft mustn't ⌘Z a sent message back). func setPlainText(_ s: String) { attributedText = NSAttributedString(string: s, attributes: baseAttributes) selectedRange = NSRange(location: (s as NSString).length, length: 0) typingAttributes = baseAttributes undoManager?.removeAllActions() placeholderLabel.isHidden = !s.isEmpty } /// A typed newline: the "\n" itself is plain (marked-break contract), the /// typing style around it survives so the next line continues the mark. func insertPlainNewline() { let saved = typingAttributes typingAttributes = baseAttributes insertText("\n") typingAttributes = saved } // ---- mark toggles (⌘B/⌘I via UIKit's standard routing, ⌘E/⌘⇧X ours) ---- override var keyCommands: [UIKeyCommand]? { var commands = super.keyCommands ?? [] commands.append(UIKeyCommand(input: "e", modifierFlags: .command, action: #selector(toggleCodeMark))) let strike = UIKeyCommand(input: "x", modifierFlags: [.command, .shift], action: #selector(toggleStrikeMark)) commands.append(strike) return commands } override func toggleBoldface(_ sender: Any?) { toggle(\.bold) } override func toggleItalics(_ sender: Any?) { toggle(\.italic) } @objc private func toggleCodeMark() { toggle(\.code) } @objc private func toggleStrikeMark() { toggle(\.strike) } override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { // No underline: the wire grammar has no marker for it, and a style // that silently vanishes on send would be a lie. if action == #selector(toggleUnderline(_:)) { return false } if action == #selector(paste(_:)), onPasteImage != nil, UIPasteboard.general.hasImages { return true } return super.canPerformAction(action, withSender: sender) } private func flipped(_ style: InlineStyle, _ key: WritableKeyPath, to on: Bool) -> InlineStyle { var out = style out[keyPath: key] = on // Code is exclusive both ways, like the web schema. if key == \.code, on { out.bold = false; out.italic = false; out.strike = false } if key != \.code, on { out.code = false } return out } private func toggle(_ key: WritableKeyPath) { let range = selectedRange if range.length == 0 { // Bare caret: flip the typing style — the next characters wear it. let style = flipped(styleAtCaret(), key, to: !styleAtCaret()[keyPath: key]) typingAttributes = renderingAttributes(for: style) return } guard let storage = textStorage as NSTextStorage? else { return } // Standard toggle semantics: all-styled → remove, else add. var allStyled = true storage.enumerateAttribute(.composerStyle, in: range) { value, _, stop in let style = (value as? ComposerStyleAttr)?.style ?? .plain if !style[keyPath: key] { allStyled = false; stop.pointee = true } } let before = storage.attributedSubstring(from: range) undoManager?.registerUndo(withTarget: self) { tv in tv.textStorage.replaceCharacters(in: range, with: before) tv.delegate?.textViewDidChange?(tv) } storage.beginEditing() storage.enumerateAttribute(.composerStyle, in: range) { value, subRange, _ in let style = (value as? ComposerStyleAttr)?.style ?? .plain let flippedStyle = flipped(style, key, to: !allStyled) storage.setAttributes(renderingAttributes(for: flippedStyle), range: subRange) } storage.endEditing() // Attribute-only edits don't fire textViewDidChange — poke the // delegate or a send right after ⌘B serializes the pre-toggle draft // (the attempt-one trap, kept fixed). delegate?.textViewDidChange?(self) } // ---- format-as-you-type ---- // Called from textViewDidChange after a single typed character: if the // text up to the caret completes an inline rule (**b**, *i*, `c`, ~s~ — // ComposerRules, drift-gated against the web), swap the marker span for // the styled content. One Backspace (or ⌘Z) restores the literal. func maybeApplyInlineRule() { guard pendingRuleCheck, !applyingRule, markedTextRange == nil else { pendingRuleCheck = false return } pendingRuleCheck = false let caret = selectedRange.location guard selectedRange.length == 0, caret > 0 else { return } let ns = text as NSString let lineStart = ns.range(of: "\n", options: .backwards, range: NSRange(location: 0, length: caret)).upperBound let lineRange = NSRange(location: lineStart == NSNotFound ? 0 : lineStart, length: caret - (lineStart == NSNotFound ? 0 : lineStart)) guard lineRange.length > 0 else { return } guard let hit = inlineCompletion(lineUpToCaret: ns.substring(with: lineRange)) else { return } let absolute = NSRange(location: lineRange.location + hit.replaceRange.lowerBound, length: hit.replaceRange.upperBound - hit.replaceRange.lowerBound) // Never inside a code run (the web's rangeHasMark(code) guard). var touchesCode = false textStorage.enumerateAttribute(.composerStyle, in: absolute) { value, _, stop in if ((value as? ComposerStyleAttr)?.style.code ?? false) { touchesCode = true; stop.pointee = true } } guard !touchesCode else { return } var style = InlineStyle() switch hit.mark { case .bold: style.bold = true case .italic: style.italic = true case .code: style.code = true case .strike: style.strike = true } applyingRule = true let before = textStorage.attributedSubstring(from: absolute) let beforeCaret = caret undoManager?.registerUndo(withTarget: self) { tv in let restored = NSRange(location: absolute.location, length: (hit.content as NSString).length) tv.textStorage.replaceCharacters(in: restored, with: before) tv.selectedRange = NSRange(location: beforeCaret, length: 0) tv.typingAttributes = tv.baseAttributes tv.delegate?.textViewDidChange?(tv) } textStorage.replaceCharacters( in: absolute, with: NSAttributedString(string: hit.content, attributes: renderingAttributes(for: style))) selectedRange = NSRange(location: absolute.location + (hit.content as NSString).length, length: 0) // The web's removeStoredMark: the next keystroke types unstyled. typingAttributes = baseAttributes applyingRule = false delegate?.textViewDidChange?(self) // Stamped AFTER the poke — it increments editCounter, and the // Backspace-undo check compares against the post-poke value. ruleUndoStamp = editCounter } // ---- paste ---- override func paste(_ sender: Any?) { let pb = UIPasteboard.general if pb.hasImages, let onPasteImage { for image in pb.images ?? [] { if let data = image.jpegData(compressionQuality: 0.92) { onPasteImage(data) } } if !pb.hasStrings { return } // image-only paste fully consumed } // Text pastes flatten to the caret's typing style (a web page's fonts // and colors have no wire form), with the web composer's guards: past // 1MB the paste is refused — attach a file instead. if let s = pb.string, !s.isEmpty { if s.utf16.count > 1_000_000 { return } insertText(s) return } super.paste(sender) } // ---- metrics / chrome (base font, never the caret run's) ---- private var lineHeight: CGFloat { baseBodyFont.lineHeight } var capHeight: CGFloat { ceil(lineHeight * 6) } override init(frame: CGRect, textContainer: NSTextContainer?) { super.init(frame: frame, textContainer: textContainer) placeholderLabel.font = .preferredFont(forTextStyle: .body) placeholderLabel.adjustsFontForContentSizeCategory = true placeholderLabel.textColor = .placeholderText placeholderLabel.numberOfLines = 1 addSubview(placeholderLabel) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func layoutSubviews() { super.layoutSubviews() placeholderLabel.frame = CGRect(x: 0, y: 0, width: bounds.width, height: lineHeight) } // Dynamic Type: refresh the base font and re-derive every styled run from // its semantic attribute (the reason fonts are never the style truth). override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { super.traitCollectionDidChange(previousTraitCollection) guard previousTraitCollection?.preferredContentSizeCategory != traitCollection.preferredContentSizeCategory else { return } baseBodyFont = .preferredFont(forTextStyle: .body) let full = NSRange(location: 0, length: textStorage.length) textStorage.beginEditing() textStorage.enumerateAttribute(.composerStyle, in: full) { value, range, _ in let style = (value as? ComposerStyleAttr)?.style ?? .plain textStorage.setAttributes(renderingAttributes(for: style), range: range) } textStorage.endEditing() typingAttributes = renderingAttributes(for: styleAtCaret()) } override func pressesBegan(_ presses: Set, with event: UIPressesEvent?) { if let mods = presses.first?.key?.modifierFlags { shiftHeld = mods.contains(.shift) } if presses.first?.key?.keyCode == .keyboardUpArrow, text.isEmpty, let onEditLast { onEditLast() return } // Escape peels the composer's innermost layer (typeahead → command // panel → armed edit/reply → …); an unconsumed press stays UIKit's. if presses.first?.key?.keyCode == .keyboardEscape, let onEscape, onEscape() { return } super.pressesBegan(presses, with: event) } override func pressesEnded(_ presses: Set, with event: UIPressesEvent?) { shiftHeld = false super.pressesEnded(presses, with: event) } override func pressesCancelled(_ presses: Set, with event: UIPressesEvent?) { shiftHeld = false super.pressesCancelled(presses, with: event) } }