16 Commits

23 changed files with 550 additions and 585 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
.pre-commit-config.yaml .pre-commit-config.yaml
build build
build
.gradle .gradle
bin 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

View File

@@ -30,3 +30,4 @@ next_floating = "ALT+F12"
close_pane = "ALT+X" close_pane = "ALT+X"
open_font_selector = "ALT+T" open_font_selector = "ALT+T"
open_scrollback = "ALT+S" open_scrollback = "ALT+S"
paste = "CTRL+SHIFT+V"

View File

@@ -1,65 +0,0 @@
{
"nodes": {
"devenv": {
"locked": {
"dir": "src/modules",
"lastModified": 1779749056,
"narHash": "sha256-AtocdrunzuxTvSDn+82RntEhrs6TicM6Z4/zNQS9KKg=",
"owner": "cachix",
"repo": "devenv",
"rev": "099ac65fcef79e88127bdc06adbd1ea94255274a",
"type": "github"
},
"original": {
"dir": "src/modules",
"owner": "cachix",
"repo": "devenv",
"type": "github"
}
},
"nixpkgs": {
"inputs": {
"nixpkgs-src": "nixpkgs-src"
},
"locked": {
"lastModified": 1778507786,
"narHash": "sha256-HzSQCKMsMr8r55LwM1JuzIOB+8bzk0FEv6sItKvsfoY=",
"owner": "cachix",
"repo": "devenv-nixpkgs",
"rev": "8f24a228a782e24576b155d1e39f0d914b380691",
"type": "github"
},
"original": {
"owner": "cachix",
"ref": "rolling",
"repo": "devenv-nixpkgs",
"type": "github"
}
},
"nixpkgs-src": {
"flake": false,
"locked": {
"lastModified": 1778274207,
"narHash": "sha256-I4puXmX1iovcCHZlRmztO3vW0mAbbRvq4F8wgIMQ1MM=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "b3da656039dc7a6240f27b2ef8cc6a3ef3bccae7",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"devenv": "devenv",
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}

View File

@@ -1,66 +0,0 @@
{ pkgs, lib, config, inputs, ... }:
let
system = pkgs.stdenv.hostPlatform.system;
jlibghostty = builtins.getFlake
"git+https://gitea.gregorlohaus.com/gregor/jlibghostty.git";
jlib = jlibghostty.packages.${system}.jlibghostty;
hostNvidiaLibs = ".devenv/host-nvidia-libs";
in
{
packages = [
pkgs.git
pkgs.gradle_9
pkgs.jdk25
pkgs.jdt-language-server
pkgs.openjfx
pkgs.glib
pkgs.xorg.libXxf86vm
pkgs.xorg.libXrender
pkgs.xorg.libXtst
pkgs.xorg.libXi
pkgs.xorg.libXrandr
pkgs.libGL
pkgs.gtk3
pkgs.alsa-lib
pkgs.mesa-demos
];
env.LD_LIBRARY_PATH = "${hostNvidiaLibs}:" + lib.makeLibraryPath [
pkgs.openjfx
pkgs.glib
pkgs.xorg.libXxf86vm
pkgs.xorg.libXrender
pkgs.xorg.libXtst
pkgs.xorg.libXi
pkgs.xorg.libXrandr
pkgs.libGL
pkgs.gtk3
pkgs.alsa-lib
] + ":/usr/lib/x86_64-linux-gnu/nvidia/current";
env.__GLX_VENDOR_LIBRARY_NAME = "nvidia";
env.__EGL_VENDOR_LIBRARY_FILENAMES = "/usr/share/glvnd/egl_vendor.d/10_nvidia.json";
env.JLIBGHOSTTY_MAVEN_REPO = "${jlib}/maven";
enterShell = ''
mkdir -p ${hostNvidiaLibs}
for lib in \
/usr/lib/x86_64-linux-gnu/libnvidia*.so* \
/usr/lib/x86_64-linux-gnu/libGLX_nvidia.so* \
/usr/lib/x86_64-linux-gnu/libEGL_nvidia.so* \
/usr/lib/x86_64-linux-gnu/nvidia/current/libnvidia*.so* \
/usr/lib/x86_64-linux-gnu/nvidia/current/libGLX_nvidia.so* \
/usr/lib/x86_64-linux-gnu/nvidia/current/libEGL_nvidia.so*
do
if [ -e "$lib" ]; then
ln -sfn "$lib" ${hostNvidiaLibs}/"$(basename "$lib")"
fi
done
'';
}

View File

@@ -1,18 +0,0 @@
# yaml-language-server: $schema=https://devenv.sh/devenv.schema.json
inputs:
nixpkgs:
url: github:cachix/devenv-nixpkgs/rolling
# If you're using non-OSS software, you can set allowUnfree to true.
# allowUnfree: true
# If you're not willing to allow unsupported packages:
# allowUnsupportedSystem: false
# If you're willing to use a package that's vulnerable
# permittedInsecurePackages:
# - "openssl-1.1.1w"
# If you have more than one devenv you can merge them
#imports:
# - ./backend

8
flake.lock generated
View File

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

View File

@@ -38,12 +38,14 @@ public record AppConfig(
"toggle_floating", "toggle_floating",
"new_pane", "new_pane",
"next_floating", "next_floating",
"promote_floating",
"close_pane", "close_pane",
"new_tab", "new_tab",
"previous_tab", "previous_tab",
"next_tab", "next_tab",
"open_font_selector", "open_font_selector",
"open_scrollback" "open_scrollback",
"paste"
); );
public static AppConfig load() { public static AppConfig load() {
@@ -97,12 +99,14 @@ public record AppConfig(
Map.entry("toggle_floating", KeyBinding.parse("ALT+F")), Map.entry("toggle_floating", KeyBinding.parse("ALT+F")),
Map.entry("new_pane", KeyBinding.parse("ALT+N")), Map.entry("new_pane", KeyBinding.parse("ALT+N")),
Map.entry("next_floating", KeyBinding.parse("ALT+F12")), Map.entry("next_floating", KeyBinding.parse("ALT+F12")),
Map.entry("promote_floating", KeyBinding.parse("ALT+P")),
Map.entry("close_pane", KeyBinding.parse("ALT+X")), Map.entry("close_pane", KeyBinding.parse("ALT+X")),
Map.entry("new_tab", KeyBinding.parse("ALT+A")), Map.entry("new_tab", KeyBinding.parse("ALT+A")),
Map.entry("previous_tab", KeyBinding.parse("ALT+SHIFT+H")), Map.entry("previous_tab", KeyBinding.parse("ALT+SHIFT+H")),
Map.entry("next_tab", KeyBinding.parse("ALT+SHIFT+L")), Map.entry("next_tab", KeyBinding.parse("ALT+SHIFT+L")),
Map.entry("open_font_selector", KeyBinding.parse("ALT+T")), Map.entry("open_font_selector", KeyBinding.parse("ALT+T")),
Map.entry("open_scrollback", KeyBinding.parse("ALT+S")) Map.entry("open_scrollback", KeyBinding.parse("ALT+S")),
Map.entry("paste", KeyBinding.parse("CTRL+SHIFT+V"))
) )
); );
} }

