15 Commits

Author SHA1 Message Date
cc9ac43ffa remove obsolete full-row-repaint debug toggle; document diagnostics
The fullRowRepaint toggle existed only to bisect the black-bar repaint artifact,
which is now fixed, so drop it. Document the two remaining render-debug flags
(profile, debugRepaint) in the README.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 22:31:50 +02:00
93d53fcef6 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 <noreply@anthropic.com>
2026-05-31 22:27:54 +02:00
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
7 changed files with 169 additions and 12 deletions

1
.gitignore vendored
View File

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

View File

@@ -132,3 +132,22 @@ open_scrollback = "ALT+S"
Each tab has its own stack of tiled and floating panes; only the active tab is rendered. A
thin tab bar appears at the top when more than one tab is open. Closing the last tiled pane
while floating panes exist promotes the most recently active floating pane to a tiled pane.
## Diagnostics
Two render-debugging flags are off by default and add no overhead unless enabled. Pass them
as JVM system properties (each also has an environment-variable equivalent). With the
packaged binary the JVM picks them up from `JDK_JAVA_OPTIONS`:
```sh
JDK_JAVA_OPTIONS="-Djprototerm.profile=true" ./result/bin/jprototerm
```
- `-Djprototerm.profile=true` (or `JPROTOTERM_PROFILE=1`): print a `[render-profile]` line to
stderr every N renders with the per-frame cost of each render stage — `snapshot` (terminal
state marshalling), `fingerprint` (change detection), `draw` (canvas painting), and the
`frame-total`. Set `-Djprototerm.profile.frames=<N>` to change the dump interval (default
120).
- `-Djprototerm.debugRepaint=true` (or `JPROTOTERM_DEBUG_REPAINT=1`): paint each per-column
repaint run's cleared span red instead of clearing it. Repainted regions flash red, so you
can see exactly which cells are being redrawn each frame.

8
flake.lock generated
View File

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

