import AppKit import SwiftUI /// A chat-style composer backed by AppKit: wraps long prompts, grows to fit up /// to seven lines, then scrolls. Return submits; Shift+Return inserts a /// newline. A plain SwiftUI `TextField(axis: .vertical)` cannot keep Return as /// "send", which is why this is not one. /// /// Height is driven by the caller: the representable measures the text and /// writes the clamped height into `height`, which the caller applies as a /// `.frame(height:)`. Driving the frame is what actually resizes a /// representable — `intrinsicContentSize` is ignored once SwiftUI offers a /// bigger size. struct ComposerView: NSViewRepresentable { @Binding var text: String @Binding var isFocused: Bool @Binding var height: CGFloat var placeholder: String /// When false, the composer never makes itself first responder. The panel /// turns this off while a modal field (search, edit, new workspace) or the /// sync overlay is up — otherwise every render stole focus back here. var canStealFocus: Bool = true var onSubmit: () -> Void var onEscape: (() -> Void)? = nil private let maxLines: CGFloat = 7 /// The one-line height, so the composer starts at exactly one line instead /// of snapping to its real height on the first keystroke. static var oneLineHeight: CGFloat { let font = NSFont.systemFont(ofSize: NSFont.systemFontSize) return font.ascender - font.descender + font.leading + 10 // 2 x 5 inset } func makeCoordinator() -> Coordinator { Coordinator(self) } func makeNSView(context: Context) -> NSScrollView { let scrollView = NSScrollView() scrollView.drawsBackground = false scrollView.borderType = .noBorder // The scroller starts disabled and is only switched on once the text // is taller than the max height. With it always on, every growth step // briefly overflows and the scroller flashes for one frame. scrollView.hasVerticalScroller = false scrollView.autohidesScrollers = true scrollView.horizontalScrollElasticity = .none let textView = ComposerTextView() textView.delegate = context.coordinator textView.placeholderText = placeholder textView.isRichText = false textView.isVerticallyResizable = true textView.isHorizontallyResizable = false textView.autoresizingMask = [.width] textView.textContainer?.widthTracksTextView = true textView.textContainer?.lineFragmentPadding = 0 textView.textContainerInset = NSSize(width: 0, height: 5) textView.font = .systemFont(ofSize: NSFont.systemFontSize) textView.textColor = .labelColor textView.drawsBackground = false // No containerSize: with widthTracksTextView the container follows the // view's width, which is what makes long lines wrap. scrollView.documentView = textView textView.frame = NSRect(origin: .zero, size: scrollView.contentSize) context.coordinator.watchWindow(of: textView) return scrollView } func updateNSView(_ scrollView: NSScrollView, context: Context) { guard let textView = scrollView.documentView as? ComposerTextView else { return } context.coordinator.parent = self if textView.string != text { textView.string = text updateHeight(textView) } if textView.placeholderText != placeholder { textView.placeholderText = placeholder } if isFocused, canStealFocus, scrollView.window?.firstResponder != textView { scrollView.window?.makeFirstResponder(textView) } } fileprivate func updateHeight(_ textView: ComposerTextView) { guard let layoutManager = textView.layoutManager, let textContainer = textView.textContainer else { return } layoutManager.ensureLayout(for: textContainer) let content = layoutManager.usedRect(for: textContainer).height + 2 * textView.textContainerInset.height let line = Self.oneLineHeight let maxHeight = line * maxLines + 1 textView.enclosingScrollView?.hasVerticalScroller = content > maxHeight let clamped = max(line, min(content, maxHeight)) if abs(height - clamped) > 0.5 { // Defer so the binding is not written from inside a layout pass. DispatchQueue.main.async { self.height = clamped } } } final class Coordinator: NSObject, NSTextViewDelegate { var parent: ComposerView private var windowObserver: Any? init(_ parent: ComposerView) { self.parent = parent } /// Re-assert focus every time the panel becomes key again. The focus /// binding stays `true` while hidden, so without this a re-shown panel /// would not refocus the composer. fileprivate func watchWindow(of textView: ComposerTextView) { windowObserver = NotificationCenter.default.addObserver( forName: NSWindow.didBecomeKeyNotification, object: nil, queue: .main ) { [weak self, weak textView] note in guard let self, let textView, (note.object as? NSWindow) === textView.window, self.parent.isFocused, self.parent.canStealFocus else { return } textView.window?.makeFirstResponder(textView) } } deinit { if let windowObserver { NotificationCenter.default.removeObserver(windowObserver) } } func textDidChange(_ notification: Notification) { guard let textView = notification.object as? ComposerTextView else { return } if parent.text != textView.string { parent.text = textView.string } parent.updateHeight(textView) } // Return sends, Shift+Return inserts a newline. Esc is forwarded so the // panel can clear its selection or hide, which a TextField used to do // via onExitCommand. func textView(_ textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool { if commandSelector == #selector(NSResponder.cancelOperation(_:)) { parent.onEscape?() return true } guard commandSelector == #selector(NSResponder.insertNewline(_:)) else { return false } // NSApp.currentEvent is not reliably set when the command fires, // so the Shift state comes from the keyDown currently being handled. // And returning false here does NOT fall through to the default // newline insertion once a delegate implements doCommandBy — the // event just dies — so insert the newline explicitly. if (textView as? ComposerTextView)?.keyDownModifiers.contains(.shift) == true { textView.insertNewline(nil) return true } parent.onSubmit() return true } } } private final class ComposerTextView: NSTextView { var placeholderText: String = "" { didSet { needsDisplay = true } } /// Modifier flags of the keyDown currently being handled. `doCommandBy` /// runs inside that call tree and cannot rely on `NSApp.currentEvent`. var keyDownModifiers: NSEvent.ModifierFlags = [] override func keyDown(with event: NSEvent) { keyDownModifiers = event.modifierFlags super.keyDown(with: event) keyDownModifiers = [] } override func draw(_ dirtyRect: NSRect) { super.draw(dirtyRect) guard string.isEmpty, !placeholderText.isEmpty else { return } let attributes: [NSAttributedString.Key: Any] = [ .font: font as Any, .foregroundColor: NSColor.placeholderTextColor, ] let inset = textContainerInset let origin = NSPoint(x: inset.width + (textContainer?.lineFragmentPadding ?? 0), y: inset.height) placeholderText.draw(at: origin, withAttributes: attributes) } }