From 9852b5631515dc6efca1d86b5e92e4fa215cc5ba Mon Sep 17 00:00:00 2001 From: BambinoSK Date: Sat, 17 Oct 2026 02:16:36 +0200 Subject: [PATCH] fix(ui): stop code blocks from widening chat bubbles and make their scrollbar usable A fenced code block with one long line laid the whole document body, prose included, out at the code width, so every prose line clipped at the bubble edge (#1180). ScrollableCodeView hides its preferred X span but not its minimum, and BoxView.layoutMinorAxis never allocates a child below its minimum, so the uncapped code-line minimum propagated up through the whole box hierarchy. - ScrollableCodeView.getMinimumSpan(X_AXIS) = 0, mirroring the preferred-span override that already exists - scrollbar strip is now interactive: thumb drag, track-click jump, events intercepted before the caret so no text gets selected - scrolling invalidates the paint cache (stale-frame blit made every scroll visually dead) and is baked into getOffset geometry so selection hit-testing matches the painted pixels - Shift+wheel scrolls an overflowing block, plain wheel always passes through to the chat; a widening resize re-clamps or clears the scroll so no stale offset survives - a selection dragged past the block edge auto-scrolls the block - an in-progress strip drag is dropped when the document re-renders Tests: overflow matrix incl. the streaming path and the real thinking-bubble hierarchy from #1180, interaction coverage for the scrollbar, resize and non-overflowing wheel cases. --- .../agentbridge/ui/NativeMarkdownPane.kt | 285 ++++++++++++++--- .../ui/NativeChatThinkingBubbleWidthTest.java | 201 ++++++++++++ ...tiveMarkdownCodeScrollInteractionTest.java | 299 ++++++++++++++++++ .../ui/NativeMarkdownOverflowTest.java | 261 +++++++++++++++ .../ui/NativeMarkdownPaneTestBase.java | 23 ++ 5 files changed, 1033 insertions(+), 36 deletions(-) create mode 100644 plugin-core/src/test/java/com/github/catatafishen/agentbridge/ui/NativeChatThinkingBubbleWidthTest.java create mode 100644 plugin-core/src/test/java/com/github/catatafishen/agentbridge/ui/NativeMarkdownCodeScrollInteractionTest.java create mode 100644 plugin-core/src/test/java/com/github/catatafishen/agentbridge/ui/NativeMarkdownOverflowTest.java create mode 100644 plugin-core/src/test/java/com/github/catatafishen/agentbridge/ui/NativeMarkdownPaneTestBase.java diff --git a/plugin-core/src/main/java/com/github/catatafishen/agentbridge/ui/NativeMarkdownPane.kt b/plugin-core/src/main/java/com/github/catatafishen/agentbridge/ui/NativeMarkdownPane.kt index eb83a4587..0e79e7a16 100644 --- a/plugin-core/src/main/java/com/github/catatafishen/agentbridge/ui/NativeMarkdownPane.kt +++ b/plugin-core/src/main/java/com/github/catatafishen/agentbridge/ui/NativeMarkdownPane.kt @@ -9,8 +9,8 @@ import com.intellij.ui.JBColor import com.intellij.util.ui.JBUI import com.intellij.util.ui.UIUtil import java.awt.* +import java.awt.event.MouseEvent import java.awt.event.MouseWheelEvent -import java.awt.geom.AffineTransform import java.awt.image.BufferedImage import javax.swing.JEditorPane import javax.swing.JScrollPane @@ -129,27 +129,7 @@ class NativeMarkdownPane(private val fileNavigator: FileNavigator) : JEditorPane kit.styleSheet = createStyleSheet() editorKit = kit - addMouseWheelListener { e -> - if (e.isShiftDown) { - val view = findScrollableCodeViewAt(e.point) ?: return@addMouseWheelListener - e.consume() - view.scroll((e.wheelRotation * JBUI.scale(15)).toInt()) - repaint() - } else { - // Registering any MouseWheelListener blocks automatic Swing propagation, - // so forward vertical scroll events explicitly to the enclosing scroll pane. - val sp = SwingUtilities.getAncestorOfClass(JScrollPane::class.java, this) as? JScrollPane - ?: return@addMouseWheelListener - val pt = SwingUtilities.convertPoint(this, e.point, sp) - sp.dispatchEvent( - MouseWheelEvent( - sp, e.id, e.`when`, e.modifiersEx, pt.x, pt.y, - e.xOnScreen, e.yOnScreen, e.clickCount, e.isPopupTrigger, - e.scrollType, e.scrollAmount, e.wheelRotation, e.preciseWheelRotation - ) - ) - } - } + addMouseWheelListener { e -> handleWheel(e) } addHyperlinkListener { e -> if (e.eventType == HyperlinkEvent.EventType.ACTIVATED) { @@ -221,6 +201,13 @@ class NativeMarkdownPane(private val fileNavigator: FileNavigator) : JEditorPane // (guarded by `if (length > 0)`) and goes straight to insertString(). document = (editorKit as HTMLEditorKit).createDefaultDocument() text = "$html" + // The fresh document builds a new view tree, so an in-progress strip drag + // would keep scrolling a detached view; drop the interaction state. + stripDrag = null + if (stripCursorActive) { + cursor = Cursor.getDefaultCursor() + stripCursorActive = false + } } /** @@ -511,6 +498,163 @@ class NativeMarkdownPane(private val fileNavigator: FileNavigator) : JEditorPane return ss } + /** + * Clears the cached rendered frame so the next paint re-renders. Needed after + * code-block horizontal scroll changes: [contentVersion] only tracks content + * edits, so without this the paint cache would keep blitting the frame from + * before the scroll and the block would appear frozen. + */ + private fun invalidatePaintCache() { + paintCache = null + } + + /** + * Wheel routing: Shift+wheel over a code block scrolls it horizontally; every + * other wheel event is forwarded to the enclosing scroll pane so the chat keeps + * scrolling vertically. A plain wheel must never be trapped by a code block. + */ + internal fun handleWheel(e: MouseWheelEvent) { + if (e.isShiftDown) { + val view = findScrollableCodeViewAt(e.point) + if (view != null && view.isScrollableNow()) { + e.consume() + view.scroll((e.wheelRotation * JBUI.scale(15)).toInt()) + invalidatePaintCache() + repaint() + return + } + } + // Registering any MouseWheelListener blocks automatic Swing propagation, + // so forward vertical scroll events explicitly to the enclosing scroll pane. + val sp = SwingUtilities.getAncestorOfClass(JScrollPane::class.java, this) as? JScrollPane + ?: return + val pt = SwingUtilities.convertPoint(this, e.point, sp) + sp.dispatchEvent( + MouseWheelEvent( + sp, e.id, e.`when`, e.modifiersEx, pt.x, pt.y, + e.xOnScreen, e.yOnScreen, e.clickCount, e.isPopupTrigger, + e.scrollType, e.scrollAmount, e.wheelRotation, e.preciseWheelRotation + ) + ) + } + + /** State of an in-progress drag on a code block's scrollbar strip. */ + private class StripDrag( + val view: ScrollableCodeView, + val grabOffsetInThumb: Int, + val trackX: Int, + val trackW: Int, + val thumbW: Int, + val maxScroll: Int, + ) + + private var stripDrag: StripDrag? = null + private var stripCursorActive = false + + /** + * Intercepts mouse events aimed at a code block's scrollbar strip BEFORE any + * listener (including the caret's selection handlers) sees them, so dragging the + * strip scrolls the block instead of selecting text. Events not aimed at a strip + * pass through untouched. + */ + override fun processMouseEvent(e: MouseEvent) { + if (handleStripMouse(e)) return + super.processMouseEvent(e) + } + + override fun processMouseMotionEvent(e: MouseEvent) { + if (handleStripMouse(e)) return + if (e.id == MouseEvent.MOUSE_DRAGGED && SwingUtilities.isLeftMouseButton(e)) { + handleSelectionAutoScroll(e) + } + super.processMouseMotionEvent(e) + } + + /** + * Auto-scrolls a code block horizontally while the user drags a text selection + * inside it: dragging the selection past the block's right (or left) edge + * scrolls the block along with the selection, the way editors do. Never + * consumes the event; the caret still extends the selection, and because the + * scroll is part of the view geometry (ScrollableCodeView.getOffset), the + * selection endpoint maps to the text under the cursor. The further the cursor + * is dragged beyond the edge, the further the block scrolls. + */ + internal fun handleSelectionAutoScroll(e: MouseEvent) { + val view = findScrollableCodeViewAt(e.point) ?: return + if (!view.isScrollableNow()) return + val r = view.allocationBounds() ?: return + val margin = JBUI.scale(24) + val contentX = (e.x - r.x) + view.currentScrollX() + val target = when { + e.x > r.x + r.width - margin -> contentX - (r.width - margin) + e.x < r.x + margin -> contentX - margin + else -> return + } + val clamped = target.coerceIn(0, view.maxScrollNow()) + if (clamped != view.currentScrollX()) { + view.scrollTo(clamped) + invalidatePaintCache() + repaint() + } + } + + /** Returns true when the event was consumed by strip interaction. */ + internal fun handleStripMouse(e: MouseEvent): Boolean { + when (e.id) { + MouseEvent.MOUSE_PRESSED -> { + if (!SwingUtilities.isLeftMouseButton(e)) return false + val view = findScrollableCodeViewAt(e.point) ?: return false + if (!view.isStripHit(e.point)) return false + val geo = view.dragGeometry() ?: return false + val grab = if (e.x >= geo.thumbX && e.x <= geo.thumbX + geo.thumbW) { + e.x - geo.thumbX // grabbed the thumb: keep the grab point under the cursor + } else { + geo.thumbW / 2 // grabbed the track: center the thumb under the cursor + } + stripDrag = StripDrag(view, grab, geo.trackX, geo.trackW, geo.thumbW, geo.maxScroll) + // A press jumps the thumb immediately, so a plain click on the track + // scrolls; for a press on the thumb this is a no-op. + val avail = (geo.trackW - geo.thumbW).coerceAtLeast(1) + val x = (e.x - grab - geo.trackX).coerceIn(0, avail) + view.scrollTo(((x.toFloat() / avail) * geo.maxScroll).toInt()) + invalidatePaintCache() + repaint() + e.consume() + return true + } + MouseEvent.MOUSE_DRAGGED -> { + val d = stripDrag ?: return false + val avail = (d.trackW - d.thumbW).coerceAtLeast(1) + val x = (e.x - d.grabOffsetInThumb - d.trackX).coerceIn(0, avail) + d.view.scrollTo(((x.toFloat() / avail) * d.maxScroll).toInt()) + invalidatePaintCache() + repaint() + e.consume() + return true + } + MouseEvent.MOUSE_RELEASED, MouseEvent.MOUSE_CLICKED -> { + if (stripDrag == null) return false + stripDrag = null + e.consume() + return true + } + MouseEvent.MOUSE_MOVED -> { + // Hand cursor over the strip as the only visible affordance; restored + // to default once the cursor leaves it. Never consumes the event. + val hit = findScrollableCodeViewAt(e.point)?.isStripHit(e.point) == true + if (hit && !stripCursorActive) { + cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) + stripCursorActive = true + } else if (!hit && stripCursorActive) { + cursor = Cursor.getDefaultCursor() + stripCursorActive = false + } + return false + } + } + return false + } + /** Returns the [ScrollableCodeView] whose allocated area contains [pt], or null. */ private fun findScrollableCodeViewAt(pt: Point): ScrollableCodeView? { val ui = ui as? TextUI ?: return null @@ -583,8 +727,12 @@ private class ScrollableCodeView(elem: Element) : BlockView(elem, View.Y_AXIS) { /** Natural (unconstrained) content width — only valid after [setSize] has been called. */ private var naturalWidth = 0f + private var lastAllocWidth = 0f + private var lastPaintBounds: Rectangle? = null + companion object { - private const val SCROLLBAR_H = 6 // px — thin track + private const val SCROLLBAR_H = 6 // px, thin track + private const val STRIP_HIT_H = 14 // px, generous grab zone; the visual track stays 6px private const val SCROLLBAR_TRACK_ALPHA = 60 private const val SCROLLBAR_THUMB_ALPHA = 140 } @@ -593,6 +741,17 @@ private class ScrollableCodeView(elem: Element) : BlockView(elem, View.Y_AXIS) { override fun getPreferredSpan(axis: Int): Float = if (axis == X_AXIS) 0f else super.getPreferredSpan(axis) + /** + * Reports 0 on the X axis for the same reason as [getPreferredSpan]. + * [BoxView.layoutMinorAxis] never allocates a child less than its minimum + * span (spans = max(childMin, targetSpan)), and this view's children are + * uncapped inline views whose minimum is the longest code line, so without + * this override the minimum propagated up and the whole body laid out at + * the code width. + */ + override fun getMinimumSpan(axis: Int): Float = + if (axis == X_AXIS) 0f else super.getMinimumSpan(axis) + /** * Lays out children at their natural width (calling `super.getPreferredSpan` bypasses * our override and returns the real content width). Resets [scrollX] if content changed. @@ -612,25 +771,82 @@ private class ScrollableCodeView(elem: Element) : BlockView(elem, View.Y_AXIS) { scrollX = (scrollX + delta).coerceIn(0, maxScroll.toInt()) } - private var lastAllocWidth = 0f + /** Absolute scroll position, clamped; used by strip dragging. */ + fun scrollTo(x: Int) { + val maxScroll = (naturalWidth - lastAllocWidth).coerceAtLeast(0f) + scrollX = x.coerceIn(0, maxScroll.toInt()) + } + + fun maxScrollNow(): Int = (naturalWidth - lastAllocWidth).coerceAtLeast(0f).toInt() + + fun isScrollableNow(): Boolean = naturalWidth > lastAllocWidth + 1f + + /** Component-space drag strip (taller than the 6px visual track for easy grabbing). */ + fun stripHitZone(): Rectangle? = lastPaintBounds?.let { + Rectangle(it.x, it.y + it.height - JBUI.scale(STRIP_HIT_H), it.width, JBUI.scale(STRIP_HIT_H)) + } + + /** True when the component-space point lands on this view's drag strip. */ + fun isStripHit(pt: Point): Boolean = isScrollableNow() && stripHitZone()?.contains(pt) == true + + /** Current scroll position, for tests. */ + internal fun currentScrollX(): Int = scrollX + + /** Thumb/track geometry in component space; null when the block cannot scroll. */ + fun dragGeometry(): DragGeometry? { + val r = lastPaintBounds ?: return null + val maxScroll = maxScrollNow() + if (maxScroll <= 0) return null + val thumbFrac = (r.width / naturalWidth).coerceIn(0.05f, 1f) + val thumbW = (r.width * thumbFrac).toInt() + .coerceAtLeast(JBUI.scale(20)) + .coerceAtMost(r.width) + val thumbX = r.x + ((scrollX.toFloat() / maxScroll) * (r.width - thumbW)).toInt() + return DragGeometry(r.x, r.width, thumbW, thumbX, maxScroll) + } + + class DragGeometry(val trackX: Int, val trackW: Int, val thumbW: Int, val thumbX: Int, val maxScroll: Int) + + /** + * Shifts child positions left by the current scroll. All geometry consumers + * (BoxView.paint, getChildAllocation for viewToModel/modelToView, caret and + * selection painting) go through this method, so the scroll becomes part of + * the view geometry: what is painted, what is hit-tested and what is selected + * stay glued together. Without this, a selection inside a scrolled block would + * map to the wrong characters (paint translated by the scroll, hit-testing did + * not), and selection-driven auto-scroll would drift. + */ + protected override fun getOffset(axis: Int, childIndex: Int): Int = + if (axis == X_AXIS) super.getOffset(axis, childIndex) - scrollX + else super.getOffset(axis, childIndex) + + /** Component-space allocation from the last paint, for hit testing. */ + fun allocationBounds(): Rectangle? = lastPaintBounds?.let { Rectangle(it) } override fun paint(g: Graphics, allocation: Shape) { val r = (allocation as? Rectangle) ?: allocation.bounds lastAllocWidth = r.width.toFloat() + lastPaintBounds = Rectangle(r) + + // A pure widening resize does not change the natural width, so the + // setSize() reset above will not have fired; re-clamp the scroll + // against the new allocation and clear it entirely once the block no + // longer overflows. Without this, a block scrolled before the resize + // keeps shifting its children left with no scrollbar in sight. + if (scrollX != 0) { + scrollX = if (naturalWidth > r.width) scrollX.coerceAtMost(maxScrollNow()) else 0 + } val scrollable = naturalWidth > r.width val g2 = g as Graphics2D - val savedTransform = g2.transform.clone() as AffineTransform val savedClip = g2.clip - // Clip to allocated area and translate for horizontal scroll + // Clip to the allocated area; children paint at their scrolled positions + // (see getOffset) and are clipped here, so no translate is needed. g2.setClip(r.x, r.y, r.width, r.height) - g2.translate(-scrollX.toDouble(), 0.0) - super.paint(g2, Rectangle(r.x, r.y, naturalWidth.toInt(), r.height)) + super.paint(g2, r) - // Restore - g2.transform = savedTransform g2.clip = savedClip // Overlay scrollbar on top — avoids cutting off the last line of code @@ -638,20 +854,17 @@ private class ScrollableCodeView(elem: Element) : BlockView(elem, View.Y_AXIS) { } private fun paintScrollbar(g2: Graphics2D, r: Rectangle) { + val geo = dragGeometry() ?: return val trackY = r.y + r.height - JBUI.scale(SCROLLBAR_H) val trackH = JBUI.scale(SCROLLBAR_H) - val maxScroll = (naturalWidth - r.width).coerceAtLeast(1f) - val thumbFrac = (r.width / naturalWidth).coerceIn(0.05f, 1f) - val thumbW = (r.width * thumbFrac).toInt().coerceAtLeast(JBUI.scale(20)) - val thumbX = r.x + ((scrollX / maxScroll) * (r.width - thumbW)).toInt() // Track g2.color = JBColor(Color(128, 128, 128, SCROLLBAR_TRACK_ALPHA), Color(200, 200, 200, SCROLLBAR_TRACK_ALPHA)) - g2.fillRect(r.x, trackY, r.width, trackH) + g2.fillRect(geo.trackX, trackY, geo.trackW, trackH) // Thumb g2.color = JBColor(Color(128, 128, 128, SCROLLBAR_THUMB_ALPHA), Color(200, 200, 200, SCROLLBAR_THUMB_ALPHA)) - g2.fillRoundRect(thumbX, trackY + 1, thumbW, trackH - 2, trackH, trackH) + g2.fillRoundRect(geo.thumbX, trackY + 1, geo.thumbW, trackH - 2, trackH, trackH) } } diff --git a/plugin-core/src/test/java/com/github/catatafishen/agentbridge/ui/NativeChatThinkingBubbleWidthTest.java b/plugin-core/src/test/java/com/github/catatafishen/agentbridge/ui/NativeChatThinkingBubbleWidthTest.java new file mode 100644 index 000000000..de0bf6d99 --- /dev/null +++ b/plugin-core/src/test/java/com/github/catatafishen/agentbridge/ui/NativeChatThinkingBubbleWidthTest.java @@ -0,0 +1,201 @@ +package com.github.catatafishen.agentbridge.ui; + + +import javax.swing.JComponent; +import javax.swing.text.View; +import java.awt.Rectangle; +import java.awt.Shape; +import java.util.ArrayList; +import java.util.List; + +/** + * Issue #1180 regression tests: text in the THINKING bubble (the chip-strip + * path, not the normal message-row path) must lay out at the bubble width. + * + *

Builds a real {@link NativeChatPanel}, streams thinking text in chunks + * exactly the way session/update agent_message_chunk notifications arrive, + * lays the whole hierarchy out at a fixed width, and measures the laid-out + * width of the thinking pane's content against the pane's own component + * width. Code blocks are excluded from the measurement: they are clipped and + * horizontally scrollable by design; everything else (prose, paragraphs) must + * stay inside the pane. + */ +public class NativeChatThinkingBubbleWidthTest extends NativeMarkdownPaneTestBase { + + private static final int PANEL_W = 400; + + private static final String PROSE = + "The runner collects the staged files first and then computes the diff " + + "for each of them in turn. It skips anything outside the scope of the " + + "change and records the rest. When a file was renamed the old and new " + + "paths are matched before the diff is built, so the review panel can " + + "show a single item instead of a delete plus an add. The same pass " + + "also gathers the line counts shown under the header of each entry. " + + "Anything that did not change textually is dropped here, which keeps " + + "the review small enough to read in one screen. The final list is then " + + "sorted so that the entries with the most changed lines come first, " + + "because those tend to need the most attention during review."; + + /** Prose-only thinking text wraps at the pane width. */ + public void testThinkingBubbleProseLaysOutAtPaneWidth() { + Result r = measureThinkingStream(PROSE); + assertTrue("prose in the thinking bubble must stay inside the pane (pane w=" + + r.paneWidth + ", laid-out right edge=" + r.laidOutMaxRight + ")", + r.laidOutMaxRight <= r.paneWidth + 2); + } + + /** + * The reported shape: prose plus a fenced code block with one long line in + * the same thinking entry. Before the min-span fix the whole body, prose + * included, laid out at the code width and every prose line clipped at the + * bubble edge. + */ + public void testThinkingBubbleWithCodeBlockKeepsProseInsidePane() { + String text = PROSE + "\n\n```bash\n" + + "git log --graph --pretty=format:'%C(auto)%h%d %s %C(white)(%cr by %an)' --all --date-order\n" + + "```\n\n" + PROSE; + Result r = measureThinkingStream(text); + assertTrue("prose around a code block in the thinking bubble must stay inside the pane (pane w=" + + r.paneWidth + ", laid-out right edge=" + r.laidOutMaxRight + ")", + r.laidOutMaxRight <= r.paneWidth + 2); + } + + // ------------------------------------------------------------------ + // Machinery + // ------------------------------------------------------------------ + + private static final class Result { + int paneWidth; + int laidOutMaxRight; + } + + /** + * Streams the given thinking text into a real panel at a fixed width and + * measures the thinking pane. Measurement happens before any message text + * is appended: appendText finalizes the turn and collapses the thinking + * bubble, exactly like production, and the issue reproduces while the + * bubble is expanded during streaming. + */ + private Result measureThinkingStream(String text) { + NativeChatPanel panel = new NativeChatPanel(getProject()); + JComponent root = ((ChatPanelApi) panel).getComponent(); + root.setSize(PANEL_W, 1400); + + for (String c : chunk(text, 37)) { + panel.appendThinkingText(c); + } + List all = new ArrayList<>(); + collect(root, all); + NativeMarkdownPane thinkingPane = null; + for (NativeMarkdownPane p : all) { + if (inChipStrip(p)) { thinkingPane = p; break; } + } + assertNotNull("thinking pane not found in chip strip", thinkingPane); + thinkingPane.notifyStreamDone(); + + // Headless layout: validate() alone does not cascade through the scroll + // pane viewport, so lay the tree out explicitly until component sizes + // stop changing. This mirrors the revalidate cycles the IDE runs, where + // pane preferred sizes are computed from parent widths established by + // earlier passes. + layoutUntilStable(root); + + Result r = new Result(); + r.paneWidth = thinkingPane.getWidth(); + assertTrue("thinking pane must have been laid out with a positive width", r.paneWidth > 0); + + View rv = thinkingPane.getUI().getRootView(thinkingPane); + rv.setSize((float) r.paneWidth, Short.MAX_VALUE); + float h = rv.getPreferredSpan(View.Y_AXIS); + int[] maxRight = {0}; + walk(rv, new Rectangle(0, 0, r.paneWidth, (int) h), maxRight); + r.laidOutMaxRight = maxRight[0]; + return r; + } + + private static List chunk(String text, int size) { + List out = new ArrayList<>(); + for (int i = 0; i < text.length(); i += size) { + out.add(text.substring(i, Math.min(text.length(), i + size))); + } + return out; + } + + private static void collect(java.awt.Component c, List out) { + if (c instanceof NativeMarkdownPane) { + out.add((NativeMarkdownPane) c); + } + if (c instanceof java.awt.Container) { + for (java.awt.Component child : ((java.awt.Container) c).getComponents()) { + collect(child, out); + } + } + } + + private static boolean inChipStrip(java.awt.Component c) { + java.awt.Container p = c.getParent(); + while (p != null) { + if ("ChipStripPanel".equals(p.getClass().getSimpleName())) return true; + p = p.getParent(); + } + return false; + } + + private static void layoutUntilStable(java.awt.Component c) { + String prev = signature(c); + for (int i = 0; i < 10; i++) { + layoutTree(c); + String now = signature(c); + if (now.equals(prev)) break; + prev = now; + } + } + + private static void layoutTree(java.awt.Component c) { + if (c instanceof java.awt.Container) { + java.awt.Container cont = (java.awt.Container) c; + cont.doLayout(); + for (java.awt.Component child : cont.getComponents()) { + layoutTree(child); + } + } + } + + private static String signature(java.awt.Component c) { + StringBuilder sb = new StringBuilder(); + List all = new ArrayList<>(); + flat(c, all); + for (java.awt.Component x : all) { + sb.append(x.getSize().width).append('x').append(x.getSize().height).append(';'); + } + return sb.toString(); + } + + private static void flat(java.awt.Component c, List out) { + out.add(c); + if (c instanceof java.awt.Container) { + for (java.awt.Component child : ((java.awt.Container) c).getComponents()) { + flat(child, out); + } + } + } + + private static void walk(View parent, Shape alloc, int[] maxRight) { + for (int i = 0; i < parent.getViewCount(); i++) { + View child = parent.getView(i); + String name = child.getElement() != null ? String.valueOf(child.getElement().getName()) : "?"; + if ("pre".equals(name)) { + // Code blocks are clipped and horizontally scrollable by design; + // their internal line views lay out at natural width. Do not + // measure and do not descend. + continue; + } + Shape s = parent.getChildAllocation(i, alloc); + if (s == null) continue; + Rectangle b = s.getBounds(); + int right = b.x + b.width; + if (right > maxRight[0]) maxRight[0] = right; + walk(child, s, maxRight); + } + } +} diff --git a/plugin-core/src/test/java/com/github/catatafishen/agentbridge/ui/NativeMarkdownCodeScrollInteractionTest.java b/plugin-core/src/test/java/com/github/catatafishen/agentbridge/ui/NativeMarkdownCodeScrollInteractionTest.java new file mode 100644 index 000000000..7771e67ae --- /dev/null +++ b/plugin-core/src/test/java/com/github/catatafishen/agentbridge/ui/NativeMarkdownCodeScrollInteractionTest.java @@ -0,0 +1,299 @@ +package com.github.catatafishen.agentbridge.ui; + + +import javax.swing.text.View; +import java.awt.Graphics2D; +import java.awt.Rectangle; +import java.awt.event.InputEvent; +import java.awt.event.MouseEvent; +import java.awt.event.MouseWheelEvent; +import java.awt.image.BufferedImage; +import java.lang.reflect.Field; +import java.lang.reflect.Method; + +/** + * Interaction with the per-code-block horizontal scrollbar. + * + *

+ * + *

Drives the pane's real event handlers with synthetic MouseEvents and + * asserts on the internal state of the file-private ScrollableCodeView, + * reached through reflection: Kotlin mangles internal member names with a + * module suffix, so methods are matched by simple-name prefix. + */ +public class NativeMarkdownCodeScrollInteractionTest extends NativeMarkdownPaneTestBase { + + private static final int PANE_WIDTH = 320; + + private NativeMarkdownPane pane; + private View codeView; + + private static final String MD = "Before the block.\n\n```java\n" + + "this.is.a.very.long.chain.of.method.calls().that().goes().on().and().on().and().never().stops().at().all\n" + + "short();\n```\n\nAfter the block.\n"; + + @Override + protected void setUp() throws Exception { + super.setUp(); + pane = new NativeMarkdownPane(new FileNavigator(getProject())); + pane.setSize(PANE_WIDTH, 4096); + pane.setCompleteMarkdown(MD); + + // Lay out at the pane width, then paint once so ScrollableCodeView.paint + // records its allocation (the drag geometry is derived from it). + View root = pane.getUI().getRootView(pane); + root.setSize((float) PANE_WIDTH, Short.MAX_VALUE); + int height = (int) root.getPreferredSpan(View.Y_AXIS) + 8; + BufferedImage img = new BufferedImage(PANE_WIDTH, height, BufferedImage.TYPE_INT_RGB); + Graphics2D g = img.createGraphics(); + g.setClip(0, 0, PANE_WIDTH, height); + root.paint(g, new Rectangle(0, 0, PANE_WIDTH, height)); + g.dispose(); + + codeView = findScrollableCodeView(root); + assertNotNull("code block must render as a ScrollableCodeView", codeView); + assertTrue("block with a long line must be scrollable", (Boolean) inv(codeView, "isScrollableNow")); + } + + public void testPlainWheelOverBlockPassesThroughToChat() throws Exception { + Rectangle strip = (Rectangle) inv(codeView, "stripHitZone"); + assertNotNull(strip); + int before = (Integer) inv(codeView, "currentScrollX"); + + int x = strip.x + strip.width / 2; + int y = strip.y + 2; + MouseWheelEvent e = new MouseWheelEvent( + pane, MouseWheelEvent.MOUSE_WHEEL, System.currentTimeMillis(), 0, + x, y, x, y, 1, false, + MouseWheelEvent.WHEEL_UNIT_SCROLL, 3, 2, 2d); + invokePane("handleWheel", e); + + assertFalse("plain wheel must pass through so the chat can scroll vertically", e.isConsumed()); + assertEquals("plain wheel must not scroll the code block", + before, (int) (Integer) inv(codeView, "currentScrollX")); + } + + public void testShiftWheelOverBlockIsConsumed() throws Exception { + Rectangle strip = (Rectangle) inv(codeView, "stripHitZone"); + int x = strip.x + strip.width / 2; + int y = strip.y + 2; + MouseWheelEvent e = new MouseWheelEvent( + pane, MouseWheelEvent.MOUSE_WHEEL, System.currentTimeMillis(), + InputEvent.SHIFT_DOWN_MASK, + x, y, x, y, 1, false, + MouseWheelEvent.WHEEL_UNIT_SCROLL, 3, 1, 1d); + invokePane("handleWheel", e); + assertTrue("shift+wheel over an overflowing block is the block's scroll gesture", + e.isConsumed()); + } + + public void testStripDragScrollsToEndWithoutSelectingText() throws Exception { + Rectangle strip = (Rectangle) inv(codeView, "stripHitZone"); + Object geo = inv(codeView, "dragGeometry"); + assertNotNull(geo); + int trackX = fld(geo, "trackX"); + int trackW = fld(geo, "trackW"); + int thumbW = fld(geo, "thumbW"); + int thumbX = fld(geo, "thumbX"); + int maxScroll = fld(geo, "maxScroll"); + + // Press on the thumb. The constructor variant with the trailing button + // parameter carries the button; the xAbs/yAbs variant without it + // hard-wires NOBUTTON and fails isLeftMouseButton. + MouseEvent press = new MouseEvent(pane, MouseEvent.MOUSE_PRESSED, + System.currentTimeMillis(), InputEvent.BUTTON1_DOWN_MASK, + thumbX + 4, strip.y + strip.height / 2, 1, false, MouseEvent.BUTTON1); + assertTrue("press on the strip must be consumed", (Boolean) invokePane("handleStripMouse", press)); + assertTrue(press.isConsumed()); + + // Drag the thumb to the far right of the track. The drag target adds the + // grab offset (4) so the grab point, not the thumb's left edge, reaches + // the end; the thumb follows the grab point. + int dragX = trackX + (trackW - thumbW) + 4; + MouseEvent drag = new MouseEvent(pane, MouseEvent.MOUSE_DRAGGED, + System.currentTimeMillis(), InputEvent.BUTTON1_DOWN_MASK, + dragX, strip.y + strip.height / 2, 1, false, MouseEvent.BUTTON1); + assertTrue("drag on the strip must be consumed", (Boolean) invokePane("handleStripMouse", drag)); + + int scroll = (Integer) inv(codeView, "currentScrollX"); + assertEquals("dragging the thumb to the track end must reach max scroll", + maxScroll, scroll); + + // Release ends the drag. + MouseEvent release = new MouseEvent(pane, MouseEvent.MOUSE_RELEASED, + System.currentTimeMillis(), InputEvent.BUTTON1_DOWN_MASK, + dragX, strip.y + strip.height / 2, 1, false, MouseEvent.BUTTON1); + assertTrue((Boolean) invokePane("handleStripMouse", release)); + + // No text selection may result from the interaction. + assertEquals("strip drag must not select text", + pane.getSelectionStart(), pane.getSelectionEnd()); + } + + public void testSelectionDragAutoScrollsBlock() throws Exception { + Rectangle strip = (Rectangle) inv(codeView, "stripHitZone"); + Object geo = inv(codeView, "dragGeometry"); + int maxScroll = fld(geo, "maxScroll"); + int y = strip.y - strip.height; // inside the code text area, above the strip + + // Drag the selection to the right edge of the block, then past it. + MouseEvent dragEdge = new MouseEvent(pane, MouseEvent.MOUSE_DRAGGED, + System.currentTimeMillis(), InputEvent.BUTTON1_DOWN_MASK, + PANE_WIDTH - 2, y, PANE_WIDTH - 2, y, 1, false, MouseEvent.BUTTON1); + invokePane("handleSelectionAutoScroll", dragEdge); + int afterEdge = (Integer) inv(codeView, "currentScrollX"); + assertTrue("dragging the selection to the right edge must start scrolling the block", + afterEdge > 0); + + // Further right: the block scrolls further with the selection. + MouseEvent dragFar = new MouseEvent(pane, MouseEvent.MOUSE_DRAGGED, + System.currentTimeMillis(), InputEvent.BUTTON1_DOWN_MASK, + PANE_WIDTH + 60, y, PANE_WIDTH + 60, y, 1, false, MouseEvent.BUTTON1); + invokePane("handleSelectionAutoScroll", dragFar); + int afterFar = (Integer) inv(codeView, "currentScrollX"); + assertTrue("dragging further right must scroll further (afterEdge=" + afterEdge + + " afterFar=" + afterFar + ")", afterFar > afterEdge); + assertTrue(afterFar <= maxScroll); + } + + public void testTrackClickJumpsThumb() throws Exception { + Rectangle strip = (Rectangle) inv(codeView, "stripHitZone"); + Object geo = inv(codeView, "dragGeometry"); + int trackX = fld(geo, "trackX"); + int trackW = fld(geo, "trackW"); + int maxScroll = fld(geo, "maxScroll"); + + // Press far right on the track, past the thumb: the thumb must jump there + // immediately, without any drag movement. + int x = trackX + trackW - 2; + int y = strip.y + strip.height / 2; + MouseEvent press = new MouseEvent(pane, MouseEvent.MOUSE_PRESSED, + System.currentTimeMillis(), InputEvent.BUTTON1_DOWN_MASK, + x, y, 1, false, MouseEvent.BUTTON1); + assertTrue((Boolean) invokePane("handleStripMouse", press)); + + int scroll = (Integer) inv(codeView, "currentScrollX"); + assertEquals("clicking the far right of the track must jump to max scroll", + maxScroll, scroll); + + MouseEvent release = new MouseEvent(pane, MouseEvent.MOUSE_RELEASED, + System.currentTimeMillis(), InputEvent.BUTTON1_DOWN_MASK, + x, y, 1, false, MouseEvent.BUTTON1); + assertTrue((Boolean) invokePane("handleStripMouse", release)); + assertEquals("strip click must not select text", + pane.getSelectionStart(), pane.getSelectionEnd()); + } + + public void testPressOutsideStripPassesThrough() throws Exception { + // A press in the prose area (top-left of the pane) is not strip business. + MouseEvent press = new MouseEvent(pane, MouseEvent.MOUSE_PRESSED, + System.currentTimeMillis(), InputEvent.BUTTON1_DOWN_MASK, + 10, 10, 1, false); + assertFalse("press outside the strip must pass through", + (Boolean) invokePane("handleStripMouse", press)); + assertFalse(press.isConsumed()); + } + + /** A widening resize must clear a scroll offset that is now stale. */ + public void testWideningResizeClearsStaleScroll() throws Exception { + Object geo = inv(codeView, "dragGeometry"); + int maxScroll = fld(geo, "maxScroll"); + assertTrue(maxScroll > 0); + inv(codeView, "scrollTo", maxScroll); + assertEquals(maxScroll, (int) (Integer) inv(codeView, "currentScrollX")); + + // Widen past the block's natural width, lay out and paint again: the + // block no longer overflows, so the scroll must reset to zero instead + // of keeping the children shifted left with no scrollbar in sight. + int wide = 800; + View root = pane.getUI().getRootView(pane); + root.setSize((float) wide, Short.MAX_VALUE); + int height = (int) root.getPreferredSpan(View.Y_AXIS) + 8; + BufferedImage img = new BufferedImage(wide, height, BufferedImage.TYPE_INT_RGB); + Graphics2D g = img.createGraphics(); + g.setClip(0, 0, wide, height); + root.paint(g, new Rectangle(0, 0, wide, height)); + g.dispose(); + + assertEquals("a widening resize must clear the stale scroll offset", + 0, (int) (Integer) inv(codeView, "currentScrollX")); + } + + /** Shift+wheel only owns blocks that actually overflow. */ + public void testShiftWheelOverNonOverflowingBlockPassesThrough() throws Exception { + pane.setCompleteMarkdown("Before.\n\n```java\nshort();\n```\n\nAfter.\n"); + View root = pane.getUI().getRootView(pane); + root.setSize((float) PANE_WIDTH, Short.MAX_VALUE); + int height = (int) root.getPreferredSpan(View.Y_AXIS) + 8; + BufferedImage img = new BufferedImage(PANE_WIDTH, height, BufferedImage.TYPE_INT_RGB); + Graphics2D g = img.createGraphics(); + g.setClip(0, 0, PANE_WIDTH, height); + root.paint(g, new Rectangle(0, 0, PANE_WIDTH, height)); + g.dispose(); + + View shortBlock = findScrollableCodeView(root); + assertNotNull(shortBlock); + assertFalse("short block must not be scrollable", (Boolean) inv(shortBlock, "isScrollableNow")); + Rectangle alloc = (Rectangle) inv(shortBlock, "allocationBounds"); + assertNotNull(alloc); + int x = alloc.x + alloc.width / 2; + int y = alloc.y + alloc.height / 2; + MouseWheelEvent e = new MouseWheelEvent( + pane, MouseWheelEvent.MOUSE_WHEEL, System.currentTimeMillis(), + InputEvent.SHIFT_DOWN_MASK, + x, y, x, y, 1, false, + MouseWheelEvent.WHEEL_UNIT_SCROLL, 3, 1, 1d); + invokePane("handleWheel", e); + assertFalse("shift+wheel over a non-overflowing block must pass through to the chat", + e.isConsumed()); + } + + // ------------------------------------------------------------------ + // Reflection helpers (ScrollableCodeView is file-private; Kotlin mangles + // internal member names with the module suffix) + // ------------------------------------------------------------------ + + private static View findScrollableCodeView(View v) { + if ("ScrollableCodeView".equals(v.getClass().getSimpleName())) return v; + for (int i = 0; i < v.getViewCount(); i++) { + View found = findScrollableCodeView(v.getView(i)); + if (found != null) return found; + } + return null; + } + + /** Invokes a method on the pane by simple-name prefix, mangling-proof. */ + private Object invokePane(String name, Object arg) throws Exception { + Method m = findMethod(NativeMarkdownPane.class, name); + return m.invoke(pane, arg); + } + + /** Invokes a method by simple-name prefix so name mangling does not matter. */ + private static Object inv(Object target, String name, Object... args) throws Exception { + Method m = findMethod(target.getClass(), name); + return m.invoke(target, args); + } + + private static Method findMethod(Class type, String name) { + for (Method m : type.getDeclaredMethods()) { + if (m.getName().equals(name) || m.getName().startsWith(name + "$")) { + m.setAccessible(true); + return m; + } + } + throw new AssertionError("method not found: " + name + " on " + type); + } + + private static int fld(Object target, String name) throws Exception { + Field f = target.getClass().getDeclaredField(name); + f.setAccessible(true); + return f.getInt(target); + } +} diff --git a/plugin-core/src/test/java/com/github/catatafishen/agentbridge/ui/NativeMarkdownOverflowTest.java b/plugin-core/src/test/java/com/github/catatafishen/agentbridge/ui/NativeMarkdownOverflowTest.java new file mode 100644 index 000000000..4ecd7242f --- /dev/null +++ b/plugin-core/src/test/java/com/github/catatafishen/agentbridge/ui/NativeMarkdownOverflowTest.java @@ -0,0 +1,261 @@ +package com.github.catatafishen.agentbridge.ui; + + +import javax.swing.text.Element; +import javax.swing.text.View; +import java.awt.Color; +import java.awt.Graphics2D; +import java.awt.Rectangle; +import java.awt.Shape; +import java.awt.image.BufferedImage; +import java.util.ArrayList; +import java.util.List; + +/** + * Bubble content must never lay out or paint wider than the pane's allocated + * width. + * + *

Renders a matrix of message shapes into a real {@link NativeMarkdownPane} + * at a fixed width and measures two things per shape: + *

    + *
  1. View-tree layout: the maximum right edge of every laid-out view + * allocation outside {@code pre} (views inside {@code pre} are + * horizontally scrollable by design and are excluded).
  2. + *
  3. Painted pixels: the laid-out view tree is painted onto a canvas twice + * as wide as the pane with no clip at the pane edge; any non-background + * pixel beyond the pane width is overflow the user would see.
  4. + *
+ * + *

The regression these tests guard: a fenced code block with one long line + * used to lay the WHOLE document body, prose included, out at the code width, + * clipping every prose line at the bubble edge (see issue #1180). The fix is + * {@code ScrollableCodeView.getMinimumSpan(X_AXIS) = 0}, mirroring the existing + * preferred-span override; {@code BoxView.layoutMinorAxis} never allocates a + * child below its minimum span, so the uncapped code-line minimum used to + * propagate up through the whole box hierarchy. + */ +public class NativeMarkdownOverflowTest extends NativeMarkdownPaneTestBase { + + private static final int PANE_WIDTH = 320; + private static final int CANVAS_WIDTH = 2 * PANE_WIDTH; + /** + * Glyph rasterization can overshoot the laid-out line advance by a few + * pixels, so lines that wrap flush against the pane edge may paint a thin + * fringe just past it. A real overflow paints hundreds of full columns + * (the regression this guards measured 319); the fringe stays within a + * dozen pixels on one or two rows. + */ + private static final int PIXEL_TOLERANCE = 12; + private static final Color SENTINEL = new Color(255, 0, 255); + + private NativeMarkdownPane pane; + + @Override + protected void setUp() throws Exception { + super.setUp(); + pane = new NativeMarkdownPane(new FileNavigator(getProject())); + pane.setSize(PANE_WIDTH, 4096); + } + + // ------------------------------------------------------------------ + // Scenarios + // ------------------------------------------------------------------ + + /** Control: plain prose with one long word must wrap within the pane. */ + public void testPlainProseStaysInsidePane() { + String md = "The quick brown fox jumps over the lazy dog and then a " + + "supercalifragilisticexpialidociouswordthatisfarlongerthanthirtychars appears here.\n"; + Report r = measure(md); + assertEquals("plain prose must not lay out wider than the pane", 0, r.nonPreOverflowCount); + assertEquals("plain prose must not paint past the pane edge", 0, r.pixelOverflowCount); + } + + public void testInlineCodeSpanStaysInsidePane() { + String md = "The configuration key " + + "`verylong.configuration.key.name.that.keeps.going.and.going.and.going.on.forever`" + + " controls the retry behavior of the client.\n"; + Report r = measure(md); + assertEquals("views outside pre must stay inside the pane", 0, r.nonPreOverflowCount); + assertEquals("painted pixels must stay inside the pane " + r.offenders, 0, r.pixelOverflowCount); + } + + public void testIndentedCodeLineStaysInsidePane() { + String md = "Intro line.\n\n" + + " this.is.an.indented.code.line().with().a().very().long().chain().of().calls().that().never().ends\n" + + "\nOutro.\n"; + Report r = measure(md); + assertEquals("views outside pre must stay inside the pane", 0, r.nonPreOverflowCount); + assertEquals("painted pixels must stay inside the pane " + r.offenders, 0, r.pixelOverflowCount); + } + + public void testInlineCodeInListItemStaysInsidePane() { + String md = "1. First normal item.\n2. Second item references " + + "`averylongidentifiername.inside.a.list.item.that.keeps.extending.past.the.bubble.width` here.\n"; + Report r = measure(md); + assertEquals("views outside pre must stay inside the pane", 0, r.nonPreOverflowCount); + assertEquals("painted pixels must stay inside the pane " + r.offenders, 0, r.pixelOverflowCount); + } + + public void testTableCodeCellStaysInsidePane() { + String md = "| Setting | Meaning |\n|---|---|\n" + + "| `client.retry.maxAttemptsWithBackoffAndJitter` | how many times |\n" + + "| timeout | seconds |\n"; + Report r = measure(md); + assertEquals("views outside pre must stay inside the pane", 0, r.nonPreOverflowCount); + assertEquals("painted pixels must stay inside the pane " + r.offenders, 0, r.pixelOverflowCount); + } + + public void testCodeInBlockquoteStaysInsidePane() { + String md = "> The helper `someExtremelyLongFunctionNameThatNeverWrapsAnywhereAtAll()` does the work.\n"; + Report r = measure(md); + assertEquals("views outside pre must stay inside the pane", 0, r.nonPreOverflowCount); + assertEquals("painted pixels must stay inside the pane " + r.offenders, 0, r.pixelOverflowCount); + } + + /** The reported regression: prose around a fenced block with one long line. */ + public void testFencedCodeBlockLongLineKeepsProseInsidePane() { + String md = "Before the block.\n\n```java\n" + + "this.is.a.very.long.chain.of.method.calls().that().goes().on().and().on().and().never().stops().at().all\n" + + "short();\n```\n\nAfter the block.\n"; + Report r = measure(md); + assertEquals("prose around a fenced code block must not lay out at the code width", + 0, r.nonPreOverflowCount); + assertEquals("prose around a fenced code block must not paint past the pane edge", + 0, r.pixelOverflowCount); + // The core of the fix: the pre view must not leak its natural width into + // the box minimums, or BoxView.layoutMinorAxis widens the whole body. + assertTrue("body minimum width must not exceed the pane width (was " + r.bodyMinimumX + ")", + r.bodyMinimumX <= PANE_WIDTH); + assertEquals("pre must report a zero X minimum, like its preferred span", + 0, r.preMinimumX); + } + + /** Same shape, but parsed through the streaming path the way chunks arrive. */ + public void testFencedCodeBlockStreamedKeepsProseInsidePane() { + String md = "Before the block.\n\n```java\n" + + "this.is.a.very.long.chain.of.method.calls().that().goes().on().and().on().and().never().stops().at().all\n" + + "short();\n```\n\nAfter the block.\n"; + for (String c : chunk(md, 41)) { + pane.appendMarkdown(c); + } + pane.notifyStreamDone(); + Report r = measureAlreadySet(); + assertEquals("views outside pre must stay inside the pane", 0, r.nonPreOverflowCount); + assertEquals("painted pixels must stay inside the pane " + r.offenders, 0, r.pixelOverflowCount); + assertTrue(r.bodyMinimumX <= PANE_WIDTH); + } + + // ------------------------------------------------------------------ + // Measurement machinery + // ------------------------------------------------------------------ + + private static List chunk(String text, int size) { + List out = new ArrayList<>(); + for (int i = 0; i < text.length(); i += size) { + out.add(text.substring(i, Math.min(text.length(), i + size))); + } + return out; + } + + + private Report measure(String markdown) { + pane.setCompleteMarkdown(markdown); + return measureAlreadySet(); + } + + private Report measureAlreadySet() { + Report r = new Report(); + View root = pane.getUI().getRootView(pane); + root.setSize((float) PANE_WIDTH, Short.MAX_VALUE); + float h = root.getPreferredSpan(View.Y_AXIS); + walk(root, new Rectangle(0, 0, PANE_WIDTH, (int) h), "root", r); + + // Span probes on the body box: the regression showed up as a body + // minimum of the longest code line, so both are recorded. + View body = root.getView(0); + while (body != null && body.getViewCount() > 0 + && !"body".equals(body.getElement() != null ? body.getElement().getName() : "")) { + body = body.getView(0); + } + if (body != null) { + r.bodyPreferredX = (int) body.getPreferredSpan(View.X_AXIS); + r.bodyMinimumX = (int) body.getMinimumSpan(View.X_AXIS); + for (int i = 0; i < body.getViewCount(); i++) { + View c = body.getView(i); + String n = c.getElement() != null ? c.getElement().getName() : "?"; + if ("pre".equals(n)) { + r.prePreferredX = (int) c.getPreferredSpan(View.X_AXIS); + r.preMinimumX = (int) c.getMinimumSpan(View.X_AXIS); + } + } + } + + // Paint the laid-out view tree directly onto a canvas wider than the + // pane (no clip at the pane edge) so any text laid out beyond the pane + // width shows up as painted pixels, bypassing the pane's paint cache. + int height = Math.max((int) h + 8, 64); + BufferedImage img = new BufferedImage(CANVAS_WIDTH, height, BufferedImage.TYPE_INT_RGB); + Graphics2D g = img.createGraphics(); + g.setColor(SENTINEL); + g.fillRect(0, 0, CANVAS_WIDTH, height); + g.setClip(0, 0, CANVAS_WIDTH, height); + root.paint(g, new Rectangle(0, 0, PANE_WIDTH, height)); + g.dispose(); + r.pixelOverflowMaxX = 0; + r.pixelOverflowCount = 0; + for (int x = PANE_WIDTH + PIXEL_TOLERANCE; x < CANVAS_WIDTH; x++) { + for (int y = 0; y < height; y++) { + int rgb = img.getRGB(x, y); + if (rgb != SENTINEL.getRGB()) { + r.pixelOverflowCount++; + if (r.pixelOverflowCount <= 8) r.offenders.add("pixel x=" + x + " y=" + y + " rgb=" + Integer.toHexString(rgb)); + if (x > r.pixelOverflowMaxX) r.pixelOverflowMaxX = x; + break; + } + } + } + return r; + } + + private void walk(View parent, Shape alloc, String path, Report r) { + int n = parent.getViewCount(); + for (int i = 0; i < n; i++) { + View child = parent.getView(i); + Shape s = parent.getChildAllocation(i, alloc); + if (s == null) continue; + Rectangle b = s.getBounds(); + String name = child.getElement() != null ? child.getElement().getName() : "?"; + String childPath = path + "/" + name; + int right = b.x + b.width; + boolean inPre = childPath.contains("/pre"); + if (inPre) { + // Code blocks are clipped and horizontally scrollable by design. + if (right > PANE_WIDTH) { + r.preOverflowCount++; + if (right > r.preMaxRight) r.preMaxRight = right; + } + } else { + if (right > PANE_WIDTH + 2) { // 2px tolerance for borders + r.nonPreOverflowCount++; + if (right > r.nonPreMaxRight) r.nonPreMaxRight = right; + r.offenders.add(childPath + " right=" + right); + } + } + walk(child, s, childPath, r); + } + } + + private static final class Report { + int nonPreOverflowCount; + int nonPreMaxRight; + final List offenders = new ArrayList<>(); + int preOverflowCount; + int preMaxRight; + int pixelOverflowCount; + int pixelOverflowMaxX; + int bodyPreferredX; + int bodyMinimumX; + int prePreferredX; + int preMinimumX; + } +} diff --git a/plugin-core/src/test/java/com/github/catatafishen/agentbridge/ui/NativeMarkdownPaneTestBase.java b/plugin-core/src/test/java/com/github/catatafishen/agentbridge/ui/NativeMarkdownPaneTestBase.java new file mode 100644 index 000000000..27be2cc10 --- /dev/null +++ b/plugin-core/src/test/java/com/github/catatafishen/agentbridge/ui/NativeMarkdownPaneTestBase.java @@ -0,0 +1,23 @@ +package com.github.catatafishen.agentbridge.ui; + +import com.intellij.testFramework.fixtures.BasePlatformTestCase; + +/** + * Base for tests that instantiate a {@link NativeMarkdownPane} against the + * platform fixture. Disposes the per-test conversation database after each + * test so SQLite handles do not leak across tests, with a short grace period + * for the disposal to settle before the fixture tears the project down. + */ +public abstract class NativeMarkdownPaneTestBase extends BasePlatformTestCase { + + @Override + protected void tearDown() throws Exception { + try { + com.github.catatafishen.agentbridge.session.db.ConversationDatabase + .getInstance(getProject()).dispose(); + Thread.sleep(100); + } finally { + super.tearDown(); + } + } +}