24 Commits

Author SHA1 Message Date
c2ccd056af try new ligghostty 2026-06-02 03:27:08 +02:00
11734d89f7 close panes when process closes, rework open scrollback to not open with /bin/sh -c 2026-06-01 23:10:30 +02:00
4ed2b82f2f when tiling pane gains focus hide floating panes 2026-06-01 17:44:15 +02:00
64d86fe487 focus last active tiling 2026-06-01 17:27:08 +02:00
556ec9787b add demo video as tag 2026-06-01 14:50:11 +02:00
b6a65c9f4a add demo readme 2026-06-01 14:49:03 +02:00
44edff25d9 demo video 2026-06-01 14:42:23 +02:00
cc694a257a lfs conf 2026-06-01 14:42:04 +02:00
1895a48550 promote floating 2026-06-01 14:39:00 +02:00
40230dd8f7 new tab inherits cwd 2026-06-01 14:03:45 +02:00
a31cf06cbd new panes gain active pwd 2026-06-01 13:35:28 +02:00
6cf9afd664 remove unused devenv files 2026-06-01 13:31:20 +02:00
75cbea61dd paste 2026-06-01 03:47:16 +02:00
dbb5dc350b grap focus on launch 2026-06-01 03:30:45 +02:00
1776aa251a fix call xseterrorhandler while gdk error trap is up 2026-06-01 03:13:59 +02:00
0be3662a93 fix call xseterrorhandler while gdk error trap is up 2026-06-01 03:09:54 +02:00
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
26 changed files with 826 additions and 639 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

1
.gitattributes vendored Normal file
View File

@@ -0,0 +1 @@
*.mp4 filter=lfs diff=lfs merge=lfs -text

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

View File

