This commit is contained in:
Gregor Lohaus
2026-05-31 18:51:16 +02:00
parent 2bcaf951df
commit 3054b3ec77
6 changed files with 8 additions and 782 deletions

3
.ignore Normal file
View File

@@ -0,0 +1,3 @@
.gradle
result
bin

View File

@@ -1,631 +0,0 @@
package com.gregor.jprototerm;
import dev.jlibghostty.KittyImageCompression;
import dev.jlibghostty.KittyImageFormat;
import dev.jlibghostty.KittyImageSnapshot;
import dev.jlibghostty.KittyPlacement;
import dev.jlibghostty.KittyPlacementLayer;
import dev.jlibghostty.KittyPlaceholder;
import dev.jlibghostty.KittyRenderInfo;
import dev.jlibghostty.RenderCell;
import dev.jlibghostty.RenderColor;
import dev.jlibghostty.RenderCursorStyle;
import dev.jlibghostty.RenderRow;
import dev.jlibghostty.RenderStateSnapshot;
import javafx.scene.canvas.GraphicsContext;
import javafx.scene.image.Image;
import javafx.scene.image.PixelFormat;
import javafx.scene.image.WritableImage;
import javafx.scene.paint.Color;
import javafx.scene.text.FontSmoothingType;
import java.io.ByteArrayInputStream;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* The real terminal renderer: paints a pane's background, cell rows, cursor, border, padding
* and (when enabled) kitty graphics. One instance per pane, since it caches that pane's
* decoded kitty images.
*/
final class GhosttyTerminalRenderer extends TerminalRenderer {
// 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 static final Color DEFAULT_FOREGROUND = Color.rgb(225, 229, 235);
private static final Color SELECTED_BACKGROUND = Color.rgb(52, 92, 140);
// 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);
// 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 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<>();
GhosttyTerminalRenderer(TerminalMetrics metrics) {
this.metrics = metrics;
}
@Override
void paintFull(GraphicsContext gc, RenderTarget target, boolean active) {
double px = Math.round(target.x());
double py = Math.round(target.y());
double width = target.width();
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));
gc.restore();
}
@Override
void paintIncremental(GraphicsContext gc, RenderTarget target, boolean active) {
double px = Math.round(target.x());
double py = Math.round(target.y());
double width = target.width();
double height = target.height();
gc.save();
clip(gc, px, py, width, height, target.clip());
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);
} 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);
} else if (dirty == DIRTY_PARTIAL) {
drawDirtyRows(gc, snapshot, px, py, width, height, active);
}
// dirty == FALSE: nothing visible changed.
}
gc.restore();
}
// Full content render: background, border, all rows, cursor, and (when enabled) kitty
// graphics. Used by the kitty direct path and by full redraws.
private void drawContent(
GraphicsContext gc,
RenderTarget target,
RenderStateSnapshot snapshot,
double x,
double y,
double width,
double height,
boolean active,
boolean withKitty
) {
double cellWidth = metrics.cellWidth();
double lineHeight = metrics.lineHeight();
gc.setFontSmoothingType(FontSmoothingType.LCD);
gc.setFill(PANE_BACKGROUND);
gc.fillRect(x, y, width, height);
gc.setFont(metrics.font());
double left = x + TerminalMetrics.PADDING;
double top = y + TerminalMetrics.PADDING;
double baseline = top + metrics.baselineOffset();
Map<KittyPlaceholderKey, KittyPlaceholderBounds> placeholderBounds = withKitty
? kittyPlaceholderBounds(snapshot)
: Map.of();
if (withKitty) {
drawKittyGraphics(gc, target, KittyPlacementLayer.BELOW_TEXT, placeholderBounds, left, top, cellWidth, lineHeight);
}
if (snapshot != null) {
double contentBottom = top + snapshot.rows() * lineHeight;
fillVerticalPadding(gc, snapshot, x, y, width, height, top, contentBottom);
for (RenderRow row : snapshot.renderRows()) {
double y0 = Math.floor(top + (row.row() * lineHeight));
double y1 = Math.ceil(top + ((row.row() + 1) * lineHeight));
paintSidePadding(gc, row, x, width, left, cellWidth, y0, y1 - y0);
drawRow(gc, row, left, top, baseline, cellWidth, lineHeight);
}
drawCursor(gc, snapshot, left, top, cellWidth, lineHeight);
}
if (withKitty) {
drawKittyGraphics(gc, target, KittyPlacementLayer.ABOVE_TEXT, placeholderBounds, left, top, cellWidth, lineHeight);
}
drawBorder(gc, x, y, width, height, active);
}
// Incremental render: repaint only the rows ghostty flagged dirty, then restore the
// cursor and border. The local band tracks the repainted span only so the border redraw
// can be limited to it.
private void drawDirtyRows(
GraphicsContext gc,
RenderStateSnapshot snapshot,
double px,
double py,
double pw,
double ph,
boolean active
) {
double cellWidth = metrics.cellWidth();
double lineHeight = metrics.lineHeight();
gc.setFontSmoothingType(FontSmoothingType.LCD);
gc.setFont(metrics.font());
double left = px + TerminalMetrics.PADDING;
double top = py + TerminalMetrics.PADDING;
double baseline = top + metrics.baselineOffset();
double contentBottom = top + snapshot.rows() * lineHeight;
int lastRow = snapshot.rows() - 1;
boolean cursorRowDirty = false;
double bandMin = Double.POSITIVE_INFINITY;
double bandMax = Double.NEGATIVE_INFINITY;
for (RenderRow row : snapshot.renderRows()) {
if (!row.dirty()) {
continue;
}
// Snap the row band to integer pixels and paint opaque: a fractional-height fill
// 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);
paintSidePadding(gc, row, px, pw, left, cellWidth, y0, y1 - y0);
drawRow(gc, row, left, top, baseline, cellWidth, lineHeight);
bandMin = Math.min(bandMin, y0);
bandMax = Math.max(bandMax, y1);
// Edge rows also own the top/bottom padding strip; repaint it and extend the
// band so panes stacked above get restored over it too.
if (row.row() == 0) {
gc.setFill(rowEdgeBackground(row, true));
gc.fillRect(px, py, pw, top - py);
bandMin = Math.min(bandMin, py);
}
if (row.row() == lastRow) {
gc.setFill(rowEdgeBackground(row, true));
gc.fillRect(px, contentBottom, pw, py + ph - contentBottom);
bandMax = Math.max(bandMax, py + ph);
}
if (snapshot.cursorViewportHasValue() && row.row() == snapshot.cursorViewportY()) {
cursorRowDirty = true;
}
}
if (bandMin > bandMax) {
return;
}
// 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, cellWidth, lineHeight);
}
// Repainting rows clears the side borders within the band; restore just those
// segments, clipped to the band so we don't redraw the whole outline.
gc.save();
clipRect(gc, px, bandMin, pw, bandMax - bandMin);
drawBorder(gc, px, py, pw, ph, active);
gc.restore();
}
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.setLineWidth(active ? 2.0 : 1.0);
gc.strokeRect(x + 0.5, y + 0.5, width - 1.0, height - 1.0);
}
// Effective background colour of a cell as it is drawn (reverse video swaps fg/bg, an
// unset colour falls back to the defaults).
private static Color cellBackgroundColor(RenderCell cell) {
if (cell.inverse()) {
var fg = cell.foreground();
return fg.isPresent() ? toFxColor(fg.get()) : DEFAULT_FOREGROUND;
}
var bg = cell.background();
return bg.isPresent() ? toFxColor(bg.get()) : PANE_BACKGROUND;
}
private static Color rowEdgeBackground(RenderRow row, boolean firstCell) {
List<RenderCell> cells = row.cells();
if (cells.isEmpty()) {
return PANE_BACKGROUND;
}
return cellBackgroundColor(firstCell ? cells.get(0) : cells.get(cells.size() - 1));
}
// Extend the row's edge-cell backgrounds into the left/right padding (the margin and the
// right-edge rounding sliver), so the unused space matches the rendered content.
private void paintSidePadding(GraphicsContext gc, RenderRow row, double paneX, double paneWidth,
double contentLeft, double cellWidth, double yTop, double bandHeight) {
int columns = row.cells().size();
if (columns == 0) {
return;
}
double contentRight = contentLeft + (columns * cellWidth);
gc.setFill(rowEdgeBackground(row, true));
gc.fillRect(paneX, yTop, contentLeft - paneX, bandHeight);
gc.setFill(rowEdgeBackground(row, false));
gc.fillRect(contentRight, yTop, paneX + paneWidth - contentRight, bandHeight);
}
// Fill the top/bottom padding strips with the top/bottom row's edge colour.
private void fillVerticalPadding(GraphicsContext gc, RenderStateSnapshot snapshot,
double paneX, double paneY, double paneWidth, double paneHeight, double contentTop, double contentBottom) {
List<RenderRow> rows = snapshot.renderRows();
if (rows.isEmpty()) {
return;
}
gc.setFill(rowEdgeBackground(rows.get(0), true));
gc.fillRect(paneX, paneY, paneWidth, contentTop - paneY);
gc.setFill(rowEdgeBackground(rows.get(rows.size() - 1), true));
gc.fillRect(paneX, contentBottom, paneWidth, paneY + paneHeight - contentBottom);
}
private static void drawRow(
GraphicsContext gc,
RenderRow row,
double left,
double top,
double baseline,
double cellWidth,
double lineHeight
) {
for (RenderCell cell : row.cells()) {
if (cell.kittyPlaceholder().isPresent()) {
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) {
continue;
}
double y = baseline + (row.row() * lineHeight);
gc.setFill(fg);
gc.fillText(cell.text(), x, y);
}
}
private static Color toFxColor(RenderColor color) {
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) {
if (!snapshot.cursorVisible() || !snapshot.cursorViewportHasValue()) {
return;
}
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.setLineWidth(1.5);
RenderCursorStyle style = snapshot.cursorStyle();
if (style == RenderCursorStyle.BAR) {
gc.strokeLine(x + 0.5, y + 2.0, x + 0.5, y + lineHeight - 2.0);
} else if (style == RenderCursorStyle.UNDERLINE) {
gc.strokeLine(x + 1.0, y + lineHeight - 2.0, x + cellWidth - 1.0, y + lineHeight - 2.0);
} else if (style == RenderCursorStyle.BLOCK) {
gc.fillRect(x + 0.5, y + 1.0, Math.max(1.0, cellWidth - 1.0), Math.max(1.0, lineHeight - 2.0));
} else {
gc.strokeRect(x + 0.5, y + 1.0, Math.max(1.0, cellWidth - 1.0), Math.max(1.0, lineHeight - 2.0));
}
}
// ---- Kitty graphics --------------------------------------------------------------
private static boolean hasKittyGraphics(RenderTarget target) {
return target.kittyGraphics()
.map(graphics -> !graphics.placements().isEmpty())
.orElse(false);
}
private void drawKittyGraphics(
GraphicsContext gc,
RenderTarget target,
KittyPlacementLayer layer,
Map<KittyPlaceholderKey, KittyPlaceholderBounds> placeholderBounds,
double originX,
double originY,
double cellWidth,
double lineHeight
) {
target.kittyGraphics().ifPresent(graphics -> {
for (KittyPlacement placement : graphics.placements(layer)) {
Image image = imageFor(placement);
if (image == null) {
continue;
}
if (placement.virtual()) {
drawVirtualKittyPlacement(gc, placement, image, placeholderBounds, originX, originY, cellWidth, lineHeight);
} else {
drawPinnedKittyPlacement(gc, placement, image, originX, originY, cellWidth, lineHeight);
}
}
});
}
private static void drawPinnedKittyPlacement(
GraphicsContext gc,
KittyPlacement placement,
Image image,
double originX,
double originY,
double cellWidth,
double lineHeight
) {
KittyRenderInfo renderInfo = placement.renderInfo().orElse(null);
if (renderInfo == null || !renderInfo.viewportVisible()) {
return;
}
double sourceX = renderInfo.sourceX();
double sourceY = renderInfo.sourceY();
double sourceWidth = renderInfo.sourceWidth();
double sourceHeight = renderInfo.sourceHeight();
if (sourceWidth <= 0.0 || sourceHeight <= 0.0) {
return;
}
double x = originX + (renderInfo.viewportColumn() * cellWidth) + placement.xOffset();
double y = originY + (renderInfo.viewportRow() * lineHeight) + placement.yOffset();
double width = renderInfo.pixelWidth() > 0 ? renderInfo.pixelWidth() : renderInfo.gridColumns() * cellWidth;
double height = renderInfo.pixelHeight() > 0 ? renderInfo.pixelHeight() : renderInfo.gridRows() * lineHeight;
if (width <= 0.0 || height <= 0.0) {
return;
}
gc.drawImage(image, sourceX, sourceY, sourceWidth, sourceHeight, x, y, width, height);
}
private static void drawVirtualKittyPlacement(
GraphicsContext gc,
KittyPlacement placement,
Image image,
Map<KittyPlaceholderKey, KittyPlaceholderBounds> placeholderBounds,
double originX,
double originY,
double cellWidth,
double lineHeight
) {
KittyPlaceholderBounds bounds = placeholderBounds.get(new KittyPlaceholderKey(placement.imageId(), placement.placementId()));
if (bounds == null) {
bounds = placeholderBounds.get(new KittyPlaceholderKey(placement.imageId(), 0));
}
if (bounds == null && placement.placementId() == 0) {
bounds = placeholderBounds.entrySet().stream()
.filter(entry -> entry.getKey().imageId() == placement.imageId())
.map(Map.Entry::getValue)
.findFirst()
.orElse(null);
}
if (bounds == null || bounds.isEmpty()) {
return;
}
SourceRect source = sourceRect(placement, image);
if (source.width() <= 0.0 || source.height() <= 0.0) {
return;
}
long gridColumns = gridColumns(placement, bounds);
long gridRows = gridRows(placement, bounds);
double sourceCellWidth = source.width() / Math.max(1L, gridColumns);
double sourceCellHeight = source.height() / Math.max(1L, gridRows);
double sourceX = source.x() + (bounds.minSourceColumn * sourceCellWidth);
double sourceY = source.y() + (bounds.minSourceRow * sourceCellHeight);
double sourceWidth = bounds.sourceColumns() * sourceCellWidth;
double sourceHeight = bounds.sourceRows() * sourceCellHeight;
double x = originX + (bounds.minColumn * cellWidth);
double y = originY + (bounds.minRow * lineHeight);
double availableWidth = bounds.columns() * cellWidth;
double availableHeight = bounds.rows() * lineHeight;
if (sourceWidth <= 0.0 || sourceHeight <= 0.0 || availableWidth <= 0.0 || availableHeight <= 0.0) {
return;
}
double scale = Math.min(availableWidth / sourceWidth, availableHeight / sourceHeight);
double width = sourceWidth * scale;
double height = sourceHeight * scale;
gc.drawImage(image, sourceX, sourceY, sourceWidth, sourceHeight, x, y, width, height);
}
private static long gridColumns(KittyPlacement placement, KittyPlaceholderBounds bounds) {
if (placement.columns() > 0) {
return placement.columns();
}
return Math.max(bounds.maxSourceColumn + 1, bounds.sourceColumns());
}
private static long gridRows(KittyPlacement placement, KittyPlaceholderBounds bounds) {
if (placement.rows() > 0) {
return placement.rows();
}
return Math.max(bounds.maxSourceRow + 1, bounds.sourceRows());
}
private static SourceRect sourceRect(KittyPlacement placement, Image image) {
double sourceX = placement.sourceX();
double sourceY = placement.sourceY();
double sourceWidth = placement.sourceWidth() > 0 ? placement.sourceWidth() : image.getWidth() - sourceX;
double sourceHeight = placement.sourceHeight() > 0 ? placement.sourceHeight() : image.getHeight() - sourceY;
return new SourceRect(sourceX, sourceY, Math.min(sourceWidth, image.getWidth() - sourceX), Math.min(sourceHeight, image.getHeight() - sourceY));
}
private Image imageFor(KittyPlacement placement) {
return placement.image().map(snapshot -> {
byte[] data = snapshot.data();
KittyImageKey key = KittyImageKey.of(snapshot, data);
Image cached = kittyImageCache.get(key);
if (cached != null) {
return cached;
}
kittyImageCache.keySet().removeIf(existing -> existing.id() == snapshot.id());
Image decoded = decodeImage(snapshot, data);
if (decoded != null) {
kittyImageCache.put(key, decoded);
}
return decoded;
}).orElse(null);
}
private Image decodeImage(KittyImageSnapshot snapshot, byte[] data) {
if (snapshot.compression() != KittyImageCompression.NONE) {
return null;
}
if (snapshot.format() == KittyImageFormat.PNG) {
return new Image(new ByteArrayInputStream(data));
}
int width = Math.toIntExact(snapshot.width());
int height = Math.toIntExact(snapshot.height());
WritableImage image = new WritableImage(width, height);
if (snapshot.format() == KittyImageFormat.RGBA) {
image.getPixelWriter().setPixels(0, 0, width, height, PixelFormat.getByteBgraInstance(), rgbaToBgra(data), 0, width * 4);
} else if (snapshot.format() == KittyImageFormat.RGB) {
image.getPixelWriter().setPixels(0, 0, width, height, PixelFormat.getByteRgbInstance(), data, 0, width * 3);
}
return image;
}
private static byte[] rgbaToBgra(byte[] rgba) {
byte[] bgra = new byte[rgba.length];
for (int i = 0; i + 3 < rgba.length; i += 4) {
bgra[i] = rgba[i + 2];
bgra[i + 1] = rgba[i + 1];
bgra[i + 2] = rgba[i];
bgra[i + 3] = rgba[i + 3];
}
return bgra;
}
private static Map<KittyPlaceholderKey, KittyPlaceholderBounds> kittyPlaceholderBounds(RenderStateSnapshot snapshot) {
if (snapshot == null) {
return Map.of();
}
Map<KittyPlaceholderKey, KittyPlaceholderBounds> result = new HashMap<>();
for (RenderRow row : snapshot.renderRows()) {
for (RenderCell cell : row.cells()) {
cell.kittyPlaceholder().ifPresent(placeholder -> {
KittyPlaceholderKey key = new KittyPlaceholderKey(placeholder.imageId(), placeholder.placementId());
result.computeIfAbsent(key, ignored -> new KittyPlaceholderBounds()).include(row.row(), cell.column(), placeholder);
});
}
}
return result;
}
// 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) {
return new KittyImageKey(
snapshot.id(),
snapshot.number(),
snapshot.width(),
snapshot.height(),
snapshot.format(),
data.length
);
}
}
private record KittyPlaceholderKey(long imageId, long placementId) {
}
private record SourceRect(double x, double y, double width, double height) {
}
private static final class KittyPlaceholderBounds {
private int minRow = Integer.MAX_VALUE;
private int maxRow = Integer.MIN_VALUE;
private int minColumn = Integer.MAX_VALUE;
private int maxColumn = Integer.MIN_VALUE;
private long minSourceRow = Long.MAX_VALUE;
private long maxSourceRow = Long.MIN_VALUE;
private long minSourceColumn = Long.MAX_VALUE;
private long maxSourceColumn = Long.MIN_VALUE;
private void include(int row, int column, KittyPlaceholder placeholder) {
minRow = Math.min(minRow, row);
maxRow = Math.max(maxRow, row);
minColumn = Math.min(minColumn, column);
maxColumn = Math.max(maxColumn, column);
minSourceRow = Math.min(minSourceRow, placeholder.sourceRow());
maxSourceRow = Math.max(maxSourceRow, placeholder.sourceRow());
minSourceColumn = Math.min(minSourceColumn, placeholder.sourceColumn());
maxSourceColumn = Math.max(maxSourceColumn, placeholder.sourceColumn());
}
private boolean isEmpty() {
return minRow == Integer.MAX_VALUE;
}
private int rows() {
return maxRow - minRow + 1;
}
private int columns() {
return maxColumn - minColumn + 1;
}
private long sourceRows() {
return maxSourceRow - minSourceRow + 1;
}
private long sourceColumns() {
return maxSourceColumn - minSourceColumn + 1;
}
}
}

