13 Commits

Author SHA1 Message Date
b0ec6c7014 update jlibghostty 2026-05-31 22:20:56 +02:00
3c913fefd3 fix black seam bars: opaque base fill instead of clearRect
The persistent black bars in the partial-repaint path were clearRect leaving
the run's fractional edge pixels transparent, which showed the near-black pane
background as a seam against the adjacent un-repainted line. Confirmed with the
debugRepaint toggle: filling the span opaque removed the bars entirely.

Fill the repaint run with PANE_BACKGROUND (the default cell background) instead
of clearing to transparent; per-cell backgrounds paint over it as before. Safe
because the per-column path never runs while kitty graphics are present (those
force a full render), so no below-text image needs a transparent row canvas.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 22:08:57 +02:00
263bcf36b7 add debugRepaint toggle that fills cleared spans red
Diagnostic for the persistent black bars: fills each repaint run's cleared span
red instead of clearing to transparent. If the bars turn red they are spans
repaintColumns clears but never refills; if they stay black those pixels are
never touched by the per-column repaint and the cause is elsewhere.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 22:02:41 +02:00
8e060b27ca Revert "snap repaint clear/fill to integer pixels to kill seam bars"
This reverts commit 6613f1f746.
2026-05-31 21:58:15 +02:00
6613f1f746 snap repaint clear/fill to integer pixels to kill seam bars
cellWidth is fractional, so in repaintColumns the clearRect cleared a run's
edge pixel to full transparency while the background fillRect covered that same
pixel only partially (antialiased), leaving a ~1px part-transparent column that
showed the near-black pane background as a thin bar 1-2 cells before the cursor.
Full-row repaint hid it because the highlighted line is then one contiguous
fill with no internal junction.

Route the clear and the background fills through a shared columnX() that rounds
each column boundary to a whole device pixel, so run edges land on integer
pixels with full single coverage and adjacent runs tile seamlessly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 21:56:01 +02:00
5728733f5f worktrees ignores 2026-05-31 21:52:39 +02:00
6e3e88919e Revert "fix glyph-overhang artifacts in partial row repaint"
This reverts commit 57103bb98b.
2026-05-31 21:50:17 +02:00
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
cb95a7188d update jlibghostty 2026-05-31 21:39:18 +02:00
Gregor Lohaus
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 Lohaus
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 Lohaus
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 Lohaus
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
5 changed files with 159 additions and 11 deletions

1
.gitignore vendored
View File

@@ -14,3 +14,4 @@ build
build build
.gradle .gradle
bin bin
.worktrees

8
flake.lock generated
View File

@@ -70,11 +70,11 @@
"nixpkgs": "nixpkgs" "nixpkgs": "nixpkgs"
}, },
"locked": { "locked": {
"lastModified": 1780079529, "lastModified": 1780258814,
"narHash": "sha256-AxlGTL8c5xSLcQHvWlm994IdOqxsN8iKrA02Cpv7vso=", "narHash": "sha256-8rxL7xaZ/loYg3zdt0w5+hfNyHFVknDZN360NzrtCsQ=",
"ref": "refs/heads/main", "ref": "refs/heads/main",
"rev": "68121d50b52fb56038871c97c97e7a12ffe987c2", "rev": "6a3d5aa0b0b1f738c958e2a2f0249574c07d9c4d",
"revCount": 20, "revCount": 23,
"type": "git", "type": "git",
"url": "https://gitea.gregorlohaus.com/gregor/jlibghostty.git" "url": "https://gitea.gregorlohaus.com/gregor/jlibghostty.git"
}, },

View File

@@ -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;
}
}

View File

@@ -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;
} }

View File

@@ -44,6 +44,19 @@ 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"));
// Debug toggle: paint each repaint run's cleared span red instead of clearing it to
// transparent. If the black bars turn red, they are spans repaintColumns clears but never
// refills; if they stay black, those pixels are never touched by repaintColumns at all.
private static final boolean DEBUG_REPAINT =
Boolean.getBoolean("jprototerm.debugRepaint")
|| "1".equals(System.getenv("JPROTOTERM_DEBUG_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 +104,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 +117,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 +135,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 +191,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 +223,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 +240,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 +266,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 +305,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 +731,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 +739,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 +764,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 +792,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;
} }
@@ -794,7 +846,15 @@ final class TerminalPaneNode extends Region {
double x = TerminalMetrics.PADDING + startColumn * cellWidth; double x = TerminalMetrics.PADDING + startColumn * cellWidth;
double width = (endColumn - startColumn + 1) * cellWidth; double width = (endColumn - startColumn + 1) * cellWidth;
gc.clearRect(x, 0.0, width, canvas.getHeight()); // Opaque base fill rather than clearRect: a transparent clear leaves the run's
// fractional edge pixels transparent, which show the near-black pane background
// as a thin seam bar against the adjacent (un-repainted) line. Filling opaque
// removes every transparent pixel; per-cell backgrounds then paint on top, and
// default-background cells correctly show PANE_BACKGROUND. Safe because the
// per-column path never runs while kitty graphics (which need a transparent row
// canvas for below-text images) are present.
gc.setFill(DEBUG_REPAINT ? Color.RED : PANE_BACKGROUND);
gc.fillRect(x, 0.0, width, canvas.getHeight());
if (startColumn == 0) { if (startColumn == 0) {
gc.setFill(rowEdgeBackground(row, true)); gc.setFill(rowEdgeBackground(row, true));
gc.fillRect(0.0, 0.0, TerminalMetrics.PADDING, canvas.getHeight()); gc.fillRect(0.0, 0.0, TerminalMetrics.PADDING, canvas.getHeight());