8 Commits

Author SHA1 Message Date
8f70c4bf45 open on active monitor 2026-06-01 03:03:42 +02:00
6738051da1 fix null pointer access 2026-06-01 02:58:20 +02:00
65f69d5c75 remove dead code 2026-06-01 02:50:21 +02:00
85f2d86c09 hybrig image rendering 2026-06-01 02:45:46 +02:00
5f0edcbe31 try to fix graphics path 2026-06-01 02:18:01 +02:00
ebf87c0bff scrollback opens in floating pane 2026-06-01 00:46:28 +02:00
a51bee3b43 cleanup repo 2026-06-01 00:35:51 +02:00
aa5ca0451c Merge branch 'codex-performance-improvements' 2026-05-31 23:24:06 +02:00
15 changed files with 367 additions and 427 deletions

View File

@@ -1,18 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<classpath>
<classpathentry kind="src" output="bin/main" path="src/main/java">
<attributes>
<attribute name="gradle_scope" value="main"/>
<attribute name="gradle_used_by_scope" value="main,test"/>
</attributes>
</classpathentry>
<classpathentry kind="src" output="bin/main" path="src/main/resources">
<attributes>
<attribute name="gradle_scope" value="main"/>
<attribute name="gradle_used_by_scope" value="main,test"/>
</attributes>
</classpathentry>
<classpathentry kind="con" path="org.eclipse.jdt.launching.JRE_CONTAINER/org.eclipse.jdt.internal.debug.ui.launcher.StandardVMType/JavaSE-21/"/>
<classpathentry kind="con" path="org.eclipse.buildship.core.gradleclasspathcontainer"/>
<classpathentry kind="output" path="bin/default"/>
</classpath>

View File

@@ -1 +0,0 @@
019e6999-b7c8-7591-a8aa-ea51b89a7f7e

6
.gitignore vendored
View File

@@ -11,6 +11,10 @@ devenv.local.yaml
# pre-commit
.pre-commit-config.yaml
build
build
.gradle
bin
.settings
.project
.worktrees
.classpath
.codexsession

4
.ignore Normal file
View File

@@ -0,0 +1,4 @@
.gradle
bin
result
.worktrees

View File

@@ -1,34 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<projectDescription>
<name>jprototerm</name>
<comment>Project jprototerm created by Buildship.</comment>
<projects>
</projects>
<buildSpec>
<buildCommand>
<name>org.eclipse.jdt.core.javabuilder</name>
<arguments>
</arguments>
</buildCommand>
<buildCommand>
<name>org.eclipse.buildship.core.gradleprojectbuilder</name>
<arguments>
</arguments>
</buildCommand>
</buildSpec>
<natures>
<nature>org.eclipse.jdt.core.javanature</nature>
<nature>org.eclipse.buildship.core.gradleprojectnature</nature>
</natures>
<filteredResources>
<filter>
<id>1779917652126</id>
<name></name>
<type>30</type>
<matcher>
<id>org.eclipse.core.resources.regexFilterMatcher</id>
<arguments>node_modules|\.git|__CREATED_BY_JAVA_LANGUAGE_SERVER__</arguments>
</matcher>
</filter>
</filteredResources>
</projectDescription>

View File

@@ -1,13 +0,0 @@
arguments=--init-script /home/anon/Src/eclipse.jdt.ls/org.eclipse.jdt.ls.product/target/repository/configuration/org.eclipse.osgi/58/0/.cp/gradle/init/init.gradle
auto.sync=false
build.scans.enabled=false
connection.gradle.distribution=GRADLE_DISTRIBUTION(LOCAL_INSTALLATION(/home/anon/.sdkman/candidates/gradle/current))
connection.project.dir=
eclipse.preferences.version=1
gradle.user.home=
java.home=/home/anon/.local/lib/graalvm-jdk-21.0.9+7.1
jvm.arguments=
offline.mode=false
override.workspace.settings=true
show.console.view=true
show.executions.view=true

8
flake.lock generated
View File

