no image caching, no transparency for performance

This commit is contained in:
Gregor Lohaus
2026-05-29 21:18:16 +02:00
parent 40d6287867
commit 4c3449129c

View File

@@ -41,12 +41,17 @@ public final class TerminalCanvasView {
// The default cell background (used for cells with no explicit bg, and as the foreground // 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). // 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 PANE_BACKGROUND = Color.rgb(9, 10, 12);
// Canvas background shown wherever no pane covers (gaps, behind nothing once tiled panes
// fill the canvas). Painted on a full recomposite.
private static final Color GAP_BACKGROUND = Color.rgb(16, 16, 18);
private final Canvas canvas = new Canvas(); private final Canvas canvas = new Canvas();
private final TerminalWorkspace workspace; private final TerminalWorkspace workspace;
private final AppConfig config; private final AppConfig config;
private final Map<KittyImageKey, Image> kittyImageCache = new HashMap<>(); private final Map<KittyImageKey, Image> kittyImageCache = new HashMap<>();
private final Map<TerminalPane, PaneRenderCache> paneRenderCache = new HashMap<>(); // Last content version drawn to the canvas per pane, so a content frame repaints only
// the panes that actually changed.
private final Map<TerminalPane, Long> paneContentVersion = new HashMap<>();
private String fontFamily; private String fontFamily;
private double fontSize; private double fontSize;
private Font cachedFont; private Font cachedFont;
@@ -85,22 +90,28 @@ public final class TerminalCanvasView {
this.fontSize = size; this.fontSize = size;
cachedFont = null; cachedFont = null;
cachedMetrics = null; cachedMetrics = null;
paneRenderCache.clear(); paneContentVersion.clear();
lastWidth = -1.0; // force a redraw on the next frame lastWidth = -1.0; // force a redraw on the next frame
} }
// GhosttyRenderStateDirty values (stable C ABI; see ghostty/vt/render.h).
private static final int DIRTY_PARTIAL = 1;
private static final int DIRTY_FULL = 2;
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 workspaceVersion = workspace.version();
long renderTick = TerminalPane.renderTick(); long renderTick = TerminalPane.renderTick();
if (width == lastWidth && height == lastHeight
&& fontSize == lastFontSize && java.util.Objects.equals(fontFamily, lastFontFamily) // Two kinds of change: a layout change (size, font, active pane, pane set / z-order)
&& workspaceVersion == lastWorkspaceVersion && renderTick == lastRenderTick) { // forces a full recomposite; a content change (renderTick) only repaints the panes
// whose terminal content changed. Idle frames — neither — bail out immediately.
boolean layoutChanged = width != lastWidth || height != lastHeight
|| fontSize != lastFontSize || !java.util.Objects.equals(fontFamily, lastFontFamily)
|| workspaceVersion != lastWorkspaceVersion;
boolean contentChanged = renderTick != lastRenderTick;
if (!layoutChanged && !contentChanged) {
return; return;
} }
lastWidth = width; lastWidth = width;
@@ -115,105 +126,129 @@ public final class TerminalCanvasView {
FontMetrics metrics = currentFontMetrics(); FontMetrics metrics = currentFontMetrics();
List<TerminalPane> panes = workspace.panes(); List<TerminalPane> panes = workspace.panes();
// Apply terminal resizes up front so snapshots reflect current geometry (a no-op
// when the grid is unchanged).
for (TerminalPane pane : panes) {
applyResize(pane, metrics);
}
GraphicsContext gc = canvas.getGraphicsContext2D(); GraphicsContext gc = canvas.getGraphicsContext2D();
gc.setFill(Color.rgb(16, 16, 18));
gc.fillRect(0, 0, width, height);
gc.setFontSmoothingType(FontSmoothingType.LCD); gc.setFontSmoothingType(FontSmoothingType.LCD);
paneRenderCache.keySet().removeIf(pane -> !panes.contains(pane)); if (layoutChanged) {
for (TerminalPane pane : panes) { // Recomposite everything onto the retained canvas: clear, then paint panes
drawPane(gc, pane, font, metrics); // bottom-to-top (workspace.panes() puts the active floating pane last == on top).
} paneContentVersion.keySet().retainAll(panes);
} gc.setFill(GAP_BACKGROUND);
gc.fillRect(0, 0, width, height);
// GhosttyRenderStateDirty values (stable C ABI; see ghostty/vt/render.h). for (TerminalPane pane : panes) {
private static final int DIRTY_PARTIAL = 1; paintPane(gc, pane, font, metrics, pane.renderSnapshotFull());
private static final int DIRTY_FULL = 2; paneContentVersion.put(pane, pane.renderVersion());
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)) {
// 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);
gc.save();
if (pane.floating()) {
gc.setGlobalAlpha(0.96);
} }
gc.beginPath();
gc.rect(pane.x(), pane.y(), pane.width(), pane.height());
gc.clip();
drawPaneContent(gc, pane, font, metrics, pane.renderSnapshotFull(),
pane.x(), pane.y(), pane.width(), pane.height(), true);
gc.restore();
return; return;
} }
PaneRenderCache cache = paneRenderCache.computeIfAbsent(pane, ignored -> new PaneRenderCache()); // Content-only frame: repaint just the panes whose content changed, directly on the
int imageWidth = Math.max(1, (int) Math.ceil(pane.width())); // retained canvas, then restore any panes stacked above where they overlap.
int imageHeight = Math.max(1, (int) Math.ceil(pane.height())); for (int i = 0; i < panes.size(); i++) {
TerminalPane pane = panes.get(i);
// Reuse the offscreen buffers; only reallocate when the pane size changes. Long drawn = paneContentVersion.get(pane);
boolean sizeChanged = cache.canvas == null || cache.image == null if (drawn != null && drawn == pane.renderVersion()) {
|| cache.imageWidth != imageWidth || cache.imageHeight != imageHeight; continue;
if (sizeChanged) { }
cache.canvas = new Canvas(imageWidth, imageHeight); repaintPaneContent(gc, panes, i, font, metrics);
cache.image = new WritableImage(imageWidth, imageHeight); paneContentVersion.put(pane, pane.renderVersion());
cache.imageWidth = imageWidth;
cache.imageHeight = imageHeight;
cache.layoutKey = null;
} }
}
String layoutKey = paneLayoutKey(pane, metrics); private void applyResize(TerminalPane pane, FontMetrics metrics) {
boolean firstDraw = sizeChanged || cache.layoutKey == null; int columns = Math.max(1, (int) ((pane.width() - 24.0) / metrics.cellWidth));
boolean layoutChanged = !layoutKey.equals(cache.layoutKey); int rows = Math.max(1, (int) ((pane.height() - 24.0) / metrics.lineHeight));
boolean contentChanged = pane.renderVersion() != cache.contentVersion; pane.resize(columns, rows, (int) Math.round(metrics.cellWidth), (int) Math.round(metrics.lineHeight));
}
GraphicsContext cacheGc = cache.canvas.getGraphicsContext2D(); // Paint a pane's whole body, clipped to its rect. Used for full recomposites.
boolean imageChanged = false; private void paintPane(GraphicsContext gc, TerminalPane pane, Font font, FontMetrics metrics, RenderStateSnapshot snapshot) {
double px = Math.round(pane.x());
double py = Math.round(pane.y());
gc.save();
clipRect(gc, px, py, pane.width(), pane.height());
drawPaneContent(gc, pane, font, metrics, snapshot, px, py, pane.width(), pane.height(),
config.kittyGraphics() && paneHasKittyGraphics(pane));
gc.restore();
}
if (firstDraw || contentChanged) { // Repaint one pane whose content changed, then restore the (opaque) panes stacked above
// it wherever they overlap the repainted region, so the z-order stays correct.
private void repaintPaneContent(GraphicsContext gc, List<TerminalPane> panes, int index, Font font, FontMetrics metrics) {
TerminalPane pane = panes.get(index);
double px = Math.round(pane.x());
double py = Math.round(pane.y());
double pw = pane.width();
double ph = pane.height();
boolean kitty = config.kittyGraphics() && paneHasKittyGraphics(pane);
double regionY0;
double regionY1;
gc.save();
clipRect(gc, px, py, pw, ph);
if (kitty) {
drawPaneContent(gc, pane, font, metrics, pane.renderSnapshotFull(), px, py, pw, ph, true);
regionY0 = py;
regionY1 = py + ph;
} else {
RenderStateSnapshot snapshot = pane.renderSnapshot(); RenderStateSnapshot snapshot = pane.renderSnapshot();
int dirty = snapshot == null ? DIRTY_FULL : snapshot.dirty(); int dirty = snapshot == null ? DIRTY_FULL : snapshot.dirty();
if (firstDraw || dirty == DIRTY_FULL) { if (dirty == DIRTY_FULL) {
cacheGc.clearRect(0.0, 0.0, imageWidth, imageHeight); drawPaneContent(gc, pane, font, metrics, snapshot, px, py, pw, ph, false);
drawPaneContent(cacheGc, pane, font, metrics, snapshot, 0.0, 0.0, imageWidth, imageHeight, false); regionY0 = py;
imageChanged = true; regionY1 = py + ph;
} else if (dirty == DIRTY_PARTIAL) { } else if (dirty == DIRTY_PARTIAL) {
drawDirtyRows(cacheGc, pane, font, metrics, snapshot, imageWidth, imageHeight); double[] band = drawDirtyRows(gc, pane, font, metrics, snapshot, px, py, pw, ph);
imageChanged = true; gc.restore();
if (band == null) {
return;
}
restoreStackedAbove(gc, panes, index, font, metrics, px, band[0], pw, band[1] - band[0]);
return;
} else {
gc.restore();
return; // dirty == FALSE: nothing visible changed.
} }
// dirty == FALSE: the write produced no visible change; keep the buffer.
} }
if (!imageChanged && layoutChanged) { gc.restore();
// Only the active-border state changed; repaint the border over retained content. restoreStackedAbove(gc, panes, index, font, metrics, px, regionY0, pw, regionY1 - regionY0);
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;
// Blit on integer pixels: a fractional destination resamples the whole buffer and // Redraw any panes above `index` in z-order that intersect the given screen rect, so a
// smears the (now pixel-aligned) content back into seams. // lower pane's repaint doesn't leak through a pane stacked on top of it.
double destX = Math.round(pane.x()); private void restoreStackedAbove(GraphicsContext gc, List<TerminalPane> panes, int index,
double destY = Math.round(pane.y()); Font font, FontMetrics metrics, double rx, double ry, double rw, double rh) {
if (pane.floating()) { for (int j = index + 1; j < panes.size(); j++) {
gc.setGlobalAlpha(0.96); TerminalPane above = panes.get(j);
gc.drawImage(cache.image, destX, destY); double ax = Math.round(above.x());
gc.setGlobalAlpha(1.0); double ay = Math.round(above.y());
} else { double ox0 = Math.max(rx, ax);
gc.drawImage(cache.image, destX, destY); double oy0 = Math.max(ry, ay);
double ox1 = Math.min(rx + rw, ax + above.width());
double oy1 = Math.min(ry + rh, ay + above.height());
if (ox1 <= ox0 || oy1 <= oy0) {
continue;
}
gc.save();
clipRect(gc, ox0, oy0, ox1 - ox0, oy1 - oy0);
drawPaneContent(gc, above, font, metrics, above.renderSnapshotFull(), ax, ay, above.width(), above.height(),
config.kittyGraphics() && paneHasKittyGraphics(above));
gc.restore();
} }
} }
private static void clipRect(GraphicsContext gc, double x, double y, double width, double height) {
gc.beginPath();
gc.rect(x, y, width, height);
gc.clip();
}
// Full content render: background, border, all rows, cursor, and (when enabled) kitty // Full content render: background, border, all rows, cursor, and (when enabled) kitty
// graphics. Used by the kitty direct path and by full offscreen redraws. // graphics. Used by the kitty direct path and by full offscreen redraws.
private void drawPaneContent( private void drawPaneContent(
@@ -261,48 +296,64 @@ public final class TerminalCanvasView {
} }
} }
// Incremental render: repaint only the rows ghostty flagged dirty in the offscreen // Incremental render: repaint only the rows ghostty flagged dirty, at the pane's screen
// buffer (origin 0,0), then restore the cursor and border. // origin, then restore the cursor and border. Returns the screen-space [minY, maxY] band
private void drawDirtyRows( // that was repainted (for restoring panes stacked above), or null if nothing was dirty.
private double[] drawDirtyRows(
GraphicsContext gc, GraphicsContext gc,
TerminalPane pane, TerminalPane pane,
Font font, Font font,
FontMetrics metrics, FontMetrics metrics,
RenderStateSnapshot snapshot, RenderStateSnapshot snapshot,
double width, double px,
double height double py,
double pw,
double ph
) { ) {
gc.setFontSmoothingType(FontSmoothingType.LCD); gc.setFontSmoothingType(FontSmoothingType.LCD);
gc.setFont(font); gc.setFont(font);
double left = 12.0; double left = px + 12.0;
double top = 12.0; double top = py + 12.0;
double baseline = top + metrics.baselineOffset; double baseline = top + metrics.baselineOffset;
boolean cursorRowDirty = false; boolean cursorRowDirty = false;
double bandMin = Double.POSITIVE_INFINITY;
double bandMax = Double.NEGATIVE_INFINITY;
for (RenderRow row : snapshot.renderRows()) { for (RenderRow row : snapshot.renderRows()) {
if (!row.dirty()) { if (!row.dirty()) {
continue; continue;
} }
// Snap the row band to integer pixels and paint opaque (no clearRect): a // Snap the row band to integer pixels and paint opaque: a fractional-height fill
// fractional-height fill would leave sub-pixel-transparent seams between rows, // would leave sub-pixel seams between rows.
// which the floating-pane alpha compositing reveals as faint horizontal lines.
double y0 = Math.floor(top + (row.row() * metrics.lineHeight)); double y0 = Math.floor(top + (row.row() * metrics.lineHeight));
double y1 = Math.ceil(top + ((row.row() + 1) * metrics.lineHeight)); double y1 = Math.ceil(top + ((row.row() + 1) * metrics.lineHeight));
gc.setFill(Color.rgb(9, 10, 12)); gc.setFill(PANE_BACKGROUND);
gc.fillRect(0.0, y0, width, y1 - y0); gc.fillRect(px, y0, pw, y1 - y0);
drawRow(gc, row, left, top, baseline, metrics.cellWidth, metrics.lineHeight); drawRow(gc, row, left, top, baseline, metrics.cellWidth, metrics.lineHeight);
bandMin = Math.min(bandMin, y0);
bandMax = Math.max(bandMax, y1);
if (snapshot.cursorViewportHasValue() && row.row() == snapshot.cursorViewportY()) { if (snapshot.cursorViewportHasValue() && row.row() == snapshot.cursorViewportY()) {
cursorRowDirty = true; cursorRowDirty = true;
} }
} }
if (bandMin > bandMax) {
return null;
}
// The cursor overlays its cell; redraw it only when its row was repainted, so we // 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. // neither leave a stale cursor nor stack the translucent overlay on itself.
if (cursorRowDirty) { if (cursorRowDirty) {
drawCursor(gc, snapshot, left, top, metrics.cellWidth, metrics.lineHeight); drawCursor(gc, snapshot, left, top, metrics.cellWidth, metrics.lineHeight);
} }
// Repainting rows clears the full width, erasing the side borders; restore the frame. // Repainting rows clears the side borders within the band; restore just those
drawBorder(gc, pane, 0.0, 0.0, width, height); // segments. Clipping to the band is important: the full border rectangle extends
// outside the repainted region, and only the band gets restored over panes stacked
// above — an unclipped stroke would leave this pane's outline on top of them.
gc.save();
clipRect(gc, px, bandMin, pw, bandMax - bandMin);
drawBorder(gc, pane, px, py, pw, ph);
gc.restore();
return new double[] {bandMin, bandMax};
} }
private void drawBorder(GraphicsContext gc, TerminalPane pane, double x, double y, double width, double height) { private void drawBorder(GraphicsContext gc, TerminalPane pane, double x, double y, double width, double height) {
@@ -347,17 +398,6 @@ public final class TerminalCanvasView {
// Layout identity of a pane: everything that forces a redraw EXCEPT terminal content // Layout identity of a pane: everything that forces a redraw EXCEPT terminal content
// (which is tracked separately by renderVersion). Deliberately omits renderVersion so // (which is tracked separately by renderVersion). Deliberately omits renderVersion so
// content changes go through the incremental dirty-row path instead of a full redraw. // content changes go through the incremental dirty-row path instead of a full redraw.
private String paneLayoutKey(TerminalPane pane, FontMetrics metrics) {
return workspace.isActive(pane)
+ ":" + pane.width()
+ ":" + pane.height()
+ ":" + fontFamily
+ ":" + fontSize
+ ":" + metrics.cellWidth
+ ":" + metrics.lineHeight
+ ":" + config.kittyGraphics();
}
private static Map<KittyPlaceholderKey, KittyPlaceholderBounds> kittyPlaceholderBounds(RenderStateSnapshot snapshot) { private static Map<KittyPlaceholderKey, KittyPlaceholderBounds> kittyPlaceholderBounds(RenderStateSnapshot snapshot) {
if (snapshot == null) { if (snapshot == null) {
return Map.of(); return Map.of();
@@ -903,13 +943,4 @@ public final class TerminalCanvasView {
return maxSourceColumn - minSourceColumn + 1; return maxSourceColumn - minSourceColumn + 1;
} }
} }
private static final class PaneRenderCache {
private Canvas canvas;
private WritableImage image;
private int imageWidth;
private int imageHeight;
private String layoutKey;
private long contentVersion = Long.MIN_VALUE;
}
} }