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
27 changes: 20 additions & 7 deletions graphcode/Sources/Features/Nod/NodComposerView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
3 changes: 2 additions & 1 deletion graphcode/Sources/Features/Nod/NodStyle.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
35 changes: 35 additions & 0 deletions graphcode/Tests/NodComposerHeightTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down
Loading