From 663be18406d6933fd3d1245a315b886038e4516a Mon Sep 17 00:00:00 2001 From: foxnne Date: Fri, 16 Oct 2026 10:08:49 -0500 Subject: [PATCH] markdown: an inline render no longer shrinks for a frame as it changes `renderTopLevel` pinned a block's emitted height to the height table even with no viewport, where every block is laid out every frame and a pin has nothing to hide. Each edit re-parses, which leaves the edited block with its estimate (biased low) or the deferred height its first frame filed, and the next frame's pin emitted that in place of its real height. Pins now apply only to a virtualized render. Co-Authored-By: Claude Opus 5.5 --- plugins/markdown/src/md/render_ast.zig | 12 ++++++++++-- tests/integration.zig | 18 ++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/plugins/markdown/src/md/render_ast.zig b/plugins/markdown/src/md/render_ast.zig index 8a13d0fd7..795dca39a 100644 --- a/plugins/markdown/src/md/render_ast.zig +++ b/plugins/markdown/src/md/render_ast.zig @@ -1949,8 +1949,16 @@ fn renderTopLevel(doc_node: ast.Node, ids: *IdGen, ctx: RenderContext) void { // everything below jumps, and next frame's anchor "corrects" it — the flash that // only happens when scrolling up. Pin the *emitted* height to the table; `record` still // learns the real size from the unconstrained inner box (see `measured` below). - const pin_for_above = known_h > 0 and !width_in_flux and y <= anchor_y; - const pin_h: ?f32 = if (known_h > 0 and !width_in_flux and + // + // Only a virtualized render pins. With no viewport (`drawInline`: a chat bubble, a + // tooltip) every block is laid out every frame, so no block comes back from a spacer and + // there is nothing for a pin to hide. There a pin does harm: an edit re-parses, which + // leaves the edited block with an estimate (biased low) or the deferred height its first + // frame filed, and next frame's pin emits that in place of its real height. The caller + // sees the block shrink by a line and grow back; a chat streaming a reply re-parses on + // every chunk, so its conversation bounced by a line each time. + const pin_for_above = virtualize and known_h > 0 and !width_in_flux and y <= anchor_y; + const pin_h: ?f32 = if (virtualize and known_h > 0 and !width_in_flux and (pin_for_above or (frozen and block_state != .estimated) or pinnable)) known_h else null; diff --git a/tests/integration.zig b/tests/integration.zig index 0cbe048a8..87b276feb 100644 --- a/tests/integration.zig +++ b/tests/integration.zig @@ -7281,6 +7281,24 @@ test "headless: the whole editor comes up, opens a file, plays the tour and goes for (0..3) |_| _ = try dvui.testing.step(inlineMarkdownFrame); try std.testing.expect(inline_md_height > one_para * 2); inline_md.?.release(0x1d1e); + + // Streamed in a few bytes a frame, as a chat reply arrives, it only ever grows. Each + // re-parse left the edited block with an estimate that a pin then emitted for a frame, so + // a streaming reply shrank by a line and grew back, over and over. + const reply = "Sure. Let me look at how the view lays out a message while it streams, because that is where the motion comes from.\n\nThe **scroll area** sizes its content from last frame's sizes, and a message that grows by a line is a frame late.\n\n- one point\n- and a second that goes on long enough to wrap at this width, and then some more"; + var last: f32 = 0; + var n: usize = 0; + while (n < reply.len) { + n = @min(reply.len, n + 9); + inline_md_text = reply[0..n]; + _ = try dvui.testing.step(inlineMarkdownFrame); + if (inline_md_height + 0.5 < last) { + std.debug.print("inline markdown shrank from {d:.1} to {d:.1} at byte {d}\n", .{ last, inline_md_height, n }); + return error.TestUnexpectedResult; + } + last = inline_md_height; + } + inline_md.?.release(0x1d1e); } // The `plugins` service: what each plugin's loading did. A built-in is loaded; a reload that