View File

@@ -1,45 +0,0 @@
package com.gregor.jprototerm;
import dev.jlibghostty.KittyGraphics;
import dev.jlibghostty.RenderStateSnapshot;
import javafx.scene.shape.Shape;
import java.util.Optional;
/**
* The read-only view of a pane that a {@link TerminalRenderer} draws: its on-screen rect, its
* current render snapshot, and its kitty-graphics state. Decoupling the renderer from
* {@link TerminalPane} through this interface lets the renderer be swapped (e.g. a debug
* renderer that just outlines bounds and clip bands) and unit-tested against a synthetic
* target without a real terminal.
*/
interface RenderTarget {
double x();
double y();
double width();
double height();
/** Whether kitty graphics should be drawn for this target at all. */
boolean kittyEnabled();
Optional<KittyGraphics> kittyGraphics();
/**
* Incremental snapshot: only rows that changed since the last frame are populated. May be
* {@code null} before the first snapshot exists.
*/
RenderStateSnapshot snapshot();
/** Full snapshot with every row populated, regardless of dirty state. */
RenderStateSnapshot snapshotFull();
/**
* The region this target may draw into, or {@code null} to clip to its plain rect. Set at
* layout time (a tiled pane gets its rect minus the floating panes that cover it), so the
* renderer can clip its own output and never paint over a pane on top.
*/
Shape clip();
}

