| 1 | import SwiftUI |
| 2 | import AppKit |
| 3 | import 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). |
| 24 | struct 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 |
| 288 | final 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. |
| 314 | final 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 | |