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 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:
+ * 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
+ *
+ *
+ *
+ *
+ *
+ *