View File

@@ -108,9 +108,6 @@ final class Tab implements AutoCloseable {
floatingWidth,
floatingHeight);
}
tiled.forEach(pane -> pane.setClip(null));
floating.forEach(pane -> pane.setClip(null));
}
boolean navigate(Direction direction) {

View File

@@ -12,35 +12,27 @@ import dev.jlibghostty.RenderStateSnapshot;
import dev.jlibghostty.ScrollViewport;
import dev.jlibghostty.Terminal;
import dev.jlibghostty.TerminalOptions;
import javafx.scene.canvas.GraphicsContext;
import javafx.scene.shape.Shape;
import java.util.Optional;
/**
* One terminal: owns its ghostty {@link Terminal}, the {@link ShellSession}/pty driving it,
* and its on-screen geometry and grid. It does not draw itself — it is a {@link RenderTarget}
* that a {@link TerminalRenderer} paints. {@link #paintFull}/{@link #paintIncremental} are the
* only rendering API exposed to the {@link Compositor}, and they just delegate to that
* renderer; the compositor decides z-order and which rect each pane occupies.
* and its on-screen geometry and grid. It does not draw itself; {@link TerminalPaneNode}
* reads snapshots from it and represents the visible rows and kitty graphics as JavaFX nodes.
*/
public final class TerminalPane implements AutoCloseable, RenderTarget {
public final class TerminalPane implements AutoCloseable {
private final Terminal terminal;
private final TerminalMetrics metrics;
private final boolean kittyEnabled;
// Run on every content change so the owning tab can bump its content version — the
// compositor's O(1) "did the current tab change?" gate.
private final Runnable onContentChange;
private final TerminalRenderer renderer;
private final MouseEncoder mouseEncoder = new MouseEncoder();
// 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;
// Clip region for rendering (rect minus the panes covering this one), set at layout time;
// null means clip to the plain bounds. See RenderTarget#clip().
private Shape clip;
private double x;
private double y;
private double width;
@@ -53,12 +45,11 @@ public final class TerminalPane implements AutoCloseable, RenderTarget {
private long snapshotVersion = -1;
private TerminalPane(Terminal terminal, TerminalMetrics metrics, boolean kittyEnabled,
Runnable onContentChange, TerminalRenderer renderer, int columns, int rows) {
Runnable onContentChange, int columns, int rows) {
this.terminal = terminal;
this.metrics = metrics;
this.kittyEnabled = kittyEnabled;
this.onContentChange = onContentChange;
this.renderer = renderer;
this.columns = columns;
this.rows = rows;
}
@@ -75,8 +66,7 @@ public final class TerminalPane implements AutoCloseable, RenderTarget {
int rows = heightPx > 0 ? metrics.rowsFor(heightPx) : config.rows();
Terminal terminal = Ghostty.open(new TerminalOptions(columns, rows, config.maxScrollback()));
terminal.setDeviceAttributesProvider(DeviceAttributes::xtermCompatible);
TerminalPane pane = new TerminalPane(terminal, metrics, config.kittyGraphics(), onContentChange,
new GhosttyTerminalRenderer(metrics), columns, rows);
TerminalPane pane = new TerminalPane(terminal, metrics, config.kittyGraphics(), onContentChange, columns, rows);
pane.refresh();
pane.attach(ShellSession.start(config.shell(), config.envOverride(), pane, columns, rows));
return pane;
@@ -153,7 +143,6 @@ public final class TerminalPane implements AutoCloseable, RenderTarget {
* Snapshotting is deferred here rather than done in refresh(), so a burst of writes
* between two frames collapses into a single snapshot.
*/
@Override
public RenderStateSnapshot snapshot() {
return takeSnapshot(false);
}
@@ -162,7 +151,6 @@ public final class TerminalPane implements AutoCloseable, RenderTarget {
* Full snapshot with every row's cells populated. Used where the whole pane is redrawn
* regardless of dirty state (the kitty-graphics path).
*/
@Override
public RenderStateSnapshot snapshotFull() {
return takeSnapshot(true);
}
@@ -195,34 +183,28 @@ public final class TerminalPane implements AutoCloseable, RenderTarget {
return contentVersion;
}
@Override
public boolean kittyEnabled() {
return kittyEnabled;
}
@Override
public Optional<KittyGraphics> kittyGraphics() {
synchronized (terminal) {
return terminal.kittyGraphics();
}
}
@Override
public double x() {
return x;
}
@Override
public double y() {
return y;
}
@Override
public double width() {
return width;
}
@Override
public double height() {
return height;
}
@@ -234,16 +216,6 @@ public final class TerminalPane implements AutoCloseable, RenderTarget {
this.height = height;
}
/** Set the clip region applied on the next paints (see {@link RenderTarget#clip()}). */
public void setClip(Shape clip) {
this.clip = clip;
}
@Override
public Shape clip() {
return clip;
}
/** Recompute the ghostty grid from the current bounds and the shared cell metrics. */
public void fitToBounds() {
int columns = metrics.columnsFor(width);
@@ -280,16 +252,6 @@ public final class TerminalPane implements AutoCloseable, RenderTarget {
onContentChange.run();
}
/** Paint the whole pane; see {@link TerminalRenderer#paintFull}. */
public void paintFull(GraphicsContext gc, boolean active) {
renderer.paintFull(gc, this, active);
}
/** Repaint what changed; see {@link TerminalRenderer#paintIncremental}. */
public void paintIncremental(GraphicsContext gc, boolean active) {
renderer.paintIncremental(gc, this, active);
}
@Override
public void close() {
if (session != null) {

View File

@@ -1,60 +0,0 @@
package com.gregor.jprototerm;
import javafx.scene.canvas.GraphicsContext;
import javafx.scene.shape.ClosePath;
import javafx.scene.shape.LineTo;
import javafx.scene.shape.MoveTo;
import javafx.scene.shape.Path;
import javafx.scene.shape.PathElement;
import javafx.scene.shape.Shape;
/**
* Draws a {@link RenderTarget} onto a JavaFX canvas. The {@link Compositor} owns positioning
* and z-order; a renderer only fills the target's rect, clipped to the target's {@link
* RenderTarget#clip() clip region} so a repaint can never bleed over a pane on top.
* Implementations can change the look entirely — {@link GhosttyTerminalRenderer} is the real
* terminal renderer; a debug renderer could outline pane bounds instead.
*
* <p>A renderer may hold per-target state (e.g. a decoded-image cache), so an instance belongs
* to a single {@link TerminalPane}.
*/
abstract class TerminalRenderer {
/** Paint the whole target into its rect, clipped to its clip region. */
abstract void paintFull(GraphicsContext gc, RenderTarget target, boolean active);
/** Repaint only what changed since the last frame, clipped to the target's clip region. */
abstract void paintIncremental(GraphicsContext gc, RenderTarget target, boolean active);
protected static void clipRect(GraphicsContext gc, double x, double y, double width, double height) {
gc.beginPath();
gc.rect(x, y, width, height);
gc.clip();
}
/**
* Clip to {@code region} if given (the pane's rect minus the panes covering it, computed by
* {@code Shape.subtract} at layout), otherwise to the plain rect. The region is a rectilinear
* path, so it replays onto the canvas as move/line/close segments.
*/
protected static void clip(GraphicsContext gc, double x, double y, double width, double height, Shape region) {
if (region == null) {
clipRect(gc, x, y, width, height);
return;
}
var elements = ((Path) region).getElements();
gc.beginPath();
if (elements.isEmpty()) {
gc.rect(x, y, 0.0, 0.0); // fully covered: clip to nothing
}
for (PathElement element : elements) {
if (element instanceof MoveTo moveTo) {
gc.moveTo(moveTo.getX(), moveTo.getY());
} else if (element instanceof LineTo lineTo) {
gc.lineTo(lineTo.getX(), lineTo.getY());
} else if (element instanceof ClosePath) {
gc.closePath();
}
}
gc.clip();
}
}