Skip to content

Commit 40dff89

Browse files
afollestadcodex
andcommitted
Refresh block layout metrics after editor width changes
Coalesce width reconciliation after AppKit layout so wrapped rows and subsequently mounted blocks use consistent geometry. Co-authored-by: Codex <codex@openai.com>
1 parent 0a0aa9f commit 40dff89

3 files changed

Lines changed: 139 additions & 0 deletions

File tree

Sources/BlockInputKit/AppKit/BlockInputView+DocumentWidth.swift

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ extension BlockInputView {
4747
// already invalidates from — doing it here would feed a layout pass back into itself.
4848
if widthChanged {
4949
collectionView.collectionViewLayout?.invalidateLayout()
50+
scheduleFlowLayoutWidthSync()
5051
updateVisibleItemWidthsForCurrentWidth()
5152
updatePlaceholderLayout()
5253
invalidatePreferredHeight()
@@ -88,6 +89,8 @@ extension BlockInputView {
8889
guard let firstIndex = staleItems.first?.index else {
8990
return
9091
}
92+
// Autoresizing can update the document width before its bounds observer sees it.
93+
scheduleFlowLayoutWidthSync()
9194
for indexedItem in staleItems {
9295
guard let block = block(at: indexedItem.index) else {
9396
continue
@@ -103,6 +106,32 @@ extension BlockInputView {
103106
reflowVisibleItemsAfterHeightChange(startingAt: firstIndex)
104107
}
105108

109+
/// AppKit can retain pre-resize delegate metrics during layout, even after visible rows
110+
/// rewrap. Refresh after that pass unwinds so newly mounted rows use the same geometry.
111+
private func scheduleFlowLayoutWidthSync() {
112+
guard !isFlowLayoutWidthSyncScheduled else {
113+
return
114+
}
115+
isFlowLayoutWidthSyncScheduled = true
116+
DispatchQueue.main.async { [weak self] in
117+
guard let self else {
118+
return
119+
}
120+
syncCollectionViewDocumentSizeForVisibleBounds()
121+
isFlowLayoutWidthSyncScheduled = false
122+
if let flowLayout = collectionView.collectionViewLayout as? NSCollectionViewFlowLayout {
123+
let context = NSCollectionViewFlowLayoutInvalidationContext()
124+
context.invalidateFlowLayoutDelegateMetrics = true
125+
flowLayout.invalidateLayout(with: context)
126+
} else {
127+
collectionView.collectionViewLayout?.invalidateLayout()
128+
}
129+
collectionView.layoutSubtreeIfNeeded()
130+
syncCollectionViewDocumentSizeForVisibleBounds()
131+
invalidatePreferredHeight()
132+
}
133+
}
134+
106135
/// Height the document view needs, floored at the viewport.
107136
///
108137
/// Deliberately not `currentDocumentContentHeight()`: that one maxes with the scroll view's

Sources/BlockInputKit/AppKit/BlockInputView.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ public final class BlockInputView: NSView {
4646
var heightSizing: BlockInputEditorHeightSizing?, lastReportedPreferredHeight: CGFloat?
4747
var isPreferredHeightCallbackScheduled = false
4848
var isFlowLayoutShrinkSyncScheduled = false
49+
var isFlowLayoutWidthSyncScheduled = false
4950
var imageLoader: any BlockInputImageLoading = BlockInputDefaultImageLoader()
5051
var imageDiskCache: (any BlockInputImageDiskCaching)?
5152
var imageBaseURL: URL?, fileBaseURL: URL?
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import AppKit
2+
import XCTest
3+
@testable import BlockInputKit
4+
5+
extension BlockInputViewNarrowResizeTests {
6+
func testWrappedRowsStaySeparatedAfterOpeningScrollingAndResizing() async throws {
7+
for scrollerStyle in [NSScroller.Style.legacy, .overlay] {
8+
let view = BlockInputView(frame: NSRect(x: 0, y: 0, width: 572, height: 480))
9+
view.scrollView.scrollerStyle = scrollerStyle
10+
view.configure(BlockInputConfiguration(document: Self.reflowDocument, allowsBlockReordering: false))
11+
let window = NSWindow(contentRect: view.frame, styleMask: [.titled], backing: .buffered, defer: false)
12+
window.contentView = view
13+
defer { window.contentView = nil }
14+
15+
for width in [CGFloat(572), 672, 572] {
16+
if window.contentView?.bounds.width != width {
17+
window.setContentSize(NSSize(width: width, height: 480))
18+
}
19+
await settleReflow(view, window: window)
20+
let maximumOffset = max(0, view.collectionView.frame.height - view.scrollView.contentView.bounds.height)
21+
let offsets = Array(stride(from: 0.0, through: maximumOffset, by: 120)) + [maximumOffset, 0]
22+
for offset in offsets {
23+
view.scrollView.contentView.scroll(to: NSPoint(x: 0, y: offset))
24+
view.scrollView.reflectScrolledClipView(view.scrollView.contentView)
25+
await settleReflow(view, window: window)
26+
try assertVisibleRowsFit(view, context: "width \(width), scroller \(scrollerStyle), offset \(offset)")
27+
}
28+
}
29+
}
30+
}
31+
32+
func testWidthReflowPreservesMountedSelectionAndScrollOffset() async throws {
33+
let mounted = makeMountedBlockInputView(configuration: BlockInputConfiguration(
34+
document: Self.reflowDocument,
35+
allowsBlockReordering: false
36+
), size: NSSize(width: 572, height: 480))
37+
defer { mounted.window.contentView = nil }
38+
await settleReflow(mounted.view, window: mounted.window)
39+
mounted.view.scrollView.contentView.scroll(to: NSPoint(x: 0, y: 80))
40+
await settleReflow(mounted.view, window: mounted.window)
41+
let item = try XCTUnwrap(mounted.view.collectionView.item(at: IndexPath(item: 2, section: 0)) as? BlockInputBlockItem)
42+
let selection = NSRange(location: 5, length: 8)
43+
XCTAssertTrue(mounted.window.makeFirstResponder(item.textView))
44+
item.textView.setSelectedRange(selection)
45+
let offset = mounted.view.scrollView.contentView.bounds.origin
46+
47+
// Only the final width should reach the deferred reconciliation.
48+
mounted.window.setContentSize(NSSize(width: 672, height: 480))
49+
mounted.view.layoutSubtreeIfNeeded()
50+
mounted.window.setContentSize(NSSize(width: 620, height: 480))
51+
await settleReflow(mounted.view, window: mounted.window)
52+
53+
XCTAssertTrue(mounted.view.collectionView.item(at: IndexPath(item: 2, section: 0)) === item)
54+
XCTAssertTrue(mounted.window.firstResponder === item.textView)
55+
XCTAssertEqual(item.textView.selectedRange(), selection)
56+
XCTAssertEqual(mounted.view.scrollView.contentView.bounds.origin, offset)
57+
try assertVisibleRowsFit(mounted.view, context: "Coalesced width changes")
58+
}
59+
60+
private func settleReflow(_ view: BlockInputView, window: NSWindow) async {
61+
// Width reconciliation can schedule another pass when a legacy scrollbar appears.
62+
for _ in 0..<4 {
63+
view.layoutSubtreeIfNeeded()
64+
window.displayIfNeeded()
65+
await withCheckedContinuation { continuation in
66+
DispatchQueue.main.async { continuation.resume() }
67+
}
68+
}
69+
}
70+
71+
private func assertVisibleRowsFit(_ view: BlockInputView, context: String) throws {
72+
let items = view.collectionView.visibleItems().compactMap { $0 as? BlockInputBlockItem }.sorted {
73+
$0.view.frame.minY < $1.view.frame.minY
74+
}
75+
XCTAssertFalse(items.isEmpty, context)
76+
for item in items {
77+
let indexPath = try XCTUnwrap(view.collectionView.indexPath(for: item))
78+
let attributes = try XCTUnwrap(view.layout.layoutAttributesForItem(at: indexPath))
79+
XCTAssertEqual(attributes.frame.width, item.view.frame.width, accuracy: 0.5, context)
80+
let block = try XCTUnwrap(item.renderedBlock)
81+
let textContainer = try XCTUnwrap(item.textView.textContainer)
82+
let layoutManager = try XCTUnwrap(item.textView.layoutManager)
83+
layoutManager.ensureLayout(for: textContainer)
84+
let metrics = BlockInputBlockItem.verticalMetrics(for: block)
85+
let renderedHeight = ceil(layoutManager.usedRect(for: textContainer).maxY)
86+
+ metrics.topContentInset + metrics.bottomContentInset
87+
XCTAssertLessThanOrEqual(renderedHeight, item.view.frame.height + 0.5, context)
88+
}
89+
for (previous, next) in zip(items, items.dropFirst()) {
90+
XCTAssertLessThanOrEqual(previous.view.frame.maxY, next.view.frame.minY + 0.5, context)
91+
}
92+
}
93+
94+
private static var reflowDocument: BlockInputDocument {
95+
let bullets = [
96+
"Check correctness, security, performance, readability, and maintainability. Comment on the changed lines, "
97+
+ "not pre-existing code, unless the change breaks it.",
98+
"Only include actionable findings that point at a specific problem or concrete suggestion, framed as a question "
99+
+ "where that reads naturally. No praise, no \"looks good\" filler: a comment that is not actionable is omitted "
100+
+ "entirely, not softened.",
101+
"Decide deliberately whether each minor finding earns a comment; note in your reply any you considered and "
102+
+ "left out rather than dropping them silently."
103+
]
104+
return BlockInputDocument(blocks: (0..<6).flatMap { section in
105+
[BlockInputBlock(kind: .heading(level: 2), text: "Section \(section)")]
106+
+ bullets.map { BlockInputBlock(kind: .bulletedListItem, text: $0) }
107+
})
108+
}
109+
}

0 commit comments

Comments
 (0)