View File

@@ -5,6 +5,7 @@ import dev.jlibghostty.MouseButton;
import dev.jlibghostty.MouseEncoderSize; import dev.jlibghostty.MouseEncoderSize;
import dev.jlibghostty.MouseInput; import dev.jlibghostty.MouseInput;
import javafx.geometry.VPos; import javafx.geometry.VPos;
import javafx.scene.Node;
import javafx.scene.canvas.Canvas; import javafx.scene.canvas.Canvas;
import javafx.scene.canvas.GraphicsContext; import javafx.scene.canvas.GraphicsContext;
import javafx.scene.input.InputEvent; import javafx.scene.input.InputEvent;
@@ -38,6 +39,8 @@ public final class Compositor {
private static final double TAB_BAR_HEIGHT = 22.0; private static final double TAB_BAR_HEIGHT = 22.0;
private final Canvas canvas = new Canvas(); 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 AppConfig config;
private final TerminalMetrics metrics; private final TerminalMetrics metrics;
private final List<Tab> tabs = new ArrayList<>(); private final List<Tab> tabs = new ArrayList<>();
@@ -75,6 +78,11 @@ public final class Compositor {
return canvas; 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) { public void setFont(String family, double size) {
metrics.setFont(family, size); metrics.setFont(family, size);
paneContentVersion.clear(); paneContentVersion.clear();
@@ -113,6 +121,16 @@ public final class Compositor {
layoutVersion++; 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() { public void nextFloatingPane() {
if (isEmpty()) { if (isEmpty()) {
return; return;
@@ -121,6 +139,14 @@ public final class Compositor {
layoutVersion++; layoutVersion++;
} }
public void promoteActiveFloating() {
if (isEmpty()) {
return;
}
currentTab().promoteActiveFloating();
layoutVersion++;
}
public void closeActivePane() { public void closeActivePane() {
if (isEmpty()) { if (isEmpty()) {
return; return;
@@ -138,7 +164,10 @@ public final class Compositor {
} }
public void newTab() { public void newTab() {
tabs.add(new Tab(config, metrics)); // Open the new tab in the currently active pane's working directory, so it lands where the
// user currently is rather than always in home.
String workingDirectory = isEmpty() ? null : currentTab().activePane().currentWorkingDirectory();
tabs.add(new Tab(config, metrics, workingDirectory));
currentTabIndex = tabs.size() - 1; currentTabIndex = tabs.size() - 1;
layoutVersion++; layoutVersion++;
} }
@@ -246,6 +275,7 @@ public final class Compositor {
for (TerminalPane pane : panes) { for (TerminalPane pane : panes) {
paneContentVersion.put(pane, pane.paintFull(gc, isActive(pane))); 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 // Repaint just the panes whose content changed, directly on the retained canvas. Each pane
@@ -262,6 +292,7 @@ public final class Compositor {
continue; continue;
} }
paneContentVersion.put(pane, pane.paintIncremental(gc, isActive(pane))); paneContentVersion.put(pane, pane.paintIncremental(gc, isActive(pane)));
imageOverlay.updatePane(pane);
} }
} }

View File

@@ -1,8 +1,8 @@
package com.gregor.jprototerm; package com.gregor.jprototerm;
import dev.jlibghostty.KittyImage;
import dev.jlibghostty.KittyImageCompression; import dev.jlibghostty.KittyImageCompression;
import dev.jlibghostty.KittyImageFormat; import dev.jlibghostty.KittyImageFormat;
import dev.jlibghostty.KittyImageSnapshot;
import dev.jlibghostty.KittyPlacement; import dev.jlibghostty.KittyPlacement;
import dev.jlibghostty.KittyPlacementLayer; import dev.jlibghostty.KittyPlacementLayer;
import dev.jlibghostty.KittyPlaceholder; import dev.jlibghostty.KittyPlaceholder;
@@ -27,15 +27,18 @@ import javafx.scene.text.Text;
import java.io.ByteArrayInputStream; import java.io.ByteArrayInputStream;
import java.nio.IntBuffer; import java.nio.IntBuffer;
import java.util.ArrayList;
import java.util.Arrays; import java.util.Arrays;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
/** /**
* The real terminal renderer: paints a pane's background, cell rows, cursor, border, padding * The real terminal renderer: rasterizes a pane's background, cell rows, cursor, border and
* and (when enabled) kitty graphics. One instance per pane, since it caches that pane's * padding into the {@link SoftwareBackbuffer} and blits it to the canvas. Kitty images are not
* decoded kitty images. * 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 { final class GhosttyTerminalRenderer extends TerminalRenderer {
// GhosttyRenderStateDirty values (stable C ABI; see ghostty/vt/render.h). // 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). // Decoded kitty images for this renderer's pane (kitty graphics state is per-terminal).
private final Map<KittyImageKey, Image> kittyImageCache = new HashMap<>(); private final Map<KittyImageKey, Image> kittyImageCache = new HashMap<>();
private final SoftwareBackbuffer software = new SoftwareBackbuffer(); 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) { GhosttyTerminalRenderer(TerminalMetrics metrics) {
this.metrics = metrics; this.metrics = metrics;
@@ -73,15 +79,10 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
double height = target.height(); double height = target.height();
gc.save(); gc.save();
clip(gc, px, py, width, height, target.clip()); clip(gc, px, py, width, height, target.clip());
boolean withKitty = target.kittyEnabled() && hasKittyGraphics(target);
RenderStateSnapshot snapshot = target.snapshotFull(); 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(); gc.restore();
collectKittyImages(target, snapshot, px, py);
} }
@Override @Override
@@ -93,10 +94,15 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
gc.save(); gc.save();
clip(gc, px, py, width, height, target.clip()); clip(gc, px, py, width, height, target.clip());
if (target.kittyEnabled() && hasKittyGraphics(target)) { if (target.kittyEnabled() && hasKittyGraphics(target)) {
// Kitty placements can move without a per-row dirty flag, so always redraw whole. // Images render as overlay nodes, not on the canvas, but their positions track the
drawContent(gc, target, target.snapshotFull(), px, py, width, height, active, true); // grid (scroll/cursor), so we need a full snapshot to locate placeholder cells. The
software.invalidate(); // software path itself still repaints only the text rows whose hash changed.
} else { RenderStateSnapshot snapshot = target.snapshotFull();
software.paintFullOrShifted(gc, snapshot, px, py, width, height, active);
gc.restore();
collectKittyImages(target, snapshot, px, py);
return;
}
RenderStateSnapshot snapshot = target.snapshot(); RenderStateSnapshot snapshot = target.snapshot();
int dirty = snapshot == null ? DIRTY_FULL : snapshot.dirty(); int dirty = snapshot == null ? DIRTY_FULL : snapshot.dirty();
if (dirty == DIRTY_FULL) { if (dirty == DIRTY_FULL) {
@@ -105,159 +111,13 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
software.paintDirty(gc, target, snapshot, px, py, width, height, active); software.paintDirty(gc, target, snapshot, px, py, width, height, active);
} }
// dirty == FALSE: nothing visible changed. // dirty == FALSE: nothing visible changed.
}
gc.restore(); gc.restore();
kittyImageNodes = List.of();
} }
// Full content render: background, border, all rows, cursor, and (when enabled) kitty @Override
// graphics. Used by the kitty direct path and by full redraws. List<KittyImageNode> kittyImages() {
private void drawContent( return kittyImageNodes;
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) {
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 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);
} }
// Effective background colour of a cell as it is drawn (reverse video swaps fg/bg, an // 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)); 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. // Background override for a cell: null means the pane default background already covers it.
private static Color cellBackgroundOverride(RenderCell cell) { private static Color cellBackgroundOverride(RenderCell cell) {
if (cell.inverse()) { if (cell.inverse()) {
@@ -434,46 +176,42 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
return 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(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 -------------------------------------------------------------- // ---- Kitty graphics --------------------------------------------------------------
private static boolean hasKittyGraphics(RenderTarget target) { private static boolean hasKittyGraphics(RenderTarget target) {
return target.kittyGraphics() return target.kittyGraphics()
.map(graphics -> !graphics.placements().isEmpty()) .map(graphics -> !graphics.isEmpty())
.orElse(false); .orElse(false);
} }
private void drawKittyGraphics( // Build the image-node descriptors for the current frame. Reads the decoded-image cache and
GraphicsContext gc, // 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, RenderTarget target,
KittyPlacementLayer layer, KittyPlacementLayer layer,
Map<KittyPlaceholderKey, KittyPlaceholderBounds> placeholderBounds, Map<KittyPlaceholderKey, KittyPlaceholderBounds> placeholderBounds,
double originX, double originX,
double originY, double originY,
double cellWidth, double cellWidth,
double lineHeight double lineHeight,
List<KittyImageNode> out
) { ) {
target.kittyGraphics().ifPresent(graphics -> { target.kittyGraphics().ifPresent(graphics -> {
for (KittyPlacement placement : graphics.placements(layer)) { for (KittyPlacement placement : graphics.placements(layer)) {
@@ -482,17 +220,17 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
continue; continue;
} }
if (placement.virtual()) { KittyImageNode node = placement.virtual()
drawVirtualKittyPlacement(gc, placement, image, placeholderBounds, originX, originY, cellWidth, lineHeight); ? virtualNode(placement, image, placeholderBounds, originX, originY, cellWidth, lineHeight)
} else { : pinnedNode(placement, image, originX, originY, cellWidth, lineHeight);
drawPinnedKittyPlacement(gc, placement, image, originX, originY, cellWidth, lineHeight); if (node != null) {
out.add(node);
} }
} }
}); });
} }
private static void drawPinnedKittyPlacement( private static KittyImageNode pinnedNode(
GraphicsContext gc,
KittyPlacement placement, KittyPlacement placement,
Image image, Image image,
double originX, double originX,
@@ -502,15 +240,13 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
) { ) {
KittyRenderInfo renderInfo = placement.renderInfo().orElse(null); KittyRenderInfo renderInfo = placement.renderInfo().orElse(null);
if (renderInfo == null || !renderInfo.viewportVisible()) { if (renderInfo == null || !renderInfo.viewportVisible()) {
return; return null;
} }
double sourceX = renderInfo.sourceX();
double sourceY = renderInfo.sourceY();
double sourceWidth = renderInfo.sourceWidth(); double sourceWidth = renderInfo.sourceWidth();
double sourceHeight = renderInfo.sourceHeight(); double sourceHeight = renderInfo.sourceHeight();
if (sourceWidth <= 0.0 || sourceHeight <= 0.0) { if (sourceWidth <= 0.0 || sourceHeight <= 0.0) {
return; return null;
} }
double x = originX + (renderInfo.viewportColumn() * cellWidth) + placement.xOffset(); 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 width = renderInfo.pixelWidth() > 0 ? renderInfo.pixelWidth() : renderInfo.gridColumns() * cellWidth;
double height = renderInfo.pixelHeight() > 0 ? renderInfo.pixelHeight() : renderInfo.gridRows() * lineHeight; double height = renderInfo.pixelHeight() > 0 ? renderInfo.pixelHeight() : renderInfo.gridRows() * lineHeight;
if (width <= 0.0 || height <= 0.0) { 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( private static KittyImageNode virtualNode(
GraphicsContext gc,
KittyPlacement placement, KittyPlacement placement,
Image image, Image image,
Map<KittyPlaceholderKey, KittyPlaceholderBounds> placeholderBounds, Map<KittyPlaceholderKey, KittyPlaceholderBounds> placeholderBounds,
@@ -546,12 +282,12 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
.orElse(null); .orElse(null);
} }
if (bounds == null || bounds.isEmpty()) { if (bounds == null || bounds.isEmpty()) {
return; return null;
} }
SourceRect source = sourceRect(placement, image); SourceRect source = sourceRect(placement, image);
if (source.width() <= 0.0 || source.height() <= 0.0) { if (source.width() <= 0.0 || source.height() <= 0.0) {
return; return null;
} }
long gridColumns = gridColumns(placement, bounds); long gridColumns = gridColumns(placement, bounds);
@@ -569,13 +305,14 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
double availableHeight = bounds.rows() * lineHeight; double availableHeight = bounds.rows() * lineHeight;
if (sourceWidth <= 0.0 || sourceHeight <= 0.0 || availableWidth <= 0.0 || availableHeight <= 0.0) { 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 scale = Math.min(availableWidth / sourceWidth, availableHeight / sourceHeight);
double width = sourceWidth * scale; double width = sourceWidth * scale;
double height = sourceHeight * 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) { private static long gridColumns(KittyPlacement placement, KittyPlaceholderBounds bounds) {
@@ -601,16 +338,17 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
} }
private Image imageFor(KittyPlacement placement) { private Image imageFor(KittyPlacement placement) {
return placement.image().map(snapshot -> { return placement.image().map(image -> {
byte[] data = snapshot.data(); // Build the cache key from cheap metadata only — the pixel buffer is never copied out
KittyImageKey key = KittyImageKey.of(snapshot, data); // of native memory on a cache hit (the common per-frame case).
KittyImageKey key = KittyImageKey.of(image);
Image cached = kittyImageCache.get(key); Image cached = kittyImageCache.get(key);
if (cached != null) { if (cached != null) {
return cached; return cached;
} }
kittyImageCache.keySet().removeIf(existing -> existing.id() == snapshot.id()); kittyImageCache.keySet().removeIf(existing -> existing.id() == image.id());
Image decoded = decodeImage(snapshot, data); Image decoded = decodeImage(image);
if (decoded != null) { if (decoded != null) {
kittyImageCache.put(key, decoded); kittyImageCache.put(key, decoded);
} }
@@ -618,22 +356,25 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
}).orElse(null); }).orElse(null);
} }
private Image decodeImage(KittyImageSnapshot snapshot, byte[] data) { private Image decodeImage(KittyImage source) {
if (snapshot.compression() != KittyImageCompression.NONE) { if (source.compression() != KittyImageCompression.NONE) {
return null; 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)); return new Image(new ByteArrayInputStream(data));
} }
int width = Math.toIntExact(snapshot.width()); int width = Math.toIntExact(source.width());
int height = Math.toIntExact(snapshot.height()); int height = Math.toIntExact(source.height());
WritableImage image = new WritableImage(width, 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); 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); image.getPixelWriter().setPixels(0, 0, width, height, PixelFormat.getByteRgbInstance(), data, 0, width * 3);
} }
return image; return image;
@@ -1321,15 +1062,15 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
// identity + dimensions + payload length are enough to key the decoded-image cache, and // 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 // we avoid fingerprinting the whole payload — which previously ran once per frame per
// placement (O(image size)) just to look the image up. // 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 record KittyImageKey(long id, long number, long width, long height, KittyImageFormat format, long dataLength) {
private static KittyImageKey of(KittyImageSnapshot snapshot, byte[] data) { private static KittyImageKey of(KittyImage image) {
return new KittyImageKey( return new KittyImageKey(
snapshot.id(), image.id(),
snapshot.number(), image.number(),
snapshot.width(), image.width(),
snapshot.height(), image.height(),
snapshot.format(), image.format(),
data.length 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

@@ -9,6 +9,9 @@ import java.lang.foreign.MemorySegment;
import java.lang.foreign.SymbolLookup; import java.lang.foreign.SymbolLookup;
import java.lang.foreign.ValueLayout; import java.lang.foreign.ValueLayout;
import java.lang.invoke.MethodHandle; import java.lang.invoke.MethodHandle;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@@ -74,6 +77,7 @@ public final class LinuxPty implements AutoCloseable {
private static final long SPAWN_ACTIONS_SIZE = 256; private static final long SPAWN_ACTIONS_SIZE = 256;
private static final long SPAWN_ATTR_SIZE = 512; private static final long SPAWN_ATTR_SIZE = 512;
private static final MethodHandle TCGETPGRP = handle("tcgetpgrp", FD_INT_INT);
private static final MethodHandle POSIX_OPENPT = handle("posix_openpt", FD_INT_INT); private static final MethodHandle POSIX_OPENPT = handle("posix_openpt", FD_INT_INT);
private static final MethodHandle GRANTPT = handle("grantpt", FD_INT_INT); private static final MethodHandle GRANTPT = handle("grantpt", FD_INT_INT);
private static final MethodHandle UNLOCKPT = handle("unlockpt", FD_INT_INT); private static final MethodHandle UNLOCKPT = handle("unlockpt", FD_INT_INT);
@@ -205,6 +209,25 @@ public final class LinuxPty implements AutoCloseable {
} }
} }
/**
* Best-effort current working directory of the terminal's foreground process group, read from
* {@code /proc}. This tracks the directory the user is actually in (a {@code cd} in the shell,
* or a child program that changed dir), so a newly opened pane can start there. Falls back to
* the shell's own pid, and returns {@code null} if it cannot be determined.
*/
public String currentWorkingDirectory() {
if (closed) {
return null;
}
int pgid = callInt(TCGETPGRP, masterFd);
int target = pgid > 0 ? pgid : pid;
try {
return Files.readSymbolicLink(Path.of("/proc", Integer.toString(target), "cwd")).toString();
} catch (IOException | RuntimeException ex) {
return null;
}
}
/** Resizes the terminal window. */ /** Resizes the terminal window. */
public void setWinSize(int columns, int rows) { public void setWinSize(int columns, int rows) {
if (closed) { if (closed) {

View File

@@ -3,6 +3,7 @@ package com.gregor.jprototerm;
import javafx.animation.AnimationTimer; import javafx.animation.AnimationTimer;
import javafx.application.Application; import javafx.application.Application;
import javafx.application.Platform; import javafx.application.Platform;
import javafx.geometry.Rectangle2D;
import javafx.scene.Scene; import javafx.scene.Scene;
import javafx.scene.control.ButtonType; import javafx.scene.control.ButtonType;
import javafx.scene.control.ComboBox; import javafx.scene.control.ComboBox;
@@ -10,15 +11,18 @@ import javafx.scene.control.Dialog;
import javafx.scene.control.Label; import javafx.scene.control.Label;
import javafx.scene.control.Spinner; import javafx.scene.control.Spinner;
import javafx.scene.control.SpinnerValueFactory; import javafx.scene.control.SpinnerValueFactory;
import javafx.scene.input.Clipboard;
import javafx.scene.input.KeyEvent; import javafx.scene.input.KeyEvent;
import javafx.scene.layout.GridPane; import javafx.scene.layout.GridPane;
import javafx.scene.layout.StackPane; import javafx.scene.layout.StackPane;
import javafx.scene.text.Font; import javafx.scene.text.Font;
import javafx.stage.Screen;
import javafx.stage.Stage; import javafx.stage.Stage;
import java.io.IOException; import java.io.IOException;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.List;
public final class Main extends Application { public final class Main extends Application {
private Compositor compositor; private Compositor compositor;
@@ -32,7 +36,7 @@ public final class Main extends Application {
metrics = new TerminalMetrics(config.fontFamily(), config.fontSize()); metrics = new TerminalMetrics(config.fontFamily(), config.fontSize());
compositor = new Compositor(config, metrics); 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().widthProperty().bind(root.widthProperty());
compositor.canvas().heightProperty().bind(root.heightProperty()); compositor.canvas().heightProperty().bind(root.heightProperty());
@@ -52,10 +56,37 @@ public final class Main extends Application {
stage.setOnCloseRequest(event -> { stage.setOnCloseRequest(event -> {
compositor.close(); 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(); stage.show();
// Ask the window manager to raise and focus the new window so the user can type right
// away; the canvas requestFocus() below only routes events within the scene.
stage.toFront();
stage.requestFocus();
compositor.canvas().requestFocus(); 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() {
int[] at = X11Pointer.query();
if (at != null) {
// libX11 and JavaFX share a coordinate space on the X11 virtual screen.
List<Screen> screens = Screen.getScreensForRectangle(at[0], at[1], 1.0, 1.0);
if (!screens.isEmpty()) {
return screens.get(0);
}
}
return Screen.getPrimary();
}
private void handlePressed(KeyEvent event) { private void handlePressed(KeyEvent event) {
if (config.keybindings().get("navigate_left").matches(event)) { if (config.keybindings().get("navigate_left").matches(event)) {
compositor.navigate(Direction.LEFT); compositor.navigate(Direction.LEFT);
@@ -78,6 +109,9 @@ public final class Main extends Application {
} else if (config.keybindings().get("next_floating").matches(event)) { } else if (config.keybindings().get("next_floating").matches(event)) {
compositor.nextFloatingPane(); compositor.nextFloatingPane();
event.consume(); event.consume();
} else if (config.keybindings().get("promote_floating").matches(event)) {
compositor.promoteActiveFloating();
event.consume();
} else if (config.keybindings().get("close_pane").matches(event)) { } else if (config.keybindings().get("close_pane").matches(event)) {
compositor.closeActivePane(); compositor.closeActivePane();
event.consume(); event.consume();
@@ -101,6 +135,9 @@ public final class Main extends Application {
} else if (config.keybindings().get("open_scrollback").matches(event)) { } else if (config.keybindings().get("open_scrollback").matches(event)) {
openScrollbackInEditor(); openScrollbackInEditor();
event.consume(); event.consume();
} else if (config.keybindings().get("paste").matches(event)) {
pasteFromClipboard();
event.consume();
} else { } else {
String encoded = KeyEncoder.encode(event); String encoded = KeyEncoder.encode(event);
if (encoded != null) { if (encoded != null) {
@@ -122,6 +159,13 @@ public final class Main extends Application {
} }
} }
private void pasteFromClipboard() {
Clipboard clipboard = Clipboard.getSystemClipboard();
if (clipboard.hasString()) {
compositor.activePane().paste(clipboard.getString());
}
}
private void openFontSelector() { private void openFontSelector() {
Dialog<ButtonType> dialog = new Dialog<>(); Dialog<ButtonType> dialog = new Dialog<>();
dialog.setTitle("Font"); dialog.setTitle("Font");
@@ -167,11 +211,16 @@ public final class Main extends Application {
private void openScrollbackInEditor() { private void openScrollbackInEditor() {
try { 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"); Path file = Files.createTempFile("jprototerm-scrollback-", ".txt");
Files.writeString(file, compositor.activePane().scrollbackText()); Files.writeString(file, compositor.activePane().scrollbackText());
file.toFile().deleteOnExit(); file.toFile().deleteOnExit();
compositor.activePane().send(scrollbackEditorCommand(file) + "\r"); TerminalPane pane = compositor.openFloatingPane();
if (pane != null) {
pane.send(scrollbackEditorCommand(file) + "\r");
}
} catch (IOException ex) { } catch (IOException ex) {
System.err.println("Could not open scrollback in editor: " + ex.getMessage()); System.err.println("Could not open scrollback in editor: " + ex.getMessage());
} }

View File

@@ -20,7 +20,8 @@ public final class ShellSession implements AutoCloseable {
}); });
} }
public static ShellSession start(String shell, Map<String, String> envOverride, TerminalPane pane, int columns, int rows) { public static ShellSession start(String shell, Map<String, String> envOverride, TerminalPane pane,
int columns, int rows, String workingDirectory) {
try { try {
Map<String, String> environment = new HashMap<>(System.getenv()); Map<String, String> environment = new HashMap<>(System.getenv());
environment.put("TERM", "xterm-kitty"); environment.put("TERM", "xterm-kitty");
@@ -31,7 +32,7 @@ public final class ShellSession implements AutoCloseable {
LinuxPty pty = LinuxPty.spawn( LinuxPty pty = LinuxPty.spawn(
new String[] {shell, "-i"}, new String[] {shell, "-i"},
environment, environment,
System.getProperty("user.home")); workingDirectory != null ? workingDirectory : System.getProperty("user.home"));
ShellSession session = new ShellSession(pty); ShellSession session = new ShellSession(pty);
session.resize(columns, rows); session.resize(columns, rows);
return session; return session;
@@ -69,6 +70,11 @@ public final class ShellSession implements AutoCloseable {
reader.submit(() -> readOutput(pane)); reader.submit(() -> readOutput(pane));
} }
/** Best-effort current working directory of the running shell, or {@code null} if unknown. */
public String currentWorkingDirectory() {
return closed ? null : pty.currentWorkingDirectory();
}
public void resize(int columns, int rows) { public void resize(int columns, int rows) {
if (closed) { if (closed) {
return; return;

View File

@@ -23,6 +23,7 @@ final class Tab implements AutoCloseable {
private final List<TerminalPane> floating = new ArrayList<>(); private final List<TerminalPane> floating = new ArrayList<>();
private boolean floatingVisible; private boolean floatingVisible;
private TerminalPane active; private TerminalPane active;
private final String initialWorkingDirectory;
// The floating pane to re-focus when the group is shown again, and to prefer when promoting // The floating pane to re-focus when the group is shown again, and to prefer when promoting
// after the last tiled pane closes. // after the last tiled pane closes.
private TerminalPane lastFocusedFloating; private TerminalPane lastFocusedFloating;
@@ -37,10 +38,19 @@ final class Tab implements AutoCloseable {
private final AtomicLong contentVersion = new AtomicLong(); private final AtomicLong contentVersion = new AtomicLong();
Tab(AppConfig config, TerminalMetrics metrics) { Tab(AppConfig config, TerminalMetrics metrics) {
this(config, metrics, null);
}
/**
* Creates a tab whose first pane starts in {@code initialWorkingDirectory} (e.g. the cwd of the
* pane that was active when this tab was opened), or the user's home when {@code null}.
*/
Tab(AppConfig config, TerminalMetrics metrics, String initialWorkingDirectory) {
this.config = config; this.config = config;
this.metrics = metrics; this.metrics = metrics;
this.lastWidth = config.windowWidth(); this.lastWidth = config.windowWidth();
this.lastHeight = config.windowHeight(); this.lastHeight = config.windowHeight();
this.initialWorkingDirectory = initialWorkingDirectory;
TerminalPane first = openPane(false); TerminalPane first = openPane(false);
tiled.add(first); tiled.add(first);
active = first; active = first;
@@ -212,6 +222,22 @@ final class Tab implements AutoCloseable {
setActive(floating.get((current + 1 + floating.size()) % floating.size())); setActive(floating.get((current + 1 + floating.size()) % floating.size()));
} }
/** Promotes the active floating pane to a tiled pane, joining the tiled row. No-op otherwise. */
void promoteActiveFloating() {
TerminalPane promote = active;
if (!floating.remove(promote)) {
return; // active pane is tiled (or there is none); nothing to promote
}
if (promote == lastFocusedFloating) {
lastFocusedFloating = floating.isEmpty() ? null : floating.get(floating.size() - 1);
}
tiled.add(promote);
if (floating.isEmpty()) {
floatingVisible = false;
}
setActive(promote);
}
void closeActivePane() { void closeActivePane() {
TerminalPane closing = active; TerminalPane closing = active;
boolean wasFloating = floating.remove(closing); boolean wasFloating = floating.remove(closing);
@@ -257,11 +283,12 @@ final class Tab implements AutoCloseable {
} }
} }
private void createFloatingPane() { TerminalPane createFloatingPane() {
TerminalPane pane = openPane(true); TerminalPane pane = openPane(true);
floating.add(pane); floating.add(pane);
floatingVisible = true; floatingVisible = true;
setActive(pane); setActive(pane);
return pane;
} }
private boolean navigateFloatingStack(Direction direction) { private boolean navigateFloatingStack(Direction direction) {
@@ -307,7 +334,11 @@ final class Tab implements AutoCloseable {
widthPx = lastWidth / (tiled.size() + 1); widthPx = lastWidth / (tiled.size() + 1);
heightPx = availHeight; heightPx = availHeight;
} }
return TerminalPane.create(config, metrics, this::markContentChanged, widthPx, heightPx); // Open the new pane in the active pane's working directory, so a split/new pane lands
// where the user currently is. With no active pane yet (the tab's first pane), fall back to
// the directory this tab was opened in. null (cwd unknown) falls back to home downstream.
String workingDirectory = active != null ? active.currentWorkingDirectory() : initialWorkingDirectory;
return TerminalPane.create(config, metrics, this::markContentChanged, widthPx, heightPx, workingDirectory);
} }
private static boolean directionFilter(Direction direction, TerminalPane current, TerminalPane candidate) { private static boolean directionFilter(Direction direction, TerminalPane current, TerminalPane candidate) {

View File

@@ -69,9 +69,11 @@ public final class TerminalPane implements AutoCloseable, RenderTarget {
* columns and rows fit, and that grid is handed to ghostty and the shell at start-up. A * columns and rows fit, and that grid is handed to ghostty and the shell at start-up. A
* non-positive size falls back to the configured default grid (used before the first * non-positive size falls back to the configured default grid (used before the first
* layout, when no rect is known yet). The pane owns the shell session it starts and runs * layout, when no rect is known yet). The pane owns the shell session it starts and runs
* {@code onContentChange} on every content change. * {@code onContentChange} on every content change. The shell starts in {@code workingDirectory}
* (e.g. the active pane's cwd), or the user's home when {@code null}.
*/ */
public static TerminalPane create(AppConfig config, TerminalMetrics metrics, Runnable onContentChange, double widthPx, double heightPx) { public static TerminalPane create(AppConfig config, TerminalMetrics metrics, Runnable onContentChange,
double widthPx, double heightPx, String workingDirectory) {
int columns = widthPx > 0 ? metrics.columnsFor(widthPx) : config.columns(); int columns = widthPx > 0 ? metrics.columnsFor(widthPx) : config.columns();
int rows = heightPx > 0 ? metrics.rowsFor(heightPx) : config.rows(); int rows = heightPx > 0 ? metrics.rowsFor(heightPx) : config.rows();
Terminal terminal = Ghostty.open(new TerminalOptions(columns, rows, config.maxScrollback())); Terminal terminal = Ghostty.open(new TerminalOptions(columns, rows, config.maxScrollback()));
@@ -79,7 +81,7 @@ public final class TerminalPane implements AutoCloseable, RenderTarget {
TerminalPane pane = new TerminalPane(terminal, metrics, config.kittyGraphics(), onContentChange, TerminalPane pane = new TerminalPane(terminal, metrics, config.kittyGraphics(), onContentChange,
new GhosttyTerminalRenderer(metrics), columns, rows); new GhosttyTerminalRenderer(metrics), columns, rows);
pane.refresh(); pane.refresh();
pane.attach(ShellSession.start(config.shell(), config.envOverride(), pane, columns, rows)); pane.attach(ShellSession.start(config.shell(), config.envOverride(), pane, columns, rows, workingDirectory));
return pane; return pane;
} }
@@ -115,6 +117,19 @@ public final class TerminalPane implements AutoCloseable, RenderTarget {
} }
} }
/**
* Paste text to the shell: ghostty sanitises it (stripping anything that could smuggle in
* control sequences) and wraps it in the bracketed-paste markers, then it goes to the pty
* like typed input. We always request bracketed mode — every modern shell and editor enables
* DECSET 2004, and the jlibghostty API does not expose querying the terminal's live mode.
*/
public void paste(String text) {
if (text == null || text.isEmpty()) {
return;
}
send(Ghostty.encodePaste(text, true));
}
public boolean sendMouse(MouseInput input, MouseEncoderSize size, boolean anyButtonPressed) { public boolean sendMouse(MouseInput input, MouseEncoderSize size, boolean anyButtonPressed) {
synchronized (terminal) { synchronized (terminal) {
mouseEncoder.syncFromTerminal(terminal); mouseEncoder.syncFromTerminal(terminal);
@@ -192,6 +207,12 @@ public final class TerminalPane implements AutoCloseable, RenderTarget {
} }
} }
/** Best-effort current working directory of this pane's shell, or {@code null} if unknown. */
public String currentWorkingDirectory() {
ShellSession current = session;
return current != null ? current.currentWorkingDirectory() : null;
}
/** This pane's own content revision, bumped on every change (see {@link #refresh()}). */ /** This pane's own content revision, bumped on every change (see {@link #refresh()}). */
public long contentVersion() { public long contentVersion() {
return contentVersion.get(); return contentVersion.get();
@@ -294,6 +315,14 @@ public final class TerminalPane implements AutoCloseable, RenderTarget {
return snapshotVersion; 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 @Override
public void close() { public void close() {
if (session != null) { 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. */ /** Repaint only what changed since the last frame, clipped to the target's clip region. */
abstract void paintIncremental(GraphicsContext gc, RenderTarget target, boolean active); 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) { protected static void clipRect(GraphicsContext gc, double x, double y, double width, double height) {
gc.beginPath(); gc.beginPath();
gc.rect(x, y, width, height); gc.rect(x, y, width, height);

View File

@@ -0,0 +1,66 @@
package com.gregor.jprototerm;
import java.lang.foreign.Arena;
import java.lang.foreign.FunctionDescriptor;
import java.lang.foreign.Linker;
import java.lang.foreign.MemorySegment;
import java.lang.foreign.SymbolLookup;
import java.lang.invoke.MethodHandle;
import static java.lang.foreign.ValueLayout.ADDRESS;
import static java.lang.foreign.ValueLayout.JAVA_INT;
import static java.lang.foreign.ValueLayout.JAVA_LONG;
/**
* Reads the X11 pointer location directly via libX11 ({@code XQueryPointer}). Unlike AWT's
* {@code MouseInfo}, this never calls {@code XSetErrorHandler}, so it doesn't trip GDK's
* "XSetErrorHandler called with a GDK error trap pushed" warning when JavaFX's GTK backend is
* already up. Returns {@code null} when not on X11 or libX11 can't be loaded.
*/
final class X11Pointer {
private X11Pointer() {
}
/** {@code {x, y}} of the pointer in X root-window (virtual screen) space, or {@code null}. */
static int[] query() {
try (Arena arena = Arena.ofConfined()) {
Linker linker = Linker.nativeLinker();
SymbolLookup x11 = SymbolLookup.libraryLookup("libX11.so.6", arena);
MethodHandle openDisplay = linker.downcallHandle(x11.find("XOpenDisplay").orElseThrow(),
FunctionDescriptor.of(ADDRESS, ADDRESS));
MethodHandle defaultRootWindow = linker.downcallHandle(x11.find("XDefaultRootWindow").orElseThrow(),
FunctionDescriptor.of(JAVA_LONG, ADDRESS));
MethodHandle queryPointer = linker.downcallHandle(x11.find("XQueryPointer").orElseThrow(),
FunctionDescriptor.of(JAVA_INT, ADDRESS, JAVA_LONG,
ADDRESS, ADDRESS, ADDRESS, ADDRESS, ADDRESS, ADDRESS, ADDRESS));
MethodHandle closeDisplay = linker.downcallHandle(x11.find("XCloseDisplay").orElseThrow(),
FunctionDescriptor.of(JAVA_INT, ADDRESS));
MemorySegment display = (MemorySegment) openDisplay.invoke(MemorySegment.NULL);
if (display.address() == 0) {
return null;
}
try {
long root = (long) defaultRootWindow.invoke(display);
MemorySegment rootReturn = arena.allocate(JAVA_LONG);
MemorySegment childReturn = arena.allocate(JAVA_LONG);
MemorySegment rootX = arena.allocate(JAVA_INT);
MemorySegment rootY = arena.allocate(JAVA_INT);
MemorySegment winX = arena.allocate(JAVA_INT);
MemorySegment winY = arena.allocate(JAVA_INT);
MemorySegment mask = arena.allocate(JAVA_INT);
int onSameScreen = (int) queryPointer.invoke(display, root,
rootReturn, childReturn, rootX, rootY, winX, winY, mask);
if (onSameScreen == 0) {
return null;
}
return new int[] { rootX.get(JAVA_INT, 0), rootY.get(JAVA_INT, 0) };
} finally {
closeDisplay.invoke(display);
}
} catch (Throwable ignored) {
// Not X11, libX11 missing, or the call failed — caller falls back to the primary screen.
return null;
}
}
}