eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1import SwiftUI
2import AppKit
3import LChatCore
4
5// The Mac native composer's text input — the NSTextView-backed field the
6// SwiftUI TextField couldn't be: it has a real caret (mid-text typeahead, and
7// Shift/⌥+Return finally inserts newlines AT the caret — issue #819), a real
8// paste pipeline, and room for the rich stages of #890 to build on. This
9// stage is deliberately PLAIN text only: the `text` binding is the raw string,
10// behavior matches the old field exactly (Return sends, typeahead keys route
11// first, 1–9 line growth), and formatting lands in a later, separately-gated PR.
12//
13// Attempt one (#471, reverted #482) died on two Mac-specific failures this
14// file fixes structurally:
15// - SwiftUI never re-invokes sizeThatFits when only the CONTENT changes, so
16// the old field scrolled instead of growing. Height is now PUSHED: measured
17// in textDidChange (and on width/font changes) and written to a binding the
18// parent applies as .frame(height:). sizeThatFits stays only as the
19// backstop that answers every probe with a finite height — without it a
20// probe miss reads as "greedy" and the field swallows the pane.
21// - Serializing on every SwiftUI update wedged long pastes. updateNSView now
22// does a plain string compare, and pastes above 1MB are refused outright
23// (attach a file instead — the web composer's exact threshold).
24struct MacComposerField: NSViewRepresentable {
25 @Binding var text: String
26 @Binding var focused: Bool
27 // The composer's measured height (1–9 lines) — apply as .frame(height:).
28 @Binding var height: CGFloat
29 // The caret as a UTF-16 offset into `text` — drives the typeahead.
30 var caret: Binding<Int>? = nil
31 var placeholder: String = ""
32 var fontSize: CGFloat
33 var isDisabled: Bool = false
34 // Format-as-you-type (the closing-marker rules). OFF still leaves the
35 // shortcuts working — typed markers stay literal and render in the feed.
36 var formatAsYouType: Bool = true
37 // Programmatic edits (typeahead splices, panel inserts) act on the view —
38 // never by rewriting the binding, which would reset the caret to the end.
39 var controller: MacComposerController? = nil
40 var onSend: () -> Void
41 // Typeahead/shortcut hooks — each returns whether it consumed the key.
42 var onMoveUp: () -> Bool = { false }
43 var onMoveDown: () -> Bool = { false }
44 var onAccept: () -> Bool = { false } // Tab / Return over an open typeahead
45 var onEscape: () -> Bool = { false }
46 // Stage images / file URLs from a paste; returns whether anything staged.
47 // nil in a default-command thread (text-only surface, like the web).
48 var onStagePaste: ((NSPasteboard) -> Bool)? = nil
49 // Stage a file/image DRAG dropped on the field; returns whether anything
50 // staged. The window-wide drop zone normally claims the drag before it
51 // reaches the text view — this is the backstop that keeps NSTextView's
52 // default (insert the file's PATH as text) from ever being the outcome.
53 var onStageDrop: ((NSPasteboard) -> Bool)? = nil
54
55 func makeCoordinator() -> Coordinator { Coordinator(self) }
56
57 func makeNSView(context: Context) -> NSScrollView {
58 let tv = ComposerTextView()
59 tv.baseFontSize = fontSize
60 tv.delegate = context.coordinator
61 tv.onFocusChange = { [weak coordinator = context.coordinator] has in
62 guard let parent = coordinator?.parent else { return }
63 if parent.focused != has { parent.focused = has }
64 }
65 tv.isRichText = true // attributed editing; pastes are flattened in paste(_:)
66 tv.allowsUndo = true
67 tv.drawsBackground = false
68 tv.textContainerInset = .zero
69 tv.textContainer?.lineFragmentPadding = 0
70 tv.textContainer?.widthTracksTextView = true
71 tv.isVerticallyResizable = true
72 tv.isHorizontallyResizable = false
73 tv.autoresizingMask = [.width]
74 // The classic scroll-view pairing: without an unbounded maxSize the
75 // text view can't grow past its initial frame, and content beyond it
76 // would neither lay out nor scroll.
77 tv.minSize = .zero
78 tv.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude,
79 height: CGFloat.greatestFiniteMagnitude)
80 tv.usesFontPanel = false
81 tv.importsGraphics = false
82 tv.isAutomaticLinkDetectionEnabled = false
83 tv.applyBaseTypingAttributes()
84
85 let scroll = NSScrollView()
86 scroll.documentView = tv
87 scroll.drawsBackground = false
88 scroll.borderType = .noBorder
89 scroll.hasVerticalScroller = false // enabled by remeasure only past the 9-line cap
90 scroll.autohidesScrollers = true
91
92 controller?.view = tv
93 // Width changes (window resize) re-wrap the text — re-measure, since
94 // SwiftUI won't ask (the attempt-one lesson).
95 tv.postsFrameChangedNotifications = true
96 context.coordinator.frameObserver = NotificationCenter.default.addObserver(
97 forName: NSView.frameDidChangeNotification, object: tv, queue: .main
98 ) { [weak coordinator = context.coordinator, weak tv] _ in
99 guard let coordinator, let tv else { return }
100 // Height changes are OUR OWN doing (the binding landing) — acting
101 // on them loops layout into jitter. Only a real width change
102 // re-wraps the text.
103 guard tv.frame.width != coordinator.lastMeasuredWidth else { return }
104 coordinator.remeasure(tv)
105 }
106 DispatchQueue.main.async { context.coordinator.remeasure(tv) }
107 return scroll
108 }
109
110 static func dismantleNSView(_ nsView: NSScrollView, coordinator: Coordinator) {
111 if let observer = coordinator.frameObserver {
112 NotificationCenter.default.removeObserver(observer)
113 }
114 }
115
116 func updateNSView(_ scroll: NSScrollView, context: Context) {
117 context.coordinator.parent = self
118 guard let tv = scroll.documentView as? ComposerTextView else { return }
119 controller?.view = tv
120 tv.onStagePaste = onStagePaste
121 tv.onStageDrop = onStageDrop
122 tv.isEditable = !isDisabled
123 tv.formatAsYouType = formatAsYouType
124 if tv.baseFontSize != fontSize {
125 tv.baseFontSize = fontSize
126 tv.applyBaseTypingAttributes()
127 tv.rederiveFonts() // every styled run re-derives from .composerStyle
128 context.coordinator.remeasure(tv)
129 }
130 // A plain string compare — user edits round-trip exactly, so only
131 // EXTERNAL sets (edit-load, clear after send) reach the view here.
132 if tv.string != text {
133 tv.setPlainText(text)
134 context.coordinator.remeasure(tv)
135 }
136 tv.placeholderLabel.stringValue = placeholder
137 tv.placeholderLabel.isHidden = !text.isEmpty
138 // Drive first responder from the focus binding — async so we never
139 // mutate responder state mid-update. Compare against the live first
140 // responder so a re-assert to `true` still re-grabs focus.
141 let isFirst = tv.window?.firstResponder === tv
142 if focused != isFirst {
143 DispatchQueue.main.async {
144 guard let window = tv.window else { return }
145 if focused {
146 window.makeFirstResponder(tv)
147 } else if window.firstResponder === tv {
148 window.makeFirstResponder(nil)
149 }
150 }
151 }
152 }
153
154 // The BACKSTOP only (height is pushed via the binding): every probe —
155 // zero, unspecified, infinite — must get a finite, capped answer, or
156 // SwiftUI marks the view greedy and the field swallows the whole pane
157 // (attempt one's giant-composer bug).
158 func sizeThatFits(_ proposal: ProposedViewSize, nsView: NSScrollView, context: Context) -> CGSize? {
159 guard let tv = nsView.documentView as? ComposerTextView,
160 let container = tv.textContainer, let layout = tv.layoutManager else { return nil }
161 // A PURE measurement — never mutate the frame from a probe: SwiftUI
162 // probes with zero/unspecified widths, and resizing the text view to
163 // match forced a bogus narrow wrap whose frame change re-entered
164 // layout through the observer (the mid-text jitter bug).
165 layout.ensureLayout(for: container)
166 let line = layout.defaultLineHeight(for: tv.baseFont)
167 let used = layout.usedRect(for: container).height
168 let h = ceil(min(max(line, used), ceil(line * 9)))
169 var width = proposal.width ?? tv.bounds.width
170 if !width.isFinite || width <= 0 { width = max(tv.bounds.width, 100) }
171 return CGSize(width: width, height: h)
172 }
173
174 final class Coordinator: NSObject, NSTextViewDelegate {
175 var parent: MacComposerField
176 var frameObserver: NSObjectProtocol?
177 var lastMeasuredWidth: CGFloat = 0
178 init(_ parent: MacComposerField) { self.parent = parent }
179
180 // Grow with the text up to nine lines (the old field's lineLimit(1...9)),
181 // then scroll within. Pushed to the binding — SwiftUI never re-asks
182 // sizeThatFits on content change (the failure that sank attempt one).
183 // `sync` writes the binding immediately (safe from textDidChange, the
184 // same discipline as the text write); calls that can land mid-update
185 // (updateNSView, frame observer) stay deferred.
186 func remeasure(_ tv: ComposerTextView, sync: Bool = false) {
187 guard let container = tv.textContainer, let layout = tv.layoutManager else { return }
188 lastMeasuredWidth = tv.frame.width
189 layout.ensureLayout(for: container)
190 let line = layout.defaultLineHeight(for: tv.baseFont)
191 let used = layout.usedRect(for: container).height
192 let cap = ceil(line * 9)
193 let h = ceil(min(max(line, used), cap))
194 // No scroller below the cap: the frame always fits the content
195 // there, and the auto-hiding overlay scroller would otherwise
196 // flash in the beat between AppKit laying out the taller text and
197 // SwiftUI applying the new frame height.
198 tv.enclosingScrollView?.hasVerticalScroller = used > cap
199 if parent.height != h {
200 if sync {
201 parent.height = h
202 } else {
203 DispatchQueue.main.async { [self] in
204 if parent.height != h { parent.height = h }
205 }
206 }
207 }
208 }
209
210 func textDidChange(_ notification: Notification) {
211 guard let tv = notification.object as? ComposerTextView else { return }
212 tv.editCounter &+= 1
213 if parent.formatAsYouType { tv.maybeApplyInlineRule() }
214 parent.text = tv.string
215 parent.caret?.wrappedValue = tv.selectedRange().location
216 tv.placeholderLabel.isHidden = !tv.string.isEmpty
217 remeasure(tv, sync: true)
218 tv.scrollRangeToVisible(tv.selectedRange())
219 }
220
221 func textViewDidChangeSelection(_ notification: Notification) {
222 guard let tv = notification.object as? ComposerTextView else { return }
223 parent.caret?.wrappedValue = tv.selectedRange().location
224 }
225
226 func textView(_ textView: NSTextView, shouldChangeTextIn affectedCharRange: NSRange,
227 replacementString: String?) -> Bool {
228 guard let tv = textView as? ComposerTextView, let replacement = replacementString else { return true }
229 // Backspace right after a rule fired restores the literal text
230 // (the web's undoInputRule-on-Backspace parity).
231 if replacement.isEmpty, affectedCharRange.length > 0,
232 tv.ruleUndoStamp == tv.editCounter, tv.undoManager?.canUndo == true {
233 tv.undoManager?.undo()
234 return false
235 }
236 // Rules fire on single typed characters only — never pastes,
237 // never IME composition.
238 tv.pendingRuleCheck = affectedCharRange.length == 0 && replacement.count == 1
239 && replacement != "\n" && !tv.hasMarkedText()
240 return true
241 }
242
243 func textView(_ textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
244 switch commandSelector {
245 case #selector(NSResponder.insertNewline(_:)):
246 // Desktop shouldSend: Shift/Option+Return newlines — falling
247 // through to the standard insertion puts them AT THE CARET
248 // (the #819 fix); plain Return accepts the typeahead or sends.
249 let mods = NSApp.currentEvent?.modifierFlags ?? []
250 if mods.contains(.shift) || mods.contains(.option) {
251 // A TYPED newline carries plain style (the marked-break
252 // contract: **a**\n**b** on the wire) while the typing
253 // style survives it, like the web after Shift+Enter.
254 (textView as? ComposerTextView)?.insertPlainNewline()
255 return true
256 }
257 if parent.onAccept() { return true }
258 parent.onSend()
259 return true
260 case #selector(NSResponder.insertLineBreak(_:)):
261 // ⌃Return maps here, and the standard behaviour inserts
262 // U+2028 (a line SEPARATOR, not "\n") — a character the wire
263 // format must never carry. Normalize to a plain newline.
264 (textView as? ComposerTextView)?.insertPlainNewline()
265 return true
266 case #selector(NSResponder.moveUp(_:)):
267 return parent.onMoveUp()
268 case #selector(NSResponder.moveDown(_:)):
269 return parent.onMoveDown()
270 case #selector(NSResponder.insertTab(_:)):
271 return parent.onAccept()
272 case #selector(NSResponder.cancelOperation(_:)):
273 return parent.onEscape()
274 case #selector(NSResponder.complete(_:)):
275 return true // ours is the only completion UI — never the system popup
276 default:
277 return false
278 }
279 }
280 }
281}
282
283// Programmatic access to the live text view for edits that must not disturb
284// the caret: typeahead splices and panel inserts go through insertText (undo,
285// delegate notifications and caret placement all behave), never through a
286// binding rewrite. The web facade's replaceRange/insertAtCaret, in AppKit.
287@MainActor
288final class MacComposerController {
289 weak var view: NSTextView?
290
291 // Replace the given UTF-16 range of the current text (nil = the selection)
292 // with literal text; the caret lands after the insert.
293 func insert(_ text: String, replacingUTF16 range: NSRange?) {
294 guard let tv = view else { return }
295 tv.insertText(text, replacementRange: range ?? tv.selectedRange())
296 }
297
298 func focus() {
299 guard let tv = view else { return }
300 tv.window?.makeFirstResponder(tv)
301 }
302
303 // The wire form of the draft — serialized markdown from the LChatCore
304 // twin (vector-pinned against the web serializer). The binding stays the
305 // display string; this is the getMarkdown to its getText.
306 func serializedMarkdown(escapeLiterals: Bool = false) -> String {
307 guard let tv = view, let storage = tv.textStorage else { return "" }
308 return composerMarkdown(of: storage, escapeLiterals: escapeLiterals)
309 }
310}
311
312// The NSTextView itself: hand-assembled TextKit stack, placeholder, guarded
313// pastes. Plain text at this stage — the rich toggles return in a later PR.
314final class ComposerTextView: NSTextView {
315 // The click that brings the app forward should LAND, not just activate:
316 // without this, entering the app by clicking the composer eats the click
317 // (macOS's activation-click swallow), which reads as a dead composer —
318 // especially when the post-activation re-render hitch delays the caret.
319 // Messages accepts its composer's first mouse; so do we.
320 override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true }
321
322 // The placeholder is decoration, and decoration must be click-through
323 // (the FrostedBand rule): a plain NSTextField label hit-tests but its
324 // mouseDown neither focuses anything nor forwards up the chain — and the
325 // label spans the field's full width (see layout()), so an EMPTY composer
326 // silently ate the very click meant to focus it whenever focus sat
327 // elsewhere (caught by ClickLab: hit chain ended at the label, responder
328 // never moved).
329 private final class ClickThroughLabel: NSTextField {
330 override func hitTest(_ point: NSPoint) -> NSView? { nil }
331 }
332 let placeholderLabel: NSTextField = ClickThroughLabel(labelWithString: "")
333 var onStagePaste: ((NSPasteboard) -> Bool)?
334 var onStageDrop: ((NSPasteboard) -> Bool)?
335 var onFocusChange: ((Bool) -> Void)?
336 // Format-as-you-type bookkeeping (armed in shouldChangeText, consumed in
337 // textDidChange; the stamp drives Backspace-restores-the-literal).
338 var formatAsYouType = true
339 var pendingRuleCheck = false
340 var editCounter: UInt64 = 0
341 var ruleUndoStamp: UInt64 = .max
342 private var applyingRule = false
343
344 var baseFontSize: CGFloat = NSFont.systemFontSize {
345 didSet {
346 placeholderLabel.font = .systemFont(ofSize: baseFontSize)
347 if string.isEmpty { applyBaseTypingAttributes() }
348 }
349 }
350 var baseFont: NSFont { .systemFont(ofSize: baseFontSize) }
351 private var baseColor: NSColor { .labelColor }
352
353 override init(frame frameRect: NSRect, textContainer container: NSTextContainer?) {
354 super.init(frame: frameRect, textContainer: container)
355 setUpPlaceholder()
356 }
357 // AppKit (unlike UIKit) does NOT build the text system for a nil container
358 // — init(frame:textContainer: nil) yields a view with no textContainer, no
359 // layoutManager, no textStorage: typing is dead and every measurement
360 // guard bails (which reads as "greedy" — the composer-swallows-the-pane
361 // bug). Assemble the classic TextKit stack by hand.
362 convenience init() {
363 let storage = NSTextStorage()
364 let layout = NSLayoutManager()
365 storage.addLayoutManager(layout)
366 let container = NSTextContainer(size: NSSize(width: 0, height: CGFloat.greatestFiniteMagnitude))
367 container.widthTracksTextView = true
368 layout.addTextContainer(container)
369 self.init(frame: .zero, textContainer: container)
370 }
371 required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
372
373 private func setUpPlaceholder() {
374 placeholderLabel.font = .systemFont(ofSize: baseFontSize)
375 placeholderLabel.textColor = .placeholderTextColor
376 placeholderLabel.lineBreakMode = .byTruncatingTail
377 placeholderLabel.maximumNumberOfLines = 1
378 addSubview(placeholderLabel)
379 }
380
381 override func layout() {
382 super.layout()
383 placeholderLabel.frame = NSRect(x: 0, y: 0, width: bounds.width,
384 height: placeholderLabel.intrinsicContentSize.height)
385 }
386
387 var baseAttributes: [NSAttributedString.Key: Any] {
388 [.font: baseFont, .foregroundColor: baseColor]
389 }
390
391 func applyBaseTypingAttributes() {
392 typingAttributes = baseAttributes
393 }
394
395 // Rendering attributes derived from the semantic style — the ONLY place
396 // style becomes fonts (never sniffed back; the Dynamic-Type/ladder trap).
397 func renderingAttributes(for style: InlineStyle) -> [NSAttributedString.Key: Any] {
398 var attrs = baseAttributes
399 guard !style.isPlain else { return attrs }
400 attrs[.composerStyle] = ComposerStyleAttr(style)
401 if style.code {
402 attrs[.font] = NSFont.monospacedSystemFont(ofSize: baseFont.pointSize * 0.92, weight: .regular)
403 attrs[.backgroundColor] = NSColor.labelColor.withAlphaComponent(0.08) // the web chip's ink-8%
404 return attrs
405 }
406 var traits: NSFontDescriptor.SymbolicTraits = []
407 if style.bold { traits.insert(.bold) }
408 if style.italic { traits.insert(.italic) }
409 if !traits.isEmpty {
410 let d = baseFont.fontDescriptor.withSymbolicTraits(traits)
411 attrs[.font] = NSFont(descriptor: d, size: baseFont.pointSize) ?? baseFont
412 }
413 if style.strike { attrs[.strikethroughStyle] = NSUnderlineStyle.single.rawValue }
414 return attrs
415 }
416
417 private func styleAtCaret() -> InlineStyle {
418 (typingAttributes[.composerStyle] as? ComposerStyleAttr)?.style ?? .plain
419 }
420
421 // The font ladder changed: re-derive every styled run from its semantic
422 // attribute.
423 func rederiveFonts() {
424 guard let storage = textStorage else { return }
425 storage.beginEditing()
426 storage.enumerateAttribute(.composerStyle, in: NSRange(location: 0, length: storage.length)) { value, range, _ in
427 let style = (value as? ComposerStyleAttr)?.style ?? .plain
428 storage.setAttributes(renderingAttributes(for: style), range: range)
429 }
430 storage.endEditing()
431 typingAttributes = renderingAttributes(for: styleAtCaret())
432 }
433
434 // ---- mark toggles: ⌘B/⌘I/⌘E/⌘⇧X, ⌘U swallowed ----
435
436 // Key equivalents walk the VIEW hierarchy, not the responder chain —
437 // every space's pane stays mounted (hidden), so without this guard the
438 // first composer in the tree would format a hidden draft (the #481 bug).
439 override func performKeyEquivalent(with event: NSEvent) -> Bool {
440 guard window?.firstResponder === self else { return super.performKeyEquivalent(with: event) }
441 let mods = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
442 if mods == .command, let key = event.charactersIgnoringModifiers?.lowercased() {
443 switch key {
444 case "b": toggle(\.bold); return true
445 case "i": toggle(\.italic); return true
446 case "e": toggle(\.code); return true
447 case "u": return true // no underline marker — a style that vanished on send would be a lie
448 default: break
449 }
450 }
451 if mods == [.command, .shift], event.charactersIgnoringModifiers?.lowercased() == "x" {
452 toggle(\.strike)
453 return true
454 }
455 return super.performKeyEquivalent(with: event)
456 }
457
458 private func flipped(_ style: InlineStyle, _ key: WritableKeyPath<InlineStyle, Bool>, to on: Bool) -> InlineStyle {
459 var out = style
460 out[keyPath: key] = on
461 // Code is exclusive both ways, like the web schema.
462 if key == \.code, on { out.bold = false; out.italic = false; out.strike = false }
463 if key != \.code, on { out.code = false }
464 return out
465 }
466
467 // Toggle over the selection (all-styled → remove, else add) or flip the
468 // typing style at a bare caret. Selection edits ride shouldChangeText/
469 // didChangeText so undo works AND the delegate hears the attribute-only
470 // edit — or a send right after ⌘B serializes the pre-toggle draft.
471 private func toggle(_ key: WritableKeyPath<InlineStyle, Bool>) {
472 let range = selectedRange()
473 if range.length == 0 {
474 let current = styleAtCaret()
475 typingAttributes = renderingAttributes(for: flipped(current, key, to: !current[keyPath: key]))
476 return
477 }
478 guard let storage = textStorage, shouldChangeText(in: range, replacementString: nil) else { return }
479 var allStyled = true
480 storage.enumerateAttribute(.composerStyle, in: range) { value, _, stop in
481 let style = (value as? ComposerStyleAttr)?.style ?? .plain
482 if !style[keyPath: key] { allStyled = false; stop.pointee = true }
483 }
484 storage.beginEditing()
485 storage.enumerateAttribute(.composerStyle, in: range) { value, subRange, _ in
486 let style = (value as? ComposerStyleAttr)?.style ?? .plain
487 storage.setAttributes(renderingAttributes(for: flipped(style, key, to: !allStyled)), range: subRange)
488 }
489 storage.endEditing()
490 didChangeText()
491 }
492
493 /// A typed newline: the "\n" itself is plain (marked-break contract), the
494 /// typing style around it survives so the next line continues the mark.
495 func insertPlainNewline() {
496 let saved = typingAttributes
497 typingAttributes = baseAttributes
498 insertText("\n", replacementRange: selectedRange())
499 typingAttributes = saved
500 }
501
502 // ---- format-as-you-type ----
503
504 // Called from textDidChange after a single typed character: if the line
505 // up to the caret completes an inline rule (**b**, *i*, `c`, ~s~ —
506 // ComposerRules, drift-gated against the web), swap the marker span for
507 // styled content. One Backspace (or ⌘Z) restores the literal.
508 func maybeApplyInlineRule() {
509 guard pendingRuleCheck, !applyingRule, !hasMarkedText() else {
510 pendingRuleCheck = false
511 return
512 }
513 pendingRuleCheck = false
514 let caret = selectedRange().location
515 guard selectedRange().length == 0, caret > 0, let storage = textStorage else { return }
516 let ns = string as NSString
517 let nl = ns.range(of: "\n", options: .backwards, range: NSRange(location: 0, length: caret))
518 let lineStart = nl.location == NSNotFound ? 0 : nl.location + 1
519 let lineRange = NSRange(location: lineStart, length: caret - lineStart)
520 guard lineRange.length > 0,
521 let hit = inlineCompletion(lineUpToCaret: ns.substring(with: lineRange)) else { return }
522 let absolute = NSRange(location: lineRange.location + hit.replaceRange.lowerBound,
523 length: hit.replaceRange.upperBound - hit.replaceRange.lowerBound)
524 // Never inside a code run (the web's rangeHasMark(code) guard).
525 var touchesCode = false
526 storage.enumerateAttribute(.composerStyle, in: absolute) { value, _, stop in
527 if (value as? ComposerStyleAttr)?.style.code ?? false { touchesCode = true; stop.pointee = true }
528 }
529 guard !touchesCode else { return }
530 var style = InlineStyle()
531 switch hit.mark {
532 case .bold: style.bold = true
533 case .italic: style.italic = true
534 case .code: style.code = true
535 case .strike: style.strike = true
536 }
537 applyingRule = true
538 breakUndoCoalescing() // one ⌘Z restores the literal, not the whole burst
539 if shouldChangeText(in: absolute, replacementString: hit.content) {
540 storage.replaceCharacters(
541 in: absolute,
542 with: NSAttributedString(string: hit.content, attributes: renderingAttributes(for: style)))
543 setSelectedRange(NSRange(location: absolute.location + (hit.content as NSString).length, length: 0))
544 // The web's removeStoredMark: the next keystroke types unstyled.
545 applyBaseTypingAttributes()
546 didChangeText()
547 // Stamped AFTER didChangeText — it increments editCounter, and
548 // the Backspace-undo check compares the post-change value.
549 ruleUndoStamp = editCounter
550 }
551 applyingRule = false
552 }
553
554 /// An external set (edit-load, clear after send): literal plain text,
555 /// caret at the end, typing style reset so the next message starts clean.
556 func setPlainText(_ text: String) {
557 textStorage?.setAttributedString(NSAttributedString(string: text, attributes: baseAttributes))
558 setSelectedRange(NSRange(location: (text as NSString).length, length: 0))
559 applyBaseTypingAttributes()
560 undoManager?.removeAllActions() // a cleared draft mustn't ⌘Z a sent message back
561 placeholderLabel.isHidden = !text.isEmpty
562 }
563
564 override func becomeFirstResponder() -> Bool {
565 let ok = super.becomeFirstResponder()
566 if ok { onFocusChange?(true) }
567 return ok
568 }
569 override func resignFirstResponder() -> Bool {
570 let ok = super.resignFirstResponder()
571 if ok { onFocusChange?(false) }
572 return ok
573 }
574
575 // Pastes: stage images/file URLs as uploads (the web composer's
576 // paste-to-upload); text pastes stay text. Guards from the web composer:
577 // past 1MB the paste is refused (attach a file instead) — the wedge that
578 // helped sink attempt one can't recur.
579 override func paste(_ sender: Any?) {
580 let pb = NSPasteboard.general
581 let staged = onStagePaste?(pb) ?? false
582 if let s = pb.string(forType: .string), !s.isEmpty {
583 if s.utf16.count > 1_000_000 {
584 NSSound.beep()
585 return
586 }
587 insertText(s, replacementRange: selectedRange())
588 return
589 }
590 if !staged { super.pasteAsPlainText(sender) }
591 }
592
593 // Drags: a file/image drop on the field stages an attachment — in a chat
594 // composer that is always what the drop means; NSTextView's default
595 // (insert the file's PATH as text) never is. Text drags keep the standard
596 // behaviour. The window-wide drop zone usually claims the drag before it
597 // gets here; this backstop covers any path that still resolves to the
598 // text view.
599 private func stageableDrag(_ sender: NSDraggingInfo) -> Bool {
600 guard onStageDrop != nil else { return false }
601 let pb = sender.draggingPasteboard
602 if pb.canReadObject(forClasses: [NSURL.self], options: [.urlReadingFileURLsOnly: true]) { return true }
603 if pb.canReadObject(forClasses: [NSFilePromiseReceiver.self], options: nil) { return true }
604 return pb.availableType(from: [.png, .tiff]) != nil
605 }
606
607 override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation {
608 stageableDrag(sender) ? .copy : super.draggingEntered(sender)
609 }
610
611 override func draggingUpdated(_ sender: NSDraggingInfo) -> NSDragOperation {
612 stageableDrag(sender) ? .copy : super.draggingUpdated(sender)
613 }
614
615 override func prepareForDragOperation(_ sender: NSDraggingInfo) -> Bool {
616 stageableDrag(sender) ? true : super.prepareForDragOperation(sender)
617 }
618
619 override func performDragOperation(_ sender: NSDraggingInfo) -> Bool {
620 if stageableDrag(sender), let onStageDrop {
621 return onStageDrop(sender.draggingPasteboard)
622 }
623 return super.performDragOperation(sender)
624 }
625}
626