Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 50 additions & 8 deletions graphcode/Sources/Features/Nod/NodComposerView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ struct NodComposerView: View {
onEscape: { store.send(.escapePressed) },
onTab: chooseFirstMentionAlternate
)
.frame(minHeight: 20, maxHeight: 140)
.fixedSize(horizontal: false, vertical: true)
chipRow
}
Expand Down Expand Up @@ -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
}
Expand All @@ -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 {
Expand All @@ -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 {
Expand All @@ -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 = ""
Expand Down
20 changes: 20 additions & 0 deletions graphcode/Tests/NodChatRenderTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
138 changes: 138 additions & 0 deletions graphcode/Tests/NodComposerHeightTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading