| new file mode 100644 |
| index 0000000..2ebaf50 |
| --- /dev/null |
| +++ b/ios/LChatMac/MacComposerField.swift |
| @@ -0,0 +1,356 @@ |
| +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<Int>? = nil |
| + var placeholder: String = "" |
| + var fontSize: CGFloat |
| + var isDisabled: Bool = false |
| + // 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 |
| + |
| + 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 = false // the plain stage; rich lands in a later, gated PR |
| + 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 tv 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.isEditable = !isDisabled |
| + if tv.baseFontSize != fontSize { |
| + tv.baseFontSize = fontSize |
| + tv.applyBaseTypingAttributes() |
| + if let storage = tv.textStorage, storage.length > 0 { |
| + storage.addAttribute(.font, value: tv.baseFont, |
| + range: NSRange(location: 0, length: storage.length)) |
| + } |
| + 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 } |
| + var width = proposal.width ?? tv.bounds.width |
| + if !width.isFinite || width <= 0 { width = max(tv.bounds.width, 100) } |
| + if tv.frame.width != width { tv.setFrameSize(NSSize(width: width, height: tv.frame.height)) } |
| + 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))) |
| + if let w = proposal.width, w.isFinite, w > 0 { return CGSize(width: w, height: h) } |
| + return CGSize(width: width, height: h) |
| + } |
| + |
| + final class Coordinator: NSObject, NSTextViewDelegate { |
| + var parent: MacComposerField |
| + var frameObserver: NSObjectProtocol? |
| + 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 } |
| + 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 } |
| + 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, 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) { return false } |
| + 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.insertText("\n", replacementRange: textView.selectedRange()) |
| + 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 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 { |
| + let placeholderLabel = NSTextField(labelWithString: "") |
| + var onStagePaste: ((NSPasteboard) -> Bool)? |
| + var onFocusChange: ((Bool) -> Void)? |
| + |
| + 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) |
| + } |
| + |
| + func applyBaseTypingAttributes() { |
| + typingAttributes = [.font: baseFont, .foregroundColor: baseColor] |
| + } |
| + |
| + /// 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) { |
| + string = text |
| + 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) } |
| + } |
| +} |