incremental render
This commit is contained in:
8
flake.lock
generated
8
flake.lock
generated
@@ -70,11 +70,11 @@
|
|||||||
"nixpkgs": "nixpkgs"
|
"nixpkgs": "nixpkgs"
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1780050576,
|
"lastModified": 1780076430,
|
||||||
"narHash": "sha256-u06xuO3QnLDpajIOZwDdhwI0HGzMuXG7x1pR+4Zb+RA=",
|
"narHash": "sha256-N3p4+vhuo8DEQwnOYrGdTPbQmlyxnnLOqsSoDRx8eQA=",
|
||||||
"ref": "refs/heads/main",
|
"ref": "refs/heads/main",
|
||||||
"rev": "d558d554b360a76d03c2fc09d327e3ec4aade878",
|
"rev": "5bbba354ab3cd26a595cb864b29430bc312aa726",
|
||||||
"revCount": 17,
|
"revCount": 19,
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://gitea.gregorlohaus.com/gregor/jlibghostty.git"
|
"url": "https://gitea.gregorlohaus.com/gregor/jlibghostty.git"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -50,7 +50,13 @@ public final class TerminalCanvasView {
|
|||||||
private FontMetrics cachedMetrics;
|
private FontMetrics cachedMetrics;
|
||||||
private String cachedFontFamily;
|
private String cachedFontFamily;
|
||||||
private double cachedFontSize;
|
private double cachedFontSize;
|
||||||
private String lastRenderKey;
|
// Cheap per-frame dirty signal: skip the whole render when none of these changed.
|
||||||
|
private double lastWidth = -1.0;
|
||||||
|
private double lastHeight = -1.0;
|
||||||
|
private String lastFontFamily;
|
||||||
|
private double lastFontSize = -1.0;
|
||||||
|
private long lastWorkspaceVersion = Long.MIN_VALUE;
|
||||||
|
private long lastRenderTick = Long.MIN_VALUE;
|
||||||
private boolean mouseButtonPressed;
|
private boolean mouseButtonPressed;
|
||||||
private MouseButton pressedButton = MouseButton.UNKNOWN;
|
private MouseButton pressedButton = MouseButton.UNKNOWN;
|
||||||
|
|
||||||
@@ -77,23 +83,35 @@ public final class TerminalCanvasView {
|
|||||||
cachedFont = null;
|
cachedFont = null;
|
||||||
cachedMetrics = null;
|
cachedMetrics = null;
|
||||||
paneRenderCache.clear();
|
paneRenderCache.clear();
|
||||||
lastRenderKey = null;
|
lastWidth = -1.0; // force a redraw on the next frame
|
||||||
}
|
}
|
||||||
|
|
||||||
public void render() {
|
public void render() {
|
||||||
double width = canvas.getWidth();
|
double width = canvas.getWidth();
|
||||||
double height = canvas.getHeight();
|
double height = canvas.getHeight();
|
||||||
|
|
||||||
|
// Geometry is a pure function of (size, workspace version); content changes bump the
|
||||||
|
// global render tick. If none moved, nothing can have changed visually, so bail out
|
||||||
|
// before doing any layout/pane-list/render-key work — this runs ~60x/s while idle.
|
||||||
|
long workspaceVersion = workspace.version();
|
||||||
|
long renderTick = TerminalPane.renderTick();
|
||||||
|
if (width == lastWidth && height == lastHeight
|
||||||
|
&& fontSize == lastFontSize && java.util.Objects.equals(fontFamily, lastFontFamily)
|
||||||
|
&& workspaceVersion == lastWorkspaceVersion && renderTick == lastRenderTick) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastWidth = width;
|
||||||
|
lastHeight = height;
|
||||||
|
lastFontFamily = fontFamily;
|
||||||
|
lastFontSize = fontSize;
|
||||||
|
lastWorkspaceVersion = workspaceVersion;
|
||||||
|
lastRenderTick = renderTick;
|
||||||
|
|
||||||
workspace.layout(width, height);
|
workspace.layout(width, height);
|
||||||
Font font = currentFont();
|
Font font = currentFont();
|
||||||
FontMetrics metrics = currentFontMetrics();
|
FontMetrics metrics = currentFontMetrics();
|
||||||
List<TerminalPane> panes = workspace.panes();
|
List<TerminalPane> panes = workspace.panes();
|
||||||
|
|
||||||
String renderKey = renderKey(width, height, metrics, panes);
|
|
||||||
if (renderKey.equals(lastRenderKey)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
lastRenderKey = renderKey;
|
|
||||||
|
|
||||||
GraphicsContext gc = canvas.getGraphicsContext2D();
|
GraphicsContext gc = canvas.getGraphicsContext2D();
|
||||||
gc.setFill(Color.rgb(16, 16, 18));
|
gc.setFill(Color.rgb(16, 16, 18));
|
||||||
gc.fillRect(0, 0, width, height);
|
gc.fillRect(0, 0, width, height);
|
||||||
@@ -105,88 +123,122 @@ public final class TerminalCanvasView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GhosttyRenderStateDirty values (stable C ABI; see ghostty/vt/render.h).
|
||||||
|
private static final int DIRTY_PARTIAL = 1;
|
||||||
|
private static final int DIRTY_FULL = 2;
|
||||||
|
|
||||||
private void drawPane(GraphicsContext gc, TerminalPane pane, Font font, FontMetrics metrics) {
|
private void drawPane(GraphicsContext gc, TerminalPane pane, Font font, FontMetrics metrics) {
|
||||||
|
// Resize up front so a geometry change is reflected as a FULL-dirty snapshot (with
|
||||||
|
// all cells) on this same frame, before we fetch the snapshot below.
|
||||||
|
int columns = Math.max(1, (int) ((pane.width() - 24.0) / metrics.cellWidth));
|
||||||
|
int rows = Math.max(1, (int) ((pane.height() - 24.0) / metrics.lineHeight));
|
||||||
|
pane.resize(columns, rows, (int) Math.round(metrics.cellWidth), (int) Math.round(metrics.lineHeight));
|
||||||
|
|
||||||
if (config.kittyGraphics() && paneHasKittyGraphics(pane)) {
|
if (config.kittyGraphics() && paneHasKittyGraphics(pane)) {
|
||||||
|
// Panes with kitty images redraw fully each frame (images compose with text),
|
||||||
|
// so they bypass the incremental offscreen cache and need every cell.
|
||||||
paneRenderCache.remove(pane);
|
paneRenderCache.remove(pane);
|
||||||
gc.save();
|
gc.save();
|
||||||
|
if (pane.floating()) {
|
||||||
|
gc.setGlobalAlpha(0.96);
|
||||||
|
}
|
||||||
gc.beginPath();
|
gc.beginPath();
|
||||||
gc.rect(pane.x(), pane.y(), pane.width(), pane.height());
|
gc.rect(pane.x(), pane.y(), pane.width(), pane.height());
|
||||||
gc.clip();
|
gc.clip();
|
||||||
drawPaneContent(gc, pane, font, metrics, pane.x(), pane.y(), pane.width(), pane.height(), false);
|
drawPaneContent(gc, pane, font, metrics, pane.renderSnapshotFull(),
|
||||||
|
pane.x(), pane.y(), pane.width(), pane.height(), true);
|
||||||
gc.restore();
|
gc.restore();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
PaneRenderCache cache = paneRenderCache.computeIfAbsent(pane, ignored -> new PaneRenderCache());
|
PaneRenderCache cache = paneRenderCache.computeIfAbsent(pane, ignored -> new PaneRenderCache());
|
||||||
String cacheKey = paneCacheKey(pane, metrics);
|
|
||||||
int imageWidth = Math.max(1, (int) Math.ceil(pane.width()));
|
int imageWidth = Math.max(1, (int) Math.ceil(pane.width()));
|
||||||
int imageHeight = Math.max(1, (int) Math.ceil(pane.height()));
|
int imageHeight = Math.max(1, (int) Math.ceil(pane.height()));
|
||||||
|
|
||||||
// Allocate the offscreen buffers only when the pane size changes. Reallocating a
|
// Reuse the offscreen buffers; only reallocate when the pane size changes.
|
||||||
// full-pane Canvas + WritableImage on every content change churns ~20 MB per frame,
|
boolean sizeChanged = cache.canvas == null || cache.image == null
|
||||||
// which the native image's serial GC turns into Full-GC frame drops.
|
|| cache.imageWidth != imageWidth || cache.imageHeight != imageHeight;
|
||||||
if (cache.canvas == null || cache.image == null || cache.imageWidth != imageWidth || cache.imageHeight != imageHeight) {
|
if (sizeChanged) {
|
||||||
cache.canvas = new Canvas(imageWidth, imageHeight);
|
cache.canvas = new Canvas(imageWidth, imageHeight);
|
||||||
cache.image = new WritableImage(imageWidth, imageHeight);
|
cache.image = new WritableImage(imageWidth, imageHeight);
|
||||||
cache.imageWidth = imageWidth;
|
cache.imageWidth = imageWidth;
|
||||||
cache.imageHeight = imageHeight;
|
cache.imageHeight = imageHeight;
|
||||||
cache.key = null;
|
cache.layoutKey = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Redraw and re-snapshot into the existing buffers only when content changed.
|
String layoutKey = paneLayoutKey(pane, metrics);
|
||||||
if (!cacheKey.equals(cache.key)) {
|
boolean firstDraw = sizeChanged || cache.layoutKey == null;
|
||||||
|
boolean layoutChanged = !layoutKey.equals(cache.layoutKey);
|
||||||
|
boolean contentChanged = pane.renderVersion() != cache.contentVersion;
|
||||||
|
|
||||||
GraphicsContext cacheGc = cache.canvas.getGraphicsContext2D();
|
GraphicsContext cacheGc = cache.canvas.getGraphicsContext2D();
|
||||||
cacheGc.clearRect(0, 0, imageWidth, imageHeight);
|
boolean imageChanged = false;
|
||||||
drawPaneContent(cacheGc, pane, font, metrics, 0.0, 0.0, imageWidth, imageHeight, true);
|
|
||||||
cache.canvas.snapshot(null, cache.image);
|
|
||||||
cache.key = cacheKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
if (firstDraw || contentChanged) {
|
||||||
|
RenderStateSnapshot snapshot = pane.renderSnapshot();
|
||||||
|
int dirty = snapshot == null ? DIRTY_FULL : snapshot.dirty();
|
||||||
|
if (firstDraw || dirty == DIRTY_FULL) {
|
||||||
|
cacheGc.clearRect(0.0, 0.0, imageWidth, imageHeight);
|
||||||
|
drawPaneContent(cacheGc, pane, font, metrics, snapshot, 0.0, 0.0, imageWidth, imageHeight, false);
|
||||||
|
imageChanged = true;
|
||||||
|
} else if (dirty == DIRTY_PARTIAL) {
|
||||||
|
drawDirtyRows(cacheGc, pane, font, metrics, snapshot, imageWidth, imageHeight);
|
||||||
|
imageChanged = true;
|
||||||
|
}
|
||||||
|
// dirty == FALSE: the write produced no visible change; keep the buffer.
|
||||||
|
}
|
||||||
|
if (!imageChanged && layoutChanged) {
|
||||||
|
// Only the active-border state changed; repaint the border over retained content.
|
||||||
|
drawBorder(cacheGc, pane, 0.0, 0.0, imageWidth, imageHeight);
|
||||||
|
imageChanged = true;
|
||||||
|
}
|
||||||
|
if (imageChanged) {
|
||||||
|
cache.canvas.snapshot(null, cache.image);
|
||||||
|
}
|
||||||
|
cache.contentVersion = pane.renderVersion();
|
||||||
|
cache.layoutKey = layoutKey;
|
||||||
|
|
||||||
|
if (pane.floating()) {
|
||||||
|
gc.setGlobalAlpha(0.96);
|
||||||
|
gc.drawImage(cache.image, pane.x(), pane.y());
|
||||||
|
gc.setGlobalAlpha(1.0);
|
||||||
|
} else {
|
||||||
gc.drawImage(cache.image, pane.x(), pane.y());
|
gc.drawImage(cache.image, pane.x(), pane.y());
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full content render: background, border, all rows, cursor, and (when enabled) kitty
|
||||||
|
// graphics. Used by the kitty direct path and by full offscreen redraws.
|
||||||
private void drawPaneContent(
|
private void drawPaneContent(
|
||||||
GraphicsContext gc,
|
GraphicsContext gc,
|
||||||
TerminalPane pane,
|
TerminalPane pane,
|
||||||
Font font,
|
Font font,
|
||||||
FontMetrics metrics,
|
FontMetrics metrics,
|
||||||
|
RenderStateSnapshot snapshot,
|
||||||
double x,
|
double x,
|
||||||
double y,
|
double y,
|
||||||
double width,
|
double width,
|
||||||
double height,
|
double height,
|
||||||
boolean clear
|
boolean withKitty
|
||||||
) {
|
) {
|
||||||
if (clear) {
|
|
||||||
gc.clearRect(x, y, width, height);
|
|
||||||
}
|
|
||||||
gc.setFontSmoothingType(FontSmoothingType.LCD);
|
gc.setFontSmoothingType(FontSmoothingType.LCD);
|
||||||
if (pane.floating()) {
|
// Paint content fully opaque. LCD subpixel text rendering produces colour fringing
|
||||||
gc.setGlobalAlpha(0.96);
|
// on a translucent surface, so floating-pane translucency is applied by the caller
|
||||||
}
|
// when the finished (opaque) buffer is composited onto the canvas.
|
||||||
gc.setFill(Color.rgb(9, 10, 12));
|
gc.setFill(Color.rgb(9, 10, 12));
|
||||||
gc.fillRect(x, y, width, height);
|
gc.fillRect(x, y, width, height);
|
||||||
gc.setGlobalAlpha(1.0);
|
drawBorder(gc, pane, x, y, width, height);
|
||||||
|
|
||||||
gc.setStroke(workspace.isActive(pane) ? Color.rgb(87, 166, 255) : Color.rgb(52, 57, 65));
|
|
||||||
gc.setLineWidth(workspace.isActive(pane) ? 2.0 : 1.0);
|
|
||||||
gc.strokeRect(x + 0.5, y + 0.5, width - 1.0, height - 1.0);
|
|
||||||
|
|
||||||
gc.setFont(font);
|
gc.setFont(font);
|
||||||
|
|
||||||
int columns = Math.max(1, (int) ((width - 24.0) / metrics.cellWidth));
|
|
||||||
int rows = Math.max(1, (int) ((height - 24.0) / metrics.lineHeight));
|
|
||||||
pane.resize(columns, rows, (int) Math.round(metrics.cellWidth), (int) Math.round(metrics.lineHeight));
|
|
||||||
|
|
||||||
double left = x + 12.0;
|
double left = x + 12.0;
|
||||||
double top = y + 12.0;
|
double top = y + 12.0;
|
||||||
double baseline = top + metrics.baselineOffset;
|
double baseline = top + metrics.baselineOffset;
|
||||||
|
|
||||||
RenderStateSnapshot snapshot = pane.renderSnapshot();
|
Map<KittyPlaceholderKey, KittyPlaceholderBounds> placeholderBounds = withKitty
|
||||||
Map<KittyPlaceholderKey, KittyPlaceholderBounds> placeholderBounds = config.kittyGraphics()
|
|
||||||
? kittyPlaceholderBounds(snapshot)
|
? kittyPlaceholderBounds(snapshot)
|
||||||
: Map.of();
|
: Map.of();
|
||||||
|
|
||||||
if (config.kittyGraphics()) {
|
if (withKitty) {
|
||||||
drawKittyGraphics(gc, pane, KittyPlacementLayer.BELOW_TEXT, placeholderBounds, left, top, metrics.cellWidth, metrics.lineHeight);
|
drawKittyGraphics(gc, pane, KittyPlacementLayer.BELOW_TEXT, placeholderBounds, left, top, metrics.cellWidth, metrics.lineHeight);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,17 +246,64 @@ public final class TerminalCanvasView {
|
|||||||
for (RenderRow row : snapshot.renderRows()) {
|
for (RenderRow row : snapshot.renderRows()) {
|
||||||
drawRow(gc, row, left, top, baseline, metrics.cellWidth, metrics.lineHeight);
|
drawRow(gc, row, left, top, baseline, metrics.cellWidth, metrics.lineHeight);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (snapshot != null) {
|
|
||||||
drawCursor(gc, snapshot, left, top, metrics.cellWidth, metrics.lineHeight);
|
drawCursor(gc, snapshot, left, top, metrics.cellWidth, metrics.lineHeight);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (config.kittyGraphics()) {
|
if (withKitty) {
|
||||||
drawKittyGraphics(gc, pane, KittyPlacementLayer.ABOVE_TEXT, placeholderBounds, left, top, metrics.cellWidth, metrics.lineHeight);
|
drawKittyGraphics(gc, pane, KittyPlacementLayer.ABOVE_TEXT, placeholderBounds, left, top, metrics.cellWidth, metrics.lineHeight);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Incremental render: repaint only the rows ghostty flagged dirty in the offscreen
|
||||||
|
// buffer (origin 0,0), then restore the cursor and border.
|
||||||
|
private void drawDirtyRows(
|
||||||
|
GraphicsContext gc,
|
||||||
|
TerminalPane pane,
|
||||||
|
Font font,
|
||||||
|
FontMetrics metrics,
|
||||||
|
RenderStateSnapshot snapshot,
|
||||||
|
double width,
|
||||||
|
double height
|
||||||
|
) {
|
||||||
|
gc.setFontSmoothingType(FontSmoothingType.LCD);
|
||||||
|
gc.setFont(font);
|
||||||
|
double left = 12.0;
|
||||||
|
double top = 12.0;
|
||||||
|
double baseline = top + metrics.baselineOffset;
|
||||||
|
|
||||||
|
boolean cursorRowDirty = false;
|
||||||
|
for (RenderRow row : snapshot.renderRows()) {
|
||||||
|
if (!row.dirty()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Snap the row band to integer pixels and paint opaque (no clearRect): a
|
||||||
|
// fractional-height fill would leave sub-pixel-transparent seams between rows,
|
||||||
|
// which the floating-pane alpha compositing reveals as faint horizontal lines.
|
||||||
|
double y0 = Math.floor(top + (row.row() * metrics.lineHeight));
|
||||||
|
double y1 = Math.ceil(top + ((row.row() + 1) * metrics.lineHeight));
|
||||||
|
gc.setFill(Color.rgb(9, 10, 12));
|
||||||
|
gc.fillRect(0.0, y0, width, y1 - y0);
|
||||||
|
drawRow(gc, row, left, top, baseline, metrics.cellWidth, metrics.lineHeight);
|
||||||
|
if (snapshot.cursorViewportHasValue() && row.row() == snapshot.cursorViewportY()) {
|
||||||
|
cursorRowDirty = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The cursor overlays its cell; redraw it only when its row was repainted, so we
|
||||||
|
// neither leave a stale cursor nor stack the translucent overlay on itself.
|
||||||
|
if (cursorRowDirty) {
|
||||||
|
drawCursor(gc, snapshot, left, top, metrics.cellWidth, metrics.lineHeight);
|
||||||
|
}
|
||||||
|
// Repainting rows clears the full width, erasing the side borders; restore the frame.
|
||||||
|
drawBorder(gc, pane, 0.0, 0.0, width, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void drawBorder(GraphicsContext gc, TerminalPane pane, double x, double y, double width, double height) {
|
||||||
|
gc.setStroke(workspace.isActive(pane) ? Color.rgb(87, 166, 255) : Color.rgb(52, 57, 65));
|
||||||
|
gc.setLineWidth(workspace.isActive(pane) ? 2.0 : 1.0);
|
||||||
|
gc.strokeRect(x + 0.5, y + 0.5, width - 1.0, height - 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
private static FontMetrics measureFontMetrics(Font font) {
|
private static FontMetrics measureFontMetrics(Font font) {
|
||||||
Text text = new Text("┃MgÅjy");
|
Text text = new Text("┃MgÅjy");
|
||||||
text.setFont(font);
|
text.setFont(font);
|
||||||
@@ -234,31 +333,11 @@ public final class TerminalCanvasView {
|
|||||||
return cachedMetrics;
|
return cachedMetrics;
|
||||||
}
|
}
|
||||||
|
|
||||||
private String renderKey(double width, double height, FontMetrics metrics, List<TerminalPane> panes) {
|
// Layout identity of a pane: everything that forces a redraw EXCEPT terminal content
|
||||||
StringBuilder builder = new StringBuilder();
|
// (which is tracked separately by renderVersion). Deliberately omits renderVersion so
|
||||||
builder.append(width).append(':')
|
// content changes go through the incremental dirty-row path instead of a full redraw.
|
||||||
.append(height).append(':')
|
private String paneLayoutKey(TerminalPane pane, FontMetrics metrics) {
|
||||||
.append(workspace.version()).append(':')
|
return workspace.isActive(pane)
|
||||||
.append(fontFamily).append(':')
|
|
||||||
.append(fontSize).append(':')
|
|
||||||
.append(metrics.cellWidth).append(':')
|
|
||||||
.append(metrics.lineHeight);
|
|
||||||
for (TerminalPane pane : panes) {
|
|
||||||
builder.append('|')
|
|
||||||
.append(System.identityHashCode(pane)).append(',')
|
|
||||||
.append(pane.renderVersion()).append(',')
|
|
||||||
.append(workspace.isActive(pane)).append(',')
|
|
||||||
.append(pane.x()).append(',')
|
|
||||||
.append(pane.y()).append(',')
|
|
||||||
.append(pane.width()).append(',')
|
|
||||||
.append(pane.height());
|
|
||||||
}
|
|
||||||
return builder.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
private String paneCacheKey(TerminalPane pane, FontMetrics metrics) {
|
|
||||||
return pane.renderVersion()
|
|
||||||
+ ":" + workspace.isActive(pane)
|
|
||||||
+ ":" + pane.width()
|
+ ":" + pane.width()
|
||||||
+ ":" + pane.height()
|
+ ":" + pane.height()
|
||||||
+ ":" + fontFamily
|
+ ":" + fontFamily
|
||||||
@@ -475,10 +554,12 @@ public final class TerminalCanvasView {
|
|||||||
|
|
||||||
double x = left + (cell.column() * cellWidth);
|
double x = left + (cell.column() * cellWidth);
|
||||||
double cellTop = top + (row.row() * lineHeight);
|
double cellTop = top + (row.row() * lineHeight);
|
||||||
cell.background().ifPresent(background -> {
|
// Avoid the capturing lambda / Optional.map allocations per cell on this hot path.
|
||||||
gc.setFill(toFxColor(background));
|
var background = cell.background();
|
||||||
|
if (background.isPresent()) {
|
||||||
|
gc.setFill(toFxColor(background.get()));
|
||||||
gc.fillRect(x, cellTop, cellWidth, lineHeight);
|
gc.fillRect(x, cellTop, cellWidth, lineHeight);
|
||||||
});
|
}
|
||||||
if (cell.selected()) {
|
if (cell.selected()) {
|
||||||
gc.setFill(SELECTED_BACKGROUND);
|
gc.setFill(SELECTED_BACKGROUND);
|
||||||
gc.fillRect(x, cellTop, cellWidth, lineHeight);
|
gc.fillRect(x, cellTop, cellWidth, lineHeight);
|
||||||
@@ -488,14 +569,29 @@ public final class TerminalCanvasView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
double y = baseline + (row.row() * lineHeight);
|
double y = baseline + (row.row() * lineHeight);
|
||||||
Color foreground = cell.foreground().map(TerminalCanvasView::toFxColor).orElse(DEFAULT_FOREGROUND);
|
var foregroundColor = cell.foreground();
|
||||||
gc.setFill(foreground);
|
gc.setFill(foregroundColor.isPresent() ? toFxColor(foregroundColor.get()) : DEFAULT_FOREGROUND);
|
||||||
gc.fillText(cell.text(), x, y);
|
gc.fillText(cell.text(), x, y);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 truecolor gradient can't grow it without limit.
|
||||||
|
private static final Map<Integer, Color> COLOR_CACHE = new HashMap<>();
|
||||||
|
|
||||||
private static Color toFxColor(RenderColor color) {
|
private static Color toFxColor(RenderColor color) {
|
||||||
return Color.rgb(color.red(), color.green(), color.blue());
|
int key = (color.red() << 16) | (color.green() << 8) | color.blue();
|
||||||
|
Color cached = COLOR_CACHE.get(key);
|
||||||
|
if (cached != null) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
if (COLOR_CACHE.size() >= 4096) {
|
||||||
|
COLOR_CACHE.clear();
|
||||||
|
}
|
||||||
|
Color created = Color.rgb(color.red(), color.green(), color.blue());
|
||||||
|
COLOR_CACHE.put(key, created);
|
||||||
|
return created;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void drawCursor(GraphicsContext gc, RenderStateSnapshot snapshot, double left, double top, double cellWidth, double lineHeight) {
|
private static void drawCursor(GraphicsContext gc, RenderStateSnapshot snapshot, double left, double top, double cellWidth, double lineHeight) {
|
||||||
@@ -718,7 +814,12 @@ public final class TerminalCanvasView {
|
|||||||
private record MouseTarget(MouseEncoderSize size, long screenWidth, long screenHeight) {
|
private record MouseTarget(MouseEncoderSize size, long screenWidth, long screenHeight) {
|
||||||
}
|
}
|
||||||
|
|
||||||
private record KittyImageKey(long id, long number, long width, long height, KittyImageFormat format, int dataLength, long fingerprint) {
|
// 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 we avoid fingerprinting the whole payload — which previously ran once per frame
|
||||||
|
// per placement (O(image size)) just to look the image up.
|
||||||
|
private record KittyImageKey(long id, long number, long width, long height, KittyImageFormat format, int dataLength) {
|
||||||
private static KittyImageKey of(KittyImageSnapshot snapshot, byte[] data) {
|
private static KittyImageKey of(KittyImageSnapshot snapshot, byte[] data) {
|
||||||
return new KittyImageKey(
|
return new KittyImageKey(
|
||||||
snapshot.id(),
|
snapshot.id(),
|
||||||
@@ -726,19 +827,9 @@ public final class TerminalCanvasView {
|
|||||||
snapshot.width(),
|
snapshot.width(),
|
||||||
snapshot.height(),
|
snapshot.height(),
|
||||||
snapshot.format(),
|
snapshot.format(),
|
||||||
data.length,
|
data.length
|
||||||
fingerprint(data)
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static long fingerprint(byte[] data) {
|
|
||||||
long hash = 0xcbf29ce484222325L;
|
|
||||||
for (byte value : data) {
|
|
||||||
hash ^= Byte.toUnsignedInt(value);
|
|
||||||
hash *= 0x100000001b3L;
|
|
||||||
}
|
|
||||||
return hash;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private record KittyPlaceholderKey(long imageId, long placementId) {
|
private record KittyPlaceholderKey(long imageId, long placementId) {
|
||||||
@@ -794,6 +885,7 @@ public final class TerminalCanvasView {
|
|||||||
private WritableImage image;
|
private WritableImage image;
|
||||||
private int imageWidth;
|
private int imageWidth;
|
||||||
private int imageHeight;
|
private int imageHeight;
|
||||||
private String key;
|
private String layoutKey;
|
||||||
|
private long contentVersion = Long.MIN_VALUE;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import dev.jlibghostty.MouseAction;
|
|||||||
import dev.jlibghostty.MouseEncoder;
|
import dev.jlibghostty.MouseEncoder;
|
||||||
import dev.jlibghostty.MouseEncoderSize;
|
import dev.jlibghostty.MouseEncoderSize;
|
||||||
import dev.jlibghostty.MouseInput;
|
import dev.jlibghostty.MouseInput;
|
||||||
|
import dev.jlibghostty.RenderState;
|
||||||
import dev.jlibghostty.RenderStateSnapshot;
|
import dev.jlibghostty.RenderStateSnapshot;
|
||||||
import dev.jlibghostty.ScrollViewport;
|
import dev.jlibghostty.ScrollViewport;
|
||||||
import dev.jlibghostty.Terminal;
|
import dev.jlibghostty.Terminal;
|
||||||
@@ -13,12 +14,23 @@ import dev.jlibghostty.TerminalOptions;
|
|||||||
import dev.jlibghostty.DeviceAttributes;
|
import dev.jlibghostty.DeviceAttributes;
|
||||||
|
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.concurrent.atomic.AtomicReference;
|
import java.util.concurrent.atomic.AtomicLong;
|
||||||
|
|
||||||
public final class TerminalPane implements AutoCloseable {
|
public final class TerminalPane implements AutoCloseable {
|
||||||
|
// Monotonic across all panes, bumped on every content change. Lets the renderer detect
|
||||||
|
// "nothing changed" in O(1) without scanning panes or building a render key.
|
||||||
|
private static final AtomicLong RENDER_TICK = new AtomicLong();
|
||||||
|
|
||||||
|
public static long renderTick() {
|
||||||
|
return RENDER_TICK.get();
|
||||||
|
}
|
||||||
|
|
||||||
private final Terminal terminal;
|
private final Terminal terminal;
|
||||||
private final MouseEncoder mouseEncoder = new MouseEncoder();
|
private final MouseEncoder mouseEncoder = new MouseEncoder();
|
||||||
private final AtomicReference<RenderStateSnapshot> renderSnapshot = new AtomicReference<>();
|
// A persistent render state (reused across frames) is what makes ghostty's per-row
|
||||||
|
// dirty tracking meaningful: update() accumulates dirty since the last resetDirty().
|
||||||
|
private final RenderState renderState = new RenderState();
|
||||||
|
private RenderStateSnapshot cachedSnapshot;
|
||||||
private ShellSession session;
|
private ShellSession session;
|
||||||
private boolean floating;
|
private boolean floating;
|
||||||
private boolean visible = true;
|
private boolean visible = true;
|
||||||
@@ -31,6 +43,7 @@ public final class TerminalPane implements AutoCloseable {
|
|||||||
private int pixelWidth;
|
private int pixelWidth;
|
||||||
private int pixelHeight;
|
private int pixelHeight;
|
||||||
private long renderVersion;
|
private long renderVersion;
|
||||||
|
private long snapshotVersion = -1;
|
||||||
|
|
||||||
private TerminalPane(Terminal terminal, int columns, int rows) {
|
private TerminalPane(Terminal terminal, int columns, int rows) {
|
||||||
this.terminal = terminal;
|
this.terminal = terminal;
|
||||||
@@ -111,8 +124,39 @@ public final class TerminalPane implements AutoCloseable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Incremental snapshot: cells are marshalled only for rows that changed since the last
|
||||||
|
* frame (global dirty == PARTIAL), reused across calls for the same content version.
|
||||||
|
* Snapshotting is deferred here rather than done in refresh(), so a burst of writes
|
||||||
|
* between two frames collapses into a single snapshot.
|
||||||
|
*/
|
||||||
public RenderStateSnapshot renderSnapshot() {
|
public RenderStateSnapshot renderSnapshot() {
|
||||||
return renderSnapshot.get();
|
return snapshot(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full snapshot with every row's cells populated. Used where the whole pane is redrawn
|
||||||
|
* regardless of dirty state (the kitty-graphics path).
|
||||||
|
*/
|
||||||
|
public RenderStateSnapshot renderSnapshotFull() {
|
||||||
|
return snapshot(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private RenderStateSnapshot snapshot(boolean full) {
|
||||||
|
synchronized (terminal) {
|
||||||
|
if (full) {
|
||||||
|
renderState.update(terminal);
|
||||||
|
cachedSnapshot = renderState.snapshot();
|
||||||
|
renderState.resetDirty();
|
||||||
|
snapshotVersion = renderVersion;
|
||||||
|
} else if (snapshotVersion != renderVersion) {
|
||||||
|
renderState.update(terminal);
|
||||||
|
cachedSnapshot = renderState.snapshotIncremental();
|
||||||
|
renderState.resetDirty();
|
||||||
|
snapshotVersion = renderVersion;
|
||||||
|
}
|
||||||
|
return cachedSnapshot;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public String scrollbackText() {
|
public String scrollbackText() {
|
||||||
@@ -192,8 +236,10 @@ public final class TerminalPane implements AutoCloseable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void refresh() {
|
private void refresh() {
|
||||||
renderSnapshot.set(terminal.renderSnapshot());
|
// Only mark the pane dirty; the snapshot itself is computed lazily in
|
||||||
|
// renderSnapshot() so a burst of writes collapses into a single snapshot per frame.
|
||||||
renderVersion++;
|
renderVersion++;
|
||||||
|
RENDER_TICK.incrementAndGet();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -203,6 +249,7 @@ public final class TerminalPane implements AutoCloseable {
|
|||||||
session = null;
|
session = null;
|
||||||
}
|
}
|
||||||
mouseEncoder.close();
|
mouseEncoder.close();
|
||||||
|
renderState.close();
|
||||||
terminal.close();
|
terminal.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user