From 94bdb884ad297fef3fc252c1cb61e6fa698c725d Mon Sep 17 00:00:00 2001 From: megaproxy Date: Fri, 22 May 2026 21:40:16 +0100 Subject: [PATCH] Fix resize artifacts: rAF-throttle drag + force xterm repaint Two related fixes for stale glyphs / visual artifacts while dragging a gutter: - Gutter.tsx: pointermove now writes the new ratio into a ref and schedules a single requestAnimationFrame flush per frame. Without this, setTree fires 60+ times per second during a drag and React + ResizeObserver + xterm's DOM renderer get out of sync. The pointerup handler flushes any pending ratio so the final position always lands. - XtermPane.tsx: the ResizeObserver callback now also rAF-coalesces AND calls term.refresh(0, term.rows - 1) after fit.fit(). xterm's DOM renderer doesn't reliably repaint freed-up rows after a shrink, so the explicit refresh wipes any stale glyphs. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/XtermPane.tsx | 27 +++++++++++++++++++-------- src/lib/layout/Gutter.tsx | 32 +++++++++++++++++++++++++++++--- 2 files changed, 48 insertions(+), 11 deletions(-) diff --git a/src/components/XtermPane.tsx b/src/components/XtermPane.tsx index 0beab95..3a6594b 100644 --- a/src/components/XtermPane.tsx +++ b/src/components/XtermPane.tsx @@ -165,16 +165,27 @@ export default function XtermPane({ if (ta) ta.addEventListener("focus", () => onFocusRef.current?.(), true); } - // Re-fit on container resize; forward new size to the PTY. + // Re-fit on container resize; forward new size to the PTY. We + // coalesce multiple ResizeObserver firings into one rAF so a single + // gutter drag tick = one fit/resize/repaint, then explicitly call + // term.refresh() so the DOM renderer fully repaints (without this, + // shrinking a pane sometimes leaves stale glyphs in the freed rows). + let resizeRaf: number | null = null; ro = new ResizeObserver(() => { - try { - fit.fit(); - if (paneId != null && term) { - void resizePane(paneId, term.cols, term.rows); + if (resizeRaf != null) return; + resizeRaf = requestAnimationFrame(() => { + resizeRaf = null; + if (!term) return; + try { + fit.fit(); + if (paneId != null) { + void resizePane(paneId, term.cols, term.rows); + } + term.refresh(0, term.rows - 1); + } catch (e) { + console.warn("resize failed", e); } - } catch (e) { - console.warn("resize failed", e); - } + }); }); ro.observe(container); diff --git a/src/lib/layout/Gutter.tsx b/src/lib/layout/Gutter.tsx index 79afa5b..8cb85e3 100644 --- a/src/lib/layout/Gutter.tsx +++ b/src/lib/layout/Gutter.tsx @@ -24,6 +24,20 @@ export default function Gutter({ }) { const [dragging, setDragging] = useState(false); const draggingRef = useRef(false); + // rAF-throttle the ratio updates so we don't fire React + ResizeObserver + // 60+ times per second during a drag (xterm's DOM renderer can't keep up + // and leaves artifacts). + const pendingRatioRef = useRef(null); + const rafRef = useRef(null); + + const flushPending = useCallback(() => { + rafRef.current = null; + const r = pendingRatioRef.current; + if (r != null) { + pendingRatioRef.current = null; + onRatioChange(info.splitId, r); + } + }, [info.splitId, onRatioChange]); const onPointerDown = useCallback((e: PointerEvent) => { (e.target as HTMLElement).setPointerCapture(e.pointerId); @@ -46,9 +60,12 @@ export default function Gutter({ ? (xFrac - pb.left) / pb.width : (yFrac - pb.top) / pb.height; const ratio = Math.max(0.05, Math.min(0.95, rawRatio)); - onRatioChange(info.splitId, ratio); + pendingRatioRef.current = ratio; + if (rafRef.current == null) { + rafRef.current = requestAnimationFrame(flushPending); + } }, - [containerRef, info, onRatioChange], + [containerRef, info, flushPending], ); const onPointerUp = useCallback((e: PointerEvent) => { @@ -56,7 +73,16 @@ export default function Gutter({ (e.target as HTMLElement).releasePointerCapture(e.pointerId); draggingRef.current = false; setDragging(false); - }, []); + // Make sure the final ratio lands even if the rAF hadn't fired. + if (rafRef.current != null) { + cancelAnimationFrame(rafRef.current); + rafRef.current = null; + } + if (pendingRatioRef.current != null) { + onRatioChange(info.splitId, pendingRatioRef.current); + pendingRatioRef.current = null; + } + }, [info.splitId, onRatioChange]); const isH = info.orientation === "h"; // Visible 4px line, but the draggable hitbox is wider for grabbability.