Merge branch 'codex-performance-improvements'

This commit is contained in:
2026-05-31 23:24:06 +02:00
8 changed files with 834 additions and 71 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));
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);
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;
}
// Resolve fg/bg (null bg = terminal default, painted by the pane background).
// Avoid Optional.map's allocation on this hot path.
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;
// 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) {
continue;
}
double y = baseline + (row.row() * lineHeight);
gc.setFill(fg);
gc.fillText(cell.text(), x, y);
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

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

@@ -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;
int offset = 0;
while (offset < data.length) {
long n = callLong(WRITE, masterFd, buf.asSlice(offset), data.length - offset);
if (n < 0) {
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