From 9b7247a4e02b9ff1fb58cc19718de24f9c1d1070 Mon Sep 17 00:00:00 2001 From: Gregor Lohaus Date: Sun, 31 May 2026 16:50:12 +0200 Subject: [PATCH 01/18] small improvements --- .../com/gregor/jprototerm/Compositor.java | 5 +- .../jprototerm/GhosttyTerminalRenderer.java | 198 ++++++++++++++---- .../java/com/gregor/jprototerm/LinuxPty.java | 20 +- src/main/java/com/gregor/jprototerm/Tab.java | 7 +- .../com/gregor/jprototerm/TerminalPane.java | 14 +- 5 files changed, 189 insertions(+), 55 deletions(-) diff --git a/src/main/java/com/gregor/jprototerm/Compositor.java b/src/main/java/com/gregor/jprototerm/Compositor.java index 58df043..1db1a69 100644 --- a/src/main/java/com/gregor/jprototerm/Compositor.java +++ b/src/main/java/com/gregor/jprototerm/Compositor.java @@ -384,7 +384,10 @@ public final class Compositor { double ey = localY(event.getY(), pane, target); KeyModifiers modifiers = modifiers(event); for (int i = 0; i < rows; i++) { - sent |= send(pane, target, MouseInput.press(wheelButton, ex, ey, modifiers), mouseButtonPressed, event); + if (!send(pane, target, MouseInput.press(wheelButton, ex, ey, modifiers), mouseButtonPressed, event)) { + break; + } + sent = true; } } if (!sent) { diff --git a/src/main/java/com/gregor/jprototerm/GhosttyTerminalRenderer.java b/src/main/java/com/gregor/jprototerm/GhosttyTerminalRenderer.java index f6e20e8..52b5afd 100644 --- a/src/main/java/com/gregor/jprototerm/GhosttyTerminalRenderer.java +++ b/src/main/java/com/gregor/jprototerm/GhosttyTerminalRenderer.java @@ -39,6 +39,9 @@ final class GhosttyTerminalRenderer extends TerminalRenderer { // The default cell background (used for cells with no explicit bg, and as the foreground // for reverse-video cells whose background is the terminal default). private static final Color PANE_BACKGROUND = Color.rgb(9, 10, 12); + private static final Color ACTIVE_BORDER = Color.rgb(87, 166, 255); + private static final Color INACTIVE_BORDER = Color.rgb(52, 57, 65); + private static final Color CURSOR_FILL = Color.rgb(225, 229, 235, 0.28); // A full-screen redraw asks for one Color per cell; most cells share a handful of colors, // so cache them by packed RGB instead of allocating a Color each time. Bounded so a @@ -48,6 +51,7 @@ 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 final StringBuilder textRun = new StringBuilder(256); GhosttyTerminalRenderer(TerminalMetrics metrics) { this.metrics = metrics; @@ -163,10 +167,17 @@ final class GhosttyTerminalRenderer extends TerminalRenderer { double contentBottom = top + snapshot.rows() * lineHeight; int lastRow = snapshot.rows() - 1; + List rows = snapshot.renderRows(); + boolean allRowsDirty = allRowsDirty(snapshot, rows); + if (allRowsDirty) { + gc.setFill(PANE_BACKGROUND); + gc.fillRect(px, py, pw, ph); + } + boolean cursorRowDirty = false; double bandMin = Double.POSITIVE_INFINITY; double bandMax = Double.NEGATIVE_INFINITY; - for (RenderRow row : snapshot.renderRows()) { + for (RenderRow row : rows) { if (!row.dirty()) { continue; } @@ -174,8 +185,10 @@ final class GhosttyTerminalRenderer extends TerminalRenderer { // would leave sub-pixel seams between rows. double y0 = Math.floor(top + (row.row() * lineHeight)); double y1 = Math.ceil(top + ((row.row() + 1) * lineHeight)); - gc.setFill(PANE_BACKGROUND); - gc.fillRect(px, y0, pw, y1 - y0); + if (!allRowsDirty) { + 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); bandMin = Math.min(bandMin, y0); @@ -213,8 +226,21 @@ final class GhosttyTerminalRenderer extends TerminalRenderer { gc.restore(); } + private static boolean allRowsDirty(RenderStateSnapshot snapshot, List rows) { + if (rows.size() != snapshot.rows()) { + return false; + } + for (int i = 0; i < rows.size(); i++) { + RenderRow row = rows.get(i); + if (!row.dirty() || row.row() != i) { + return false; + } + } + return true; + } + private void drawBorder(GraphicsContext gc, double x, double y, double width, double height, boolean active) { - gc.setStroke(active ? Color.rgb(87, 166, 255) : Color.rgb(52, 57, 65)); + gc.setStroke(active ? ACTIVE_BORDER : INACTIVE_BORDER); gc.setLineWidth(active ? 2.0 : 1.0); gc.strokeRect(x + 0.5, y + 0.5, width - 1.0, height - 1.0); } @@ -266,7 +292,7 @@ final class GhosttyTerminalRenderer extends TerminalRenderer { gc.fillRect(paneX, contentBottom, paneWidth, paneY + paneHeight - contentBottom); } - private static void drawRow( + private void drawRow( GraphicsContext gc, RenderRow row, double left, @@ -275,45 +301,143 @@ final class GhosttyTerminalRenderer extends TerminalRenderer { double cellWidth, double lineHeight ) { + drawRowBackgrounds(gc, row, left, top, cellWidth, lineHeight); + drawRowText(gc, row, left, baseline, cellWidth, lineHeight); + } + + private static void drawRowBackgrounds( + GraphicsContext gc, + RenderRow row, + double left, + double top, + double cellWidth, + double lineHeight + ) { + Color runBackground = null; + int runStartColumn = 0; + int previousColumn = -1; for (RenderCell cell : row.cells()) { if (cell.kittyPlaceholder().isPresent()) { + flushBackgroundRun(gc, runBackground, left, top, cellWidth, lineHeight, row.row(), runStartColumn, previousColumn); + runBackground = null; + previousColumn = -1; continue; } - double x = left + (cell.column() * cellWidth); - double cellTop = top + (row.row() * lineHeight); - - // Resolve fg/bg (null bg = terminal default, painted by the pane background). - // Avoid Optional.map's allocation on this hot path. - var fgOpt = cell.foreground(); - var bgOpt = cell.background(); - Color fg = fgOpt.isPresent() ? toFxColor(fgOpt.get()) : DEFAULT_FOREGROUND; - Color bg = bgOpt.isPresent() ? toFxColor(bgOpt.get()) : null; - - // Reverse video: ghostty does not bake inverse into the resolved colours, so we - // swap them here, falling back to the terminal defaults for whichever is unset. - if (cell.inverse()) { - Color swappedBg = fg; - fg = (bg != null) ? bg : PANE_BACKGROUND; - bg = swappedBg; - } - - if (bg != null) { - gc.setFill(bg); - gc.fillRect(x, cellTop, cellWidth, lineHeight); - } - if (cell.selected()) { - gc.setFill(SELECTED_BACKGROUND); - gc.fillRect(x, cellTop, cellWidth, lineHeight); - } - if (cell.codepoints().length == 0) { + Color bg = cell.selected() ? SELECTED_BACKGROUND : cellBackgroundOverride(cell); + if (bg == null) { + flushBackgroundRun(gc, runBackground, left, top, cellWidth, lineHeight, row.row(), runStartColumn, previousColumn); + runBackground = null; + previousColumn = -1; continue; } - double y = baseline + (row.row() * lineHeight); - gc.setFill(fg); - gc.fillText(cell.text(), x, y); + if (runBackground == null || bg != runBackground || cell.column() != previousColumn + 1) { + flushBackgroundRun(gc, runBackground, left, top, cellWidth, lineHeight, row.row(), runStartColumn, previousColumn); + runBackground = bg; + runStartColumn = cell.column(); + } + previousColumn = cell.column(); } + flushBackgroundRun(gc, runBackground, left, top, cellWidth, lineHeight, row.row(), runStartColumn, previousColumn); + } + + private static void flushBackgroundRun( + GraphicsContext gc, + Color background, + double left, + double top, + double cellWidth, + double lineHeight, + int row, + int startColumn, + int endColumn + ) { + if (background == null || endColumn < startColumn) { + return; + } + gc.setFill(background); + gc.fillRect( + left + (startColumn * cellWidth), + top + (row * lineHeight), + (endColumn - startColumn + 1) * cellWidth, + lineHeight); + } + + private void drawRowText( + GraphicsContext gc, + RenderRow row, + double left, + double baseline, + double cellWidth, + double lineHeight + ) { + StringBuilder run = textRun; + run.setLength(0); + Color runForeground = null; + int runStartColumn = 0; + int previousColumn = -1; + + for (RenderCell cell : row.cells()) { + if (cell.kittyPlaceholder().isPresent() || cell.codepoints().length == 0) { + flushTextRun(gc, run, runForeground, left, baseline, cellWidth, lineHeight, row.row(), runStartColumn); + runForeground = null; + previousColumn = -1; + continue; + } + + Color fg = cellForegroundColor(cell); + if (run.length() == 0 || fg != runForeground || cell.column() != previousColumn + 1) { + flushTextRun(gc, run, runForeground, left, baseline, cellWidth, lineHeight, row.row(), runStartColumn); + runForeground = fg; + runStartColumn = cell.column(); + } + run.append(cell.text()); + previousColumn = cell.column(); + } + flushTextRun(gc, run, runForeground, left, baseline, cellWidth, lineHeight, row.row(), runStartColumn); + } + + private static void flushTextRun( + GraphicsContext gc, + StringBuilder run, + Color foreground, + double left, + double baseline, + double cellWidth, + double lineHeight, + int row, + int startColumn + ) { + if (run.length() == 0) { + return; + } + gc.setFill(foreground); + gc.fillText(run.toString(), left + (startColumn * cellWidth), baseline + (row * lineHeight)); + run.setLength(0); + } + + // Background override for a cell: null means the pane default background already covers it. + private static Color cellBackgroundOverride(RenderCell cell) { + if (cell.inverse()) { + var fg = cell.foreground(); + return fg.isPresent() ? toFxColor(fg.get()) : DEFAULT_FOREGROUND; + } + var bgOpt = cell.background(); + Color bg = bgOpt.isPresent() ? toFxColor(bgOpt.get()) : null; + return bg; + } + + private static Color cellForegroundColor(RenderCell cell) { + var fgOpt = cell.foreground(); + var bgOpt = cell.background(); + Color fg = fgOpt.isPresent() ? toFxColor(fgOpt.get()) : DEFAULT_FOREGROUND; + Color bg = bgOpt.isPresent() ? toFxColor(bgOpt.get()) : null; + + if (cell.inverse()) { + return (bg != null) ? bg : PANE_BACKGROUND; + } + return fg; } private static Color toFxColor(RenderColor color) { @@ -337,8 +461,8 @@ final class GhosttyTerminalRenderer extends TerminalRenderer { double x = left + (snapshot.cursorViewportX() * cellWidth); double y = top + (snapshot.cursorViewportY() * lineHeight); - gc.setStroke(Color.rgb(225, 229, 235)); - gc.setFill(Color.rgb(225, 229, 235, 0.28)); + gc.setStroke(DEFAULT_FOREGROUND); + gc.setFill(CURSOR_FILL); gc.setLineWidth(1.5); RenderCursorStyle style = snapshot.cursorStyle(); diff --git a/src/main/java/com/gregor/jprototerm/LinuxPty.java b/src/main/java/com/gregor/jprototerm/LinuxPty.java index 28e9d42..2b2eb81 100644 --- a/src/main/java/com/gregor/jprototerm/LinuxPty.java +++ b/src/main/java/com/gregor/jprototerm/LinuxPty.java @@ -97,6 +97,7 @@ public final class LinuxPty implements AutoCloseable { private final Arena arena = Arena.ofShared(); private final MemorySegment readBuffer = arena.allocate(65536); + private final MemorySegment writeBuffer = arena.allocate(65536); private final Object writeLock = new Object(); private final int masterFd; private final int pid; @@ -186,17 +187,20 @@ public final class LinuxPty implements AutoCloseable { return; } synchronized (writeLock) { - try (Arena a = Arena.ofConfined()) { - MemorySegment buf = a.allocate(data.length); - MemorySegment.copy(data, 0, buf, ValueLayout.JAVA_BYTE, 0, data.length); - long offset = 0; - while (offset < data.length) { - long n = callLong(WRITE, masterFd, buf.asSlice(offset), data.length - offset); - if (n < 0) { + int offset = 0; + while (offset < data.length) { + int chunk = (int) Math.min(writeBuffer.byteSize(), data.length - offset); + MemorySegment.copy(data, offset, writeBuffer, ValueLayout.JAVA_BYTE, 0, chunk); + + long written = 0; + while (written < chunk) { + long n = callLong(WRITE, masterFd, writeBuffer.asSlice(written), chunk - written); + if (n <= 0) { throw new IllegalStateException("write to pty failed"); } - offset += n; + written += n; } + offset += chunk; } } } diff --git a/src/main/java/com/gregor/jprototerm/Tab.java b/src/main/java/com/gregor/jprototerm/Tab.java index e90f492..4a8146e 100644 --- a/src/main/java/com/gregor/jprototerm/Tab.java +++ b/src/main/java/com/gregor/jprototerm/Tab.java @@ -6,6 +6,7 @@ import javafx.scene.shape.Shape; import java.util.ArrayList; import java.util.Comparator; import java.util.List; +import java.util.concurrent.atomic.AtomicLong; import java.util.stream.Stream; /** @@ -33,7 +34,7 @@ final class Tab implements AutoCloseable { private double lastTopInset; // Bumped whenever one of this tab's panes changes content; the compositor reads the current // tab's value each frame as an O(1) "anything to repaint?" check. - private long contentVersion; + private final AtomicLong contentVersion = new AtomicLong(); Tab(AppConfig config, TerminalMetrics metrics) { this.config = config; @@ -54,7 +55,7 @@ final class Tab implements AutoCloseable { } long contentVersion() { - return contentVersion; + return contentVersion.get(); } /** @@ -291,7 +292,7 @@ final class Tab implements AutoCloseable { } private void markContentChanged() { - contentVersion++; + contentVersion.incrementAndGet(); } private TerminalPane openPane(boolean asFloating) { diff --git a/src/main/java/com/gregor/jprototerm/TerminalPane.java b/src/main/java/com/gregor/jprototerm/TerminalPane.java index 6ad0890..05961ec 100644 --- a/src/main/java/com/gregor/jprototerm/TerminalPane.java +++ b/src/main/java/com/gregor/jprototerm/TerminalPane.java @@ -16,6 +16,7 @@ import javafx.scene.canvas.GraphicsContext; import javafx.scene.shape.Shape; import java.util.Optional; +import java.util.concurrent.atomic.AtomicLong; /** * One terminal: owns its ghostty {@link Terminal}, the {@link ShellSession}/pty driving it, @@ -49,7 +50,7 @@ public final class TerminalPane implements AutoCloseable, RenderTarget { private int rows; private int pixelWidth; private int pixelHeight; - private long contentVersion; + private final AtomicLong contentVersion = new AtomicLong(); private long snapshotVersion = -1; private TerminalPane(Terminal terminal, TerminalMetrics metrics, boolean kittyEnabled, @@ -169,16 +170,17 @@ public final class TerminalPane implements AutoCloseable, RenderTarget { private RenderStateSnapshot takeSnapshot(boolean full) { synchronized (terminal) { + long version = contentVersion.get(); if (full) { renderState.update(terminal); cachedSnapshot = renderState.snapshot(); renderState.resetDirty(); - snapshotVersion = contentVersion; - } else if (snapshotVersion != contentVersion) { + snapshotVersion = version; + } else if (snapshotVersion != version) { renderState.update(terminal); cachedSnapshot = renderState.snapshotIncremental(); renderState.resetDirty(); - snapshotVersion = contentVersion; + snapshotVersion = version; } return cachedSnapshot; } @@ -192,7 +194,7 @@ public final class TerminalPane implements AutoCloseable, RenderTarget { /** This pane's own content revision, bumped on every change (see {@link #refresh()}). */ public long contentVersion() { - return contentVersion; + return contentVersion.get(); } @Override @@ -276,7 +278,7 @@ public final class TerminalPane implements AutoCloseable, RenderTarget { // Mark this pane's content dirty (the snapshot is computed lazily in the paint path, // so a burst of writes collapses into one snapshot per frame) and tell the owning tab // one of its panes changed. - contentVersion++; + contentVersion.incrementAndGet(); onContentChange.run(); } From 9903e9174f8532feffe50bced61afbbb0173e92a Mon Sep 17 00:00:00 2001 From: Gregor Lohaus Date: Sun, 31 May 2026 16:58:11 +0200 Subject: [PATCH 02/18] fix cell shifting regression --- .../jprototerm/GhosttyTerminalRenderer.java | 40 +------------------ 1 file changed, 2 insertions(+), 38 deletions(-) diff --git a/src/main/java/com/gregor/jprototerm/GhosttyTerminalRenderer.java b/src/main/java/com/gregor/jprototerm/GhosttyTerminalRenderer.java index 52b5afd..4cf7365 100644 --- a/src/main/java/com/gregor/jprototerm/GhosttyTerminalRenderer.java +++ b/src/main/java/com/gregor/jprototerm/GhosttyTerminalRenderer.java @@ -51,7 +51,6 @@ 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 final StringBuilder textRun = new StringBuilder(256); GhosttyTerminalRenderer(TerminalMetrics metrics) { this.metrics = metrics; @@ -372,49 +371,14 @@ final class GhosttyTerminalRenderer extends TerminalRenderer { double cellWidth, double lineHeight ) { - StringBuilder run = textRun; - run.setLength(0); - Color runForeground = null; - int runStartColumn = 0; - int previousColumn = -1; - for (RenderCell cell : row.cells()) { if (cell.kittyPlaceholder().isPresent() || cell.codepoints().length == 0) { - flushTextRun(gc, run, runForeground, left, baseline, cellWidth, lineHeight, row.row(), runStartColumn); - runForeground = null; - previousColumn = -1; continue; } - Color fg = cellForegroundColor(cell); - if (run.length() == 0 || fg != runForeground || cell.column() != previousColumn + 1) { - flushTextRun(gc, run, runForeground, left, baseline, cellWidth, lineHeight, row.row(), runStartColumn); - runForeground = fg; - runStartColumn = cell.column(); - } - run.append(cell.text()); - previousColumn = cell.column(); + gc.setFill(cellForegroundColor(cell)); + gc.fillText(cell.text(), left + (cell.column() * cellWidth), baseline + (row.row() * lineHeight)); } - flushTextRun(gc, run, runForeground, left, baseline, cellWidth, lineHeight, row.row(), runStartColumn); - } - - private static void flushTextRun( - GraphicsContext gc, - StringBuilder run, - Color foreground, - double left, - double baseline, - double cellWidth, - double lineHeight, - int row, - int startColumn - ) { - if (run.length() == 0) { - return; - } - gc.setFill(foreground); - gc.fillText(run.toString(), left + (startColumn * cellWidth), baseline + (row * lineHeight)); - run.setLength(0); } // Background override for a cell: null means the pane default background already covers it. From d8faf8d6dfc99732dbf44cadaad2f1fc2f042739 Mon Sep 17 00:00:00 2001 From: Gregor Lohaus Date: Sun, 31 May 2026 17:02:44 +0200 Subject: [PATCH 03/18] safe batching --- .../jprototerm/GhosttyTerminalRenderer.java | 89 ++++++++++++++++++- 1 file changed, 88 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/gregor/jprototerm/GhosttyTerminalRenderer.java b/src/main/java/com/gregor/jprototerm/GhosttyTerminalRenderer.java index 4cf7365..82434d9 100644 --- a/src/main/java/com/gregor/jprototerm/GhosttyTerminalRenderer.java +++ b/src/main/java/com/gregor/jprototerm/GhosttyTerminalRenderer.java @@ -17,7 +17,9 @@ import javafx.scene.image.Image; import javafx.scene.image.PixelFormat; import javafx.scene.image.WritableImage; import javafx.scene.paint.Color; +import javafx.scene.text.Font; import javafx.scene.text.FontSmoothingType; +import javafx.scene.text.Text; import java.io.ByteArrayInputStream; import java.util.HashMap; @@ -51,6 +53,10 @@ 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 final StringBuilder asciiRun = new StringBuilder(256); + private Font asciiBatchFont; + private double asciiBatchCellWidth; + private boolean asciiBatchSafe; GhosttyTerminalRenderer(TerminalMetrics metrics) { this.metrics = metrics; @@ -371,14 +377,95 @@ final class GhosttyTerminalRenderer extends TerminalRenderer { double cellWidth, double lineHeight ) { + boolean canBatchAscii = canBatchAscii(); + StringBuilder run = asciiRun; + run.setLength(0); + Color runForeground = null; + int runStartColumn = 0; + int previousColumn = -1; + for (RenderCell cell : row.cells()) { if (cell.kittyPlaceholder().isPresent() || cell.codepoints().length == 0) { + flushAsciiRun(gc, run, runForeground, left, baseline, cellWidth, lineHeight, row.row(), runStartColumn); + runForeground = null; + previousColumn = -1; continue; } - gc.setFill(cellForegroundColor(cell)); + Color foreground = cellForegroundColor(cell); + if (canBatchAscii && isBatchableAscii(cell)) { + if (run.length() == 0 || foreground != runForeground || cell.column() != previousColumn + 1) { + flushAsciiRun(gc, run, runForeground, left, baseline, cellWidth, lineHeight, row.row(), runStartColumn); + runForeground = foreground; + runStartColumn = cell.column(); + } + run.append(cell.text()); + previousColumn = cell.column(); + continue; + } + + flushAsciiRun(gc, run, runForeground, left, baseline, cellWidth, lineHeight, row.row(), runStartColumn); + runForeground = null; + previousColumn = -1; + gc.setFill(foreground); gc.fillText(cell.text(), left + (cell.column() * cellWidth), baseline + (row.row() * lineHeight)); } + flushAsciiRun(gc, run, runForeground, left, baseline, cellWidth, lineHeight, row.row(), runStartColumn); + } + + private boolean canBatchAscii() { + Font font = metrics.font(); + double cellWidth = metrics.cellWidth(); + if (font == asciiBatchFont && cellWidth == asciiBatchCellWidth) { + return asciiBatchSafe; + } + + asciiBatchFont = font; + asciiBatchCellWidth = cellWidth; + asciiBatchSafe = printableAsciiHasCellWidth(font, cellWidth); + return asciiBatchSafe; + } + + private static boolean printableAsciiHasCellWidth(Font font, double cellWidth) { + Text probe = new Text(); + probe.setFont(font); + for (int codepoint = 0x20; codepoint <= 0x7e; codepoint++) { + probe.setText(Character.toString((char) codepoint)); + if (Math.abs(Math.round(probe.getLayoutBounds().getWidth()) - cellWidth) > 0.01) { + return false; + } + } + for (String sample : List.of("iiii", "mmmm", "WwWw", "0O0O", "fi", "->", "==", "!=", "<=", ">=")) { + probe.setText(sample); + if (Math.abs(Math.round(probe.getLayoutBounds().getWidth()) - (sample.length() * cellWidth)) > 0.01) { + return false; + } + } + return true; + } + + private static boolean isBatchableAscii(RenderCell cell) { + int[] codepoints = cell.codepoints(); + return codepoints.length == 1 && codepoints[0] >= 0x20 && codepoints[0] <= 0x7e; + } + + private static void flushAsciiRun( + GraphicsContext gc, + StringBuilder run, + Color foreground, + double left, + double baseline, + double cellWidth, + double lineHeight, + int row, + int startColumn + ) { + if (run.length() == 0) { + return; + } + gc.setFill(foreground); + gc.fillText(run.toString(), left + (startColumn * cellWidth), baseline + (row * lineHeight)); + run.setLength(0); } // Background override for a cell: null means the pane default background already covers it. From 137db2402348cea4a362365dff89848d4c852c50 Mon Sep 17 00:00:00 2001 From: Gregor Lohaus Date: Sun, 31 May 2026 17:04:17 +0200 Subject: [PATCH 04/18] refert safe batching --- .../jprototerm/GhosttyTerminalRenderer.java | 89 +------------------ 1 file changed, 1 insertion(+), 88 deletions(-) diff --git a/src/main/java/com/gregor/jprototerm/GhosttyTerminalRenderer.java b/src/main/java/com/gregor/jprototerm/GhosttyTerminalRenderer.java index 82434d9..4cf7365 100644 --- a/src/main/java/com/gregor/jprototerm/GhosttyTerminalRenderer.java +++ b/src/main/java/com/gregor/jprototerm/GhosttyTerminalRenderer.java @@ -17,9 +17,7 @@ import javafx.scene.image.Image; import javafx.scene.image.PixelFormat; import javafx.scene.image.WritableImage; import javafx.scene.paint.Color; -import javafx.scene.text.Font; import javafx.scene.text.FontSmoothingType; -import javafx.scene.text.Text; import java.io.ByteArrayInputStream; import java.util.HashMap; @@ -53,10 +51,6 @@ 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 final StringBuilder asciiRun = new StringBuilder(256); - private Font asciiBatchFont; - private double asciiBatchCellWidth; - private boolean asciiBatchSafe; GhosttyTerminalRenderer(TerminalMetrics metrics) { this.metrics = metrics; @@ -377,95 +371,14 @@ final class GhosttyTerminalRenderer extends TerminalRenderer { double cellWidth, double lineHeight ) { - boolean canBatchAscii = canBatchAscii(); - StringBuilder run = asciiRun; - run.setLength(0); - Color runForeground = null; - int runStartColumn = 0; - int previousColumn = -1; - for (RenderCell cell : row.cells()) { if (cell.kittyPlaceholder().isPresent() || cell.codepoints().length == 0) { - flushAsciiRun(gc, run, runForeground, left, baseline, cellWidth, lineHeight, row.row(), runStartColumn); - runForeground = null; - previousColumn = -1; continue; } - Color foreground = cellForegroundColor(cell); - if (canBatchAscii && isBatchableAscii(cell)) { - if (run.length() == 0 || foreground != runForeground || cell.column() != previousColumn + 1) { - flushAsciiRun(gc, run, runForeground, left, baseline, cellWidth, lineHeight, row.row(), runStartColumn); - runForeground = foreground; - runStartColumn = cell.column(); - } - run.append(cell.text()); - previousColumn = cell.column(); - continue; - } - - flushAsciiRun(gc, run, runForeground, left, baseline, cellWidth, lineHeight, row.row(), runStartColumn); - runForeground = null; - previousColumn = -1; - gc.setFill(foreground); + gc.setFill(cellForegroundColor(cell)); gc.fillText(cell.text(), left + (cell.column() * cellWidth), baseline + (row.row() * lineHeight)); } - flushAsciiRun(gc, run, runForeground, left, baseline, cellWidth, lineHeight, row.row(), runStartColumn); - } - - private boolean canBatchAscii() { - Font font = metrics.font(); - double cellWidth = metrics.cellWidth(); - if (font == asciiBatchFont && cellWidth == asciiBatchCellWidth) { - return asciiBatchSafe; - } - - asciiBatchFont = font; - asciiBatchCellWidth = cellWidth; - asciiBatchSafe = printableAsciiHasCellWidth(font, cellWidth); - return asciiBatchSafe; - } - - private static boolean printableAsciiHasCellWidth(Font font, double cellWidth) { - Text probe = new Text(); - probe.setFont(font); - for (int codepoint = 0x20; codepoint <= 0x7e; codepoint++) { - probe.setText(Character.toString((char) codepoint)); - if (Math.abs(Math.round(probe.getLayoutBounds().getWidth()) - cellWidth) > 0.01) { - return false; - } - } - for (String sample : List.of("iiii", "mmmm", "WwWw", "0O0O", "fi", "->", "==", "!=", "<=", ">=")) { - probe.setText(sample); - if (Math.abs(Math.round(probe.getLayoutBounds().getWidth()) - (sample.length() * cellWidth)) > 0.01) { - return false; - } - } - return true; - } - - private static boolean isBatchableAscii(RenderCell cell) { - int[] codepoints = cell.codepoints(); - return codepoints.length == 1 && codepoints[0] >= 0x20 && codepoints[0] <= 0x7e; - } - - private static void flushAsciiRun( - GraphicsContext gc, - StringBuilder run, - Color foreground, - double left, - double baseline, - double cellWidth, - double lineHeight, - int row, - int startColumn - ) { - if (run.length() == 0) { - return; - } - gc.setFill(foreground); - gc.fillText(run.toString(), left + (startColumn * cellWidth), baseline + (row * lineHeight)); - run.setLength(0); } // Background override for a cell: null means the pane default background already covers it. From 86f7174eee9770fe0d7007dc65e37a8b48b1ddd4 Mon Sep 17 00:00:00 2001 From: Gregor Lohaus Date: Sun, 31 May 2026 17:14:07 +0200 Subject: [PATCH 05/18] 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; From a99cbdc61a708e1001d4be7b65b0bcf54404c1ad Mon Sep 17 00:00:00 2001 From: Gregor Lohaus Date: Sun, 31 May 2026 17:20:13 +0200 Subject: [PATCH 06/18] revert row diffing --- .../jprototerm/GhosttyTerminalRenderer.java | 177 +----------------- 1 file changed, 3 insertions(+), 174 deletions(-) diff --git a/src/main/java/com/gregor/jprototerm/GhosttyTerminalRenderer.java b/src/main/java/com/gregor/jprototerm/GhosttyTerminalRenderer.java index cbba25c..4cf7365 100644 --- a/src/main/java/com/gregor/jprototerm/GhosttyTerminalRenderer.java +++ b/src/main/java/com/gregor/jprototerm/GhosttyTerminalRenderer.java @@ -51,8 +51,6 @@ 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; @@ -66,10 +64,8 @@ final class GhosttyTerminalRenderer extends TerminalRenderer { double height = target.height(); gc.save(); clip(gc, px, py, width, height, target.clip()); - RenderStateSnapshot snapshot = target.snapshotFull(); - drawContent(gc, target, snapshot, px, py, width, height, active, + drawContent(gc, target, target.snapshotFull(), px, py, width, height, active, target.kittyEnabled() && hasKittyGraphics(target)); - rememberSnapshot(snapshot); gc.restore(); } @@ -83,18 +79,12 @@ 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. - RenderStateSnapshot snapshot = target.snapshotFull(); - drawContent(gc, target, snapshot, px, py, width, height, active, true); - rememberSnapshot(snapshot); + drawContent(gc, target, target.snapshotFull(), px, py, width, height, active, true); } else { RenderStateSnapshot snapshot = target.snapshot(); int dirty = snapshot == null ? DIRTY_FULL : snapshot.dirty(); if (dirty == DIRTY_FULL) { - 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); - } + drawContent(gc, target, snapshot, px, py, width, height, active, false); } else if (dirty == DIRTY_PARTIAL) { drawDirtyRows(gc, snapshot, px, py, width, height, active); } @@ -103,86 +93,6 @@ 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( @@ -280,7 +190,6 @@ 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 @@ -308,7 +217,6 @@ 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(); @@ -336,62 +244,6 @@ 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) { @@ -823,29 +675,6 @@ 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; From 494d2c40cf17bd88861b9dac2aa6ca4f955203b7 Mon Sep 17 00:00:00 2001 From: Gregor Lohaus Date: Sun, 31 May 2026 17:41:33 +0200 Subject: [PATCH 07/18] pixel buffer, scroll inference --- .../jprototerm/GhosttyTerminalRenderer.java | 602 +++++++++++++++++- 1 file changed, 598 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/gregor/jprototerm/GhosttyTerminalRenderer.java b/src/main/java/com/gregor/jprototerm/GhosttyTerminalRenderer.java index 4cf7365..471d302 100644 --- a/src/main/java/com/gregor/jprototerm/GhosttyTerminalRenderer.java +++ b/src/main/java/com/gregor/jprototerm/GhosttyTerminalRenderer.java @@ -12,14 +12,21 @@ import dev.jlibghostty.RenderColor; import dev.jlibghostty.RenderCursorStyle; import dev.jlibghostty.RenderRow; import dev.jlibghostty.RenderStateSnapshot; +import javafx.scene.SnapshotParameters; +import javafx.scene.canvas.Canvas; import javafx.scene.canvas.GraphicsContext; import javafx.scene.image.Image; import javafx.scene.image.PixelFormat; +import javafx.scene.image.PixelBuffer; +import javafx.scene.image.PixelReader; import javafx.scene.image.WritableImage; import javafx.scene.paint.Color; import javafx.scene.text.FontSmoothingType; +import javafx.scene.text.Text; import java.io.ByteArrayInputStream; +import java.nio.IntBuffer; +import java.util.Arrays; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -51,6 +58,7 @@ 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 final SoftwareBackbuffer software = new SoftwareBackbuffer(); GhosttyTerminalRenderer(TerminalMetrics metrics) { this.metrics = metrics; @@ -64,8 +72,14 @@ 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, - target.kittyEnabled() && hasKittyGraphics(target)); + boolean withKitty = target.kittyEnabled() && hasKittyGraphics(target); + RenderStateSnapshot snapshot = target.snapshotFull(); + if (withKitty) { + drawContent(gc, target, snapshot, px, py, width, height, active, true); + software.invalidate(); + } else { + software.paintFull(gc, snapshot, px, py, width, height, active); + } gc.restore(); } @@ -80,13 +94,18 @@ final class GhosttyTerminalRenderer extends TerminalRenderer { 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); + software.invalidate(); } 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); + software.paintFullOrShifted(gc, target.snapshotFull(), px, py, width, height, active); } else if (dirty == DIRTY_PARTIAL) { - drawDirtyRows(gc, snapshot, px, py, width, height, active); + if (snapshot != null && snapshot.renderRows().size() == snapshot.rows()) { + software.paintFullOrShifted(gc, snapshot, px, py, width, height, active); + } else { + software.paintDirty(gc, snapshot, px, py, width, height, active); + } } // dirty == FALSE: nothing visible changed. } @@ -651,6 +670,581 @@ final class GhosttyTerminalRenderer extends TerminalRenderer { return result; } + private final class SoftwareBackbuffer { + private int width; + private int height; + private int[] pixels = new int[0]; + private PixelBuffer pixelBuffer; + private WritableImage image; + private long[] rowHashes = new long[0]; + private CursorState lastCursor = CursorState.none(); + private GlyphCache glyphs; + + private void invalidate() { + rowHashes = new long[0]; + lastCursor = CursorState.none(); + } + + private void paintFull(GraphicsContext gc, RenderStateSnapshot snapshot, + double px, double py, double paneWidth, double paneHeight, boolean active) { + ensure(paneWidth, paneHeight); + fillRect(0, 0, width, height, argbPre(PANE_BACKGROUND)); + if (snapshot != null) { + paintSnapshot(snapshot); + drawCursor(snapshot); + rememberSnapshot(snapshot); + } else { + invalidate(); + } + drawBorder(active); + present(gc, px, py); + } + + private void paintFullOrShifted(GraphicsContext gc, RenderStateSnapshot snapshot, + double px, double py, double paneWidth, double paneHeight, boolean active) { + ensure(paneWidth, paneHeight); + if (snapshot == null || !canDiff(snapshot)) { + paintFull(gc, snapshot, px, py, paneWidth, paneHeight, active); + return; + } + + long[] currentHashes = hashes(snapshot); + int scroll = inferScroll(currentHashes); + if (scroll != 0) { + scrollContentPixels(scroll); + scrollHashes(scroll); + } + + CursorState cursor = CursorState.from(snapshot); + int oldCursorRow = shiftedCursorRow(lastCursor, scroll); + int newCursorRow = cursor.viewportRow(); + boolean cursorChanged = !cursor.equals(lastCursor); + for (RenderRow row : snapshot.renderRows()) { + int rowIndex = row.row(); + boolean repaint = currentHashes[rowIndex] != rowHashes[rowIndex] + || rowIndex == newCursorRow + || (cursorChanged && rowIndex == oldCursorRow); + if (repaint) { + paintRow(row); + rowHashes[rowIndex] = currentHashes[rowIndex]; + } + } + lastCursor = cursor; + drawCursor(snapshot); + drawBorder(active); + present(gc, px, py); + } + + private void paintDirty(GraphicsContext gc, RenderStateSnapshot snapshot, + double px, double py, double paneWidth, double paneHeight, boolean active) { + ensure(paneWidth, paneHeight); + if (snapshot == null) { + return; + } + if (rowHashes.length != snapshot.rows()) { + paintFull(gc, snapshot, px, py, paneWidth, paneHeight, active); + return; + } + + CursorState cursor = CursorState.from(snapshot); + int oldCursorRow = lastCursor.viewportRow(); + int newCursorRow = cursor.viewportRow(); + boolean cursorChanged = !cursor.equals(lastCursor); + boolean[] repainted = new boolean[snapshot.rows()]; + + for (RenderRow row : snapshot.renderRows()) { + if (!row.dirty()) { + continue; + } + paintRow(row); + rowHashes[row.row()] = rowHash(row); + repainted[row.row()] = true; + } + + if (cursorChanged) { + repaintCursorRow(snapshot, oldCursorRow, repainted); + } + repaintCursorRow(snapshot, newCursorRow, repainted); + lastCursor = cursor; + drawCursor(snapshot); + drawBorder(active); + present(gc, px, py); + } + + private void repaintCursorRow(RenderStateSnapshot snapshot, int rowIndex, boolean[] repainted) { + if (rowIndex < 0 || rowIndex >= repainted.length || repainted[rowIndex]) { + return; + } + RenderRow row = rowByIndex(snapshot, rowIndex); + if (row != null) { + paintRow(row); + rowHashes[rowIndex] = rowHash(row); + repainted[rowIndex] = true; + } + } + + private RenderRow rowByIndex(RenderStateSnapshot snapshot, int rowIndex) { + for (RenderRow row : snapshot.renderRows()) { + if (row.row() == rowIndex) { + return row; + } + } + return null; + } + + private void ensure(double paneWidth, double paneHeight) { + int nextWidth = Math.max(1, (int) Math.round(paneWidth)); + int nextHeight = Math.max(1, (int) Math.round(paneHeight)); + if (nextWidth == width && nextHeight == height && image != null) { + ensureGlyphs(); + return; + } + + width = nextWidth; + height = nextHeight; + pixels = new int[width * height]; + pixelBuffer = new PixelBuffer<>(width, height, IntBuffer.wrap(pixels), PixelFormat.getIntArgbPreInstance()); + image = new WritableImage(pixelBuffer); + invalidate(); + ensureGlyphs(); + } + + private void ensureGlyphs() { + int cellWidth = cellWidth(); + int lineHeight = lineHeight(); + double baseline = metrics.baselineOffset(); + if (glyphs == null || glyphs.font != metrics.font() + || glyphs.cellWidth != cellWidth || glyphs.lineHeight != lineHeight || glyphs.baseline != baseline) { + glyphs = new GlyphCache(metrics.font(), cellWidth, lineHeight, baseline); + } + } + + private void present(GraphicsContext gc, double px, double py) { + pixelBuffer.updateBuffer(ignored -> null); + gc.drawImage(image, px, py); + } + + private boolean canDiff(RenderStateSnapshot snapshot) { + return rowHashes.length == snapshot.rows() && snapshot.renderRows().size() == snapshot.rows(); + } + + private void rememberSnapshot(RenderStateSnapshot snapshot) { + if (rowHashes.length != snapshot.rows()) { + rowHashes = new long[snapshot.rows()]; + } + for (RenderRow row : snapshot.renderRows()) { + rowHashes[row.row()] = rowHash(row); + } + lastCursor = CursorState.from(snapshot); + } + + private long[] hashes(RenderStateSnapshot snapshot) { + long[] hashes = new long[snapshot.rows()]; + for (RenderRow row : snapshot.renderRows()) { + hashes[row.row()] = rowHash(row); + } + return hashes; + } + + private int inferScroll(long[] currentHashes) { + int rows = currentHashes.length; + int bestDelta = 0; + int bestScore = 0; + int maxDelta = Math.min(8, Math.max(0, rows - 1)); + for (int delta = -maxDelta; delta <= maxDelta; delta++) { + if (delta == 0) { + continue; + } + int score = 0; + int overlap = 0; + for (int row = 0; row < rows; row++) { + int previous = row - delta; + if (previous < 0 || previous >= rows) { + continue; + } + overlap++; + if (currentHashes[row] == rowHashes[previous]) { + score++; + } + } + if (score > bestScore || (score == bestScore && Math.abs(delta) < Math.abs(bestDelta))) { + bestScore = score; + bestDelta = delta; + } + } + int threshold = Math.max(3, (int) Math.ceil(rows * 0.55)); + return bestScore >= threshold ? bestDelta : 0; + } + + private int shiftedCursorRow(CursorState cursor, int scroll) { + int row = cursor.viewportRow(); + if (row < 0) { + return -1; + } + row += scroll; + return row >= 0 && row < rowHashes.length ? row : -1; + } + + private void scrollHashes(int rows) { + long[] shifted = new long[rowHashes.length]; + for (int row = 0; row < shifted.length; row++) { + int previous = row - rows; + if (previous >= 0 && previous < rowHashes.length) { + shifted[row] = rowHashes[previous]; + } + } + rowHashes = shifted; + } + + private void scrollContentPixels(int rows) { + int dy = rows * lineHeight(); + int top = contentTop(); + int contentHeight = rowHashes.length * lineHeight(); + if (dy == 0 || Math.abs(dy) >= contentHeight) { + fillRect(0, top, width, contentHeight, argbPre(PANE_BACKGROUND)); + return; + } + + if (dy < 0) { + int srcY = top - dy; + int dstY = top; + int copyHeight = contentHeight + dy; + for (int y = 0; y < copyHeight; y++) { + System.arraycopy(pixels, (srcY + y) * width, pixels, (dstY + y) * width, width); + } + fillRect(0, top + copyHeight, width, -dy, argbPre(PANE_BACKGROUND)); + } else { + int srcY = top; + int dstY = top + dy; + int copyHeight = contentHeight - dy; + for (int y = copyHeight - 1; y >= 0; y--) { + System.arraycopy(pixels, (srcY + y) * width, pixels, (dstY + y) * width, width); + } + fillRect(0, top, width, dy, argbPre(PANE_BACKGROUND)); + } + } + + private void paintSnapshot(RenderStateSnapshot snapshot) { + List rows = snapshot.renderRows(); + if (rows.isEmpty()) { + return; + } + int top = contentTop(); + int contentBottom = top + snapshot.rows() * lineHeight(); + fillRect(0, 0, width, top, argbPre(rowEdgeBackground(rows.get(0), true))); + fillRect(0, contentBottom, width, height - contentBottom, argbPre(rowEdgeBackground(rows.get(rows.size() - 1), true))); + for (RenderRow row : rows) { + paintRow(row); + } + } + + private void paintRow(RenderRow row) { + int rowTop = contentTop() + (row.row() * lineHeight()); + int rowHeight = lineHeight(); + fillRect(0, rowTop, width, rowHeight, argbPre(PANE_BACKGROUND)); + if (row.row() == 0) { + fillRect(0, 0, width, contentTop(), argbPre(rowEdgeBackground(row, true))); + } + if (row.row() == rowHashes.length - 1) { + int bottom = contentTop() + (rowHashes.length * lineHeight()); + fillRect(0, bottom, width, height - bottom, argbPre(rowEdgeBackground(row, true))); + } + paintRowSidePadding(row, rowTop, rowHeight); + paintRowBackgrounds(row, rowTop, rowHeight); + paintRowText(row, rowTop); + } + + private void paintRowSidePadding(RenderRow row, int rowTop, int rowHeight) { + List cells = row.cells(); + if (cells.isEmpty()) { + return; + } + int left = contentLeft(); + int contentRight = left + (cells.size() * cellWidth()); + fillRect(0, rowTop, left, rowHeight, argbPre(rowEdgeBackground(row, true))); + fillRect(contentRight, rowTop, width - contentRight, rowHeight, argbPre(rowEdgeBackground(row, false))); + } + + private void paintRowBackgrounds(RenderRow row, int rowTop, int rowHeight) { + int cellWidth = cellWidth(); + Color runBackground = null; + int runStartColumn = 0; + int previousColumn = -1; + for (RenderCell cell : row.cells()) { + if (cell.kittyPlaceholder().isPresent()) { + flushBackground(runBackground, runStartColumn, previousColumn, rowTop, rowHeight); + runBackground = null; + previousColumn = -1; + continue; + } + + Color bg = cell.selected() ? SELECTED_BACKGROUND : cellBackgroundOverride(cell); + if (bg == null) { + flushBackground(runBackground, runStartColumn, previousColumn, rowTop, rowHeight); + runBackground = null; + previousColumn = -1; + continue; + } + if (runBackground == null || bg != runBackground || cell.column() != previousColumn + 1) { + flushBackground(runBackground, runStartColumn, previousColumn, rowTop, rowHeight); + runBackground = bg; + runStartColumn = cell.column(); + } + previousColumn = cell.column(); + } + flushBackground(runBackground, runStartColumn, previousColumn, rowTop, rowHeight); + } + + private void flushBackground(Color background, int startColumn, int endColumn, int rowTop, int rowHeight) { + if (background == null || endColumn < startColumn) { + return; + } + fillRect(contentLeft() + (startColumn * cellWidth()), rowTop, + (endColumn - startColumn + 1) * cellWidth(), rowHeight, argbPre(background)); + } + + private void paintRowText(RenderRow row, int rowTop) { + int cellWidth = cellWidth(); + int x0 = contentLeft(); + for (RenderCell cell : row.cells()) { + if (cell.kittyPlaceholder().isPresent() || cell.codepoints().length == 0) { + continue; + } + Glyph glyph = glyphs.glyph(cell.text()); + int color = rgb(cellForegroundColor(cell)); + blitGlyph(glyph, x0 + (cell.column() * cellWidth), rowTop, color); + } + } + + private void blitGlyph(Glyph glyph, int x, int y, int rgb) { + int red = (rgb >> 16) & 0xff; + int green = (rgb >> 8) & 0xff; + int blue = rgb & 0xff; + for (int gy = 0; gy < glyph.height; gy++) { + int py = y + gy; + if (py < 0 || py >= height) { + continue; + } + int rowOffset = py * width; + int glyphOffset = gy * glyph.width; + for (int gx = 0; gx < glyph.width; gx++) { + int px = x + gx; + if (px < 0 || px >= width) { + continue; + } + int alpha = glyph.alpha[glyphOffset + gx] & 0xff; + if (alpha == 0) { + continue; + } + int index = rowOffset + px; + pixels[index] = blendOpaque(pixels[index], red, green, blue, alpha); + } + } + } + + private int blendOpaque(int dst, int red, int green, int blue, int alpha) { + int inv = 255 - alpha; + int dstRed = (dst >> 16) & 0xff; + int dstGreen = (dst >> 8) & 0xff; + int dstBlue = dst & 0xff; + int outRed = ((red * alpha) + (dstRed * inv) + 127) / 255; + int outGreen = ((green * alpha) + (dstGreen * inv) + 127) / 255; + int outBlue = ((blue * alpha) + (dstBlue * inv) + 127) / 255; + return 0xff000000 | (outRed << 16) | (outGreen << 8) | outBlue; + } + + private void drawCursor(RenderStateSnapshot snapshot) { + if (!snapshot.cursorVisible() || !snapshot.cursorViewportHasValue()) { + return; + } + int x = contentLeft() + ((int) snapshot.cursorViewportX() * cellWidth()); + int y = contentTop() + ((int) snapshot.cursorViewportY() * lineHeight()); + int cw = cellWidth(); + int lh = lineHeight(); + RenderCursorStyle style = snapshot.cursorStyle(); + if (style == RenderCursorStyle.BAR) { + fillRect(x, y + 2, 1, Math.max(1, lh - 4), argbPre(DEFAULT_FOREGROUND)); + } else if (style == RenderCursorStyle.UNDERLINE) { + fillRect(x + 1, y + lh - 2, Math.max(1, cw - 2), 1, argbPre(DEFAULT_FOREGROUND)); + } else if (style == RenderCursorStyle.BLOCK) { + fillRectAlpha(x, y + 1, Math.max(1, cw - 1), Math.max(1, lh - 2), CURSOR_FILL); + } else { + strokeRect(x, y + 1, Math.max(1, cw - 1), Math.max(1, lh - 2), argbPre(DEFAULT_FOREGROUND), 1); + } + } + + private void drawBorder(boolean active) { + strokeRect(0, 0, width, height, argbPre(active ? ACTIVE_BORDER : INACTIVE_BORDER), active ? 2 : 1); + } + + private void strokeRect(int x, int y, int w, int h, int color, int lineWidth) { + for (int i = 0; i < lineWidth; i++) { + fillRect(x + i, y + i, w - (2 * i), 1, color); + fillRect(x + i, y + h - 1 - i, w - (2 * i), 1, color); + fillRect(x + i, y + i, 1, h - (2 * i), color); + fillRect(x + w - 1 - i, y + i, 1, h - (2 * i), color); + } + } + + private void fillRectAlpha(int x, int y, int w, int h, Color color) { + int alpha = (int) Math.round(color.getOpacity() * 255.0); + int rgb = rgb(color); + int red = (rgb >> 16) & 0xff; + int green = (rgb >> 8) & 0xff; + int blue = rgb & 0xff; + int x0 = Math.max(0, x); + int y0 = Math.max(0, y); + int x1 = Math.min(width, x + w); + int y1 = Math.min(height, y + h); + for (int py = y0; py < y1; py++) { + int offset = py * width; + for (int px = x0; px < x1; px++) { + int index = offset + px; + pixels[index] = blendOpaque(pixels[index], red, green, blue, alpha); + } + } + } + + private void fillRect(int x, int y, int w, int h, int color) { + int x0 = Math.max(0, x); + int y0 = Math.max(0, y); + int x1 = Math.min(width, x + w); + int y1 = Math.min(height, y + h); + if (x0 >= x1 || y0 >= y1) { + return; + } + for (int py = y0; py < y1; py++) { + Arrays.fill(pixels, (py * width) + x0, (py * width) + x1, color); + } + } + + private int contentLeft() { + return (int) Math.round(TerminalMetrics.PADDING); + } + + private int contentTop() { + return (int) Math.round(TerminalMetrics.PADDING); + } + + private int cellWidth() { + return Math.max(1, (int) Math.round(metrics.cellWidth())); + } + + private int lineHeight() { + return Math.max(1, (int) Math.round(metrics.lineHeight())); + } + } + + private final class GlyphCache { + private final javafx.scene.text.Font font; + private final int cellWidth; + private final int lineHeight; + private final double baseline; + private final Map glyphs = new HashMap<>(); + + private GlyphCache(javafx.scene.text.Font font, int cellWidth, int lineHeight, double baseline) { + this.font = font; + this.cellWidth = cellWidth; + this.lineHeight = lineHeight; + this.baseline = baseline; + } + + private Glyph glyph(String text) { + return glyphs.computeIfAbsent(text, this::renderGlyph); + } + + private Glyph renderGlyph(String value) { + Text measured = new Text(value); + measured.setFont(font); + int glyphWidth = Math.max(cellWidth, (int) Math.ceil(measured.getLayoutBounds().getWidth()) + 2); + Canvas canvas = new Canvas(glyphWidth, lineHeight); + GraphicsContext gc = canvas.getGraphicsContext2D(); + gc.setFontSmoothingType(FontSmoothingType.GRAY); + gc.setFont(font); + gc.setFill(Color.WHITE); + gc.fillText(value, 0.0, baseline); + + SnapshotParameters parameters = new SnapshotParameters(); + parameters.setFill(Color.TRANSPARENT); + WritableImage snapshot = canvas.snapshot(parameters, null); + PixelReader reader = snapshot.getPixelReader(); + byte[] alpha = new byte[glyphWidth * lineHeight]; + for (int y = 0; y < lineHeight; y++) { + int offset = y * glyphWidth; + for (int x = 0; x < glyphWidth; x++) { + alpha[offset + x] = (byte) ((reader.getArgb(x, y) >>> 24) & 0xff); + } + } + return new Glyph(glyphWidth, lineHeight, alpha); + } + } + + private record Glyph(int width, int height, byte[] alpha) { + } + + private record CursorState(boolean visible, boolean hasViewport, int column, int row, RenderCursorStyle style) { + private static CursorState none() { + return new CursorState(false, false, -1, -1, null); + } + + private static CursorState from(RenderStateSnapshot snapshot) { + if (snapshot == null || !snapshot.cursorVisible() || !snapshot.cursorViewportHasValue()) { + return none(); + } + return new CursorState(true, true, (int) snapshot.cursorViewportX(), (int) snapshot.cursorViewportY(), snapshot.cursorStyle()); + } + + private int viewportRow() { + return visible && hasViewport ? row : -1; + } + } + + private static long rowHash(RenderRow row) { + long hash = 0xcbf29ce484222325L; + 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) { + return color == null ? mix(hash, -1) : mix(hash, (color.red() << 16) | (color.green() << 8) | color.blue()); + } + + private static long mix(long hash, long value) { + hash ^= value; + return hash * 0x100000001b3L; + } + + private static int rgb(Color color) { + int red = (int) Math.round(color.getRed() * 255.0); + int green = (int) Math.round(color.getGreen() * 255.0); + int blue = (int) Math.round(color.getBlue() * 255.0); + return (red << 16) | (green << 8) | blue; + } + + private static int argbPre(Color color) { + int alpha = (int) Math.round(color.getOpacity() * 255.0); + int red = (int) Math.round(color.getRed() * alpha); + int green = (int) Math.round(color.getGreen() * alpha); + int blue = (int) Math.round(color.getBlue() * alpha); + return (alpha << 24) | (red << 16) | (green << 8) | blue; + } + // A kitty image is immutable for a given (id, number); re-transmitting under the same id // changes the number (and the snapshot below evicts stale entries by id anyway). So the // identity + dimensions + payload length are enough to key the decoded-image cache, and From 586150de591d5bd6acaca7085851b954cb97870f Mon Sep 17 00:00:00 2001 From: Gregor Lohaus Date: Sun, 31 May 2026 17:48:04 +0200 Subject: [PATCH 08/18] Fixed the partial-dirty blanking regression --- .../jprototerm/GhosttyTerminalRenderer.java | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/src/main/java/com/gregor/jprototerm/GhosttyTerminalRenderer.java b/src/main/java/com/gregor/jprototerm/GhosttyTerminalRenderer.java index 471d302..088dc47 100644 --- a/src/main/java/com/gregor/jprototerm/GhosttyTerminalRenderer.java +++ b/src/main/java/com/gregor/jprototerm/GhosttyTerminalRenderer.java @@ -101,7 +101,9 @@ final class GhosttyTerminalRenderer extends TerminalRenderer { if (dirty == DIRTY_FULL) { software.paintFullOrShifted(gc, target.snapshotFull(), px, py, width, height, active); } else if (dirty == DIRTY_PARTIAL) { - if (snapshot != null && snapshot.renderRows().size() == snapshot.rows()) { + if (!software.canPaintDirty(snapshot)) { + software.paintFullOrShifted(gc, target.snapshotFull(), px, py, width, height, active); + } else if (snapshot != null && snapshot.renderRows().size() == snapshot.rows()) { software.paintFullOrShifted(gc, snapshot, px, py, width, height, active); } else { software.paintDirty(gc, snapshot, px, py, width, height, active); @@ -679,10 +681,16 @@ final class GhosttyTerminalRenderer extends TerminalRenderer { private long[] rowHashes = new long[0]; private CursorState lastCursor = CursorState.none(); private GlyphCache glyphs; + private boolean valid; private void invalidate() { rowHashes = new long[0]; lastCursor = CursorState.none(); + valid = false; + } + + private boolean canPaintDirty(RenderStateSnapshot snapshot) { + return valid && snapshot != null && rowHashes.length == snapshot.rows(); } private void paintFull(GraphicsContext gc, RenderStateSnapshot snapshot, @@ -693,6 +701,7 @@ final class GhosttyTerminalRenderer extends TerminalRenderer { paintSnapshot(snapshot); drawCursor(snapshot); rememberSnapshot(snapshot); + valid = true; } else { invalidate(); } @@ -730,6 +739,7 @@ final class GhosttyTerminalRenderer extends TerminalRenderer { } } lastCursor = cursor; + valid = true; drawCursor(snapshot); drawBorder(active); present(gc, px, py); @@ -738,14 +748,9 @@ final class GhosttyTerminalRenderer extends TerminalRenderer { private void paintDirty(GraphicsContext gc, RenderStateSnapshot snapshot, double px, double py, double paneWidth, double paneHeight, boolean active) { ensure(paneWidth, paneHeight); - if (snapshot == null) { + if (!canPaintDirty(snapshot)) { return; } - if (rowHashes.length != snapshot.rows()) { - paintFull(gc, snapshot, px, py, paneWidth, paneHeight, active); - return; - } - CursorState cursor = CursorState.from(snapshot); int oldCursorRow = lastCursor.viewportRow(); int newCursorRow = cursor.viewportRow(); @@ -766,6 +771,7 @@ final class GhosttyTerminalRenderer extends TerminalRenderer { } repaintCursorRow(snapshot, newCursorRow, repainted); lastCursor = cursor; + valid = true; drawCursor(snapshot); drawBorder(active); present(gc, px, py); @@ -856,13 +862,11 @@ final class GhosttyTerminalRenderer extends TerminalRenderer { continue; } int score = 0; - int overlap = 0; for (int row = 0; row < rows; row++) { int previous = row - delta; if (previous < 0 || previous >= rows) { continue; } - overlap++; if (currentHashes[row] == rowHashes[previous]) { score++; } From 38822d66b8736dc120548671c02d3fa99eaab5b2 Mon Sep 17 00:00:00 2001 From: Gregor Lohaus Date: Sun, 31 May 2026 17:51:53 +0200 Subject: [PATCH 09/18] Fixed the partial-dirty blanking regression --- .../jprototerm/GhosttyTerminalRenderer.java | 52 ++++++++++++++++--- 1 file changed, 46 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/gregor/jprototerm/GhosttyTerminalRenderer.java b/src/main/java/com/gregor/jprototerm/GhosttyTerminalRenderer.java index 088dc47..f84709a 100644 --- a/src/main/java/com/gregor/jprototerm/GhosttyTerminalRenderer.java +++ b/src/main/java/com/gregor/jprototerm/GhosttyTerminalRenderer.java @@ -679,18 +679,21 @@ final class GhosttyTerminalRenderer extends TerminalRenderer { private PixelBuffer pixelBuffer; private WritableImage image; private long[] rowHashes = new long[0]; + private boolean[] paintedRows = new boolean[0]; private CursorState lastCursor = CursorState.none(); private GlyphCache glyphs; private boolean valid; private void invalidate() { rowHashes = new long[0]; + paintedRows = new boolean[0]; lastCursor = CursorState.none(); valid = false; } private boolean canPaintDirty(RenderStateSnapshot snapshot) { - return valid && snapshot != null && rowHashes.length == snapshot.rows(); + return valid && snapshot != null && rowHashes.length == snapshot.rows() + && paintedRows.length == snapshot.rows() && allRowsPainted(); } private void paintFull(GraphicsContext gc, RenderStateSnapshot snapshot, @@ -698,10 +701,11 @@ final class GhosttyTerminalRenderer extends TerminalRenderer { ensure(paneWidth, paneHeight); fillRect(0, 0, width, height, argbPre(PANE_BACKGROUND)); if (snapshot != null) { + prepareRows(snapshot.rows()); paintSnapshot(snapshot); drawCursor(snapshot); rememberSnapshot(snapshot); - valid = true; + valid = allRowsPainted(); } else { invalidate(); } @@ -722,6 +726,7 @@ final class GhosttyTerminalRenderer extends TerminalRenderer { if (scroll != 0) { scrollContentPixels(scroll); scrollHashes(scroll); + scrollPaintedRows(scroll); } CursorState cursor = CursorState.from(snapshot); @@ -739,7 +744,7 @@ final class GhosttyTerminalRenderer extends TerminalRenderer { } } lastCursor = cursor; - valid = true; + valid = allRowsPainted(); drawCursor(snapshot); drawBorder(active); present(gc, px, py); @@ -771,7 +776,7 @@ final class GhosttyTerminalRenderer extends TerminalRenderer { } repaintCursorRow(snapshot, newCursorRow, repainted); lastCursor = cursor; - valid = true; + valid = allRowsPainted(); drawCursor(snapshot); drawBorder(active); present(gc, px, py); @@ -831,12 +836,12 @@ final class GhosttyTerminalRenderer extends TerminalRenderer { } private boolean canDiff(RenderStateSnapshot snapshot) { - return rowHashes.length == snapshot.rows() && snapshot.renderRows().size() == snapshot.rows(); + return canPaintDirty(snapshot) && snapshot.renderRows().size() == snapshot.rows(); } private void rememberSnapshot(RenderStateSnapshot snapshot) { if (rowHashes.length != snapshot.rows()) { - rowHashes = new long[snapshot.rows()]; + prepareRows(snapshot.rows()); } for (RenderRow row : snapshot.renderRows()) { rowHashes[row.row()] = rowHash(row); @@ -900,6 +905,17 @@ final class GhosttyTerminalRenderer extends TerminalRenderer { rowHashes = shifted; } + private void scrollPaintedRows(int rows) { + boolean[] shifted = new boolean[paintedRows.length]; + for (int row = 0; row < shifted.length; row++) { + int previous = row - rows; + if (previous >= 0 && previous < paintedRows.length) { + shifted[row] = paintedRows[previous]; + } + } + paintedRows = shifted; + } + private void scrollContentPixels(int rows) { int dy = rows * lineHeight(); int top = contentTop(); @@ -956,6 +972,30 @@ final class GhosttyTerminalRenderer extends TerminalRenderer { paintRowSidePadding(row, rowTop, rowHeight); paintRowBackgrounds(row, rowTop, rowHeight); paintRowText(row, rowTop); + markPainted(row.row()); + } + + private void prepareRows(int rows) { + rowHashes = new long[rows]; + paintedRows = new boolean[rows]; + } + + private void markPainted(int row) { + if (row >= 0 && row < paintedRows.length) { + paintedRows[row] = true; + } + } + + private boolean allRowsPainted() { + if (paintedRows.length == 0) { + return false; + } + for (boolean painted : paintedRows) { + if (!painted) { + return false; + } + } + return true; } private void paintRowSidePadding(RenderRow row, int rowTop, int rowHeight) { From e6848ec684b879494ea928c2f5ea9d2ae4ff506c Mon Sep 17 00:00:00 2001 From: Gregor Lohaus Date: Sun, 31 May 2026 17:56:36 +0200 Subject: [PATCH 10/18] revert failed fixed --- .../jprototerm/GhosttyTerminalRenderer.java | 66 ++++--------------- 1 file changed, 11 insertions(+), 55 deletions(-) diff --git a/src/main/java/com/gregor/jprototerm/GhosttyTerminalRenderer.java b/src/main/java/com/gregor/jprototerm/GhosttyTerminalRenderer.java index f84709a..471d302 100644 --- a/src/main/java/com/gregor/jprototerm/GhosttyTerminalRenderer.java +++ b/src/main/java/com/gregor/jprototerm/GhosttyTerminalRenderer.java @@ -101,9 +101,7 @@ final class GhosttyTerminalRenderer extends TerminalRenderer { if (dirty == DIRTY_FULL) { software.paintFullOrShifted(gc, target.snapshotFull(), px, py, width, height, active); } else if (dirty == DIRTY_PARTIAL) { - if (!software.canPaintDirty(snapshot)) { - software.paintFullOrShifted(gc, target.snapshotFull(), px, py, width, height, active); - } else if (snapshot != null && snapshot.renderRows().size() == snapshot.rows()) { + if (snapshot != null && snapshot.renderRows().size() == snapshot.rows()) { software.paintFullOrShifted(gc, snapshot, px, py, width, height, active); } else { software.paintDirty(gc, snapshot, px, py, width, height, active); @@ -679,21 +677,12 @@ final class GhosttyTerminalRenderer extends TerminalRenderer { private PixelBuffer pixelBuffer; private WritableImage image; private long[] rowHashes = new long[0]; - private boolean[] paintedRows = new boolean[0]; private CursorState lastCursor = CursorState.none(); private GlyphCache glyphs; - private boolean valid; private void invalidate() { rowHashes = new long[0]; - paintedRows = new boolean[0]; lastCursor = CursorState.none(); - valid = false; - } - - private boolean canPaintDirty(RenderStateSnapshot snapshot) { - return valid && snapshot != null && rowHashes.length == snapshot.rows() - && paintedRows.length == snapshot.rows() && allRowsPainted(); } private void paintFull(GraphicsContext gc, RenderStateSnapshot snapshot, @@ -701,11 +690,9 @@ final class GhosttyTerminalRenderer extends TerminalRenderer { ensure(paneWidth, paneHeight); fillRect(0, 0, width, height, argbPre(PANE_BACKGROUND)); if (snapshot != null) { - prepareRows(snapshot.rows()); paintSnapshot(snapshot); drawCursor(snapshot); rememberSnapshot(snapshot); - valid = allRowsPainted(); } else { invalidate(); } @@ -726,7 +713,6 @@ final class GhosttyTerminalRenderer extends TerminalRenderer { if (scroll != 0) { scrollContentPixels(scroll); scrollHashes(scroll); - scrollPaintedRows(scroll); } CursorState cursor = CursorState.from(snapshot); @@ -744,7 +730,6 @@ final class GhosttyTerminalRenderer extends TerminalRenderer { } } lastCursor = cursor; - valid = allRowsPainted(); drawCursor(snapshot); drawBorder(active); present(gc, px, py); @@ -753,9 +738,14 @@ final class GhosttyTerminalRenderer extends TerminalRenderer { private void paintDirty(GraphicsContext gc, RenderStateSnapshot snapshot, double px, double py, double paneWidth, double paneHeight, boolean active) { ensure(paneWidth, paneHeight); - if (!canPaintDirty(snapshot)) { + if (snapshot == null) { return; } + if (rowHashes.length != snapshot.rows()) { + paintFull(gc, snapshot, px, py, paneWidth, paneHeight, active); + return; + } + CursorState cursor = CursorState.from(snapshot); int oldCursorRow = lastCursor.viewportRow(); int newCursorRow = cursor.viewportRow(); @@ -776,7 +766,6 @@ final class GhosttyTerminalRenderer extends TerminalRenderer { } repaintCursorRow(snapshot, newCursorRow, repainted); lastCursor = cursor; - valid = allRowsPainted(); drawCursor(snapshot); drawBorder(active); present(gc, px, py); @@ -836,12 +825,12 @@ final class GhosttyTerminalRenderer extends TerminalRenderer { } private boolean canDiff(RenderStateSnapshot snapshot) { - return canPaintDirty(snapshot) && snapshot.renderRows().size() == snapshot.rows(); + return rowHashes.length == snapshot.rows() && snapshot.renderRows().size() == snapshot.rows(); } private void rememberSnapshot(RenderStateSnapshot snapshot) { if (rowHashes.length != snapshot.rows()) { - prepareRows(snapshot.rows()); + rowHashes = new long[snapshot.rows()]; } for (RenderRow row : snapshot.renderRows()) { rowHashes[row.row()] = rowHash(row); @@ -867,11 +856,13 @@ final class GhosttyTerminalRenderer extends TerminalRenderer { continue; } int score = 0; + int overlap = 0; for (int row = 0; row < rows; row++) { int previous = row - delta; if (previous < 0 || previous >= rows) { continue; } + overlap++; if (currentHashes[row] == rowHashes[previous]) { score++; } @@ -905,17 +896,6 @@ final class GhosttyTerminalRenderer extends TerminalRenderer { rowHashes = shifted; } - private void scrollPaintedRows(int rows) { - boolean[] shifted = new boolean[paintedRows.length]; - for (int row = 0; row < shifted.length; row++) { - int previous = row - rows; - if (previous >= 0 && previous < paintedRows.length) { - shifted[row] = paintedRows[previous]; - } - } - paintedRows = shifted; - } - private void scrollContentPixels(int rows) { int dy = rows * lineHeight(); int top = contentTop(); @@ -972,30 +952,6 @@ final class GhosttyTerminalRenderer extends TerminalRenderer { paintRowSidePadding(row, rowTop, rowHeight); paintRowBackgrounds(row, rowTop, rowHeight); paintRowText(row, rowTop); - markPainted(row.row()); - } - - private void prepareRows(int rows) { - rowHashes = new long[rows]; - paintedRows = new boolean[rows]; - } - - private void markPainted(int row) { - if (row >= 0 && row < paintedRows.length) { - paintedRows[row] = true; - } - } - - private boolean allRowsPainted() { - if (paintedRows.length == 0) { - return false; - } - for (boolean painted : paintedRows) { - if (!painted) { - return false; - } - } - return true; } private void paintRowSidePadding(RenderRow row, int rowTop, int rowHeight) { From 2fcdb286af2006d5bb8df06b3dc47372c9a2322f Mon Sep 17 00:00:00 2001 From: Gregor Lohaus Date: Sun, 31 May 2026 17:59:26 +0200 Subject: [PATCH 11/18] Fixed the partial-dirty blanking regression --- .../jprototerm/GhosttyTerminalRenderer.java | 32 +++++++++++++++---- 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/gregor/jprototerm/GhosttyTerminalRenderer.java b/src/main/java/com/gregor/jprototerm/GhosttyTerminalRenderer.java index 471d302..5871588 100644 --- a/src/main/java/com/gregor/jprototerm/GhosttyTerminalRenderer.java +++ b/src/main/java/com/gregor/jprototerm/GhosttyTerminalRenderer.java @@ -101,8 +101,10 @@ final class GhosttyTerminalRenderer extends TerminalRenderer { if (dirty == DIRTY_FULL) { software.paintFullOrShifted(gc, target.snapshotFull(), px, py, width, height, active); } else if (dirty == DIRTY_PARTIAL) { - if (snapshot != null && snapshot.renderRows().size() == snapshot.rows()) { + if (snapshot != null && software.isComplete(snapshot)) { software.paintFullOrShifted(gc, snapshot, px, py, width, height, active); + } else if (!software.canPaintPartial(snapshot)) { + software.paintFullOrShifted(gc, target.snapshotFull(), px, py, width, height, active); } else { software.paintDirty(gc, snapshot, px, py, width, height, active); } @@ -679,10 +681,16 @@ final class GhosttyTerminalRenderer extends TerminalRenderer { private long[] rowHashes = new long[0]; private CursorState lastCursor = CursorState.none(); private GlyphCache glyphs; + private boolean fullFrameReady; private void invalidate() { rowHashes = new long[0]; lastCursor = CursorState.none(); + fullFrameReady = false; + } + + private boolean canPaintPartial(RenderStateSnapshot snapshot) { + return fullFrameReady && snapshot != null && rowHashes.length == snapshot.rows(); } private void paintFull(GraphicsContext gc, RenderStateSnapshot snapshot, @@ -693,6 +701,7 @@ final class GhosttyTerminalRenderer extends TerminalRenderer { paintSnapshot(snapshot); drawCursor(snapshot); rememberSnapshot(snapshot); + fullFrameReady = isComplete(snapshot); } else { invalidate(); } @@ -730,6 +739,7 @@ final class GhosttyTerminalRenderer extends TerminalRenderer { } } lastCursor = cursor; + fullFrameReady = true; drawCursor(snapshot); drawBorder(active); present(gc, px, py); @@ -741,8 +751,7 @@ final class GhosttyTerminalRenderer extends TerminalRenderer { if (snapshot == null) { return; } - if (rowHashes.length != snapshot.rows()) { - paintFull(gc, snapshot, px, py, paneWidth, paneHeight, active); + if (!canPaintPartial(snapshot)) { return; } @@ -766,6 +775,7 @@ final class GhosttyTerminalRenderer extends TerminalRenderer { } repaintCursorRow(snapshot, newCursorRow, repainted); lastCursor = cursor; + fullFrameReady = true; drawCursor(snapshot); drawBorder(active); present(gc, px, py); @@ -825,7 +835,19 @@ final class GhosttyTerminalRenderer extends TerminalRenderer { } private boolean canDiff(RenderStateSnapshot snapshot) { - return rowHashes.length == snapshot.rows() && snapshot.renderRows().size() == snapshot.rows(); + return fullFrameReady && rowHashes.length == snapshot.rows() && isComplete(snapshot); + } + + private boolean isComplete(RenderStateSnapshot snapshot) { + if (snapshot.renderRows().size() != snapshot.rows()) { + return false; + } + for (int i = 0; i < snapshot.renderRows().size(); i++) { + if (snapshot.renderRows().get(i).row() != i) { + return false; + } + } + return true; } private void rememberSnapshot(RenderStateSnapshot snapshot) { @@ -856,13 +878,11 @@ final class GhosttyTerminalRenderer extends TerminalRenderer { continue; } int score = 0; - int overlap = 0; for (int row = 0; row < rows; row++) { int previous = row - delta; if (previous < 0 || previous >= rows) { continue; } - overlap++; if (currentHashes[row] == rowHashes[previous]) { score++; } From 54b08c7ecaa6206071d888e64045de672c60e03a Mon Sep 17 00:00:00 2001 From: Gregor Lohaus Date: Sun, 31 May 2026 18:00:49 +0200 Subject: [PATCH 12/18] revert failed fix --- .../jprototerm/GhosttyTerminalRenderer.java | 32 ++++--------------- 1 file changed, 6 insertions(+), 26 deletions(-) diff --git a/src/main/java/com/gregor/jprototerm/GhosttyTerminalRenderer.java b/src/main/java/com/gregor/jprototerm/GhosttyTerminalRenderer.java index 5871588..471d302 100644 --- a/src/main/java/com/gregor/jprototerm/GhosttyTerminalRenderer.java +++ b/src/main/java/com/gregor/jprototerm/GhosttyTerminalRenderer.java @@ -101,10 +101,8 @@ final class GhosttyTerminalRenderer extends TerminalRenderer { if (dirty == DIRTY_FULL) { software.paintFullOrShifted(gc, target.snapshotFull(), px, py, width, height, active); } else if (dirty == DIRTY_PARTIAL) { - if (snapshot != null && software.isComplete(snapshot)) { + if (snapshot != null && snapshot.renderRows().size() == snapshot.rows()) { software.paintFullOrShifted(gc, snapshot, px, py, width, height, active); - } else if (!software.canPaintPartial(snapshot)) { - software.paintFullOrShifted(gc, target.snapshotFull(), px, py, width, height, active); } else { software.paintDirty(gc, snapshot, px, py, width, height, active); } @@ -681,16 +679,10 @@ final class GhosttyTerminalRenderer extends TerminalRenderer { private long[] rowHashes = new long[0]; private CursorState lastCursor = CursorState.none(); private GlyphCache glyphs; - private boolean fullFrameReady; private void invalidate() { rowHashes = new long[0]; lastCursor = CursorState.none(); - fullFrameReady = false; - } - - private boolean canPaintPartial(RenderStateSnapshot snapshot) { - return fullFrameReady && snapshot != null && rowHashes.length == snapshot.rows(); } private void paintFull(GraphicsContext gc, RenderStateSnapshot snapshot, @@ -701,7 +693,6 @@ final class GhosttyTerminalRenderer extends TerminalRenderer { paintSnapshot(snapshot); drawCursor(snapshot); rememberSnapshot(snapshot); - fullFrameReady = isComplete(snapshot); } else { invalidate(); } @@ -739,7 +730,6 @@ final class GhosttyTerminalRenderer extends TerminalRenderer { } } lastCursor = cursor; - fullFrameReady = true; drawCursor(snapshot); drawBorder(active); present(gc, px, py); @@ -751,7 +741,8 @@ final class GhosttyTerminalRenderer extends TerminalRenderer { if (snapshot == null) { return; } - if (!canPaintPartial(snapshot)) { + if (rowHashes.length != snapshot.rows()) { + paintFull(gc, snapshot, px, py, paneWidth, paneHeight, active); return; } @@ -775,7 +766,6 @@ final class GhosttyTerminalRenderer extends TerminalRenderer { } repaintCursorRow(snapshot, newCursorRow, repainted); lastCursor = cursor; - fullFrameReady = true; drawCursor(snapshot); drawBorder(active); present(gc, px, py); @@ -835,19 +825,7 @@ final class GhosttyTerminalRenderer extends TerminalRenderer { } private boolean canDiff(RenderStateSnapshot snapshot) { - return fullFrameReady && rowHashes.length == snapshot.rows() && isComplete(snapshot); - } - - private boolean isComplete(RenderStateSnapshot snapshot) { - if (snapshot.renderRows().size() != snapshot.rows()) { - return false; - } - for (int i = 0; i < snapshot.renderRows().size(); i++) { - if (snapshot.renderRows().get(i).row() != i) { - return false; - } - } - return true; + return rowHashes.length == snapshot.rows() && snapshot.renderRows().size() == snapshot.rows(); } private void rememberSnapshot(RenderStateSnapshot snapshot) { @@ -878,11 +856,13 @@ final class GhosttyTerminalRenderer extends TerminalRenderer { continue; } int score = 0; + int overlap = 0; for (int row = 0; row < rows; row++) { int previous = row - delta; if (previous < 0 || previous >= rows) { continue; } + overlap++; if (currentHashes[row] == rowHashes[previous]) { score++; } From 71a533ec343be01082a1ea582eb69cc5014fb71a Mon Sep 17 00:00:00 2001 From: Gregor Lohaus Date: Sun, 31 May 2026 18:05:57 +0200 Subject: [PATCH 13/18] clear context new fix --- .../jprototerm/GhosttyTerminalRenderer.java | 47 ++++++++++++------- 1 file changed, 31 insertions(+), 16 deletions(-) diff --git a/src/main/java/com/gregor/jprototerm/GhosttyTerminalRenderer.java b/src/main/java/com/gregor/jprototerm/GhosttyTerminalRenderer.java index 471d302..63ed40a 100644 --- a/src/main/java/com/gregor/jprototerm/GhosttyTerminalRenderer.java +++ b/src/main/java/com/gregor/jprototerm/GhosttyTerminalRenderer.java @@ -101,11 +101,7 @@ final class GhosttyTerminalRenderer extends TerminalRenderer { if (dirty == DIRTY_FULL) { software.paintFullOrShifted(gc, target.snapshotFull(), px, py, width, height, active); } else if (dirty == DIRTY_PARTIAL) { - if (snapshot != null && snapshot.renderRows().size() == snapshot.rows()) { - software.paintFullOrShifted(gc, snapshot, px, py, width, height, active); - } else { - software.paintDirty(gc, snapshot, px, py, width, height, active); - } + software.paintDirty(gc, target, snapshot, px, py, width, height, active); } // dirty == FALSE: nothing visible changed. } @@ -735,14 +731,14 @@ final class GhosttyTerminalRenderer extends TerminalRenderer { present(gc, px, py); } - private void paintDirty(GraphicsContext gc, RenderStateSnapshot snapshot, + private void paintDirty(GraphicsContext gc, RenderTarget target, RenderStateSnapshot snapshot, double px, double py, double paneWidth, double paneHeight, boolean active) { ensure(paneWidth, paneHeight); if (snapshot == null) { return; } if (rowHashes.length != snapshot.rows()) { - paintFull(gc, snapshot, px, py, paneWidth, paneHeight, active); + paintFull(gc, target.snapshotFull(), px, py, paneWidth, paneHeight, active); return; } @@ -751,6 +747,7 @@ final class GhosttyTerminalRenderer extends TerminalRenderer { int newCursorRow = cursor.viewportRow(); boolean cursorChanged = !cursor.equals(lastCursor); boolean[] repainted = new boolean[snapshot.rows()]; + boolean needsCursorDraw = cursorChanged; for (RenderRow row : snapshot.renderRows()) { if (!row.dirty()) { @@ -759,28 +756,46 @@ final class GhosttyTerminalRenderer extends TerminalRenderer { paintRow(row); rowHashes[row.row()] = rowHash(row); repainted[row.row()] = true; + if (row.row() == newCursorRow) { + needsCursorDraw = true; + } } if (cursorChanged) { - repaintCursorRow(snapshot, oldCursorRow, repainted); + if (!repaintCursorRow(snapshot, oldCursorRow, repainted)) { + paintFullOrShifted(gc, target.snapshotFull(), px, py, paneWidth, paneHeight, active); + return; + } + } + if (repaintedRowHasCursor(newCursorRow, repainted) + && !repaintCursorRow(snapshot, newCursorRow, repainted)) { + paintFullOrShifted(gc, target.snapshotFull(), px, py, paneWidth, paneHeight, active); + return; } - repaintCursorRow(snapshot, newCursorRow, repainted); lastCursor = cursor; - drawCursor(snapshot); + if (needsCursorDraw) { + drawCursor(snapshot); + } drawBorder(active); present(gc, px, py); } - private void repaintCursorRow(RenderStateSnapshot snapshot, int rowIndex, boolean[] repainted) { + private boolean repaintCursorRow(RenderStateSnapshot snapshot, int rowIndex, boolean[] repainted) { if (rowIndex < 0 || rowIndex >= repainted.length || repainted[rowIndex]) { - return; + return true; } RenderRow row = rowByIndex(snapshot, rowIndex); - if (row != null) { - paintRow(row); - rowHashes[rowIndex] = rowHash(row); - repainted[rowIndex] = true; + if (row == null || !row.dirty()) { + return false; } + paintRow(row); + rowHashes[rowIndex] = rowHash(row); + repainted[rowIndex] = true; + return true; + } + + private boolean repaintedRowHasCursor(int rowIndex, boolean[] repainted) { + return rowIndex >= 0 && rowIndex < repainted.length && repainted[rowIndex]; } private RenderRow rowByIndex(RenderStateSnapshot snapshot, int rowIndex) { From 2c020bb6cb194bbe9f05f2da7997d44467bb6fd7 Mon Sep 17 00:00:00 2001 From: Gregor Lohaus Date: Sun, 31 May 2026 18:12:44 +0200 Subject: [PATCH 14/18] fix race condition --- src/main/java/com/gregor/jprototerm/Compositor.java | 6 ++---- src/main/java/com/gregor/jprototerm/TerminalPane.java | 6 ++++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/gregor/jprototerm/Compositor.java b/src/main/java/com/gregor/jprototerm/Compositor.java index 1db1a69..6a57cea 100644 --- a/src/main/java/com/gregor/jprototerm/Compositor.java +++ b/src/main/java/com/gregor/jprototerm/Compositor.java @@ -244,8 +244,7 @@ public final class Compositor { drawTabBar(gc, canvas.getWidth(), topInset); } for (TerminalPane pane : panes) { - pane.paintFull(gc, isActive(pane)); - paneContentVersion.put(pane, pane.contentVersion()); + paneContentVersion.put(pane, pane.paintFull(gc, isActive(pane))); } } @@ -262,8 +261,7 @@ public final class Compositor { if (drawn != null && drawn == pane.contentVersion()) { continue; } - pane.paintIncremental(gc, isActive(pane)); - paneContentVersion.put(pane, pane.contentVersion()); + paneContentVersion.put(pane, pane.paintIncremental(gc, isActive(pane))); } } diff --git a/src/main/java/com/gregor/jprototerm/TerminalPane.java b/src/main/java/com/gregor/jprototerm/TerminalPane.java index 05961ec..a44f962 100644 --- a/src/main/java/com/gregor/jprototerm/TerminalPane.java +++ b/src/main/java/com/gregor/jprototerm/TerminalPane.java @@ -283,13 +283,15 @@ public final class TerminalPane implements AutoCloseable, RenderTarget { } /** Paint the whole pane; see {@link TerminalRenderer#paintFull}. */ - public void paintFull(GraphicsContext gc, boolean active) { + public long paintFull(GraphicsContext gc, boolean active) { renderer.paintFull(gc, this, active); + return snapshotVersion; } /** Repaint what changed; see {@link TerminalRenderer#paintIncremental}. */ - public void paintIncremental(GraphicsContext gc, boolean active) { + public long paintIncremental(GraphicsContext gc, boolean active) { renderer.paintIncremental(gc, this, active); + return snapshotVersion; } @Override From bdb33450f13ad9fc5cb8604a23ce9fe717a02f1b Mon Sep 17 00:00:00 2001 From: Gregor Lohaus Date: Sun, 31 May 2026 21:51:57 +0200 Subject: [PATCH 15/18] update jlibghostty --- flake.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/flake.lock b/flake.lock index c35fd42..c9f179a 100644 --- a/flake.lock +++ b/flake.lock @@ -70,11 +70,11 @@ "nixpkgs": "nixpkgs" }, "locked": { - "lastModified": 1780079529, - "narHash": "sha256-AxlGTL8c5xSLcQHvWlm994IdOqxsN8iKrA02Cpv7vso=", + "lastModified": 1780256181, + "narHash": "sha256-/saXdnYMbAMfP7u6USSqtNkBIgqZhU+CPr3F8tUQhHU=", "ref": "refs/heads/main", - "rev": "68121d50b52fb56038871c97c97e7a12ffe987c2", - "revCount": 20, + "rev": "db5ee5d20daf8855de3a3b2fa9349eced70946f0", + "revCount": 21, "type": "git", "url": "https://gitea.gregorlohaus.com/gregor/jlibghostty.git" }, From 07585a314cda43d2d629755081f904a71e7003cd Mon Sep 17 00:00:00 2001 From: Gregor Lohaus Date: Sun, 31 May 2026 22:12:45 +0200 Subject: [PATCH 16/18] Upload only changed rows to GPU and hoist glyph bounds checks paintIncremental's per-row dirty work was negated by present() calling PixelBuffer.updateBuffer(null), which re-uploads the whole pane texture every frame. Track the vertical band of buffer rows written since the last present and hand that to updateBuffer so only changed rows upload. The border is now drawn without extending the dirty band (its pixels are unchanged between incremental frames). Also clamp blitGlyph's rectangle once instead of bounds-checking every glyph pixel in the inner loop. Co-Authored-By: Claude Opus 4.8 --- .settings/org.eclipse.buildship.core.prefs | 2 +- .../jprototerm/GhosttyTerminalRenderer.java | 92 +++++++++++++++---- 2 files changed, 75 insertions(+), 19 deletions(-) diff --git a/.settings/org.eclipse.buildship.core.prefs b/.settings/org.eclipse.buildship.core.prefs index ec93094..e801c51 100644 --- a/.settings/org.eclipse.buildship.core.prefs +++ b/.settings/org.eclipse.buildship.core.prefs @@ -5,7 +5,7 @@ connection.gradle.distribution=GRADLE_DISTRIBUTION(LOCAL_INSTALLATION(/home/anon connection.project.dir= eclipse.preferences.version=1 gradle.user.home= -java.home=/nix/store/c3pl7bqrx3d2rc3dh98z6yaj0mv1p52g-openjdk-21.0.10+7/lib/openjdk +java.home=/home/anon/.local/lib/graalvm-jdk-21.0.9+7.1 jvm.arguments= offline.mode=false override.workspace.settings=true diff --git a/src/main/java/com/gregor/jprototerm/GhosttyTerminalRenderer.java b/src/main/java/com/gregor/jprototerm/GhosttyTerminalRenderer.java index 63ed40a..3ef383f 100644 --- a/src/main/java/com/gregor/jprototerm/GhosttyTerminalRenderer.java +++ b/src/main/java/com/gregor/jprototerm/GhosttyTerminalRenderer.java @@ -12,6 +12,7 @@ import dev.jlibghostty.RenderColor; import dev.jlibghostty.RenderCursorStyle; import dev.jlibghostty.RenderRow; import dev.jlibghostty.RenderStateSnapshot; +import javafx.geometry.Rectangle2D; import javafx.scene.SnapshotParameters; import javafx.scene.canvas.Canvas; import javafx.scene.canvas.GraphicsContext; @@ -675,12 +676,42 @@ final class GhosttyTerminalRenderer extends TerminalRenderer { private long[] rowHashes = new long[0]; private CursorState lastCursor = CursorState.none(); private GlyphCache glyphs; + // Half-open [min, max) vertical span of buffer rows written since the last present, so + // present() can upload only that band to the GPU instead of the whole pane texture. + private int dirtyMinY = Integer.MAX_VALUE; + private int dirtyMaxY = Integer.MIN_VALUE; private void invalidate() { rowHashes = new long[0]; lastCursor = CursorState.none(); } + // Record that buffer rows [y0, y1) changed; clamped to the buffer in dirtyRegion(). + private void markDirtyRows(int y0, int y1) { + if (y0 < dirtyMinY) { + dirtyMinY = y0; + } + if (y1 > dirtyMaxY) { + dirtyMaxY = y1; + } + } + + private void resetDirty() { + dirtyMinY = Integer.MAX_VALUE; + dirtyMaxY = Integer.MIN_VALUE; + } + + // The region to hand PixelBuffer.updateBuffer: a full-width band covering the rows + // written this frame (clamped to the buffer), or EMPTY when nothing changed. + private Rectangle2D dirtyRegion() { + int y0 = Math.max(0, dirtyMinY); + int y1 = Math.min(height, dirtyMaxY); + if (y0 >= y1) { + return Rectangle2D.EMPTY; + } + return new Rectangle2D(0, y0, width, y1 - y0); + } + private void paintFull(GraphicsContext gc, RenderStateSnapshot snapshot, double px, double py, double paneWidth, double paneHeight, boolean active) { ensure(paneWidth, paneHeight); @@ -835,7 +866,11 @@ final class GhosttyTerminalRenderer extends TerminalRenderer { } private void present(GraphicsContext gc, double px, double py) { - pixelBuffer.updateBuffer(ignored -> null); + // Only re-upload the rows that actually changed; the unchanged remainder of the pane + // texture is already correct on the GPU from the previous frame. + Rectangle2D dirty = dirtyRegion(); + resetDirty(); + pixelBuffer.updateBuffer(ignored -> dirty); gc.drawImage(image, px, py); } @@ -915,6 +950,9 @@ final class GhosttyTerminalRenderer extends TerminalRenderer { int dy = rows * lineHeight(); int top = contentTop(); int contentHeight = rowHashes.length * lineHeight(); + // The whole content region shifts; the arraycopy below moves pixels that the + // per-strip fillRect calls don't touch, so mark the full content band for upload. + markDirtyRows(top, top + contentHeight); if (dy == 0 || Math.abs(dy) >= contentHeight) { fillRect(0, top, width, contentHeight, argbPre(PANE_BACKGROUND)); return; @@ -1035,26 +1073,28 @@ final class GhosttyTerminalRenderer extends TerminalRenderer { int red = (rgb >> 16) & 0xff; int green = (rgb >> 8) & 0xff; int blue = rgb & 0xff; - for (int gy = 0; gy < glyph.height; gy++) { - int py = y + gy; - if (py < 0 || py >= height) { - continue; - } - int rowOffset = py * width; + // Clamp the glyph rectangle to the buffer once, so the inner loops carry no + // per-pixel bounds check (this is the hottest pixel loop on a text repaint). + int gyStart = Math.max(0, -y); + int gyEnd = Math.min(glyph.height, height - y); + int gxStart = Math.max(0, -x); + int gxEnd = Math.min(glyph.width, width - x); + if (gyStart >= gyEnd || gxStart >= gxEnd) { + return; + } + for (int gy = gyStart; gy < gyEnd; gy++) { + int rowOffset = (y + gy) * width; int glyphOffset = gy * glyph.width; - for (int gx = 0; gx < glyph.width; gx++) { - int px = x + gx; - if (px < 0 || px >= width) { - continue; - } + for (int gx = gxStart; gx < gxEnd; gx++) { int alpha = glyph.alpha[glyphOffset + gx] & 0xff; if (alpha == 0) { continue; } - int index = rowOffset + px; + int index = rowOffset + x + gx; pixels[index] = blendOpaque(pixels[index], red, green, blue, alpha); } } + markDirtyRows(y + gyStart, y + gyEnd); } private int blendOpaque(int dst, int red, int green, int blue, int alpha) { @@ -1093,11 +1133,16 @@ final class GhosttyTerminalRenderer extends TerminalRenderer { } private void strokeRect(int x, int y, int w, int h, int color, int lineWidth) { + // The border is redrawn every frame to restore the side edges over the rows we + // repaint, but its pixels never change between incremental frames. Write it without + // marking the dirty band: the segments inside a repainted row's band are already + // covered by that band (and so re-uploaded), and the segments outside it are + // identical to what is already on the GPU, so they need no upload. for (int i = 0; i < lineWidth; i++) { - fillRect(x + i, y + i, w - (2 * i), 1, color); - fillRect(x + i, y + h - 1 - i, w - (2 * i), 1, color); - fillRect(x + i, y + i, 1, h - (2 * i), color); - fillRect(x + w - 1 - i, y + i, 1, h - (2 * i), color); + fillRectRaw(x + i, y + i, w - (2 * i), 1, color); + fillRectRaw(x + i, y + h - 1 - i, w - (2 * i), 1, color); + fillRectRaw(x + i, y + i, 1, h - (2 * i), color); + fillRectRaw(x + w - 1 - i, y + i, 1, h - (2 * i), color); } } @@ -1118,19 +1163,30 @@ final class GhosttyTerminalRenderer extends TerminalRenderer { pixels[index] = blendOpaque(pixels[index], red, green, blue, alpha); } } + markDirtyRows(y0, y1); } private void fillRect(int x, int y, int w, int h, int color) { + int y0 = Math.max(0, y); + int y1 = Math.min(height, y + h); + if (fillRectRaw(x, y, w, h, color)) { + markDirtyRows(y0, y1); + } + } + + // Raw fill with no dirty-band tracking; returns whether any pixels were written. + private boolean fillRectRaw(int x, int y, int w, int h, int color) { int x0 = Math.max(0, x); int y0 = Math.max(0, y); int x1 = Math.min(width, x + w); int y1 = Math.min(height, y + h); if (x0 >= x1 || y0 >= y1) { - return; + return false; } for (int py = y0; py < y1; py++) { Arrays.fill(pixels, (py * width) + x0, (py * width) + x1, color); } + return true; } private int contentLeft() { From 6bf69e8572926305aab26801a17c8ae34ab84956 Mon Sep 17 00:00:00 2001 From: Gregor Lohaus Date: Sun, 31 May 2026 22:23:14 +0200 Subject: [PATCH 17/18] update jlibghostty --- flake.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/flake.lock b/flake.lock index c9f179a..19c80b2 100644 --- a/flake.lock +++ b/flake.lock @@ -70,11 +70,11 @@ "nixpkgs": "nixpkgs" }, "locked": { - "lastModified": 1780256181, - "narHash": "sha256-/saXdnYMbAMfP7u6USSqtNkBIgqZhU+CPr3F8tUQhHU=", + "lastModified": 1780258814, + "narHash": "sha256-8rxL7xaZ/loYg3zdt0w5+hfNyHFVknDZN360NzrtCsQ=", "ref": "refs/heads/main", - "rev": "db5ee5d20daf8855de3a3b2fa9349eced70946f0", - "revCount": 21, + "rev": "6a3d5aa0b0b1f738c958e2a2f0249574c07d9c4d", + "revCount": 23, "type": "git", "url": "https://gitea.gregorlohaus.com/gregor/jlibghostty.git" }, From 8ac07218feff63298419061c82024e447b55839a Mon Sep 17 00:00:00 2001 From: Gregor Lohaus Date: Sun, 31 May 2026 22:27:54 +0200 Subject: [PATCH 18/18] send backtab (ESC [ Z) for Shift+Tab KeyEncoder mapped TAB to a plain tab regardless of Shift, so Shift+Tab sent the same byte as Tab. Apps that use backtab for reverse navigation (fish completion menu, helix theme picker) never saw it. Emit CSI Z when Shift is held. Co-Authored-By: Claude Opus 4.8 (cherry picked from commit 93d53fcef6ae2acfac3a67d5c88af519889782cb) --- src/main/java/com/gregor/jprototerm/KeyEncoder.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/gregor/jprototerm/KeyEncoder.java b/src/main/java/com/gregor/jprototerm/KeyEncoder.java index 765b1b3..296f624 100644 --- a/src/main/java/com/gregor/jprototerm/KeyEncoder.java +++ b/src/main/java/com/gregor/jprototerm/KeyEncoder.java @@ -26,7 +26,7 @@ final class KeyEncoder { return switch (code) { case ENTER -> "\r"; case BACK_SPACE -> "\u007f"; - case TAB -> "\t"; + case TAB -> event.isShiftDown() ? "\u001b[Z" : "\t"; case ESCAPE -> "\u001b"; case UP -> "\u001b[A"; case DOWN -> "\u001b[B";