@@ -12,6 +12,8 @@ floating panes and tabs.
> setups, or Mesa/AMD/Intel GPUs. I'm happy to accept pull requests that broaden host
> support.
<video src="https://gitea.gregorlohaus.com/gregor/jprototerm/media/branch/main/demo.mp4" controls></video>
## Build
```sh

View File

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

BIN
demo.mp4 LFS Normal file

Binary file not shown.

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"
},
"locked": {
"lastModified": 1780258814,
"narHash": "sha256-8rxL7xaZ/loYg3zdt0w5+hfNyHFVknDZN360NzrtCsQ=",
"lastModified": 1780363584,
"narHash": "sha256-BN6kwSBnlavuySut6vvLsfwkfKBjrPvFgJeeMMTXNtg=",
"ref": "refs/heads/main",
"rev": "6a3d5aa0b0b1f738c958e2a2f0249574c07d9c4d",
"revCount": 23,
"rev": "1cd908e5d0070fdc1c86fc6b300cf1d6dbb5d184",
"revCount": 25,
"type": "git",
"url": "https://gitea.gregorlohaus.com/gregor/jlibghostty.git"
},

View File

@@ -5,6 +5,7 @@ import io.github.wasabithumb.jtoml.document.TomlDocument;
import io.github.wasabithumb.jtoml.except.TomlException;
import io.github.wasabithumb.jtoml.key.TomlKey;
import io.github.wasabithumb.jtoml.value.TomlValue;
import io.github.wasabithumb.jtoml.value.array.TomlArray;
import io.github.wasabithumb.jtoml.value.primitive.TomlPrimitive;
import io.github.wasabithumb.jtoml.value.table.TomlTable;
@@ -12,6 +13,7 @@ import java.nio.file.Files;
import java.io.IOException;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@@ -20,7 +22,7 @@ public record AppConfig(
int columns,
int rows,
long maxScrollback,
String shell,
List<String> shell,
String fontFamily,
double fontSize,
double windowWidth,
@@ -38,12 +40,14 @@ public record AppConfig(
"toggle_floating",
"new_pane",
"next_floating",
"promote_floating",
"close_pane",
"new_tab",
"previous_tab",
"next_tab",
"open_font_selector",
"open_scrollback"
"open_scrollback",
"paste"
);
public static AppConfig load() {
@@ -60,7 +64,7 @@ public record AppConfig(
intValue(document, "terminal.columns", defaults.columns),
intValue(document, "terminal.rows", defaults.rows),
longValue(document, "terminal.max_scrollback", defaults.maxScrollback),
stringValue(document, "terminal.shell", defaults.shell),
stringListValue(document, "terminal.shell", defaults.shell),
stringValue(document, "terminal.font_family", defaults.fontFamily),
doubleValue(document, "terminal.font_size", defaults.fontSize),
doubleValue(document, "window.width", defaults.windowWidth),
@@ -97,12 +101,14 @@ public record AppConfig(
Map.entry("toggle_floating", KeyBinding.parse("ALT+F")),
Map.entry("new_pane", KeyBinding.parse("ALT+N")),
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("new_tab", KeyBinding.parse("ALT+A")),
Map.entry("previous_tab", KeyBinding.parse("ALT+SHIFT+H")),
Map.entry("next_tab", KeyBinding.parse("ALT+SHIFT+L")),
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"))
)
);
}
@@ -136,8 +142,10 @@ public record AppConfig(
return Path.of(System.getProperty("user.home"), ".config", "jprototerm", "config.toml");
}
private static String defaultShell() {
return "/bin/bash";
private static List<String> defaultShell() {
// The executable plus its arguments, spawned verbatim. -i makes bash interactive; a
// different shell can use whatever flags it needs (or none) by editing this list.
return List.of("/bin/bash", "-i");
}
private static String defaultScrollbackEditorCommand() {
@@ -184,7 +192,7 @@ public record AppConfig(
builder.append("columns = ").append(columns).append('\n');
builder.append("rows = ").append(rows).append('\n');
builder.append("max_scrollback = ").append(maxScrollback).append('\n');
builder.append("shell = ").append(quoted(shell)).append('\n');
builder.append("shell = ").append(quotedList(shell)).append('\n');
builder.append("font_family = ").append(quoted(fontFamily)).append('\n');
builder.append("font_size = ").append(trimDouble(fontSize)).append("\n\n");
builder.append("[window]\n");
@@ -209,6 +217,17 @@ public record AppConfig(
return builder.toString();
}
private static String quotedList(List<String> values) {
StringBuilder builder = new StringBuilder("[");
for (int i = 0; i < values.size(); i++) {
if (i > 0) {
builder.append(", ");
}
builder.append(quoted(values.get(i)));
}
return builder.append("]").toString();
}
private static String quoted(String value) {
return "\"" + value
.replace("\\", "\\\\")
@@ -268,6 +287,26 @@ public record AppConfig(
return primitive == null ? fallback : primitive.asString();
}
/** Reads a TOML array of strings (e.g. {@code shell = ["/bin/bash", "-i"]}), or the fallback. */
private static List<String> stringListValue(TomlTable table, String key, List<String> fallback) {
TomlValue value = table.get(key);
if (value == null || !value.isArray()) {
return fallback;
}
List<String> result = new ArrayList<>();
for (TomlValue element : value.asArray()) {
if (element.isPrimitive()) {
try {
result.add(element.asPrimitive().asString());
} catch (RuntimeException ignored) {
// Skip non-string entries; a shell command line is a list of strings.
}
}
}
// An empty or all-invalid array would mean "no program to run"; keep the default instead.
return result.isEmpty() ? fallback : List.copyOf(result);
}
private static int intValue(TomlTable table, String key, int fallback) {
TomlPrimitive primitive = primitive(table, key);
if (primitive == null) {

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<>();
@@ -58,11 +61,13 @@ public final class Compositor {
private long lastContentVersion = Long.MIN_VALUE;
private boolean mouseButtonPressed;
private MouseButton pressedButton = MouseButton.UNKNOWN;
// Run when the last pane closes (so the window can quit). No-op until Main sets it.
private Runnable onEmpty = () -> {};
public Compositor(AppConfig config, TerminalMetrics metrics) {
this.config = config;
this.metrics = metrics;
tabs.add(new Tab(config, metrics));
tabs.add(new Tab(config, metrics, this::closePane));
canvas.setFocusTraversable(true);
canvas.setOnMousePressed(this::handleMousePressed);
canvas.setOnMouseReleased(this::handleMouseReleased);
@@ -75,6 +80,16 @@ public final class Compositor {
return canvas;
}
/** Sets the callback run when the last pane closes (e.g. to quit the application). */
public void setOnEmpty(Runnable onEmpty) {
this.onEmpty = onEmpty;
}
/** 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 +128,29 @@ 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;
}
/**
* Opens a floating pane running {@code command} directly (auto-closing when it exits), makes it
* active, and returns it (null when no tab exists).
*/
public TerminalPane openFloatingPane(String command) {
if (isEmpty()) {
return null;
}
TerminalPane pane = currentTab().createFloatingPane(command);
layoutVersion++;
return pane;
}
public void nextFloatingPane() {
if (isEmpty()) {
return;
@@ -121,24 +159,58 @@ public final class Compositor {
layoutVersion++;
}
public void promoteActiveFloating() {
if (isEmpty()) {
return;
}
currentTab().promoteActiveFloating();
layoutVersion++;
}
public void closeActivePane() {
if (isEmpty()) {
return;
}
currentTab().closeActivePane();
if (currentTab().isEmpty()) {
// Closing a tab's last pane closes the tab. When no tabs remain the surface is
// empty and Main quits.
tabs.remove(currentTabIndex);
if (currentTabIndex >= tabs.size()) {
TerminalPane active = currentTab().activePane();
if (active != null) {
closePane(active);
}
}
/**
* Closes a specific pane, wherever it lives. Driven both by the key-bound close (via
* {@link #closeActivePane()}) and by a pane whose process exited on its own. Drops the owning
* tab if it becomes empty, and fires {@link #setOnEmpty} when the last pane is gone. Must run on
* the FX thread.
*/
public void closePane(TerminalPane pane) {
for (int i = 0; i < tabs.size(); i++) {
Tab tab = tabs.get(i);
if (tab.closePane(pane)) {
if (tab.isEmpty()) {
// Closing a tab's last pane closes the tab. Keep currentTabIndex pointing at the
// same tab (or clamp it when the current/last tab went away).
tabs.remove(i);
if (i < currentTabIndex) {
currentTabIndex--;
} else if (currentTabIndex >= tabs.size()) {
currentTabIndex = Math.max(0, tabs.size() - 1);
}
}
layoutVersion++;
if (isEmpty()) {
onEmpty.run();
}
return;
}
}
}
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, this::closePane));
currentTabIndex = tabs.size() - 1;
layoutVersion++;
}
@@ -246,6 +318,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 +335,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);
}
gc.restore();
collectKittyImages(target, snapshot, px, py);
}
@Override
@@ -93,10 +94,15 @@ 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 {
// 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;
}
RenderStateSnapshot snapshot = target.snapshot();
int dirty = snapshot == null ? DIRTY_FULL : snapshot.dirty();
if (dirty == DIRTY_FULL) {
@@ -105,159 +111,13 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
software.paintDirty(gc, target, snapshot, px, py, width, height, active);
}
// dirty == FALSE: nothing visible changed.
}
gc.restore();
kittyImageNodes = List.of();
}
// 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) {
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);
@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

@@ -9,6 +9,9 @@ import java.lang.foreign.MemorySegment;
import java.lang.foreign.SymbolLookup;
import java.lang.foreign.ValueLayout;
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.List;
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_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 GRANTPT = handle("grantpt", 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. */
public void setWinSize(int columns, int rows) {
if (closed) {

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;
@@ -10,15 +11,18 @@ import javafx.scene.control.Dialog;
import javafx.scene.control.Label;
import javafx.scene.control.Spinner;
import javafx.scene.control.SpinnerValueFactory;
import javafx.scene.input.Clipboard;
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;
@@ -31,8 +35,14 @@ public final class Main extends Application {
metrics = new TerminalMetrics(config.fontFamily(), config.fontSize());
compositor = new Compositor(config, metrics);
// When the last pane closes — whether via the close-pane key or because a pane's process
// exited on its own — tear down and quit.
compositor.setOnEmpty(() -> {
compositor.close();
Platform.exit();
});
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 +62,37 @@ 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();
// 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();
}
// 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) {
if (config.keybindings().get("navigate_left").matches(event)) {
compositor.navigate(Direction.LEFT);
@@ -78,14 +115,13 @@ public final class Main extends Application {
} else if (config.keybindings().get("next_floating").matches(event)) {
compositor.nextFloatingPane();
event.consume();
} else if (config.keybindings().get("promote_floating").matches(event)) {
compositor.promoteActiveFloating();
event.consume();
} else if (config.keybindings().get("close_pane").matches(event)) {
// Closing the last pane quits the app, via the compositor's onEmpty hook.
compositor.closeActivePane();
event.consume();
if (compositor.isEmpty()) {
// Closing the last pane quits the app.
compositor.close();
Platform.exit();
}
} else if (config.keybindings().get("new_tab").matches(event)) {
compositor.newTab();
event.consume();
@@ -101,6 +137,9 @@ public final class Main extends Application {
} else if (config.keybindings().get("open_scrollback").matches(event)) {
openScrollbackInEditor();
event.consume();
} else if (config.keybindings().get("paste").matches(event)) {
pasteFromClipboard();
event.consume();
} else {
String encoded = KeyEncoder.encode(event);
if (encoded != null) {
@@ -122,6 +161,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() {
Dialog<ButtonType> dialog = new Dialog<>();
dialog.setTitle("Font");
@@ -167,11 +213,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");
// Run the editor as the floating pane's process (via /bin/sh -c) rather than typing the
// command into an interactive shell. The command runs deterministically from the start
// — no shell startup/rc race — and the pane auto-closes when the editor exits.
compositor.openFloatingPane(scrollbackEditorCommand(file));
} catch (IOException ex) {
System.err.println("Could not open scrollback in editor: " + ex.getMessage());
}

View File

@@ -2,6 +2,7 @@ package com.gregor.jprototerm;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
@@ -20,8 +21,41 @@ public final class ShellSession implements AutoCloseable {
});
}
public static ShellSession start(String shell, Map<String, String> envOverride, TerminalPane pane, int columns, int rows) {
/**
* Starts the configured shell. {@code shellCommand} is the executable plus its arguments (e.g.
* {@code ["/bin/bash", "-i"]}), spawned verbatim — any interactive flag is the user's choice in
* config, not assumed here.
*/
public static ShellSession start(List<String> shellCommand, Map<String, String> envOverride, TerminalPane pane,
int columns, int rows, String workingDirectory) {
try {
return spawn(shellCommand.toArray(new String[0]), envOverride, columns, rows, workingDirectory);
} catch (RuntimeException ex) {
pane.write("failed to start shell: " + ex.getMessage() + "\r\n");
throw new IllegalStateException("Could not start shell " + String.join(" ", shellCommand), ex);
}
}
/**
* Starts a session whose first and only process is {@code /bin/sh -c command}, so the program
* runs deterministically from the start rather than being typed into an interactive shell —
* there is no startup/rc race to lose or mangle the input. When the process exits the pty
* closes and the pane auto-closes. {@code /bin/sh -c} is used (not the user's configured shell)
* because it is the portable way to run a command line and does not depend on shell-specific
* flags. {@code command} must not be null.
*/
public static ShellSession startCommand(Map<String, String> envOverride, TerminalPane pane,
int columns, int rows, String workingDirectory, String command) {
try {
return spawn(new String[] {"/bin/sh", "-c", command}, envOverride, columns, rows, workingDirectory);
} catch (RuntimeException ex) {
pane.write("failed to run command: " + ex.getMessage() + "\r\n");
throw new IllegalStateException("Could not run command: " + command, ex);
}
}
private static ShellSession spawn(String[] argv, Map<String, String> envOverride,
int columns, int rows, String workingDirectory) {
Map<String, String> environment = new HashMap<>(System.getenv());
environment.put("TERM", "xterm-kitty");
environment.put("COLORTERM", "truecolor");
@@ -29,16 +63,12 @@ public final class ShellSession implements AutoCloseable {
environment.putAll(envOverride);
LinuxPty pty = LinuxPty.spawn(
new String[] {shell, "-i"},
argv,
environment,
System.getProperty("user.home"));
workingDirectory != null ? workingDirectory : System.getProperty("user.home"));
ShellSession session = new ShellSession(pty);
session.resize(columns, rows);
return session;
} catch (RuntimeException ex) {
pane.write("failed to start shell: " + ex.getMessage() + "\r\n");
throw new IllegalStateException("Could not start shell " + shell, ex);
}
}
/**
@@ -69,6 +99,11 @@ public final class ShellSession implements AutoCloseable {
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) {
if (closed) {
return;
@@ -113,6 +148,11 @@ public final class ShellSession implements AutoCloseable {
pane.write("\r\nshell output stopped: " + ex.getMessage() + "\r\n");
}
}
// The stream ended without us closing the session, so the process exited on its own (the
// user typed `exit`, or a one-shot command pane finished). Let the pane tear itself down.
if (!closed) {
pane.handleSessionExit();
}
}
@Override

View File

@@ -7,6 +7,7 @@ import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.concurrent.atomic.AtomicLong;
import java.util.function.Consumer;
import java.util.stream.Stream;
/**
@@ -19,13 +20,19 @@ import java.util.stream.Stream;
final class Tab implements AutoCloseable {
private final AppConfig config;
private final TerminalMetrics metrics;
// Notified (on the FX thread) when one of this tab's panes' process exits on its own, so the
// compositor can close that pane and reap the tab/app if it was the last one.
private final Consumer<TerminalPane> onPaneExit;
private final List<TerminalPane> tiled = new ArrayList<>();
private final List<TerminalPane> floating = new ArrayList<>();
private boolean floatingVisible;
private TerminalPane active;
private final String initialWorkingDirectory;
// The floating pane to re-focus when the group is shown again, and to prefer when promoting
// after the last tiled pane closes.
private TerminalPane lastFocusedFloating;
// The tiled pane to re-focus when the floating group is hidden.
private TerminalPane lastFocusedTiled;
// Last laid-out size, so a newly opened pane can be created at roughly its eventual rect
// (and thus grid). Seeded from the configured window size for the first pane, which is
// opened before any layout pass runs.
@@ -36,11 +43,22 @@ final class Tab implements AutoCloseable {
// tab's value each frame as an O(1) "anything to repaint?" check.
private final AtomicLong contentVersion = new AtomicLong();
Tab(AppConfig config, TerminalMetrics metrics) {
Tab(AppConfig config, TerminalMetrics metrics, Consumer<TerminalPane> onPaneExit) {
this(config, metrics, null, onPaneExit);
}
/**
* 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,
Consumer<TerminalPane> onPaneExit) {
this.config = config;
this.metrics = metrics;
this.onPaneExit = onPaneExit;
this.lastWidth = config.windowWidth();
this.lastHeight = config.windowHeight();
this.initialWorkingDirectory = initialWorkingDirectory;
TerminalPane first = openPane(false);
tiled.add(first);
active = first;
@@ -183,7 +201,7 @@ final class Tab implements AutoCloseable {
if (floatingVisible) {
floatingVisible = false;
if (floating.contains(active)) {
setActive(tiled.get(0));
setActive(tiled.contains(lastFocusedTiled) ? lastFocusedTiled : tiled.get(0));
}
} else {
floatingVisible = true;
@@ -212,20 +230,52 @@ final class Tab implements AutoCloseable {
setActive(floating.get((current + 1 + floating.size()) % floating.size()));
}
void closeActivePane() {
TerminalPane closing = active;
boolean wasFloating = floating.remove(closing);
if (!wasFloating) {
tiled.remove(closing);
/** 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() {
if (active != null) {
closePane(active);
}
}
/**
* Closes {@code closing} (the active pane on a key-bound close, or any pane whose process just
* exited) and re-selects the active pane only when the one that closed was active. Returns
* false when the pane is not in this tab. Leaves the tab empty ({@code active == null}) when its
* last pane closes, so the compositor can drop it.
*/
boolean closePane(TerminalPane closing) {
boolean wasFloating = floating.remove(closing);
boolean wasTiled = !wasFloating && tiled.remove(closing);
if (!wasFloating && !wasTiled) {
return false; // not one of this tab's panes (already gone)
}
boolean wasActive = closing == active;
if (closing == lastFocusedFloating) {
lastFocusedFloating = null;
}
if (closing == lastFocusedTiled) {
lastFocusedTiled = null;
}
closing.close();
if (tiled.isEmpty() && floating.isEmpty()) {
active = null; // tab is now empty; the compositor drops it
return;
return true;
}
// Always keep a tiled base: if the last tiled pane just closed, promote a floating one
@@ -237,31 +287,54 @@ final class Tab implements AutoCloseable {
floating.remove(promote);
tiled.add(promote);
if (promote == lastFocusedFloating) {
lastFocusedFloating = null;
if (!floating.isEmpty()) {
lastFocusedFloating = floating.isEmpty() ? null : floating.get(nextFocussed);
}
}
}
if (floating.isEmpty()) {
floatingVisible = false;
}
setActive(wasFloating && floatingVisible ? floating.get(floating.size() - 1) : tiled.get(0));
// Only the active pane closing forces a re-selection; closing a background pane (e.g. one
// whose process exited while another is focused) leaves focus where it is.
if (wasActive) {
setActive(wasFloating && floatingVisible
? floating.get(floating.size() - 1)
: tiled.contains(lastFocusedTiled) ? lastFocusedTiled : tiled.get(0));
}
return true;
}
private void setActive(TerminalPane pane) {
active = pane;
if (floating.contains(pane)) {
lastFocusedFloating = pane;
} else if (tiled.contains(pane)) {
lastFocusedTiled = pane;
// A tiled pane gaining focus hides the floating group: leaving it shown while a tiled
// pane is active strands focus behind the overlay and disables navigation.
floatingVisible = false;
}
}
private void createFloatingPane() {
TerminalPane pane = openPane(true);
TerminalPane createFloatingPane() {
return addFloating(openPane(true));
}
/**
* Opens a floating pane whose process runs {@code command} directly (auto-closing when it
* exits), rather than an interactive shell. Used for one-shot panes like the scrollback editor.
*/
TerminalPane createFloatingPane(String command) {
double[] size = paneSize(true);
return addFloating(register(TerminalPane.createWithCommand(
config, metrics, this::markContentChanged, size[0], size[1], paneWorkingDirectory(), command)));
}
private TerminalPane addFloating(TerminalPane pane) {
floating.add(pane);
floatingVisible = true;
setActive(pane);
return pane;
}
private boolean navigateFloatingStack(Direction direction) {
@@ -296,18 +369,31 @@ final class Tab implements AutoCloseable {
}
private TerminalPane openPane(boolean asFloating) {
double availHeight = lastHeight - lastTopInset;
double widthPx;
double heightPx;
if (asFloating) {
widthPx = Math.max(420, lastWidth * 0.58);
heightPx = Math.max(260, availHeight * 0.58);
} else {
// A new tiled pane joins the row, so each gets 1/(n+1) of the width.
widthPx = lastWidth / (tiled.size() + 1);
heightPx = availHeight;
double[] size = paneSize(asFloating);
return register(TerminalPane.create(
config, metrics, this::markContentChanged, size[0], size[1], paneWorkingDirectory()));
}
return TerminalPane.create(config, metrics, this::markContentChanged, widthPx, heightPx);
private double[] paneSize(boolean asFloating) {
double availHeight = lastHeight - lastTopInset;
if (asFloating) {
return new double[] {Math.max(420, lastWidth * 0.58), Math.max(260, availHeight * 0.58)};
}
// A new tiled pane joins the row, so each gets 1/(n+1) of the width.
return new double[] {lastWidth / (tiled.size() + 1), availHeight};
}
// Open a 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.
private String paneWorkingDirectory() {
return active != null ? active.currentWorkingDirectory() : initialWorkingDirectory;
}
// Wire the pane's self-exit (process ended) back to the compositor so it gets reaped.
private TerminalPane register(TerminalPane pane) {
pane.setOnExit(() -> onPaneExit.accept(pane));
return pane;
}
private static boolean directionFilter(Direction direction, TerminalPane current, TerminalPane candidate) {

View File

@@ -12,6 +12,7 @@ import dev.jlibghostty.RenderStateSnapshot;
import dev.jlibghostty.ScrollViewport;
import dev.jlibghostty.Terminal;
import dev.jlibghostty.TerminalOptions;
import javafx.application.Platform;
import javafx.scene.canvas.GraphicsContext;
import javafx.scene.shape.Shape;
@@ -39,6 +40,10 @@ public final class TerminalPane implements AutoCloseable, RenderTarget {
private final RenderState renderState = new RenderState();
private RenderStateSnapshot cachedSnapshot;
private ShellSession session;
// Run once (on the FX thread) when this pane's process exits on its own, so the owning tab can
// remove it. Set by the Tab that creates the pane; null until then.
private Runnable onExit;
private boolean exited;
// Clip region for rendering (rect minus the panes covering this one), set at layout time;
// null means clip to the plain bounds. See RenderTarget#clip().
private Shape clip;
@@ -69,9 +74,32 @@ 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
* 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
* {@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) {
TerminalPane pane = newPane(config, metrics, onContentChange, widthPx, heightPx);
pane.attach(ShellSession.start(config.shell(), config.envOverride(), pane, pane.columns, pane.rows,
workingDirectory));
return pane;
}
/**
* Opens a pane whose process runs {@code command} directly (via {@code /bin/sh -c}) instead of
* an interactive shell. The pane auto-closes when the command exits. See
* {@link ShellSession#startCommand}.
*/
public static TerminalPane createWithCommand(AppConfig config, TerminalMetrics metrics, Runnable onContentChange,
double widthPx, double heightPx, String workingDirectory, String command) {
TerminalPane pane = newPane(config, metrics, onContentChange, widthPx, heightPx);
pane.attach(ShellSession.startCommand(config.envOverride(), pane, pane.columns, pane.rows,
workingDirectory, command));
return pane;
}
private static TerminalPane newPane(AppConfig config, TerminalMetrics metrics, Runnable onContentChange,
double widthPx, double heightPx) {
int columns = widthPx > 0 ? metrics.columnsFor(widthPx) : config.columns();
int rows = heightPx > 0 ? metrics.rowsFor(heightPx) : config.rows();
Terminal terminal = Ghostty.open(new TerminalOptions(columns, rows, config.maxScrollback()));
@@ -79,10 +107,31 @@ public final class TerminalPane implements AutoCloseable, RenderTarget {
TerminalPane pane = new TerminalPane(terminal, metrics, config.kittyGraphics(), onContentChange,
new GhosttyTerminalRenderer(metrics), columns, rows);
pane.refresh();
pane.attach(ShellSession.start(config.shell(), config.envOverride(), pane, columns, rows));
return pane;
}
/** Sets the callback run when this pane's process exits on its own (see {@link #handleSessionExit}). */
public void setOnExit(Runnable onExit) {
this.onExit = onExit;
}
/**
* Called from the shell reader thread when the pty stream ends without us closing it (the
* process exited). Hops to the FX thread and fires {@link #onExit} once, so tab/compositor
* mutation happens on the thread that owns the layout.
*/
void handleSessionExit() {
Platform.runLater(() -> {
if (exited) {
return;
}
exited = true;
if (onExit != null) {
onExit.run();
}
});
}
private void attach(ShellSession session) {
this.session = session;
terminal.setPtyWriter(bytes -> {
@@ -115,6 +164,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) {
synchronized (terminal) {
mouseEncoder.syncFromTerminal(terminal);
@@ -192,6 +254,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()}). */
public long contentVersion() {
return contentVersion.get();
@@ -294,6 +362,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);

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