From 44afb0b9a62f9c8a10f3e913d2847b681731287a Mon Sep 17 00:00:00 2001 From: scgopi Date: Sat, 3 Oct 2026 13:27:09 -0700 Subject: [PATCH] Nod composer: show every line while typing, up to five, then scroll The draft box grew when a second line was typed, but the line could sit out of view: AppKit scrolled to the caret while the text outgrew the box, and the offset survived SwiftUI's resize. After every resize the box now shows all of its text when it fits and the caret's line when it does not. It grows up to five lines (was 140pt, about nine), then scrolls with an overlay scroller. Co-Authored-By: Claude Opus 5.5 Signed-off-by: scgopi --- .../Features/Nod/NodComposerView.swift | 58 +++++++- graphcode/Tests/NodChatRenderTests.swift | 20 +++ graphcode/Tests/NodComposerHeightTests.swift | 138 ++++++++++++++++++ 3 files changed, 208 insertions(+), 8 deletions(-) create mode 100644 graphcode/Tests/NodComposerHeightTests.swift diff --git a/graphcode/Sources/Features/Nod/NodComposerView.swift b/graphcode/Sources/Features/Nod/NodComposerView.swift index 5e50132f..4a130d10 100644 --- a/graphcode/Sources/Features/Nod/NodComposerView.swift +++ b/graphcode/Sources/Features/Nod/NodComposerView.swift @@ -22,7 +22,6 @@ struct NodComposerView: View { onEscape: { store.send(.escapePressed) }, onTab: chooseFirstMentionAlternate ) - .frame(minHeight: 20, maxHeight: 140) .fixedSize(horizontal: false, vertical: true) chipRow } @@ -355,29 +354,50 @@ struct NodComposerTextView: NSViewRepresentable { /// Returns whether it consumed the ⇥. var onTab: () -> Bool + static let font = NSFont.systemFont(ofSize: 13) + /// The box grows with what is typed up to this many lines, then scrolls. + static let maximumVisibleLines = 5 + + /// The box's height for text laid out `usedHeight` tall: at least one line, at most + /// `maximumVisibleLines`. + static func height(forUsedHeight usedHeight: CGFloat, lineHeight: CGFloat) -> CGFloat { + min(max(usedHeight, lineHeight), lineHeight * CGFloat(maximumVisibleLines)) + } + + static var lineHeight: CGFloat { NSLayoutManager().defaultLineHeight(for: font) } + func makeCoordinator() -> Coordinator { Coordinator(self) } func makeNSView(context: Context) -> NSScrollView { - let textView = ComposerTextView() + let textView = ComposerTextView(frame: .zero) textView.delegate = context.coordinator textView.coordinator = context.coordinator textView.isRichText = false textView.allowsUndo = true textView.drawsBackground = false - textView.font = .systemFont(ofSize: 13) + textView.font = Self.font textView.textColor = NSColor.white.withAlphaComponent(0.92) textView.insertionPointColor = .white textView.textContainerInset = .zero textView.textContainer?.lineFragmentPadding = 0 + // Without a maximum size an NSTextView made in code never grows past its first line, + // so the box got taller while the text inside stayed one line high. + textView.minSize = .zero + textView.maxSize = NSSize( + width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude) textView.isVerticallyResizable = true textView.isHorizontallyResizable = false textView.autoresizingMask = [.width] + textView.textContainer?.containerSize = NSSize( + width: 0, height: CGFloat.greatestFiniteMagnitude) textView.textContainer?.widthTracksTextView = true textView.placeholder = placeholder - let scroll = NSScrollView() + let scroll = ComposerScrollView() scroll.drawsBackground = false - scroll.hasVerticalScroller = false + scroll.hasVerticalScroller = true + scroll.autohidesScrollers = true + scroll.scrollerStyle = .overlay scroll.documentView = textView return scroll } @@ -401,11 +421,13 @@ struct NodComposerTextView: NSViewRepresentable { guard let textView = nsView.documentView as? NSTextView, let container = textView.textContainer, let manager = textView.layoutManager else { return nil } - let width = proposal.width ?? 400 + let width = proposal.width ?? max(nsView.frame.width, 1) container.containerSize = NSSize(width: width, height: .greatestFiniteMagnitude) manager.ensureLayout(for: container) - let height = max(manager.usedRect(for: container).height, 17) - return CGSize(width: width, height: min(height, 140)) + return CGSize( + width: width, + height: Self.height( + forUsedHeight: manager.usedRect(for: container).height, lineHeight: Self.lineHeight)) } final class Coordinator: NSObject, NSTextViewDelegate { @@ -416,6 +438,8 @@ struct NodComposerTextView: NSViewRepresentable { func textDidChange(_ notification: Notification) { guard let textView = notification.object as? NSTextView else { return } parent.text = textView.string + // Past five lines the box scrolls; keep the line being typed in view. + textView.scrollRangeToVisible(textView.selectedRange()) } func textView(_ textView: NSTextView, doCommandBy selector: Selector) -> Bool { @@ -439,6 +463,24 @@ struct NodComposerTextView: NSViewRepresentable { } } + /// Typing a new line grows the text view before SwiftUI grows the box, and AppKit scrolls + /// to the caret in between; the offset outlived the resize and pushed a line out of view. + /// After every resize the box shows everything when it fits, and the caret when it does not. + final class ComposerScrollView: NSScrollView { + override func layout() { + super.layout() + guard let textView = documentView as? NSTextView else { return } + if textView.frame.height <= contentView.bounds.height + 0.5 { + if contentView.bounds.origin != .zero { + contentView.scroll(to: .zero) + reflectScrolledClipView(contentView) + } + } else { + textView.scrollRangeToVisible(textView.selectedRange()) + } + } + } + final class ComposerTextView: NSTextView { weak var coordinator: Coordinator? var placeholder = "" diff --git a/graphcode/Tests/NodChatRenderTests.swift b/graphcode/Tests/NodChatRenderTests.swift index fabc857c..7b02b01f 100644 --- a/graphcode/Tests/NodChatRenderTests.swift +++ b/graphcode/Tests/NodChatRenderTests.swift @@ -186,6 +186,26 @@ struct NodChatRenderTests { try render("7-composer", width: 720, height: 700, column) } + /// Design 2's composer grows with the draft up to five lines, then scrolls. + @Test + func theComposerGrowsToFiveLines() throws { + let two = store(.monetization) { + $0.draft = + "Fix /export and add a test that hits the limit.\nAlso log when a request is blocked." + } + let seven = store(.monetization) { + $0.draft = (1...7).map { "Line \($0) of a long instruction for Nod." }.joined(separator: "\n") + } + let column = VStack(spacing: 24) { + NodComposerView(store: two) + NodComposerView(store: seven) + } + .padding(20) + .frame(maxHeight: .infinity, alignment: .top) + .background(NodStyle.paneBackground) + try render("10-composer-lines", width: 720, height: 360, column) + } + @Test func theGraphLayerSlots() throws { var log = NodLog.monetization diff --git a/graphcode/Tests/NodComposerHeightTests.swift b/graphcode/Tests/NodComposerHeightTests.swift new file mode 100644 index 00000000..4ab010d4 --- /dev/null +++ b/graphcode/Tests/NodComposerHeightTests.swift @@ -0,0 +1,138 @@ +import AppKit +import ComposableArchitecture +import SwiftUI +import Testing + +@testable import graphcode + +@MainActor +@Suite +struct NodComposerHeightTests { + private let line = NodComposerTextView.lineHeight + + @Test + func theBoxGrowsLineByLineUpToFiveThenStops() { + #expect(NodComposerTextView.height(forUsedHeight: 0, lineHeight: line) == line) + #expect(NodComposerTextView.height(forUsedHeight: line * 3, lineHeight: line) == line * 3) + #expect(NodComposerTextView.height(forUsedHeight: line * 12, lineHeight: line) == line * 5) + } + + /// The draft lives in SwiftUI state, as it does in the pane, so typing goes the live + /// path: text view → binding → re-measure. + private struct Harness: View { + @State var draft = "" + var body: some View { + NodComposerTextView( + text: $draft, placeholder: "", onReturn: {}, onSteer: {}, onEscape: {}, + onTab: { false } + ) + .frame(width: 320) + .fixedSize(horizontal: false, vertical: true) + } + } + + private func type(_ text: String) throws -> (box: NSScrollView, text: NSTextView) { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 320, height: 400), styleMask: [.titled], + backing: .buffered, defer: false) + let host = NSHostingView(rootView: Harness()) + window.contentView = host + host.layoutSubtreeIfNeeded() + let box = try #require(Self.scrollView(in: host)) + let textView = try #require(box.documentView as? NSTextView) + window.makeFirstResponder(textView) + for character in text { + textView.insertText(String(character), replacementRange: textView.selectedRange()) + RunLoop.main.run(until: Date().addingTimeInterval(0.005)) + host.layoutSubtreeIfNeeded() + } + return (box, textView) + } + + /// The pane's own composer, its draft in the chat store, typed into a key at a time — + /// with ⇧⏎ for a new line, the way a person writes a second line. + private func typeIntoComposer(_ lines: [String]) throws -> (box: NSScrollView, text: NSTextView) { + let store = Store( + initialState: NodChatFeature.State( + nodeID: UUID(), stateDirectory: URL(fileURLWithPath: "/tmp/nod-composer"), + loopTitle: "Composer", loopType: .sketch, goal: nil) + ) { NodChatFeature() } + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 520, height: 500), styleMask: [.titled], + backing: .buffered, defer: false) + let host = NSHostingView(rootView: NodComposerView(store: store).frame(width: 480)) + window.contentView = host + host.layoutSubtreeIfNeeded() + let box = try #require(Self.scrollView(in: host)) + let textView = try #require(box.documentView as? NSTextView) + window.makeFirstResponder(textView) + for (index, line) in lines.enumerated() { + if index > 0 { + textView.insertText("\n", replacementRange: textView.selectedRange()) + settle(host) + } + for character in line { + textView.insertText(String(character), replacementRange: textView.selectedRange()) + settle(host) + } + } + return (box, textView) + } + + private func settle(_ host: NSView) { + RunLoop.main.run(until: Date().addingTimeInterval(0.01)) + host.layoutSubtreeIfNeeded() + } + + /// The live failure: the box had been scrolled while the text outgrew it, and the offset + /// survived the resize. A box whose text fits shows it from the top. + @Test + func aBoxWhoseTextFitsIsNeverLeftScrolled() throws { + let (box, _) = try type("first\nsecond") + box.contentView.scroll(to: NSPoint(x: 0, y: line)) + box.needsLayout = true + box.layoutSubtreeIfNeeded() + + #expect(visible(box).minY <= 0.5) + } + + @Test + func theSecondLineTypedIntoThePanesComposerIsOnScreen() throws { + let (box, text) = try typeIntoComposer(["what does", "this do"]) + + #expect(text.string == "what does\nthis do") + #expect(abs(box.frame.height - line * 2) < 2) + #expect(visible(box).height >= line * 2 - 1) + #expect(visible(box).minY <= 0.5) + } + + /// The visible part of the text view, in its own coordinates. + private func visible(_ box: NSScrollView) -> NSRect { box.contentView.documentVisibleRect } + + private static func scrollView(in view: NSView) -> NSScrollView? { + if let scroll = view as? NSScrollView { return scroll } + return view.subviews.lazy.compactMap { scrollView(in: $0) }.first + } + + /// The bug: the box grew, but the text view inside stayed one line tall, so the second + /// line of a draft was never on screen. + @Test + func everyLineOfAShortDraftIsVisible() throws { + let (box, text) = try type("first\nsecond\nthird") + + #expect(abs(box.frame.height - line * 3) < 2) + #expect(text.frame.height >= line * 3 - 1) + #expect(visible(box).height >= line * 3 - 1) + } + + @Test + func aLongDraftShowsFiveLinesAndScrolls() throws { + let (box, text) = try type((1...10).map { "line \($0)" }.joined(separator: "\n")) + + #expect(abs(box.frame.height - line * 5) < 2) + #expect(text.frame.height >= line * 10 - 1) + #expect(box.hasVerticalScroller) + // The caret is on the last line, so that is the one in view. + #expect(visible(box).maxY >= text.frame.height - 1) + } +}