From 86f7174eee9770fe0d7007dc65e37a8b48b1ddd4 Mon Sep 17 00:00:00 2001 From: Gregor Lohaus Date: Sun, 31 May 2026 17:14:07 +0200 Subject: [PATCH] row diffing --- .../jprototerm/GhosttyTerminalRenderer.java | 177 +++++++++++++++++- 1 file changed, 174 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/gregor/jprototerm/GhosttyTerminalRenderer.java b/src/main/java/com/gregor/jprototerm/GhosttyTerminalRenderer.java index 4cf7365..cbba25c 100644 --- a/src/main/java/com/gregor/jprototerm/GhosttyTerminalRenderer.java +++ b/src/main/java/com/gregor/jprototerm/GhosttyTerminalRenderer.java @@ -51,6 +51,8 @@ final class GhosttyTerminalRenderer extends TerminalRenderer { private final TerminalMetrics metrics; // Decoded kitty images for this renderer's pane (kitty graphics state is per-terminal). private final Map kittyImageCache = new HashMap<>(); + private long[] rowHashes = new long[0]; + private CursorState lastCursor = CursorState.none(); GhosttyTerminalRenderer(TerminalMetrics metrics) { this.metrics = metrics; @@ -64,8 +66,10 @@ final class GhosttyTerminalRenderer extends TerminalRenderer { double height = target.height(); gc.save(); clip(gc, px, py, width, height, target.clip()); - drawContent(gc, target, target.snapshotFull(), px, py, width, height, active, + RenderStateSnapshot snapshot = target.snapshotFull(); + drawContent(gc, target, snapshot, px, py, width, height, active, target.kittyEnabled() && hasKittyGraphics(target)); + rememberSnapshot(snapshot); gc.restore(); } @@ -79,12 +83,18 @@ final class GhosttyTerminalRenderer extends TerminalRenderer { clip(gc, px, py, width, height, target.clip()); if (target.kittyEnabled() && hasKittyGraphics(target)) { // Kitty placements can move without a per-row dirty flag, so always redraw whole. - drawContent(gc, target, target.snapshotFull(), px, py, width, height, active, true); + RenderStateSnapshot snapshot = target.snapshotFull(); + drawContent(gc, target, snapshot, px, py, width, height, active, true); + rememberSnapshot(snapshot); } else { RenderStateSnapshot snapshot = target.snapshot(); int dirty = snapshot == null ? DIRTY_FULL : snapshot.dirty(); if (dirty == DIRTY_FULL) { - drawContent(gc, target, snapshot, px, py, width, height, active, false); + if (!drawChangedRows(gc, snapshot, px, py, width, height, active)) { + RenderStateSnapshot fullSnapshot = target.snapshotFull(); + drawContent(gc, target, fullSnapshot, px, py, width, height, active, false); + rememberSnapshot(fullSnapshot); + } } else if (dirty == DIRTY_PARTIAL) { drawDirtyRows(gc, snapshot, px, py, width, height, active); } @@ -93,6 +103,86 @@ final class GhosttyTerminalRenderer extends TerminalRenderer { gc.restore(); } + // Some TUIs repaint the whole viewport for small logical changes. When ghostty gives us + // a full snapshot, compare row content with what we last painted and only redraw rows + // whose cells changed, plus old/new cursor rows because the cursor is an overlay. + private boolean drawChangedRows( + GraphicsContext gc, + RenderStateSnapshot snapshot, + double px, + double py, + double pw, + double ph, + boolean active + ) { + if (snapshot == null || rowHashes.length != snapshot.rows() || snapshot.renderRows().size() != snapshot.rows()) { + return false; + } + + double cellWidth = metrics.cellWidth(); + double lineHeight = metrics.lineHeight(); + gc.setFontSmoothingType(FontSmoothingType.LCD); + gc.setFont(metrics.font()); + double left = px + TerminalMetrics.PADDING; + double top = py + TerminalMetrics.PADDING; + double baseline = top + metrics.baselineOffset(); + double contentBottom = top + snapshot.rows() * lineHeight; + int lastRow = snapshot.rows() - 1; + + CursorState cursor = CursorState.from(snapshot); + long oldCursorRow = lastCursor.viewportRow(); + long newCursorRow = cursor.viewportRow(); + boolean cursorChanged = !cursor.equals(lastCursor); + double bandMin = Double.POSITIVE_INFINITY; + double bandMax = Double.NEGATIVE_INFINITY; + + for (RenderRow row : snapshot.renderRows()) { + int rowIndex = row.row(); + if (rowIndex < 0 || rowIndex >= rowHashes.length) { + return false; + } + + long hash = rowHash(row); + boolean repaint = hash != rowHashes[rowIndex] + || (cursorChanged && (rowIndex == oldCursorRow || rowIndex == newCursorRow)); + if (!repaint) { + continue; + } + + double y0 = Math.floor(top + (rowIndex * lineHeight)); + double y1 = Math.ceil(top + ((rowIndex + 1) * lineHeight)); + gc.setFill(PANE_BACKGROUND); + gc.fillRect(px, y0, pw, y1 - y0); + paintSidePadding(gc, row, px, pw, left, cellWidth, y0, y1 - y0); + drawRow(gc, row, left, top, baseline, cellWidth, lineHeight); + rowHashes[rowIndex] = hash; + bandMin = Math.min(bandMin, y0); + bandMax = Math.max(bandMax, y1); + + if (rowIndex == 0) { + gc.setFill(rowEdgeBackground(row, true)); + gc.fillRect(px, py, pw, top - py); + bandMin = Math.min(bandMin, py); + } + if (rowIndex == lastRow) { + gc.setFill(rowEdgeBackground(row, true)); + gc.fillRect(px, contentBottom, pw, py + ph - contentBottom); + bandMax = Math.max(bandMax, py + ph); + } + } + + lastCursor = cursor; + if (bandMin > bandMax) { + return true; + } + drawCursor(gc, snapshot, left, top, cellWidth, lineHeight); + gc.save(); + clipRect(gc, px, bandMin, pw, bandMax - bandMin); + drawBorder(gc, px, py, pw, ph, active); + gc.restore(); + return true; + } + // Full content render: background, border, all rows, cursor, and (when enabled) kitty // graphics. Used by the kitty direct path and by full redraws. private void drawContent( @@ -190,6 +280,7 @@ final class GhosttyTerminalRenderer extends TerminalRenderer { } paintSidePadding(gc, row, px, pw, left, cellWidth, y0, y1 - y0); drawRow(gc, row, left, top, baseline, cellWidth, lineHeight); + rememberRow(row); bandMin = Math.min(bandMin, y0); bandMax = Math.max(bandMax, y1); // Edge rows also own the top/bottom padding strip; repaint it and extend the @@ -217,6 +308,7 @@ final class GhosttyTerminalRenderer extends TerminalRenderer { if (cursorRowDirty) { drawCursor(gc, snapshot, left, top, cellWidth, lineHeight); } + lastCursor = CursorState.from(snapshot); // Repainting rows clears the side borders within the band; restore just those // segments, clipped to the band so we don't redraw the whole outline. gc.save(); @@ -244,6 +336,62 @@ final class GhosttyTerminalRenderer extends TerminalRenderer { gc.strokeRect(x + 0.5, y + 0.5, width - 1.0, height - 1.0); } + private void rememberSnapshot(RenderStateSnapshot snapshot) { + if (snapshot == null) { + rowHashes = new long[0]; + lastCursor = CursorState.none(); + return; + } + if (rowHashes.length != snapshot.rows()) { + rowHashes = new long[snapshot.rows()]; + } + for (RenderRow row : snapshot.renderRows()) { + rememberRow(row); + } + lastCursor = CursorState.from(snapshot); + } + + private void rememberRow(RenderRow row) { + if (row.row() >= 0 && row.row() < rowHashes.length) { + rowHashes[row.row()] = rowHash(row); + } + } + + private static long rowHash(RenderRow row) { + long hash = 0xcbf29ce484222325L; + hash = mix(hash, row.row()); + for (RenderCell cell : row.cells()) { + hash = mix(hash, cell.column()); + hash = mix(hash, cell.inverse() ? 1 : 0); + hash = mix(hash, cell.selected() ? 1 : 0); + hash = mixColor(hash, cell.foreground().orElse(null)); + hash = mixColor(hash, cell.background().orElse(null)); + for (int codepoint : cell.codepoints()) { + hash = mix(hash, codepoint); + } + if (cell.kittyPlaceholder().isPresent()) { + KittyPlaceholder placeholder = cell.kittyPlaceholder().get(); + hash = mix(hash, placeholder.imageId()); + hash = mix(hash, placeholder.placementId()); + hash = mix(hash, placeholder.sourceRow()); + hash = mix(hash, placeholder.sourceColumn()); + } + } + return hash; + } + + private static long mixColor(long hash, RenderColor color) { + if (color == null) { + return mix(hash, -1); + } + return mix(hash, (color.red() << 16) | (color.green() << 8) | color.blue()); + } + + private static long mix(long hash, long value) { + hash ^= value; + return hash * 0x100000001b3L; + } + // Effective background colour of a cell as it is drawn (reverse video swaps fg/bg, an // unset colour falls back to the defaults). private static Color cellBackgroundColor(RenderCell cell) { @@ -675,6 +823,29 @@ final class GhosttyTerminalRenderer extends TerminalRenderer { private record SourceRect(double x, double y, double width, double height) { } + private record CursorState(boolean visible, boolean hasViewport, long viewportX, long viewportY, RenderCursorStyle style) { + private static CursorState none() { + return new CursorState(false, false, -1, -1, null); + } + + private static CursorState from(RenderStateSnapshot snapshot) { + if (snapshot == null) { + return none(); + } + boolean hasViewport = snapshot.cursorViewportHasValue(); + return new CursorState( + snapshot.cursorVisible(), + hasViewport, + hasViewport ? snapshot.cursorViewportX() : -1, + hasViewport ? snapshot.cursorViewportY() : -1, + snapshot.cursorStyle()); + } + + private long viewportRow() { + return visible && hasViewport ? viewportY : -1; + } + } + private static final class KittyPlaceholderBounds { private int minRow = Integer.MAX_VALUE; private int maxRow = Integer.MIN_VALUE;