18 Commits

Author SHA1 Message Date
6bf69e8572 update jlibghostty 2026-05-31 22:23:14 +02:00
07585a314c 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 <noreply@anthropic.com>
2026-05-31 22:12:45 +02:00
bdb33450f1 update jlibghostty 2026-05-31 21:51:57 +02:00
Gregor Lohaus
2c020bb6cb fix race condition 2026-05-31 18:12:44 +02:00
Gregor Lohaus
71a533ec34 clear context new fix 2026-05-31 18:05:57 +02:00
Gregor Lohaus
54b08c7eca revert failed fix 2026-05-31 18:00:49 +02:00
Gregor Lohaus
2fcdb286af Fixed the partial-dirty blanking regression 2026-05-31 17:59:26 +02:00
Gregor Lohaus
e6848ec684 revert failed fixed 2026-05-31 17:56:36 +02:00
Gregor Lohaus
38822d66b8 Fixed the partial-dirty blanking regression 2026-05-31 17:51:53 +02:00
Gregor Lohaus
586150de59 Fixed the partial-dirty blanking regression 2026-05-31 17:48:04 +02:00
Gregor Lohaus
494d2c40cf pixel buffer, scroll inference 2026-05-31 17:41:33 +02:00
Gregor Lohaus
a99cbdc61a revert row diffing 2026-05-31 17:20:13 +02:00
Gregor Lohaus
86f7174eee row diffing 2026-05-31 17:14:07 +02:00
Gregor Lohaus
137db24023 refert safe batching 2026-05-31 17:04:17 +02:00
Gregor Lohaus
d8faf8d6df safe batching 2026-05-31 17:02:44 +02:00
Gregor Lohaus
9903e9174f fix cell shifting regression 2026-05-31 16:58:11 +02:00
Gregor Lohaus
9b7247a4e0 small improvements 2026-05-31 16:50:12 +02:00
Gregor Lohaus
f5562baf5f Merge branch 'refactor' 2026-05-31 16:27:21 +02:00
7 changed files with 833 additions and 70 deletions

View File

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

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

@@ -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)));
}
}
@@ -384,7 +382,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) {

View File

@@ -12,14 +12,22 @@ 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;
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;
@@ -39,6 +47,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 +59,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<KittyImageKey, Image> kittyImageCache = new HashMap<>();
private final SoftwareBackbuffer software = new SoftwareBackbuffer();
GhosttyTerminalRenderer(TerminalMetrics metrics) {
this.metrics = metrics;
@@ -61,8 +73,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();
}
@@ -77,13 +95,14 @@ 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);
software.paintDirty(gc, target, snapshot, px, py, width, height, active);
}
// dirty == FALSE: nothing visible changed.
}
@@ -163,10 +182,17 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
double contentBottom = top + snapshot.rows() * lineHeight;
int lastRow = snapshot.rows() - 1;
List<RenderRow> 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 +200,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 +241,21 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
gc.restore();
}
private static boolean allRowsDirty(RenderStateSnapshot snapshot, List<RenderRow> 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 +307,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 +316,108 @@ 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
) {
for (RenderCell cell : row.cells()) {
if (cell.kittyPlaceholder().isPresent() || cell.codepoints().length == 0) {
continue;
}
gc.setFill(cellForegroundColor(cell));
gc.fillText(cell.text(), left + (cell.column() * cellWidth), baseline + (row.row() * lineHeight));
}
}
// 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 +441,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();
@@ -563,6 +667,655 @@ 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<IntBuffer> pixelBuffer;
private WritableImage image;
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);
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, 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, target.snapshotFull(), 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()];
boolean needsCursorDraw = cursorChanged;
for (RenderRow row : snapshot.renderRows()) {
if (!row.dirty()) {
continue;
}
paintRow(row);
rowHashes[row.row()] = rowHash(row);
repainted[row.row()] = true;
if (row.row() == newCursorRow) {
needsCursorDraw = true;
}
}
if (cursorChanged) {
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;
}
lastCursor = cursor;
if (needsCursorDraw) {
drawCursor(snapshot);
}
drawBorder(active);
present(gc, px, py);
}
private boolean repaintCursorRow(RenderStateSnapshot snapshot, int rowIndex, boolean[] repainted) {
if (rowIndex < 0 || rowIndex >= repainted.length || repainted[rowIndex]) {
return true;
}
RenderRow row = rowByIndex(snapshot, rowIndex);
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) {
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) {
// 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);
}
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();
// 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;
}
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<RenderRow> 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<RenderCell> 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;
// 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 = gxStart; gx < gxEnd; gx++) {
int alpha = glyph.alpha[glyphOffset + gx] & 0xff;
if (alpha == 0) {
continue;
}
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) {
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) {
// 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++) {
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);
}
}
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);
}
}
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 false;
}
for (int py = y0; py < y1; py++) {
Arrays.fill(pixels, (py * width) + x0, (py * width) + x1, color);
}
return true;
}
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<String, Glyph> 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

View File

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

View File

@@ -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) {

View File

@@ -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,18 +278,20 @@ 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();
}
/** 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