diff --git a/graphcode/Sources/Features/Nod/NodComposerView.swift b/graphcode/Sources/Features/Nod/NodComposerView.swift index 4a130d10..f5e6e1b2 100644 --- a/graphcode/Sources/Features/Nod/NodComposerView.swift +++ b/graphcode/Sources/Features/Nod/NodComposerView.swift @@ -418,16 +418,29 @@ struct NodComposerTextView: NSViewRepresentable { func sizeThatFits(_ proposal: ProposedViewSize, nsView: NSScrollView, context: Context) -> CGSize? { - guard let textView = nsView.documentView as? NSTextView, - let container = textView.textContainer, let manager = textView.layoutManager - else { return nil } - let width = proposal.width ?? max(nsView.frame.width, 1) - container.containerSize = NSSize(width: width, height: .greatestFiniteMagnitude) - manager.ensureLayout(for: container) + guard let textView = nsView.documentView as? NSTextView else { return nil } + let proposed = proposal.width.flatMap { $0.isFinite && $0 > 0 ? $0 : nil } + let width = proposed ?? max(nsView.frame.width, 1) return CGSize( width: width, height: Self.height( - forUsedHeight: manager.usedRect(for: container).height, lineHeight: Self.lineHeight)) + forUsedHeight: Self.usedHeight(of: textView.string, width: width), + lineHeight: Self.lineHeight)) + } + + /// Measured on a scratch layout, never the live one: SwiftUI asks for sizes it will not + /// use, and resizing the live container for one of those left the box a line tall while + /// the text wrapped to four, so narrowing the pane cut the draft off. + static func usedHeight(of text: String, width: CGFloat) -> CGFloat { + let storage = NSTextStorage(string: text, attributes: [.font: font]) + let manager = NSLayoutManager() + let container = NSTextContainer( + size: NSSize(width: width, height: .greatestFiniteMagnitude)) + container.lineFragmentPadding = 0 + manager.addTextContainer(container) + storage.addLayoutManager(manager) + manager.ensureLayout(for: container) + return manager.usedRect(for: container).height } final class Coordinator: NSObject, NSTextViewDelegate { diff --git a/graphcode/Sources/Features/Nod/NodStyle.swift b/graphcode/Sources/Features/Nod/NodStyle.swift index 4997a3cc..bdd475c4 100644 --- a/graphcode/Sources/Features/Nod/NodStyle.swift +++ b/graphcode/Sources/Features/Nod/NodStyle.swift @@ -4,7 +4,8 @@ import SwiftUI /// plain GraphCode chrome, action blue for actions, orange for "needs you", and muted text /// no dimmer than 55% white (review round 2). enum NodStyle { - static let paneBackground = Color(red: 0.118, green: 0.118, blue: 0.133) // #1e1e22 + /// The graph canvas's own tone, so moving between the canvas and a Nod chat keeps one ground. + static let paneBackground = Theme.canvasBackground static let cardBackground = Color(red: 0.098, green: 0.098, blue: 0.110) // #19191c static let composerBackground = Color(red: 0.149, green: 0.149, blue: 0.165) // #26262a static let bubble = Color.white.opacity(0.07) diff --git a/graphcode/Tests/NodComposerHeightTests.swift b/graphcode/Tests/NodComposerHeightTests.swift index 4ab010d4..31ca01b7 100644 --- a/graphcode/Tests/NodComposerHeightTests.swift +++ b/graphcode/Tests/NodComposerHeightTests.swift @@ -125,6 +125,41 @@ struct NodComposerHeightTests { #expect(visible(box).height >= line * 3 - 1) } + /// Narrowing the pane after typing left the box one line tall while the draft wrapped + /// to four: only its last line showed, cut off from the rest. + @Test(arguments: [(520, 360), (760, 360)]) + func aDraftStillFitsAfterThePaneNarrows(from: CGFloat, to: CGFloat) throws { + 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: from, height: 300), styleMask: [.titled], + backing: .buffered, defer: false) + let host = NSHostingView(rootView: AnyView(NodComposerView(store: store).frame(width: from))) + window.contentView = host + host.layoutSubtreeIfNeeded() + let box = try #require(Self.scrollView(in: host)) + let textView = try #require(box.documentView as? NSTextView) + window.makeFirstResponder(textView) + let draft = + "Explain why the goal loop keeps restarting after the predicate passes, and check " + + "whether the presence hook ever reports idle before the daemon samples it" + for character in draft { + textView.insertText(String(character), replacementRange: textView.selectedRange()) + settle(host) + } + + host.rootView = AnyView(NodComposerView(store: store).frame(width: to)) + window.setContentSize(NSSize(width: to, height: 300)) + for _ in 0..<5 { settle(host) } + + #expect(textView.frame.height > line * 2) + #expect(abs(box.frame.height - textView.frame.height) < 2) + #expect(visible(box).height >= textView.frame.height - 1) + } + @Test func aLongDraftShowsFiveLinesAndScrolls() throws { let (box, text) = try type((1...10).map { "line \($0)" }.joined(separator: "\n"))