eleven / ocho Public

mac: the NSTextView composer — real caret, newlines at the caret (#819, #890 stage 1) (#920)

* mac: the NSTextView composer — real caret, newlines at the caret (#819, #890 stage 1)

MacComposerField replaces the SwiftUI TextField: plain text only, but
with a caret — Shift/⌥+Return inserts newlines AT the caret instead of
appending to the end (#819), the typeahead tracks mid-text tokens
(composerToken finally gets its caret argument on the Mac), accepting
a candidate preserves the tail after the caret, and panel inserts land
at the caret instead of the end.

Attempt one's two killers are fixed structurally: height is PUSHED —
measured in textDidChange (and on width/font changes) and applied via a
binding + .frame(height:), with the every-probe-finite sizeThatFits
kept only as the anti-giant-composer backstop — and updateNSView does
a plain string compare (never serializes), with >1MB pastes refused.
Recovered from bf66fde: the hand-built TextKit stack, doCommandBy key
routing, the focus dance, paste-to-upload (now via stagePasteboard).

Formatting comes in later, separately-gated PRs; this stage must feel
byte-identical to the old field everywhere except the caret fixes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* mac: no scroller below the 9-line cap; ⌃Return normalizes to \n

The auto-hiding overlay scroller flashed on every line growth — AppKit
lays the taller content out immediately, the frame height lands a beat
later (binding → SwiftUI). Height now writes synchronously from
textDidChange, and the scroller only exists at all past the cap.
insertLineBreak: (⌃Return) inserted U+2028, which the wire must never
carry — normalized to a plain newline.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 3a732974f7de149cfc0ab30b54324e22b33562d3 parent 9deedbb
2 files changed, +478 −77
  • ios/LChatMac/MacComposerField.swift +356 −0
  • ios/LChatMac/NativeFeedView.swift +122 −77
diff --git a/ios/LChatMac/MacComposerField.swift b/ios/LChatMac/MacComposerField.swift
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) }
+ }
+}
diff --git a/ios/LChatMac/NativeFeedView.swift b/ios/LChatMac/NativeFeedView.swift
index 7ad0827..f29dba4 100644
--- a/ios/LChatMac/NativeFeedView.swift
+++ b/ios/LChatMac/NativeFeedView.swift
@@ -205,7 +205,17 @@ struct NativeFeedView: View {
// the server (model.send returns false on a down socket, keeping the text).
@State private var draft = ""
@State private var integrityReport: String? // /integrity read-out (tamper evidence)
- @FocusState private var composerFocused: Bool
+ // Plain @State: MacComposerField drives first responder from it directly.
+ @State private var composerFocused = false
+ // The caret as a UTF-16 offset into `draft` — real caret tracking for the
+ // typeahead (the old TextField could only assume caret-at-end).
+ @State private var composerCaret = 0
+ // The field's measured height (1–9 lines), pushed by the field on every
+ // content/width/font change — SwiftUI never re-asks a representable.
+ @State private var composerHeight: CGFloat = 22
+ // Programmatic edits (typeahead splice, panel inserts) act on the view via
+ // the controller, so the caret survives them.
+ @State private var composerController = MacComposerController()
// Attachments staged on the draft: picked → uploading (spinner) → ready.
// Several can ride one message, like the web's pending list.
private struct Staged: Identifiable {
@@ -1073,15 +1083,7 @@ struct NativeFeedView: View {
.background(FrostedBand())
}
.onChange(of: draft) { _, text in
- // Caret-at-end is the common case while typing; caret tracking for
- // mid-text edits needs the NSTextView-backed field (iOS's
- // ComposerField pattern) — container work.
- taToken = composerToken(text)
- taItems = taToken.map {
- typeaheadItems($0, roster: roster, myUserId: myUserId, hasBot: hasBot,
- appCommands: appCommands, displayNames: model.senderNames)
- } ?? []
- taIndex = 0
+ refreshTypeahead()
// The typing beats: alive while there's a draft, an immediate
// clear when it empties (edits don't announce themselves).
model.typingBeat(active: !text.isEmpty && editingID == nil)
@@ -1089,13 +1091,43 @@ struct NativeFeedView: View {
// by the time Enter runs the command (a cold page queues it).
if text.first == "/", commandHost == nil { ensureCommandHost() }
}
+ // Caret moves (clicks / arrows) can enter or leave a token with the
+ // text unchanged — re-check, like the web composer's keyup handler.
+ .onChange(of: composerCaret) { _, _ in refreshTypeahead() }
+ }
+
+ // The token under the REAL caret (MacComposerField tracks it). The field
+ // reports UTF-16, the tokenizer speaks Characters — convert at the
+ // boundary, exactly as the iOS screens do.
+ private func refreshTypeahead() {
+ let utf16 = min(max(composerCaret, 0), draft.utf16.count)
+ let idx = String.Index(utf16Offset: utf16, in: draft)
+ let charCaret = draft.distance(from: draft.startIndex, to: idx)
+ taToken = composerToken(draft, caret: charCaret)
+ taItems = taToken.map {
+ typeaheadItems($0, roster: roster, myUserId: myUserId, hasBot: hasBot,
+ appCommands: appCommands, displayNames: model.senderNames)
+ } ?? []
+ taIndex = 0
}
// Accept a candidate: splice it over the token (trigger → caret), exactly
// like the web's acceptTypeahead.
private func pick(_ item: TypeaheadItem, token: ComposerToken) {
+ // Splice trigger → caret, PRESERVING the tail after the caret (the old
+ // TextField assumed caret-at-end and dropped it). Characters → UTF-16
+ // at the boundary; the field edits itself via the controller, so the
+ // caret lands right after the insert.
let chars = Array(draft)
- draft = String(chars[0..<token.start]) + item.insert
+ let utf16 = min(max(composerCaret, 0), draft.utf16.count)
+ let idx = String.Index(utf16Offset: utf16, in: draft)
+ let charCaret = draft.distance(from: draft.startIndex, to: idx)
+ let start = min(token.start, chars.count)
+ let end = min(max(charCaret, start), chars.count)
+ let head = String(chars[0..<start])
+ let span = String(chars[start..<end])
+ composerController.insert(item.insert, replacingUTF16: NSRange(
+ location: (head as NSString).length, length: (span as NSString).length))
taToken = nil
taItems = []
composerFocused = true
@@ -1207,75 +1239,52 @@ struct NativeFeedView: View {
.buttonStyle(.plain)
.padding(.bottom, 4)
}
- TextField(defaultCommand?.placeholder ?? "Message", text: $draft, axis: .vertical)
- // Paste an image (or a copied file) straight onto the draft -
- // it stages as an upload, like the web composer's paste. In a
- // default-command thread the type list is empty, so ⌘V is NOT
- // claimed here and the field's normal text paste still works.
- .onPasteCommand(of: defaultCommand == nil ? [.png, .jpeg, .tiff, .image, .fileURL] : []) { providers in
- handlePaste(providers)
- }
- .textFieldStyle(.plain)
- .font(.system(size: chatFontSize)) // the feed's reading size, like the web composer
- .lineLimit(1...9) // grows to ~9 lines, then scrolls within itself
- .padding(.horizontal, 14)
- .padding(.vertical, 9)
- // The web composer: a bordered pill on the panel background,
- // not a filled grey field.
- .background(RoundedRectangle(cornerRadius: 19, style: .continuous)
- .fill(.background))
- .overlay(RoundedRectangle(cornerRadius: 19, style: .continuous)
- .strokeBorder(.quaternary, lineWidth: 1))
- .onSubmit(sendDraft)
+ // The NSTextView-backed field (MacComposerField): a real caret —
+ // mid-text typeahead, Shift/⌥+Return newlines AT the caret (#819),
+ // caret-preserving splices — and the base the rich stages of #890
+ // build on. Plain text at this stage. Typeahead keys route through
+ // the handlers: ↑↓ move, Tab/Return accept, Esc dismisses — Return
+ // falls through to send when no menu is up; ↑ on an empty draft
+ // edits the last message.
+ MacComposerField(
+ text: $draft,
+ focused: $composerFocused,
+ height: $composerHeight,
+ caret: $composerCaret,
+ placeholder: defaultCommand?.placeholder ?? "Message",
+ fontSize: chatFontSize, // the feed's reading size, like the web composer
// Queued behind an upload = the message is committed; editing
// what's about to auto-send would race it.
- .disabled(sendQueued)
- // Typeahead keys: ↑↓ move, Tab/Return accept, Esc dismisses —
- // Return falls through to send when no menu is up. Up-arrow
- // ALSO edits the last message when the box is empty (one
- // handler — two .onKeyPress(.upArrow) on a field don't both
- // fire, which is why the edit shortcut never worked).
- .onKeyPress(.upArrow) {
+ isDisabled: sendQueued,
+ controller: composerController,
+ onSend: sendDraft,
+ onMoveUp: {
if !taItems.isEmpty {
taIndex = (taIndex + taItems.count - 1) % taItems.count
- return .handled
+ return true
}
if draft.isEmpty, editingID == nil,
let last = model.messages.last(where: { $0.mine && !$0.action }) {
startEdit(last)
- return .handled
+ return true
}
- return .ignored
- }
- .onKeyPress(.downArrow) {
- guard !taItems.isEmpty else { return .ignored }
+ return false
+ },
+ onMoveDown: {
+ guard !taItems.isEmpty else { return false }
taIndex = (taIndex + 1) % taItems.count
- return .handled
- }
- .onKeyPress(.tab) {
- guard !taItems.isEmpty, let token = taToken else { return .ignored }
+ return true
+ },
+ onAccept: {
+ guard !taItems.isEmpty, let token = taToken else { return false }
pick(taItems[taIndex], token: token)
- return .handled
- }
- .onKeyPress(phases: .down) { press in
- guard press.key == .return else { return .ignored }
- // Desktop shouldSend: Shift/Option+Return newlines, plain
- // Return accepts the typeahead or sends (via onSubmit).
- if press.modifiers.contains(.shift) || press.modifiers.contains(.option) {
- draft += "\n"
- return .handled
- }
- if !taItems.isEmpty, let token = taToken {
- pick(taItems[taIndex], token: token)
- return .handled
- }
- return .ignored
- }
- .onKeyPress(.escape) {
+ return true
+ },
+ onEscape: {
if !taItems.isEmpty {
taToken = nil
taItems = []
- return .handled
+ return true
}
// An open command panel is the most recently summoned
// layer — Escape peels it before reply/edit below (the
@@ -1283,11 +1292,32 @@ struct NativeFeedView: View {
// frame, so the panel's onClose hook always runs).
if panelVisible {
commandHost?.dismissPanel()
- return .handled
+ return true
}
- return .ignored
- }
- .focused($composerFocused)
+ return false
+ },
+ // Paste an image (or a copied file) straight onto the draft —
+ // it stages as an upload, like the web composer's paste.
+ // Absent in a default-command thread (text-only surface), so a
+ // text paste there stays a plain text paste.
+ onStagePaste: defaultCommand == nil ? { stagePasteboard($0) } : nil
+ )
+ // The field's height is pushed by the field itself (measured
+ // 1–9 lines) — SwiftUI never re-asks a representable when only
+ // its content changes.
+ .frame(height: composerHeight)
+ // Claim the row's full width (the + and send buttons keep
+ // their fixed sizes) — without this the HStack centres a
+ // short pill around the representable's measured width.
+ .frame(maxWidth: .infinity)
+ .padding(.horizontal, 14)
+ .padding(.vertical, 9)
+ // The web composer: a bordered pill on the panel background,
+ // not a filled grey field.
+ .background(RoundedRectangle(cornerRadius: 19, style: .continuous)
+ .fill(.background))
+ .overlay(RoundedRectangle(cornerRadius: 19, style: .continuous)
+ .strokeBorder(.quaternary, lineWidth: 1))
// Regaining the app drops the caret into the message box, the
// same courtesy the webview shell extends (focusComposer) —
// but ONLY from the pane actually on screen (every instance's
@@ -1390,10 +1420,9 @@ struct NativeFeedView: View {
}
}
case .composerInsert(let text):
- // A panel acting on the composer (an /emoji tap). Caret-aware
- // splicing needs the NSTextView-backed field (the typeahead's
- // known limitation) — append, the common caret-at-end case.
- draft += text
+ // A panel acting on the composer (an /emoji tap) — inserted at
+ // the caret, like the web's insertAtCaret.
+ composerController.insert(text, replacingUTF16: nil)
composerFocused = true
case .notice(let from, let text):
// A command's private "only visible to you" reply — the same
@@ -1744,9 +1773,25 @@ struct NativeFeedView: View {
}
}
- // Pasted content routes through the same provider pipeline as drops.
- private func handlePaste(_ providers: [NSItemProvider]) {
- stageProviders(providers)
+ // Pasted content (from MacComposerField's paste override): file URLs stage
+ // directly; raw image data stages as a PNG (like the web composer's
+ // paste-to-upload). Returns whether anything staged, so the field knows
+ // the paste was (partly) consumed. Drops keep their own provider pipeline
+ // (stageProviders); the pasteboard hands us its contents directly.
+ private func stagePasteboard(_ pb: NSPasteboard) -> Bool {
+ if let urls = pb.readObjects(forClasses: [NSURL.self]) as? [URL] {
+ let files = urls.filter(\.isFileURL)
+ if !files.isEmpty {
+ files.forEach { stage($0) }
+ return true
+ }
+ }
+ for type in [NSPasteboard.PasteboardType.png, .tiff] {
+ guard let data = pb.data(forType: type) else { continue }
+ stage(data: data, name: "pasted.png", type: type == .png ? .png : .tiff)
+ return true
+ }
+ return false
}
// ONE pipeline for every provider source (paste, drop): a real file URL