View File

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

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) {
long version = contentVersion.get();
if (full) {
long updateStart = RenderProfiler.start();
renderState.update(terminal);
RenderProfiler.stop(RenderProfiler.UPDATE, updateStart);
long marshalStart = RenderProfiler.start();
cachedSnapshot = renderState.snapshot();
RenderProfiler.stop(RenderProfiler.MARSHAL, marshalStart);
renderState.resetDirty();
snapshotVersion = version;
} else if (snapshotVersion != version) {
long updateStart = RenderProfiler.start();
renderState.update(terminal);
RenderProfiler.stop(RenderProfiler.UPDATE, updateStart);
long marshalStart = RenderProfiler.start();
cachedSnapshot = renderState.snapshotIncremental();
RenderProfiler.stop(RenderProfiler.MARSHAL, marshalStart);
renderState.resetDirty();
snapshotVersion = version;
}

View File

@@ -44,6 +44,13 @@ final class TerminalPaneNode extends Region {
private static final int DIRTY_PARTIAL = 1;
private static final int DIRTY_FULL = 2;
// 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 SELECTED_BACKGROUND = Color.rgb(52, 92, 140);
private static final Color PANE_BACKGROUND = Color.rgb(9, 10, 12);
@@ -91,7 +98,9 @@ final class TerminalPaneNode extends Region {
void renderFull(boolean active) {
prepareGeometry();
long snapshotStart = RenderProfiler.start();
RenderStateSnapshot snapshot = pane.snapshotFull();
RenderProfiler.stop(RenderProfiler.SNAPSHOT, snapshotStart);
long renderedVersion = pane.snapshotVersion();
boolean withKitty = pane.kittyEnabled() && hasKittyGraphics();
updateRowsFull(snapshot);
@@ -102,6 +111,13 @@ final class TerminalPaneNode extends Region {
}
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 withKitty = pane.kittyEnabled() && hasKittyGraphics();
if (drawnContentVersion == Long.MIN_VALUE || geometryChanged || withKitty) {
@@ -113,7 +129,9 @@ final class TerminalPaneNode extends Region {
return;
}
long snapshotStart = RenderProfiler.start();
RenderStateSnapshot snapshot = pane.snapshot();
RenderProfiler.stop(RenderProfiler.SNAPSHOT, snapshotStart);
long renderedVersion = pane.snapshotVersion();
int dirty = snapshot == null ? DIRTY_FULL : snapshot.dirty();
if (dirty == DIRTY_FULL) {
@@ -167,7 +185,9 @@ final class TerminalPaneNode extends Region {
Set<Integer> liveRows = new HashSet<>();
for (RenderRow row : snapshot.renderRows()) {
TerminalRowNode node = rowNode(row.row());
long fpStart = RenderProfiler.start();
long fingerprint = rowFingerprint(row);
RenderProfiler.stop(RenderProfiler.FINGERPRINT, fpStart);
node.render(row);
rowFingerprints.put(row.row(), fingerprint);
liveRows.add(row.row());
@@ -197,7 +217,9 @@ final class TerminalPaneNode extends Region {
continue;
}
TerminalRowNode node = rowNode(row.row());
long fpStart = RenderProfiler.start();
long fingerprint = rowFingerprint(row);
RenderProfiler.stop(RenderProfiler.FINGERPRINT, fpStart);
node.renderChanged(row);
rowFingerprints.put(row.row(), fingerprint);
}
@@ -212,7 +234,9 @@ final class TerminalPaneNode extends Region {
return Set.of();
}
long shiftStart = RenderProfiler.start();
ShiftPlan plan = detectShift(snapshot, changedRows);
RenderProfiler.stop(RenderProfiler.FINGERPRINT, shiftStart);
if (plan == null) {
return Set.of();
}
@@ -236,21 +260,29 @@ final class TerminalPaneNode extends Region {
}
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 bestScore = 0;
int rowCount = snapshot.rows();
for (int delta = -rowCount + 1; delta < rowCount; delta++) {
if (delta == 0) {
continue;
}
int score = 0;
for (RenderRow row : changedRows) {
int sourceRow = row.row() + delta;
for (int i = 0; i < changedCount; i++) {
int sourceRow = changedRows.get(i).row() + delta;
if (sourceRow < 0 || sourceRow >= rowCount || !rows.containsKey(sourceRow)) {
continue;
}
Long previous = rowFingerprints.get(sourceRow);
if (previous != null && previous == rowFingerprint(row)) {
if (previous != null && previous == changedHashes[i]) {
score++;
}
}
@@ -267,13 +299,14 @@ final class TerminalPaneNode extends Region {
List<RowMove> moves = new ArrayList<>(bestScore);
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;
if (sourceRow < 0 || sourceRow >= rowCount || !rows.containsKey(sourceRow)) {
continue;
}
Long previous = rowFingerprints.get(sourceRow);
if (previous != null && previous == rowFingerprint(row)) {
if (previous != null && previous == changedHashes[i]) {
moves.add(new RowMove(sourceRow, row.row()));
targetRows.add(row.row());
}
@@ -692,6 +725,7 @@ final class TerminalPaneNode extends Region {
private void render(RenderRow row) {
prepareCanvas(row);
long drawStart = RenderProfiler.start();
GraphicsContext gc = canvas.getGraphicsContext2D();
gc.clearRect(0.0, 0.0, canvas.getWidth(), canvas.getHeight());
gc.setFontSmoothingType(FontSmoothingType.LCD);
@@ -699,14 +733,20 @@ final class TerminalPaneNode extends Region {
paintSidePadding(gc, row, canvas.getWidth(), canvas.getHeight());
drawRow(gc, row, rowTop(row), metrics.cellWidth(), metrics.lineHeight());
RenderProfiler.stop(RenderProfiler.DRAW, drawStart);
long fpStart = RenderProfiler.start();
cellFingerprints = cellFingerprints(row);
RenderProfiler.stop(RenderProfiler.FINGERPRINT, fpStart);
}
private void renderChanged(RenderRow row) {
double oldWidth = canvas.getWidth();
double oldHeight = canvas.getHeight();
prepareCanvas(row);
long fpStart = RenderProfiler.start();
long[] nextFingerprints = cellFingerprints(row);
RenderProfiler.stop(RenderProfiler.FINGERPRINT, fpStart);
if (cellFingerprints.length != nextFingerprints.length
|| oldWidth != canvas.getWidth()
|| oldHeight != canvas.getHeight()) {
@@ -714,6 +754,7 @@ final class TerminalPaneNode extends Region {
return;
}
long drawStart = RenderProfiler.start();
GraphicsContext gc = canvas.getGraphicsContext2D();
gc.setFontSmoothingType(FontSmoothingType.LCD);
gc.setFont(metrics.font());
@@ -741,6 +782,7 @@ final class TerminalPaneNode extends Region {
if (runStart >= 0) {
repaintColumns(gc, row, runStart, runEnd);
}
RenderProfiler.stop(RenderProfiler.DRAW, drawStart);
cellFingerprints = nextFingerprints;
}
@@ -794,7 +836,15 @@ final class TerminalPaneNode extends Region {
double x = TerminalMetrics.PADDING + startColumn * 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) {
gc.setFill(rowEdgeBackground(row, true));
gc.fillRect(0.0, 0.0, TerminalMetrics.PADDING, canvas.getHeight());