@@ -70,11 +70,11 @@
"nixpkgs": "nixpkgs"
},
"locked": {
"lastModified": 1780258814,
"narHash": "sha256-8rxL7xaZ/loYg3zdt0w5+hfNyHFVknDZN360NzrtCsQ=",
"lastModified": 1780272954,
"narHash": "sha256-bVWY60iw8yPIu7I8FuRPf06T0H1TDvQDVUlzeHQs8UA=",
"ref": "refs/heads/main",
"rev": "6a3d5aa0b0b1f738c958e2a2f0249574c07d9c4d",
"revCount": 23,
"rev": "06a9d5d3ecf11c58f0e41214d1b59900e672dd3a",
"revCount": 24,
"type": "git",
"url": "https://gitea.gregorlohaus.com/gregor/jlibghostty.git"
},

View File

@@ -5,6 +5,7 @@ import dev.jlibghostty.MouseButton;
import dev.jlibghostty.MouseEncoderSize;
import dev.jlibghostty.MouseInput;
import javafx.geometry.VPos;
import javafx.scene.Node;
import javafx.scene.canvas.Canvas;
import javafx.scene.canvas.GraphicsContext;
import javafx.scene.input.InputEvent;
@@ -38,6 +39,8 @@ public final class Compositor {
private static final double TAB_BAR_HEIGHT = 22.0;
private final Canvas canvas = new Canvas();
// Kitty images are drawn as retained nodes layered over the canvas, not composited onto it.
private final KittyImageOverlay imageOverlay = new KittyImageOverlay();
private final AppConfig config;
private final TerminalMetrics metrics;
private final List<Tab> tabs = new ArrayList<>();
@@ -75,6 +78,11 @@ public final class Compositor {
return canvas;
}
/** The kitty-image overlay, to be stacked directly above {@link #canvas()} in the window. */
public Node imageOverlay() {
return imageOverlay.node();
}
public void setFont(String family, double size) {
metrics.setFont(family, size);
paneContentVersion.clear();
@@ -113,6 +121,16 @@ public final class Compositor {
layoutVersion++;
}
/** Opens a new floating pane, makes it active, and returns it (null when no tab exists). */
public TerminalPane openFloatingPane() {
if (isEmpty()) {
return null;
}
TerminalPane pane = currentTab().createFloatingPane();
layoutVersion++;
return pane;
}
public void nextFloatingPane() {
if (isEmpty()) {
return;
@@ -246,6 +264,7 @@ public final class Compositor {
for (TerminalPane pane : panes) {
paneContentVersion.put(pane, pane.paintFull(gc, isActive(pane)));
}
imageOverlay.sync(panes);
}
// Repaint just the panes whose content changed, directly on the retained canvas. Each pane
@@ -262,6 +281,7 @@ public final class Compositor {
continue;
}
paneContentVersion.put(pane, pane.paintIncremental(gc, isActive(pane)));
imageOverlay.updatePane(pane);
}
}

View File

@@ -1,8 +1,8 @@
package com.gregor.jprototerm;
import dev.jlibghostty.KittyImage;
import dev.jlibghostty.KittyImageCompression;
import dev.jlibghostty.KittyImageFormat;
import dev.jlibghostty.KittyImageSnapshot;
import dev.jlibghostty.KittyPlacement;
import dev.jlibghostty.KittyPlacementLayer;
import dev.jlibghostty.KittyPlaceholder;
@@ -27,15 +27,18 @@ import javafx.scene.text.Text;
import java.io.ByteArrayInputStream;
import java.nio.IntBuffer;
import java.util.ArrayList;
import java.util.Arrays;
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.
* The real terminal renderer: rasterizes a pane's background, cell rows, cursor, border and
* padding into the {@link SoftwareBackbuffer} and blits it to the canvas. Kitty images are not
* drawn here — each paint collects their placements (see {@link #kittyImages()}) for the
* compositor to render as overlay nodes above the canvas. 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).
@@ -60,6 +63,9 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
// Decoded kitty images for this renderer's pane (kitty graphics state is per-terminal).
private final Map<KittyImageKey, Image> kittyImageCache = new HashMap<>();
private final SoftwareBackbuffer software = new SoftwareBackbuffer();
// Image placements produced by the last paint; the compositor reads these and renders them
// as overlay nodes rather than compositing them onto the canvas. Empty for non-kitty panes.
private List<KittyImageNode> kittyImageNodes = List.of();
GhosttyTerminalRenderer(TerminalMetrics metrics) {
this.metrics = metrics;
@@ -73,15 +79,10 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
double height = target.height();
gc.save();
clip(gc, px, py, width, height, target.clip());
boolean withKitty = target.kittyEnabled() && hasKittyGraphics(target);
RenderStateSnapshot snapshot = target.snapshotFull();
if (withKitty) {
drawContent(gc, target, snapshot, px, py, width, height, active, true);
software.invalidate();
} else {
software.paintFull(gc, snapshot, px, py, width, height, active);
}
software.paintFull(gc, snapshot, px, py, width, height, active);
gc.restore();
collectKittyImages(target, snapshot, px, py);
}
@Override
@@ -93,171 +94,30 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
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);
software.invalidate();
} else {
RenderStateSnapshot snapshot = target.snapshot();
int dirty = snapshot == null ? DIRTY_FULL : snapshot.dirty();
if (dirty == DIRTY_FULL) {
software.paintFullOrShifted(gc, target.snapshotFull(), px, py, width, height, active);
} else if (dirty == DIRTY_PARTIAL) {
software.paintDirty(gc, target, 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;
List<RenderRow> rows = snapshot.renderRows();
boolean allRowsDirty = allRowsDirty(snapshot, rows);
if (allRowsDirty) {
gc.setFill(PANE_BACKGROUND);
gc.fillRect(px, py, pw, ph);
}
boolean cursorRowDirty = false;
double bandMin = Double.POSITIVE_INFINITY;
double bandMax = Double.NEGATIVE_INFINITY;
for (RenderRow row : rows) {
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));
if (!allRowsDirty) {
gc.setFill(PANE_BACKGROUND);
gc.fillRect(px, y0, pw, y1 - y0);
}
paintSidePadding(gc, row, px, pw, left, cellWidth, y0, y1 - y0);
drawRow(gc, row, left, top, baseline, cellWidth, lineHeight);
bandMin = Math.min(bandMin, y0);
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) {
// Images render as overlay nodes, not on the canvas, but their positions track the
// grid (scroll/cursor), so we need a full snapshot to locate placeholder cells. The
// software path itself still repaints only the text rows whose hash changed.
RenderStateSnapshot snapshot = target.snapshotFull();
software.paintFullOrShifted(gc, snapshot, px, py, width, height, active);
gc.restore();
collectKittyImages(target, snapshot, px, py);
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);
RenderStateSnapshot snapshot = target.snapshot();
int dirty = snapshot == null ? DIRTY_FULL : snapshot.dirty();
if (dirty == DIRTY_FULL) {
software.paintFullOrShifted(gc, target.snapshotFull(), px, py, width, height, active);
} else if (dirty == DIRTY_PARTIAL) {
software.paintDirty(gc, target, snapshot, px, py, width, height, active);
}
// 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);
// dirty == FALSE: nothing visible changed.
gc.restore();
kittyImageNodes = List.of();
}
private static boolean allRowsDirty(RenderStateSnapshot snapshot, List<RenderRow> rows) {
if (rows.size() != snapshot.rows()) {
return false;
}
for (int i = 0; i < rows.size(); i++) {
RenderRow row = rows.get(i);
if (!row.dirty() || row.row() != i) {
return false;
}
}
return true;
}
private void drawBorder(GraphicsContext gc, double x, double y, double width, double height, boolean active) {
gc.setStroke(active ? ACTIVE_BORDER : INACTIVE_BORDER);
gc.setLineWidth(active ? 2.0 : 1.0);
gc.strokeRect(x + 0.5, y + 0.5, width - 1.0, height - 1.0);
@Override
List<KittyImageNode> kittyImages() {
return kittyImageNodes;
}
// Effective background colour of a cell as it is drawn (reverse video swaps fg/bg, an
@@ -279,124 +139,6 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
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 void drawRow(
GraphicsContext gc,
RenderRow row,
double left,
double top,
double baseline,
double cellWidth,
double lineHeight
) {
drawRowBackgrounds(gc, row, left, top, cellWidth, lineHeight);
drawRowText(gc, row, left, baseline, cellWidth, lineHeight);
}
private static void drawRowBackgrounds(
GraphicsContext gc,
RenderRow row,
double left,
double top,
double cellWidth,
double lineHeight
) {
Color runBackground = null;
int runStartColumn = 0;
int previousColumn = -1;
for (RenderCell cell : row.cells()) {
if (cell.kittyPlaceholder().isPresent()) {
flushBackgroundRun(gc, runBackground, left, top, cellWidth, lineHeight, row.row(), runStartColumn, previousColumn);
runBackground = null;
previousColumn = -1;
continue;
}
Color bg = cell.selected() ? SELECTED_BACKGROUND : cellBackgroundOverride(cell);
if (bg == null) {
flushBackgroundRun(gc, runBackground, left, top, cellWidth, lineHeight, row.row(), runStartColumn, previousColumn);
runBackground = null;
previousColumn = -1;
continue;
}
if (runBackground == null || bg != runBackground || cell.column() != previousColumn + 1) {
flushBackgroundRun(gc, runBackground, left, top, cellWidth, lineHeight, row.row(), runStartColumn, previousColumn);
runBackground = bg;
runStartColumn = cell.column();
}
previousColumn = cell.column();
}
flushBackgroundRun(gc, runBackground, left, top, cellWidth, lineHeight, row.row(), runStartColumn, previousColumn);
}
private static void flushBackgroundRun(
GraphicsContext gc,
Color background,
double left,
double top,
double cellWidth,
double lineHeight,
int row,
int startColumn,
int endColumn
) {
if (background == null || endColumn < startColumn) {
return;
}
gc.setFill(background);
gc.fillRect(
left + (startColumn * cellWidth),
top + (row * lineHeight),
(endColumn - startColumn + 1) * cellWidth,
lineHeight);
}
private void drawRowText(
GraphicsContext gc,
RenderRow row,
double left,
double baseline,
double cellWidth,
double lineHeight
) {
for (RenderCell cell : row.cells()) {
if (cell.kittyPlaceholder().isPresent() || cell.codepoints().length == 0) {
continue;
}
gc.setFill(cellForegroundColor(cell));
gc.fillText(cell.text(), left + (cell.column() * cellWidth), baseline + (row.row() * lineHeight));
}
}
// Background override for a cell: null means the pane default background already covers it.
private static Color cellBackgroundOverride(RenderCell cell) {
if (cell.inverse()) {
@@ -434,46 +176,42 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
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(DEFAULT_FOREGROUND);
gc.setFill(CURSOR_FILL);
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())
.map(graphics -> !graphics.isEmpty())
.orElse(false);
}
private void drawKittyGraphics(
GraphicsContext gc,
// Build the image-node descriptors for the current frame. Reads the decoded-image cache and
// each placement's geometry, but draws nothing — the compositor turns these into overlay
// nodes clipped to the pane.
private void collectKittyImages(RenderTarget target, RenderStateSnapshot snapshot, double px, double py) {
if (!target.kittyEnabled() || !hasKittyGraphics(target)) {
kittyImageNodes = List.of();
return;
}
double cellWidth = metrics.cellWidth();
double lineHeight = metrics.lineHeight();
double originX = px + TerminalMetrics.PADDING;
double originY = py + TerminalMetrics.PADDING;
Map<KittyPlaceholderKey, KittyPlaceholderBounds> placeholderBounds = kittyPlaceholderBounds(snapshot);
List<KittyImageNode> nodes = new ArrayList<>();
collectLayer(target, KittyPlacementLayer.BELOW_TEXT, placeholderBounds, originX, originY, cellWidth, lineHeight, nodes);
collectLayer(target, KittyPlacementLayer.ABOVE_TEXT, placeholderBounds, originX, originY, cellWidth, lineHeight, nodes);
kittyImageNodes = nodes;
}
private void collectLayer(
RenderTarget target,
KittyPlacementLayer layer,
Map<KittyPlaceholderKey, KittyPlaceholderBounds> placeholderBounds,
double originX,
double originY,
double cellWidth,
double lineHeight
double lineHeight,
List<KittyImageNode> out
) {
target.kittyGraphics().ifPresent(graphics -> {
for (KittyPlacement placement : graphics.placements(layer)) {
@@ -482,17 +220,17 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
continue;
}
if (placement.virtual()) {
drawVirtualKittyPlacement(gc, placement, image, placeholderBounds, originX, originY, cellWidth, lineHeight);
} else {
drawPinnedKittyPlacement(gc, placement, image, originX, originY, cellWidth, lineHeight);
KittyImageNode node = placement.virtual()
? virtualNode(placement, image, placeholderBounds, originX, originY, cellWidth, lineHeight)
: pinnedNode(placement, image, originX, originY, cellWidth, lineHeight);
if (node != null) {
out.add(node);
}
}
});
}
private static void drawPinnedKittyPlacement(
GraphicsContext gc,
private static KittyImageNode pinnedNode(
KittyPlacement placement,
Image image,
double originX,
@@ -502,15 +240,13 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
) {
KittyRenderInfo renderInfo = placement.renderInfo().orElse(null);
if (renderInfo == null || !renderInfo.viewportVisible()) {
return;
return null;
}
double sourceX = renderInfo.sourceX();
double sourceY = renderInfo.sourceY();
double sourceWidth = renderInfo.sourceWidth();
double sourceHeight = renderInfo.sourceHeight();
if (sourceWidth <= 0.0 || sourceHeight <= 0.0) {
return;
return null;
}
double x = originX + (renderInfo.viewportColumn() * cellWidth) + placement.xOffset();
@@ -518,14 +254,14 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
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;
return null;
}
gc.drawImage(image, sourceX, sourceY, sourceWidth, sourceHeight, x, y, width, height);
return new KittyImageNode(placement.imageId(), placement.placementId(), image,
renderInfo.sourceX(), renderInfo.sourceY(), sourceWidth, sourceHeight, x, y, width, height);
}
private static void drawVirtualKittyPlacement(
GraphicsContext gc,
private static KittyImageNode virtualNode(
KittyPlacement placement,
Image image,
Map<KittyPlaceholderKey, KittyPlaceholderBounds> placeholderBounds,
@@ -546,12 +282,12 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
.orElse(null);
}
if (bounds == null || bounds.isEmpty()) {
return;
return null;
}
SourceRect source = sourceRect(placement, image);
if (source.width() <= 0.0 || source.height() <= 0.0) {
return;
return null;
}
long gridColumns = gridColumns(placement, bounds);
@@ -569,13 +305,14 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
double availableHeight = bounds.rows() * lineHeight;
if (sourceWidth <= 0.0 || sourceHeight <= 0.0 || availableWidth <= 0.0 || availableHeight <= 0.0) {
return;
return null;
}
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);
return new KittyImageNode(placement.imageId(), placement.placementId(), image,
sourceX, sourceY, sourceWidth, sourceHeight, x, y, width, height);
}
private static long gridColumns(KittyPlacement placement, KittyPlaceholderBounds bounds) {
@@ -601,16 +338,17 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
}
private Image imageFor(KittyPlacement placement) {
return placement.image().map(snapshot -> {
byte[] data = snapshot.data();
KittyImageKey key = KittyImageKey.of(snapshot, data);
return placement.image().map(image -> {
// Build the cache key from cheap metadata only — the pixel buffer is never copied out
// of native memory on a cache hit (the common per-frame case).
KittyImageKey key = KittyImageKey.of(image);
Image cached = kittyImageCache.get(key);
if (cached != null) {
return cached;
}
kittyImageCache.keySet().removeIf(existing -> existing.id() == snapshot.id());
Image decoded = decodeImage(snapshot, data);
kittyImageCache.keySet().removeIf(existing -> existing.id() == image.id());
Image decoded = decodeImage(image);
if (decoded != null) {
kittyImageCache.put(key, decoded);
}
@@ -618,22 +356,25 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
}).orElse(null);
}
private Image decodeImage(KittyImageSnapshot snapshot, byte[] data) {
if (snapshot.compression() != KittyImageCompression.NONE) {
private Image decodeImage(KittyImage source) {
if (source.compression() != KittyImageCompression.NONE) {
return null;
}
if (snapshot.format() == KittyImageFormat.PNG) {
// Only now — on a cache miss — do we pull the raw bytes across the native boundary.
byte[] data = source.data();
KittyImageFormat format = source.format();
if (format == KittyImageFormat.PNG) {
return new Image(new ByteArrayInputStream(data));
}
int width = Math.toIntExact(snapshot.width());
int height = Math.toIntExact(snapshot.height());
int width = Math.toIntExact(source.width());
int height = Math.toIntExact(source.height());
WritableImage image = new WritableImage(width, height);
if (snapshot.format() == KittyImageFormat.RGBA) {
if (format == KittyImageFormat.RGBA) {
image.getPixelWriter().setPixels(0, 0, width, height, PixelFormat.getByteBgraInstance(), rgbaToBgra(data), 0, width * 4);
} else if (snapshot.format() == KittyImageFormat.RGB) {
} else if (format == KittyImageFormat.RGB) {
image.getPixelWriter().setPixels(0, 0, width, height, PixelFormat.getByteRgbInstance(), data, 0, width * 3);
}
return image;
@@ -1321,15 +1062,15 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
// 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 record KittyImageKey(long id, long number, long width, long height, KittyImageFormat format, long dataLength) {
private static KittyImageKey of(KittyImage image) {
return new KittyImageKey(
snapshot.id(),
snapshot.number(),
snapshot.width(),
snapshot.height(),
snapshot.format(),
data.length
image.id(),
image.number(),
image.width(),
image.height(),
image.format(),
image.dataLength()
);
}
}

View File

@@ -0,0 +1,32 @@
package com.gregor.jprototerm;
import javafx.scene.image.Image;
/**
* A single kitty image to display, produced by the renderer and consumed by {@link
* KittyImageOverlay}. Images are not painted onto the canvas; each becomes a retained
* {@code ImageView} node positioned over the pane. The {@code source*} fields are the region of
* {@link #image()} to show (in image pixels); the {@code x/y/width/height} are where to put it,
* in scene coordinates (the same space the pane's clip {@code Shape} lives in).
*
* <p>{@code imageId}+{@code placementId} identify the placement so the overlay can reuse the
* same node across frames instead of recreating it.
*/
record KittyImageNode(
long imageId,
long placementId,
Image image,
double sourceX,
double sourceY,
double sourceWidth,
double sourceHeight,
double x,
double y,
double width,
double height
) {
/** Stable per-pane key for node reuse. Packs the two u32 ids without collision. */
long key() {
return (imageId << 32) | (placementId & 0xffffffffL);
}
}

View File

@@ -0,0 +1,151 @@
package com.gregor.jprototerm;
import javafx.geometry.Rectangle2D;
import javafx.scene.Group;
import javafx.scene.Node;
import javafx.scene.image.ImageView;
import javafx.scene.layout.Pane;
import javafx.scene.shape.Rectangle;
import javafx.scene.shape.Shape;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* Renders kitty graphics images as retained scene-graph nodes layered over the {@link Compositor}
* canvas, instead of compositing them onto the canvas. Each pane gets a {@link Group} clipped to
* that pane's region (the same clip {@code Shape} the canvas renderer uses), and each visible
* image placement is an {@link ImageView} inside it, reused across frames so an unchanged image
* costs nothing to redraw.
*
* <p>The overlay {@link #node()} is mouse-transparent and sits above the canvas in the window's
* {@code StackPane}; its children use scene coordinates, which line up with the canvas because
* both fill the same root.
*/
final class KittyImageOverlay {
private final Pane root = new Pane();
private final Map<TerminalPane, PaneOverlay> overlays = new HashMap<>();
KittyImageOverlay() {
// Input belongs to the canvas underneath; the overlay only shows pixels.
root.setMouseTransparent(true);
root.setManaged(false);
}
Node node() {
return root;
}
/**
* Full reconcile to {@code panes} (bottom-to-top): drop overlays for panes that went away,
* refresh each surviving/added pane's images and clip, and order the per-pane groups to match
* the pane z-order. Called on layout frames, after the panes have painted.
*/
void sync(List<TerminalPane> panes) {
Iterator<Map.Entry<TerminalPane, PaneOverlay>> it = overlays.entrySet().iterator();
while (it.hasNext()) {
Map.Entry<TerminalPane, PaneOverlay> entry = it.next();
if (!panes.contains(entry.getKey())) {
root.getChildren().remove(entry.getValue().group);
it.remove();
}
}
for (TerminalPane pane : panes) {
updatePane(pane);
}
// Only panes that actually have images get a group; order those to match pane z-order.
List<Node> ordered = new ArrayList<>(panes.size());
for (TerminalPane pane : panes) {
PaneOverlay overlay = overlays.get(pane);
if (overlay != null) {
ordered.add(overlay.group);
}
}
if (!root.getChildren().equals(ordered)) {
root.getChildren().setAll(ordered);
}
}
/**
* Refresh one pane's images and clip (called on content frames for each repainted pane).
* Creates the pane's group if this is the first time it has shown an image.
*/
void updatePane(TerminalPane pane) {
List<KittyImageNode> images = pane.kittyImages();
PaneOverlay overlay = overlays.get(pane);
if (overlay == null) {
if (images.isEmpty()) {
return;
}
overlay = new PaneOverlay();
overlays.put(pane, overlay);
root.getChildren().add(overlay.group);
}
overlay.group.setClip(clipFor(pane));
reconcile(overlay, images);
}
private static void reconcile(PaneOverlay overlay, List<KittyImageNode> images) {
Set<Long> seen = new HashSet<>();
for (KittyImageNode node : images) {
long key = node.key();
seen.add(key);
ImageView view = overlay.views.get(key);
if (view == null) {
view = new ImageView();
view.setManaged(false);
view.setSmooth(true);
view.setPreserveRatio(false);
overlay.views.put(key, view);
overlay.group.getChildren().add(view);
}
apply(view, node);
}
if (overlay.views.size() == seen.size()) {
return;
}
Iterator<Map.Entry<Long, ImageView>> it = overlay.views.entrySet().iterator();
while (it.hasNext()) {
Map.Entry<Long, ImageView> entry = it.next();
if (!seen.contains(entry.getKey())) {
overlay.group.getChildren().remove(entry.getValue());
it.remove();
}
}
}
private static void apply(ImageView view, KittyImageNode node) {
if (view.getImage() != node.image()) {
view.setImage(node.image());
}
view.setViewport(new Rectangle2D(node.sourceX(), node.sourceY(), node.sourceWidth(), node.sourceHeight()));
view.setFitWidth(node.width());
view.setFitHeight(node.height());
view.setLayoutX(node.x());
view.setLayoutY(node.y());
}
// The pane's occlusion clip when one is set (rect minus covering panes), else the pane's
// plain bounds so an image can't spill outside its pane. Matches Tab's pixel snapping.
private static Shape clipFor(TerminalPane pane) {
Shape clip = pane.clip();
if (clip != null) {
return clip;
}
return new Rectangle(Math.round(pane.x()), Math.round(pane.y()), pane.width(), pane.height());
}
private static final class PaneOverlay {
private final Group group = new Group();
private final Map<Long, ImageView> views = new HashMap<>();
private PaneOverlay() {
group.setManaged(false);
}
}
}

View File

@@ -3,6 +3,7 @@ package com.gregor.jprototerm;
import javafx.animation.AnimationTimer;
import javafx.application.Application;
import javafx.application.Platform;
import javafx.geometry.Rectangle2D;
import javafx.scene.Scene;
import javafx.scene.control.ButtonType;
import javafx.scene.control.ComboBox;
@@ -14,11 +15,13 @@ import javafx.scene.input.KeyEvent;
import javafx.scene.layout.GridPane;
import javafx.scene.layout.StackPane;
import javafx.scene.text.Font;
import javafx.stage.Screen;
import javafx.stage.Stage;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
public final class Main extends Application {
private Compositor compositor;
@@ -32,7 +35,7 @@ public final class Main extends Application {
metrics = new TerminalMetrics(config.fontFamily(), config.fontSize());
compositor = new Compositor(config, metrics);
StackPane root = new StackPane(compositor.canvas());
StackPane root = new StackPane(compositor.canvas(), compositor.imageOverlay());
compositor.canvas().widthProperty().bind(root.widthProperty());
compositor.canvas().heightProperty().bind(root.heightProperty());
@@ -52,10 +55,39 @@ public final class Main extends Application {
stage.setOnCloseRequest(event -> {
compositor.close();
});
// JavaFX centres a new stage on the primary screen; on X11 there's no "focused monitor"
// to honour, so place it on the screen under the mouse pointer instead.
centreOnActiveScreen(stage, config.windowWidth(), config.windowHeight());
stage.show();
compositor.canvas().requestFocus();
}
// Centre the stage within the screen the mouse pointer is on (the best proxy for the
// "active" monitor on X11, which exposes no focused-monitor concept to JavaFX).
private static void centreOnActiveScreen(Stage stage, double width, double height) {
Rectangle2D bounds = activeScreen().getVisualBounds();
stage.setX(bounds.getMinX() + ((bounds.getWidth() - width) / 2.0));
stage.setY(bounds.getMinY() + ((bounds.getHeight() - height) / 2.0));
}
private static Screen activeScreen() {
try {
// AWT is the only way to read the pointer location before any window is shown;
// its coordinate space matches JavaFX's on the X11 virtual screen.
java.awt.PointerInfo pointer = java.awt.MouseInfo.getPointerInfo();
if (pointer != null) {
java.awt.Point at = pointer.getLocation();
List<Screen> screens = Screen.getScreensForRectangle(at.x, at.y, 1.0, 1.0);
if (!screens.isEmpty()) {
return screens.get(0);
}
}
} catch (Throwable ignored) {
// Headless or AWT unavailable — fall back to the primary screen.
}
return Screen.getPrimary();
}
private void handlePressed(KeyEvent event) {
if (config.keybindings().get("navigate_left").matches(event)) {
compositor.navigate(Direction.LEFT);
@@ -167,11 +199,16 @@ public final class Main extends Application {
private void openScrollbackInEditor() {
try {
// Capture the active pane's scrollback before opening the floating pane, since that
// makes the new pane active.
Path file = Files.createTempFile("jprototerm-scrollback-", ".txt");
Files.writeString(file, compositor.activePane().scrollbackText());
file.toFile().deleteOnExit();
compositor.activePane().send(scrollbackEditorCommand(file) + "\r");
TerminalPane pane = compositor.openFloatingPane();
if (pane != null) {
pane.send(scrollbackEditorCommand(file) + "\r");
}
} catch (IOException ex) {
System.err.println("Could not open scrollback in editor: " + ex.getMessage());
}

View File

@@ -257,11 +257,12 @@ final class Tab implements AutoCloseable {
}
}
private void createFloatingPane() {
TerminalPane createFloatingPane() {
TerminalPane pane = openPane(true);
floating.add(pane);
floatingVisible = true;
setActive(pane);
return pane;
}
private boolean navigateFloatingStack(Direction direction) {

View File

@@ -294,6 +294,14 @@ public final class TerminalPane implements AutoCloseable, RenderTarget {
return snapshotVersion;
}
/**
* Kitty image placements from the most recent paint, in scene coordinates. The compositor
* renders these as overlay nodes clipped to this pane (see {@link KittyImageOverlay}).
*/
public java.util.List<KittyImageNode> kittyImages() {
return renderer.kittyImages();
}
@Override
public void close() {
if (session != null) {

View File

@@ -25,6 +25,14 @@ abstract class TerminalRenderer {
/** Repaint only what changed since the last frame, clipped to the target's clip region. */
abstract void paintIncremental(GraphicsContext gc, RenderTarget target, boolean active);
/**
* The kitty image placements produced by the most recent paint, for the compositor to render
* as overlay nodes above the canvas. Empty unless the last paint found visible images.
*/
java.util.List<KittyImageNode> kittyImages() {
return java.util.List.of();
}
protected static void clipRect(GraphicsContext gc, double x, double y, double width, double height) {
gc.beginPath();
gc.rect(x, y, width, height);