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) + } +}