Author SHA1 Message Date
gregor 6e3e88919e Revert "fix glyph-overhang artifacts in partial row repaint"
This reverts commit 57103bb98b.
2026-05-31 21:50:17 +02:00
gregorandClaude Opus 4.8 57103bb98b fix glyph-overhang artifacts in partial row repaint
repaintColumns cleared and redrew only [start,end], but a neighbouring cell's
glyph can overhang into that span. The clearRect erased the overhang and the
neighbour was never redrawn, leaving black notches through the line 1-2 cells
before the cursor that survived until a full rerender.

Redraw text for a couple of extra cells on each side, clipped to the cleared
span, so overhang from just-outside cells is restored without touching their
own cell areas. Keeps the per-column repaint efficiency (vs the full-row
repaint debug toggle, which fixed the bars but repainted every dirty cell).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 21:48:34 +02:00
gregor cb95a7188d update jlibghostty 2026-05-31 21:39:18 +02:00
Gregor LohausandClaude Opus 4.8 5ca192b7be add full-row-repaint debug toggle
-Djprototerm.fullRowRepaint=true (or JPROTOTERM_FULL_ROW_REPAINT=1) bypasses the
per-column repaint in renderChanged and repaints the whole row, to bisect the
stale black-bar artifact that appears near the cursor and survives until a full
rerender.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 21:25:13 +02:00
Gregor LohausandClaude Opus 4.8 e99a6ee33e split snapshot profiler bucket into update vs marshal
The snapshot bucket lumped ghostty's native dirty-state update together with
the Java-side cell marshaling. Time them separately to see which half of the
~7ms/frame snapshot cost (now the dominant frame cost after the detectShift
hoist) is the real target.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 21:18:07 +02:00
Gregor LohausandClaude Opus 4.8 4923ea5527 hoist row hash out of detectShift delta scan
rowFingerprint(row) is invariant across the delta loop but was recomputed for
every candidate delta, making shift detection O(rows^2 x cols) on large changes
(full-screen scroll). Precompute each changed row's hash once, dropping it to
O(rows x cols). Profiling showed fingerprint hashing at ~74% of frame time under
heavy scroll, dominated by this loop.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 21:09:54 +02:00
Gregor LohausandClaude Opus 4.8 1f7394d75a add opt-in render profiler instrumentation
Gated behind -Djprototerm.profile=true (or JPROTOTERM_PROFILE=1), accumulates
per-frame nanos into snapshot/fingerprint/draw/frame-total buckets and dumps
to stderr every N renders. Splits the three suspected render costs: native
snapshot marshaling, fingerprint hashing, and canvas draw recording.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 21:04:00 +02:00
4 changed files with 142 additions and 10 deletions
Generated
+4 -4
View File
@@ -70,11 +70,11 @@
"nixpkgs": "nixpkgs" "nixpkgs": "nixpkgs"
}, },
"locked": { "locked": {
"lastModified": 1780079529, "lastModified": 1780256181,
"narHash": "sha256-AxlGTL8c5xSLcQHvWlm994IdOqxsN8iKrA02Cpv7vso=", "narHash": "sha256-/saXdnYMbAMfP7u6USSqtNkBIgqZhU+CPr3F8tUQhHU=",
"ref": "refs/heads/main", "ref": "refs/heads/main",
"rev": "68121d50b52fb56038871c97c97e7a12ffe987c2", "rev": "db5ee5d20daf8855de3a3b2fa9349eced70946f0",
"revCount": 20, "revCount": 21,
"type": "git", "type": "git",
"url": "https://gitea.gregorlohaus.com/gregor/jlibghostty.git" "url": "https://gitea.gregorlohaus.com/gregor/jlibghostty.git"
}, },
@@ -0,0 +1,79 @@
package com.gregor.jprototerm;
/**
* Lightweight render profiler, disabled unless {@code -Djprototerm.profile=true} (or the
* {@code JPROTOTERM_PROFILE=1} environment variable) is set. It accumulates wall-clock nanos
* into a handful of buckets and prints aggregate per-frame stats to stderr every
* {@code jprototerm.profile.frames} render invocations (default 120).
*
* <p>All render work runs on the JavaFX application thread, so the accumulators are plain
* fields with no synchronization.
*
* <p>Caveat: JavaFX canvas drawing is deferred to the QuantumRenderer thread, so the
* {@link #DRAW} bucket measures only the cost of <em>recording</em> draw commands, not the
* GPU paint. Pair this with {@code -Djavafx.pulseLogger=true} to see the render-thread side.
*/
final class RenderProfiler {
static final int SNAPSHOT = 0;
static final int FINGERPRINT = 1;
static final int DRAW = 2;
static final int FRAME = 3;
static final int UPDATE = 4;
static final int MARSHAL = 5;
private static final int BUCKETS = 6;
private static final String[] NAMES =
{"snapshot", "fingerprint", "draw", "frame-total", "update", "marshal"};
private static final boolean ENABLED =
Boolean.getBoolean("jprototerm.profile") || "1".equals(System.getenv("JPROTOTERM_PROFILE"));
private static final int DUMP_FRAMES = Integer.getInteger("jprototerm.profile.frames", 120);
private static final long[] totalNanos = new long[BUCKETS];
private static final long[] counts = new long[BUCKETS];
private static int frames;
private RenderProfiler() {
}
static boolean enabled() {
return ENABLED;
}
/** Returns a start timestamp, or 0 when profiling is disabled. */
static long start() {
return ENABLED ? System.nanoTime() : 0L;
}
/** Records the time elapsed since {@code startNanos} into {@code bucket}. */
static void stop(int bucket, long startNanos) {
if (!ENABLED) {
return;
}
totalNanos[bucket] += System.nanoTime() - startNanos;
counts[bucket]++;
}
/** Marks the end of one render invocation; dumps and resets every {@code DUMP_FRAMES}. */
static void frame() {
if (!ENABLED) {
return;
}
if (++frames < DUMP_FRAMES) {
return;
}
dump();
}
private static void dump() {
StringBuilder sb = new StringBuilder(192);
sb.append("[render-profile] ").append(frames).append(" renders");
for (int i = 0; i < BUCKETS; i++) {
double totalMs = totalNanos[i] / 1_000_000.0;
sb.append(String.format(" | %s %.3fms/f (n=%d)", NAMES[i], totalMs / frames, counts[i]));
totalNanos[i] = 0;
counts[i] = 0;
}
System.err.println(sb);
frames = 0;
}
}
@@ -160,13 +160,21 @@ public final class TerminalPane implements AutoCloseable {
synchronized (terminal) { synchronized (terminal) {
long version = contentVersion.get(); long version = contentVersion.get();
if (full) { if (full) {
long updateStart = RenderProfiler.start();
renderState.update(terminal); renderState.update(terminal);
RenderProfiler.stop(RenderProfiler.UPDATE, updateStart);
long marshalStart = RenderProfiler.start();
cachedSnapshot = renderState.snapshot(); cachedSnapshot = renderState.snapshot();
RenderProfiler.stop(RenderProfiler.MARSHAL, marshalStart);
renderState.resetDirty(); renderState.resetDirty();
snapshotVersion = version; snapshotVersion = version;
} else if (snapshotVersion != version) { } else if (snapshotVersion != version) {
long updateStart = RenderProfiler.start();
renderState.update(terminal); renderState.update(terminal);
RenderProfiler.stop(RenderProfiler.UPDATE, updateStart);
long marshalStart = RenderProfiler.start();
cachedSnapshot = renderState.snapshotIncremental(); cachedSnapshot = renderState.snapshotIncremental();
RenderProfiler.stop(RenderProfiler.MARSHAL, marshalStart);
renderState.resetDirty(); renderState.resetDirty();
snapshotVersion = version; snapshotVersion = version;
} }
@@ -44,6 +44,12 @@ final class TerminalPaneNode extends Region {
private static final int DIRTY_PARTIAL = 1; private static final int DIRTY_PARTIAL = 1;
private static final int DIRTY_FULL = 2; private static final int DIRTY_FULL = 2;
// Debug toggle: when set, skip the per-column repaint and always repaint the whole row.
// Used to bisect partial-repaint artifacts (stale black bars near the cursor).
private static final boolean FULL_ROW_REPAINT =
Boolean.getBoolean("jprototerm.fullRowRepaint")
|| "1".equals(System.getenv("JPROTOTERM_FULL_ROW_REPAINT"));
private static final Color DEFAULT_FOREGROUND = Color.rgb(225, 229, 235); private static final Color DEFAULT_FOREGROUND = Color.rgb(225, 229, 235);
private static final Color SELECTED_BACKGROUND = Color.rgb(52, 92, 140); private static final Color SELECTED_BACKGROUND = Color.rgb(52, 92, 140);
private static final Color PANE_BACKGROUND = Color.rgb(9, 10, 12); private static final Color PANE_BACKGROUND = Color.rgb(9, 10, 12);
@@ -91,7 +97,9 @@ final class TerminalPaneNode extends Region {
void renderFull(boolean active) { void renderFull(boolean active) {
prepareGeometry(); prepareGeometry();
long snapshotStart = RenderProfiler.start();
RenderStateSnapshot snapshot = pane.snapshotFull(); RenderStateSnapshot snapshot = pane.snapshotFull();
RenderProfiler.stop(RenderProfiler.SNAPSHOT, snapshotStart);
long renderedVersion = pane.snapshotVersion(); long renderedVersion = pane.snapshotVersion();
boolean withKitty = pane.kittyEnabled() && hasKittyGraphics(); boolean withKitty = pane.kittyEnabled() && hasKittyGraphics();
updateRowsFull(snapshot); updateRowsFull(snapshot);
@@ -102,6 +110,13 @@ final class TerminalPaneNode extends Region {
} }
void renderIncremental(boolean active) { void renderIncremental(boolean active) {
long frameStart = RenderProfiler.start();
renderIncrementalBody(active);
RenderProfiler.stop(RenderProfiler.FRAME, frameStart);
RenderProfiler.frame();
}
private void renderIncrementalBody(boolean active) {
boolean geometryChanged = prepareGeometry(); boolean geometryChanged = prepareGeometry();
boolean withKitty = pane.kittyEnabled() && hasKittyGraphics(); boolean withKitty = pane.kittyEnabled() && hasKittyGraphics();
if (drawnContentVersion == Long.MIN_VALUE || geometryChanged || withKitty) { if (drawnContentVersion == Long.MIN_VALUE || geometryChanged || withKitty) {
@@ -113,7 +128,9 @@ final class TerminalPaneNode extends Region {
return; return;
} }
long snapshotStart = RenderProfiler.start();
RenderStateSnapshot snapshot = pane.snapshot(); RenderStateSnapshot snapshot = pane.snapshot();
RenderProfiler.stop(RenderProfiler.SNAPSHOT, snapshotStart);
long renderedVersion = pane.snapshotVersion(); long renderedVersion = pane.snapshotVersion();
int dirty = snapshot == null ? DIRTY_FULL : snapshot.dirty(); int dirty = snapshot == null ? DIRTY_FULL : snapshot.dirty();
if (dirty == DIRTY_FULL) { if (dirty == DIRTY_FULL) {
@@ -167,7 +184,9 @@ final class TerminalPaneNode extends Region {
Set<Integer> liveRows = new HashSet<>(); Set<Integer> liveRows = new HashSet<>();
for (RenderRow row : snapshot.renderRows()) { for (RenderRow row : snapshot.renderRows()) {
TerminalRowNode node = rowNode(row.row()); TerminalRowNode node = rowNode(row.row());
long fpStart = RenderProfiler.start();
long fingerprint = rowFingerprint(row); long fingerprint = rowFingerprint(row);
RenderProfiler.stop(RenderProfiler.FINGERPRINT, fpStart);
node.render(row); node.render(row);
rowFingerprints.put(row.row(), fingerprint); rowFingerprints.put(row.row(), fingerprint);
liveRows.add(row.row()); liveRows.add(row.row());
@@ -197,7 +216,9 @@ final class TerminalPaneNode extends Region {
continue; continue;
} }
TerminalRowNode node = rowNode(row.row()); TerminalRowNode node = rowNode(row.row());
long fpStart = RenderProfiler.start();
long fingerprint = rowFingerprint(row); long fingerprint = rowFingerprint(row);
RenderProfiler.stop(RenderProfiler.FINGERPRINT, fpStart);
node.renderChanged(row); node.renderChanged(row);
rowFingerprints.put(row.row(), fingerprint); rowFingerprints.put(row.row(), fingerprint);
} }
@@ -212,7 +233,9 @@ final class TerminalPaneNode extends Region {
return Set.of(); return Set.of();
} }
long shiftStart = RenderProfiler.start();
ShiftPlan plan = detectShift(snapshot, changedRows); ShiftPlan plan = detectShift(snapshot, changedRows);
RenderProfiler.stop(RenderProfiler.FINGERPRINT, shiftStart);
if (plan == null) { if (plan == null) {
return Set.of(); return Set.of();
} }
@@ -236,21 +259,29 @@ final class TerminalPaneNode extends Region {
} }
private ShiftPlan detectShift(RenderStateSnapshot snapshot, List<RenderRow> changedRows) { private ShiftPlan detectShift(RenderStateSnapshot snapshot, List<RenderRow> changedRows) {
int rowCount = snapshot.rows();
int changedCount = changedRows.size();
// The new-content hash of each changed row is invariant across the delta scan below,
// so compute it once here instead of re-hashing the whole row for every candidate delta.
long[] changedHashes = new long[changedCount];
for (int i = 0; i < changedCount; i++) {
changedHashes[i] = rowFingerprint(changedRows.get(i));
}
int bestDelta = 0; int bestDelta = 0;
int bestScore = 0; int bestScore = 0;
int rowCount = snapshot.rows();
for (int delta = -rowCount + 1; delta < rowCount; delta++) { for (int delta = -rowCount + 1; delta < rowCount; delta++) {
if (delta == 0) { if (delta == 0) {
continue; continue;
} }
int score = 0; int score = 0;
for (RenderRow row : changedRows) { for (int i = 0; i < changedCount; i++) {
int sourceRow = row.row() + delta; int sourceRow = changedRows.get(i).row() + delta;
if (sourceRow < 0 || sourceRow >= rowCount || !rows.containsKey(sourceRow)) { if (sourceRow < 0 || sourceRow >= rowCount || !rows.containsKey(sourceRow)) {
continue; continue;
} }
Long previous = rowFingerprints.get(sourceRow); Long previous = rowFingerprints.get(sourceRow);
if (previous != null && previous == rowFingerprint(row)) { if (previous != null && previous == changedHashes[i]) {
score++; score++;
} }
} }
@@ -267,13 +298,14 @@ final class TerminalPaneNode extends Region {
List<RowMove> moves = new ArrayList<>(bestScore); List<RowMove> moves = new ArrayList<>(bestScore);
Set<Integer> targetRows = new HashSet<>(); Set<Integer> targetRows = new HashSet<>();
for (RenderRow row : changedRows) { for (int i = 0; i < changedCount; i++) {
RenderRow row = changedRows.get(i);
int sourceRow = row.row() + bestDelta; int sourceRow = row.row() + bestDelta;
if (sourceRow < 0 || sourceRow >= rowCount || !rows.containsKey(sourceRow)) { if (sourceRow < 0 || sourceRow >= rowCount || !rows.containsKey(sourceRow)) {
continue; continue;
} }
Long previous = rowFingerprints.get(sourceRow); Long previous = rowFingerprints.get(sourceRow);
if (previous != null && previous == rowFingerprint(row)) { if (previous != null && previous == changedHashes[i]) {
moves.add(new RowMove(sourceRow, row.row())); moves.add(new RowMove(sourceRow, row.row()));
targetRows.add(row.row()); targetRows.add(row.row());
} }
@@ -692,6 +724,7 @@ final class TerminalPaneNode extends Region {
private void render(RenderRow row) { private void render(RenderRow row) {
prepareCanvas(row); prepareCanvas(row);
long drawStart = RenderProfiler.start();
GraphicsContext gc = canvas.getGraphicsContext2D(); GraphicsContext gc = canvas.getGraphicsContext2D();
gc.clearRect(0.0, 0.0, canvas.getWidth(), canvas.getHeight()); gc.clearRect(0.0, 0.0, canvas.getWidth(), canvas.getHeight());
gc.setFontSmoothingType(FontSmoothingType.LCD); gc.setFontSmoothingType(FontSmoothingType.LCD);
@@ -699,14 +732,24 @@ final class TerminalPaneNode extends Region {
paintSidePadding(gc, row, canvas.getWidth(), canvas.getHeight()); paintSidePadding(gc, row, canvas.getWidth(), canvas.getHeight());
drawRow(gc, row, rowTop(row), metrics.cellWidth(), metrics.lineHeight()); drawRow(gc, row, rowTop(row), metrics.cellWidth(), metrics.lineHeight());
RenderProfiler.stop(RenderProfiler.DRAW, drawStart);
long fpStart = RenderProfiler.start();
cellFingerprints = cellFingerprints(row); cellFingerprints = cellFingerprints(row);
RenderProfiler.stop(RenderProfiler.FINGERPRINT, fpStart);
} }
private void renderChanged(RenderRow row) { private void renderChanged(RenderRow row) {
if (FULL_ROW_REPAINT) {
render(row);
return;
}
double oldWidth = canvas.getWidth(); double oldWidth = canvas.getWidth();
double oldHeight = canvas.getHeight(); double oldHeight = canvas.getHeight();
prepareCanvas(row); prepareCanvas(row);
long fpStart = RenderProfiler.start();
long[] nextFingerprints = cellFingerprints(row); long[] nextFingerprints = cellFingerprints(row);
RenderProfiler.stop(RenderProfiler.FINGERPRINT, fpStart);
if (cellFingerprints.length != nextFingerprints.length if (cellFingerprints.length != nextFingerprints.length
|| oldWidth != canvas.getWidth() || oldWidth != canvas.getWidth()
|| oldHeight != canvas.getHeight()) { || oldHeight != canvas.getHeight()) {
@@ -714,6 +757,7 @@ final class TerminalPaneNode extends Region {
return; return;
} }
long drawStart = RenderProfiler.start();
GraphicsContext gc = canvas.getGraphicsContext2D(); GraphicsContext gc = canvas.getGraphicsContext2D();
gc.setFontSmoothingType(FontSmoothingType.LCD); gc.setFontSmoothingType(FontSmoothingType.LCD);
gc.setFont(metrics.font()); gc.setFont(metrics.font());
@@ -741,6 +785,7 @@ final class TerminalPaneNode extends Region {
if (runStart >= 0) { if (runStart >= 0) {
repaintColumns(gc, row, runStart, runEnd); repaintColumns(gc, row, runStart, runEnd);
} }
RenderProfiler.stop(RenderProfiler.DRAW, drawStart);
cellFingerprints = nextFingerprints; cellFingerprints = nextFingerprints;
} }