diff --git a/.ignore b/.ignore new file mode 100644 index 0000000..0137df1 --- /dev/null +++ b/.ignore @@ -0,0 +1,3 @@ +.gradle +result +bin diff --git a/src/main/java/com/gregor/jprototerm/GhosttyTerminalRenderer.java b/src/main/java/com/gregor/jprototerm/GhosttyTerminalRenderer.java deleted file mode 100644 index f6e20e8..0000000 --- a/src/main/java/com/gregor/jprototerm/GhosttyTerminalRenderer.java +++ /dev/null @@ -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 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 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 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 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 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 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 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 kittyPlaceholderBounds(RenderStateSnapshot snapshot) { - if (snapshot == null) { - return Map.of(); - } - - Map 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; - } - } -} diff --git a/src/main/java/com/gregor/jprototerm/RenderTarget.java b/src/main/java/com/gregor/jprototerm/RenderTarget.java deleted file mode 100644 index 9b538aa..0000000 --- a/src/main/java/com/gregor/jprototerm/RenderTarget.java +++ /dev/null @@ -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(); - - /** - * 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(); -} diff --git a/src/main/java/com/gregor/jprototerm/Tab.java b/src/main/java/com/gregor/jprototerm/Tab.java index fdcedf0..6ab1f28 100644 --- a/src/main/java/com/gregor/jprototerm/Tab.java +++ b/src/main/java/com/gregor/jprototerm/Tab.java @@ -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) { diff --git a/src/main/java/com/gregor/jprototerm/TerminalPane.java b/src/main/java/com/gregor/jprototerm/TerminalPane.java index 6ad0890..b951c1e 100644 --- a/src/main/java/com/gregor/jprototerm/TerminalPane.java +++ b/src/main/java/com/gregor/jprototerm/TerminalPane.java @@ -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() { 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) { diff --git a/src/main/java/com/gregor/jprototerm/TerminalRenderer.java b/src/main/java/com/gregor/jprototerm/TerminalRenderer.java deleted file mode 100644 index 286de53..0000000 --- a/src/main/java/com/gregor/jprototerm/TerminalRenderer.java +++ /dev/null @@ -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. - * - *

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(); - } -}