import SwiftUI import AppKit import LChatCore // The Mac native composer's text input — the NSTextView-backed field the // SwiftUI TextField couldn't be: it has a real caret (mid-text typeahead, and // Shift/⌥+Return finally inserts newlines AT the caret — issue #819), a real // paste pipeline, and room for the rich stages of #890 to build on. This // stage is deliberately PLAIN text only: the `text` binding is the raw string, // behavior matches the old field exactly (Return sends, typeahead keys route // first, 1–9 line growth), and formatting lands in a later, separately-gated PR. // // Attempt one (#471, reverted #482) died on two Mac-specific failures this // file fixes structurally: // - SwiftUI never re-invokes sizeThatFits when only the CONTENT changes, so // the old field scrolled instead of growing. Height is now PUSHED: measured // in textDidChange (and on width/font changes) and written to a binding the // parent applies as .frame(height:). sizeThatFits stays only as the // backstop that answers every probe with a finite height — without it a // probe miss reads as "greedy" and the field swallows the pane. // - Serializing on every SwiftUI update wedged long pastes. updateNSView now // does a plain string compare, and pastes above 1MB are refused outright // (attach a file instead — the web composer's exact threshold). struct MacComposerField: NSViewRepresentable { @Binding var text: String @Binding var focused: Bool // The composer's measured height (1–9 lines) — apply as .frame(height:). @Binding var height: CGFloat // The caret as a UTF-16 offset into `text` — drives the typeahead. var caret: Binding? = nil var placeholder: String = "" var fontSize: CGFloat var isDisabled: Bool = false // Format-as-you-type (the closing-marker rules). OFF still leaves the // shortcuts working — typed markers stay literal and render in the feed. var formatAsYouType: Bool = true // Programmatic edits (typeahead splices, panel inserts) act on the view — // never by rewriting the binding, which would reset the caret to the end. var controller: MacComposerController? = nil var onSend: () -> Void // Typeahead/shortcut hooks — each returns whether it consumed the key. var onMoveUp: () -> Bool = { false } var onMoveDown: () -> Bool = { false } var onAccept: () -> Bool = { false } // Tab / Return over an open typeahead var onEscape: () -> Bool = { false } // Stage images / file URLs from a paste; returns whether anything staged. // nil in a default-command thread (text-only surface, like the web). var onStagePaste: ((NSPasteboard) -> Bool)? = nil // Stage a file/image DRAG dropped on the field; returns whether anything // staged. The window-wide drop zone normally claims the drag before it // reaches the text view — this is the backstop that keeps NSTextView's // default (insert the file's PATH as text) from ever being the outcome. var onStageDrop: ((NSPasteboard) -> Bool)? = nil func makeCoordinator() -> Coordinator { Coordinator(self) } func makeNSView(context: Context) -> NSScrollView { let tv = ComposerTextView() tv.baseFontSize = fontSize tv.delegate = context.coordinator tv.onFocusChange = { [weak coordinator = context.coordinator] has in guard let parent = coordinator?.parent else { return } if parent.focused != has { parent.focused = has } } tv.isRichText = true // attributed editing; pastes are flattened in paste(_:) tv.allowsUndo = true tv.drawsBackground = false tv.textContainerInset = .zero tv.textContainer?.lineFragmentPadding = 0 tv.textContainer?.widthTracksTextView = true tv.isVerticallyResizable = true tv.isHorizontallyResizable = false tv.autoresizingMask = [.width] // The classic scroll-view pairing: without an unbounded maxSize the // text view can't grow past its initial frame, and content beyond it // would neither lay out nor scroll. tv.minSize = .zero tv.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude) tv.usesFontPanel = false tv.importsGraphics = false tv.isAutomaticLinkDetectionEnabled = false tv.applyBaseTypingAttributes() let scroll = NSScrollView() scroll.documentView = tv scroll.drawsBackground = false scroll.borderType = .noBorder scroll.hasVerticalScroller = false // enabled by remeasure only past the 9-line cap scroll.autohidesScrollers = true controller?.view = tv // Width changes (window resize) re-wrap the text — re-measure, since // SwiftUI won't ask (the attempt-one lesson). tv.postsFrameChangedNotifications = true context.coordinator.frameObserver = NotificationCenter.default.addObserver( forName: NSView.frameDidChangeNotification, object: tv, queue: .main ) { [weak coordinator = context.coordinator, weak tv] _ in guard let coordinator, let tv else { return } // Height changes are OUR OWN doing (the binding landing) — acting // on them loops layout into jitter. Only a real width change // re-wraps the text. guard tv.frame.width != coordinator.lastMeasuredWidth else { return } coordinator.remeasure(tv) } DispatchQueue.main.async { context.coordinator.remeasure(tv) } return scroll } static func dismantleNSView(_ nsView: NSScrollView, coordinator: Coordinator) { if let observer = coordinator.frameObserver { NotificationCenter.default.removeObserver(observer) } } func updateNSView(_ scroll: NSScrollView, context: Context) { context.coordinator.parent = self guard let tv = scroll.documentView as? ComposerTextView else { return } controller?.view = tv tv.onStagePaste = onStagePaste tv.onStageDrop = onStageDrop tv.isEditable = !isDisabled tv.formatAsYouType = formatAsYouType if tv.baseFontSize != fontSize { tv.baseFontSize = fontSize tv.applyBaseTypingAttributes() tv.rederiveFonts() // every styled run re-derives from .composerStyle context.coordinator.remeasure(tv) } // A plain string compare — user edits round-trip exactly, so only // EXTERNAL sets (edit-load, clear after send) reach the view here. if tv.string != text { tv.setPlainText(text) context.coordinator.remeasure(tv) } tv.placeholderLabel.stringValue = placeholder tv.placeholderLabel.isHidden = !text.isEmpty // Drive first responder from the focus binding — async so we never // mutate responder state mid-update. Compare against the live first // responder so a re-assert to `true` still re-grabs focus. let isFirst = tv.window?.firstResponder === tv if focused != isFirst { DispatchQueue.main.async { guard let window = tv.window else { return } if focused { window.makeFirstResponder(tv) } else if window.firstResponder === tv { window.makeFirstResponder(nil) } } } } // The BACKSTOP only (height is pushed via the binding): every probe — // zero, unspecified, infinite — must get a finite, capped answer, or // SwiftUI marks the view greedy and the field swallows the whole pane // (attempt one's giant-composer bug). func sizeThatFits(_ proposal: ProposedViewSize, nsView: NSScrollView, context: Context) -> CGSize? { guard let tv = nsView.documentView as? ComposerTextView, let container = tv.textContainer, let layout = tv.layoutManager else { return nil } // A PURE measurement — never mutate the frame from a probe: SwiftUI // probes with zero/unspecified widths, and resizing the text view to // match forced a bogus narrow wrap whose frame change re-entered // layout through the observer (the mid-text jitter bug). layout.ensureLayout(for: container) let line = layout.defaultLineHeight(for: tv.baseFont) let used = layout.usedRect(for: container).height let h = ceil(min(max(line, used), ceil(line * 9))) var width = proposal.width ?? tv.bounds.width if !width.isFinite || width <= 0 { width = max(tv.bounds.width, 100) } return CGSize(width: width, height: h) } final class Coordinator: NSObject, NSTextViewDelegate { var parent: MacComposerField var frameObserver: NSObjectProtocol? var lastMeasuredWidth: CGFloat = 0 init(_ parent: MacComposerField) { self.parent = parent } // Grow with the text up to nine lines (the old field's lineLimit(1...9)), // then scroll within. Pushed to the binding — SwiftUI never re-asks // sizeThatFits on content change (the failure that sank attempt one). // `sync` writes the binding immediately (safe from textDidChange, the // same discipline as the text write); calls that can land mid-update // (updateNSView, frame observer) stay deferred. func remeasure(_ tv: ComposerTextView, sync: Bool = false) { guard let container = tv.textContainer, let layout = tv.layoutManager else { return } lastMeasuredWidth = tv.frame.width layout.ensureLayout(for: container) let line = layout.defaultLineHeight(for: tv.baseFont) let used = layout.usedRect(for: container).height let cap = ceil(line * 9) let h = ceil(min(max(line, used), cap)) // No scroller below the cap: the frame always fits the content // there, and the auto-hiding overlay scroller would otherwise // flash in the beat between AppKit laying out the taller text and // SwiftUI applying the new frame height. tv.enclosingScrollView?.hasVerticalScroller = used > cap if parent.height != h { if sync { parent.height = h } else { DispatchQueue.main.async { [self] in if parent.height != h { parent.height = h } } } } } func textDidChange(_ notification: Notification) { guard let tv = notification.object as? ComposerTextView else { return } tv.editCounter &+= 1 if parent.formatAsYouType { tv.maybeApplyInlineRule() } parent.text = tv.string parent.caret?.wrappedValue = tv.selectedRange().location tv.placeholderLabel.isHidden = !tv.string.isEmpty remeasure(tv, sync: true) tv.scrollRangeToVisible(tv.selectedRange()) } func textViewDidChangeSelection(_ notification: Notification) { guard let tv = notification.object as? ComposerTextView else { return } parent.caret?.wrappedValue = tv.selectedRange().location } func textView(_ textView: NSTextView, shouldChangeTextIn affectedCharRange: NSRange, replacementString: String?) -> Bool { guard let tv = textView as? ComposerTextView, let replacement = replacementString else { return true } // Backspace right after a rule fired restores the literal text // (the web's undoInputRule-on-Backspace parity). if replacement.isEmpty, affectedCharRange.length > 0, tv.ruleUndoStamp == tv.editCounter, tv.undoManager?.canUndo == true { tv.undoManager?.undo() return false } // Rules fire on single typed characters only — never pastes, // never IME composition. tv.pendingRuleCheck = affectedCharRange.length == 0 && replacement.count == 1 && replacement != "\n" && !tv.hasMarkedText() return true } func textView(_ textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool { switch commandSelector { case #selector(NSResponder.insertNewline(_:)): // Desktop shouldSend: Shift/Option+Return newlines — falling // through to the standard insertion puts them AT THE CARET // (the #819 fix); plain Return accepts the typeahead or sends. let mods = NSApp.currentEvent?.modifierFlags ?? [] if mods.contains(.shift) || mods.contains(.option) { // A TYPED newline carries plain style (the marked-break // contract: **a**\n**b** on the wire) while the typing // style survives it, like the web after Shift+Enter. (textView as? ComposerTextView)?.insertPlainNewline() return true } if parent.onAccept() { return true } parent.onSend() return true case #selector(NSResponder.insertLineBreak(_:)): // ⌃Return maps here, and the standard behaviour inserts // U+2028 (a line SEPARATOR, not "\n") — a character the wire // format must never carry. Normalize to a plain newline. (textView as? ComposerTextView)?.insertPlainNewline() return true case #selector(NSResponder.moveUp(_:)): return parent.onMoveUp() case #selector(NSResponder.moveDown(_:)): return parent.onMoveDown() case #selector(NSResponder.insertTab(_:)): return parent.onAccept() case #selector(NSResponder.cancelOperation(_:)): return parent.onEscape() case #selector(NSResponder.complete(_:)): return true // ours is the only completion UI — never the system popup default: return false } } } } // Programmatic access to the live text view for edits that must not disturb // the caret: typeahead splices and panel inserts go through insertText (undo, // delegate notifications and caret placement all behave), never through a // binding rewrite. The web facade's replaceRange/insertAtCaret, in AppKit. @MainActor final class MacComposerController { weak var view: NSTextView? // Replace the given UTF-16 range of the current text (nil = the selection) // with literal text; the caret lands after the insert. func insert(_ text: String, replacingUTF16 range: NSRange?) { guard let tv = view else { return } tv.insertText(text, replacementRange: range ?? tv.selectedRange()) } func focus() { guard let tv = view else { return } tv.window?.makeFirstResponder(tv) } // The wire form of the draft — serialized markdown from the LChatCore // twin (vector-pinned against the web serializer). The binding stays the // display string; this is the getMarkdown to its getText. func serializedMarkdown(escapeLiterals: Bool = false) -> String { guard let tv = view, let storage = tv.textStorage else { return "" } return composerMarkdown(of: storage, escapeLiterals: escapeLiterals) } } // The NSTextView itself: hand-assembled TextKit stack, placeholder, guarded // pastes. Plain text at this stage — the rich toggles return in a later PR. final class ComposerTextView: NSTextView { // The click that brings the app forward should LAND, not just activate: // without this, entering the app by clicking the composer eats the click // (macOS's activation-click swallow), which reads as a dead composer — // especially when the post-activation re-render hitch delays the caret. // Messages accepts its composer's first mouse; so do we. override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true } // The placeholder is decoration, and decoration must be click-through // (the FrostedBand rule): a plain NSTextField label hit-tests but its // mouseDown neither focuses anything nor forwards up the chain — and the // label spans the field's full width (see layout()), so an EMPTY composer // silently ate the very click meant to focus it whenever focus sat // elsewhere (caught by ClickLab: hit chain ended at the label, responder // never moved). private final class ClickThroughLabel: NSTextField { override func hitTest(_ point: NSPoint) -> NSView? { nil } } let placeholderLabel: NSTextField = ClickThroughLabel(labelWithString: "") var onStagePaste: ((NSPasteboard) -> Bool)? var onStageDrop: ((NSPasteboard) -> Bool)? var onFocusChange: ((Bool) -> Void)? // Format-as-you-type bookkeeping (armed in shouldChangeText, consumed in // textDidChange; the stamp drives Backspace-restores-the-literal). var formatAsYouType = true var pendingRuleCheck = false var editCounter: UInt64 = 0 var ruleUndoStamp: UInt64 = .max private var applyingRule = false var baseFontSize: CGFloat = NSFont.systemFontSize { didSet { placeholderLabel.font = .systemFont(ofSize: baseFontSize) if string.isEmpty { applyBaseTypingAttributes() } } } var baseFont: NSFont { .systemFont(ofSize: baseFontSize) } private var baseColor: NSColor { .labelColor } override init(frame frameRect: NSRect, textContainer container: NSTextContainer?) { super.init(frame: frameRect, textContainer: container) setUpPlaceholder() } // AppKit (unlike UIKit) does NOT build the text system for a nil container // — init(frame:textContainer: nil) yields a view with no textContainer, no // layoutManager, no textStorage: typing is dead and every measurement // guard bails (which reads as "greedy" — the composer-swallows-the-pane // bug). Assemble the classic TextKit stack by hand. convenience init() { let storage = NSTextStorage() let layout = NSLayoutManager() storage.addLayoutManager(layout) let container = NSTextContainer(size: NSSize(width: 0, height: CGFloat.greatestFiniteMagnitude)) container.widthTracksTextView = true layout.addTextContainer(container) self.init(frame: .zero, textContainer: container) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } private func setUpPlaceholder() { placeholderLabel.font = .systemFont(ofSize: baseFontSize) placeholderLabel.textColor = .placeholderTextColor placeholderLabel.lineBreakMode = .byTruncatingTail placeholderLabel.maximumNumberOfLines = 1 addSubview(placeholderLabel) } override func layout() { super.layout() placeholderLabel.frame = NSRect(x: 0, y: 0, width: bounds.width, height: placeholderLabel.intrinsicContentSize.height) } var baseAttributes: [NSAttributedString.Key: Any] { [.font: baseFont, .foregroundColor: baseColor] } func applyBaseTypingAttributes() { typingAttributes = baseAttributes } // Rendering attributes derived from the semantic style — the ONLY place // style becomes fonts (never sniffed back; the Dynamic-Type/ladder trap). 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] = NSFont.monospacedSystemFont(ofSize: baseFont.pointSize * 0.92, weight: .regular) attrs[.backgroundColor] = NSColor.labelColor.withAlphaComponent(0.08) // the web chip's ink-8% return attrs } var traits: NSFontDescriptor.SymbolicTraits = [] if style.bold { traits.insert(.bold) } if style.italic { traits.insert(.italic) } if !traits.isEmpty { let d = baseFont.fontDescriptor.withSymbolicTraits(traits) attrs[.font] = NSFont(descriptor: d, size: baseFont.pointSize) ?? baseFont } if style.strike { attrs[.strikethroughStyle] = NSUnderlineStyle.single.rawValue } return attrs } private func styleAtCaret() -> InlineStyle { (typingAttributes[.composerStyle] as? ComposerStyleAttr)?.style ?? .plain } // The font ladder changed: re-derive every styled run from its semantic // attribute. func rederiveFonts() { guard let storage = textStorage else { return } storage.beginEditing() storage.enumerateAttribute(.composerStyle, in: NSRange(location: 0, length: storage.length)) { value, range, _ in let style = (value as? ComposerStyleAttr)?.style ?? .plain storage.setAttributes(renderingAttributes(for: style), range: range) } storage.endEditing() typingAttributes = renderingAttributes(for: styleAtCaret()) } // ---- mark toggles: ⌘B/⌘I/⌘E/⌘⇧X, ⌘U swallowed ---- // Key equivalents walk the VIEW hierarchy, not the responder chain — // every space's pane stays mounted (hidden), so without this guard the // first composer in the tree would format a hidden draft (the #481 bug). override func performKeyEquivalent(with event: NSEvent) -> Bool { guard window?.firstResponder === self else { return super.performKeyEquivalent(with: event) } let mods = event.modifierFlags.intersection(.deviceIndependentFlagsMask) if mods == .command, let key = event.charactersIgnoringModifiers?.lowercased() { switch key { case "b": toggle(\.bold); return true case "i": toggle(\.italic); return true case "e": toggle(\.code); return true case "u": return true // no underline marker — a style that vanished on send would be a lie default: break } } if mods == [.command, .shift], event.charactersIgnoringModifiers?.lowercased() == "x" { toggle(\.strike) return true } return super.performKeyEquivalent(with: event) } 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 } // Toggle over the selection (all-styled → remove, else add) or flip the // typing style at a bare caret. Selection edits ride shouldChangeText/ // didChangeText so undo works AND the delegate hears the attribute-only // edit — or a send right after ⌘B serializes the pre-toggle draft. private func toggle(_ key: WritableKeyPath) { let range = selectedRange() if range.length == 0 { let current = styleAtCaret() typingAttributes = renderingAttributes(for: flipped(current, key, to: !current[keyPath: key])) return } guard let storage = textStorage, shouldChangeText(in: range, replacementString: nil) else { return } 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 } } storage.beginEditing() storage.enumerateAttribute(.composerStyle, in: range) { value, subRange, _ in let style = (value as? ComposerStyleAttr)?.style ?? .plain storage.setAttributes(renderingAttributes(for: flipped(style, key, to: !allStyled)), range: subRange) } storage.endEditing() didChangeText() } /// 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", replacementRange: selectedRange()) typingAttributes = saved } // ---- format-as-you-type ---- // Called from textDidChange after a single typed character: if the line // up to the caret completes an inline rule (**b**, *i*, `c`, ~s~ — // ComposerRules, drift-gated against the web), swap the marker span for // styled content. One Backspace (or ⌘Z) restores the literal. func maybeApplyInlineRule() { guard pendingRuleCheck, !applyingRule, !hasMarkedText() else { pendingRuleCheck = false return } pendingRuleCheck = false let caret = selectedRange().location guard selectedRange().length == 0, caret > 0, let storage = textStorage else { return } let ns = string as NSString let nl = ns.range(of: "\n", options: .backwards, range: NSRange(location: 0, length: caret)) let lineStart = nl.location == NSNotFound ? 0 : nl.location + 1 let lineRange = NSRange(location: lineStart, length: caret - lineStart) guard lineRange.length > 0, 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 storage.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 breakUndoCoalescing() // one ⌘Z restores the literal, not the whole burst if shouldChangeText(in: absolute, replacementString: hit.content) { storage.replaceCharacters( in: absolute, with: NSAttributedString(string: hit.content, attributes: renderingAttributes(for: style))) setSelectedRange(NSRange(location: absolute.location + (hit.content as NSString).length, length: 0)) // The web's removeStoredMark: the next keystroke types unstyled. applyBaseTypingAttributes() didChangeText() // Stamped AFTER didChangeText — it increments editCounter, and // the Backspace-undo check compares the post-change value. ruleUndoStamp = editCounter } applyingRule = false } /// An external set (edit-load, clear after send): literal plain text, /// caret at the end, typing style reset so the next message starts clean. func setPlainText(_ text: String) { textStorage?.setAttributedString(NSAttributedString(string: text, attributes: baseAttributes)) setSelectedRange(NSRange(location: (text as NSString).length, length: 0)) applyBaseTypingAttributes() undoManager?.removeAllActions() // a cleared draft mustn't ⌘Z a sent message back placeholderLabel.isHidden = !text.isEmpty } override func becomeFirstResponder() -> Bool { let ok = super.becomeFirstResponder() if ok { onFocusChange?(true) } return ok } override func resignFirstResponder() -> Bool { let ok = super.resignFirstResponder() if ok { onFocusChange?(false) } return ok } // Pastes: stage images/file URLs as uploads (the web composer's // paste-to-upload); text pastes stay text. Guards from the web composer: // past 1MB the paste is refused (attach a file instead) — the wedge that // helped sink attempt one can't recur. override func paste(_ sender: Any?) { let pb = NSPasteboard.general let staged = onStagePaste?(pb) ?? false if let s = pb.string(forType: .string), !s.isEmpty { if s.utf16.count > 1_000_000 { NSSound.beep() return } insertText(s, replacementRange: selectedRange()) return } if !staged { super.pasteAsPlainText(sender) } } // Drags: a file/image drop on the field stages an attachment — in a chat // composer that is always what the drop means; NSTextView's default // (insert the file's PATH as text) never is. Text drags keep the standard // behaviour. The window-wide drop zone usually claims the drag before it // gets here; this backstop covers any path that still resolves to the // text view. private func stageableDrag(_ sender: NSDraggingInfo) -> Bool { guard onStageDrop != nil else { return false } let pb = sender.draggingPasteboard if pb.canReadObject(forClasses: [NSURL.self], options: [.urlReadingFileURLsOnly: true]) { return true } if pb.canReadObject(forClasses: [NSFilePromiseReceiver.self], options: nil) { return true } return pb.availableType(from: [.png, .tiff]) != nil } override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation { stageableDrag(sender) ? .copy : super.draggingEntered(sender) } override func draggingUpdated(_ sender: NSDraggingInfo) -> NSDragOperation { stageableDrag(sender) ? .copy : super.draggingUpdated(sender) } override func prepareForDragOperation(_ sender: NSDraggingInfo) -> Bool { stageableDrag(sender) ? true : super.prepareForDragOperation(sender) } override func performDragOperation(_ sender: NSDraggingInfo) -> Bool { if stageableDrag(sender), let onStageDrop { return onStageDrop(sender.draggingPasteboard) } return super.performDragOperation(sender) } }