42 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
8ac07218fe send backtab (ESC [ Z) for Shift+Tab
KeyEncoder mapped TAB to a plain tab regardless of Shift, so Shift+Tab sent the
same byte as Tab. Apps that use backtab for reverse navigation (fish completion
menu, helix theme picker) never saw it. Emit CSI Z when Shift is held.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
(cherry picked from commit 93d53fcef6)
2026-05-31 22:34:45 +02:00
6bf69e8572 update jlibghostty 2026-05-31 22:23:14 +02:00
07585a314c Upload only changed rows to GPU and hoist glyph bounds checks
paintIncremental's per-row dirty work was negated by present() calling
PixelBuffer.updateBuffer(null), which re-uploads the whole pane texture
every frame. Track the vertical band of buffer rows written since the
last present and hand that to updateBuffer so only changed rows upload.
The border is now drawn without extending the dirty band (its pixels are
unchanged between incremental frames). Also clamp blitGlyph's rectangle
once instead of bounds-checking every glyph pixel in the inner loop.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 22:12:45 +02:00
bdb33450f1 update jlibghostty 2026-05-31 21:51:57 +02:00
Gregor Lohaus
2c020bb6cb fix race condition 2026-05-31 18:12:44 +02:00
Gregor Lohaus
71a533ec34 clear context new fix 2026-05-31 18:05:57 +02:00
Gregor Lohaus
54b08c7eca revert failed fix 2026-05-31 18:00:49 +02:00
Gregor Lohaus
2fcdb286af Fixed the partial-dirty blanking regression 2026-05-31 17:59:26 +02:00
Gregor Lohaus
e6848ec684 revert failed fixed 2026-05-31 17:56:36 +02:00
Gregor Lohaus
38822d66b8 Fixed the partial-dirty blanking regression 2026-05-31 17:51:53 +02:00
Gregor Lohaus
586150de59 Fixed the partial-dirty blanking regression 2026-05-31 17:48:04 +02:00
Gregor Lohaus
494d2c40cf pixel buffer, scroll inference 2026-05-31 17:41:33 +02:00
Gregor Lohaus
a99cbdc61a revert row diffing 2026-05-31 17:20:13 +02:00
Gregor Lohaus
86f7174eee row diffing 2026-05-31 17:14:07 +02:00
Gregor Lohaus
137db24023 refert safe batching 2026-05-31 17:04:17 +02:00
Gregor Lohaus
d8faf8d6df safe batching 2026-05-31 17:02:44 +02:00
Gregor Lohaus
9903e9174f fix cell shifting regression 2026-05-31 16:58:11 +02:00
Gregor Lohaus
9b7247a4e0 small improvements 2026-05-31 16:50:12 +02:00
29 changed files with 2298 additions and 1440 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

View File

@@ -1,3 +1,4 @@
.gradle
result
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=/nix/store/c3pl7bqrx3d2rc3dh98z6yaj0mv1p52g-openjdk-21.0.10+7/lib/openjdk
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
@@ -106,6 +108,7 @@ navigate_up = "ALT+K"
navigate_right = "ALT+L"
toggle_floating = "ALT+F"
new_pane = "ALT+N"
next_floating = "ALT+F12"
close_pane = "ALT+X"
new_tab = "ALT+A"
previous_tab = "ALT+SHIFT+H"
@@ -120,6 +123,7 @@ open_scrollback = "ALT+S"
- `Alt+n`: new pane — a floating pane when floating panes are shown, otherwise a new tiled
pane (tiled panes are split equally across the width)
- `Alt+f`: show or hide all floating panes
- `Alt+F12`: cycle floating panes
- `Alt+x`: close the active pane; closing a tab's last pane closes the tab, and closing the
last pane of the last tab quits
- `Alt+a`: new tab

View File

@@ -25,7 +25,9 @@ navigate_down = "ALT+J"
navigate_up = "ALT+K"
navigate_right = "ALT+L"
toggle_floating = "ALT+F"
new_pane = "ALT+N"
new_floating = "ALT+SHIFT+F"
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": 1780079529,
"narHash": "sha256-AxlGTL8c5xSLcQHvWlm994IdOqxsN8iKrA02Cpv7vso=",
"lastModified": 1780363584,
"narHash": "sha256-BN6kwSBnlavuySut6vvLsfwkfKBjrPvFgJeeMMTXNtg=",
"ref": "refs/heads/main",
"rev": "68121d50b52fb56038871c97c97e7a12ffe987c2",
"revCount": 20,
"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,
@@ -37,12 +39,15 @@ public record AppConfig(
"navigate_right",
"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() {
@@ -59,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),
@@ -95,12 +100,15 @@ public record AppConfig(
Map.entry("navigate_right", KeyBinding.parse("ALT+L")),
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"))
)
);
}
@@ -134,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() {
@@ -182,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");
@@ -207,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("\\", "\\\\")
@@ -266,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

@@ -4,79 +4,96 @@ import dev.jlibghostty.KeyModifiers;
import dev.jlibghostty.MouseButton;
import dev.jlibghostty.MouseEncoderSize;
import dev.jlibghostty.MouseInput;
import javafx.geometry.Pos;
import javafx.scene.Parent;
import javafx.scene.control.Label;
import javafx.geometry.VPos;
import javafx.scene.Node;
import javafx.scene.canvas.Canvas;
import javafx.scene.canvas.GraphicsContext;
import javafx.scene.input.InputEvent;
import javafx.scene.input.MouseEvent;
import javafx.scene.input.ScrollEvent;
import javafx.scene.input.ScrollEvent.VerticalTextScrollUnits;
import javafx.scene.layout.Background;
import javafx.scene.layout.BackgroundFill;
import javafx.scene.layout.CornerRadii;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Pane;
import javafx.scene.paint.Color;
import javafx.scene.text.Font;
import javafx.scene.text.FontSmoothingType;
import javafx.scene.text.TextAlignment;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.Objects;
/**
* Owns the window's tabs and exposes the terminal surface as a JavaFX scene graph. Each
* terminal pane is mounted as its own node, so JavaFX child order handles stacking and clipping
* between panes. The pane model still owns terminals, ptys, cell geometry, and snapshots; this
* class handles tab/pane lifecycle, layout, focus, mouse routing, and frame scheduling.
* Owns the window's tabs and drives rendering and input. It composites only the current tab:
* each frame it lays that tab out, paints the panes bottom-to-top (so the active floating pane
* lands on top) and lets each pane paint its own content, clipped to the region the layout gave
* it. The cross-pane concerns live here — the dirty-frame bookkeeping, the tab strip, routing
* mouse/scroll to the pane under the pointer, and the tab/pane lifecycle that {@link Main}'s key
* bindings invoke.
*/
public final class Compositor {
// Canvas background shown wherever no pane covers (gaps). Painted on a full recomposite.
private static final Color GAP_BACKGROUND = Color.rgb(16, 16, 18);
private static final Color TAB_TEXT = Color.rgb(225, 229, 235);
private static final Color TAB_INACTIVE_TEXT = Color.rgb(128, 136, 148);
private static final Color TAB_ACTIVE_BACKGROUND = Color.rgb(45, 55, 72);
private static final Color TAB_INACTIVE_BACKGROUND = Color.rgb(22, 24, 28);
// Thin tab strip shown at the top when more than one tab is open.
private static final double TAB_BAR_HEIGHT = 22.0;
private final Pane root = new Pane();
private final Pane paneLayer = new Pane();
private final HBox tabBar = new HBox(1.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<>();
private final Map<TerminalPane, TerminalPaneNode> nodes = new HashMap<>();
private int currentTabIndex;
private boolean sceneDirty = true;
// Bumped on any structural change (tab switch, pane add/close/focus/move) so render()
// knows to recomposite. Terminal *content* changes are tracked separately through each
// tab's content version.
private long layoutVersion;
// Last content version drawn to the canvas per pane, so a content frame repaints only
// the panes that actually changed.
private final Map<TerminalPane, Long> paneContentVersion = new HashMap<>();
// Cheap per-frame dirty signal: skip the whole render when none of these changed.
private double lastWidth = -1.0;
private double lastHeight = -1.0;
private String lastFontFamily;
private double lastFontSize = -1.0;
private long lastLayoutVersion = Long.MIN_VALUE;
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));
root.setFocusTraversable(true);
root.setBackground(new Background(new BackgroundFill(GAP_BACKGROUND, CornerRadii.EMPTY, null)));
root.getChildren().setAll(paneLayer, tabBar);
root.setOnMousePressed(event -> root.requestFocus());
tabs.add(new Tab(config, metrics, this::closePane));
canvas.setFocusTraversable(true);
canvas.setOnMousePressed(this::handleMousePressed);
canvas.setOnMouseReleased(this::handleMouseReleased);
canvas.setOnMouseDragged(this::handleMouseDragged);
canvas.setOnMouseMoved(this::handleMouseMoved);
canvas.setOnScroll(this::handleScroll);
}
public Parent node() {
return root;
public Canvas canvas() {
return canvas;
}
public void requestFocus() {
root.requestFocus();
/** 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);
nodes.values().forEach(TerminalPaneNode::discard);
markSceneDirty();
paneContentVersion.clear();
lastWidth = -1.0; // force a redraw on the next frame
}
// ---- Tabs and panes -------------------------------------------------------------
@@ -91,7 +108,7 @@ public final class Compositor {
public void navigate(Direction direction) {
if (!isEmpty() && currentTab().navigate(direction)) {
markSceneDirty();
layoutVersion++;
}
}
@@ -100,7 +117,7 @@ public final class Compositor {
return;
}
currentTab().toggleFloating();
markSceneDirty();
layoutVersion++;
}
public void createPane() {
@@ -108,40 +125,107 @@ public final class Compositor {
return;
}
currentTab().createPane();
markSceneDirty();
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;
}
currentTab().nextFloatingPane();
layoutVersion++;
}
public void promoteActiveFloating() {
if (isEmpty()) {
return;
}
currentTab().promoteActiveFloating();
layoutVersion++;
}
public void closeActivePane() {
if (isEmpty()) {
return;
}
currentTab().closeActivePane();
if (currentTab().isEmpty()) {
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);
}
}
markSceneDirty();
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;
markSceneDirty();
layoutVersion++;
}
public void nextTab() {
if (tabs.size() > 1) {
currentTabIndex = (currentTabIndex + 1) % tabs.size();
markSceneDirty();
layoutVersion++;
}
}
public void previousTab() {
if (tabs.size() > 1) {
currentTabIndex = (currentTabIndex - 1 + tabs.size()) % tabs.size();
markSceneDirty();
layoutVersion++;
}
}
@@ -150,8 +234,6 @@ public final class Compositor {
tab.close();
}
tabs.clear();
nodes.clear();
paneLayer.getChildren().clear();
}
private Tab currentTab() {
@@ -162,140 +244,143 @@ public final class Compositor {
return tabs.isEmpty() ? List.of() : currentTab().panes();
}
private List<TerminalPane> allOpenPanes() {
List<TerminalPane> panes = new ArrayList<>();
for (Tab tab : tabs) {
panes.addAll(tab.allPanes());
}
return panes;
}
private boolean isActive(TerminalPane pane) {
return !tabs.isEmpty() && currentTab().isActive(pane);
}
private void focus(TerminalPane pane) {
if (!tabs.isEmpty() && currentTab().focus(pane)) {
markSceneDirty();
layoutVersion++;
}
}
// ---- Rendering ------------------------------------------------------------------
public void render() {
double width = root.getWidth();
double height = root.getHeight();
long contentVersion = tabs.isEmpty() ? 0 : currentTab().contentVersion();
boolean geometryChanged = width != lastWidth || height != lastHeight;
boolean contentChanged = contentVersion != lastContentVersion;
boolean syncScene = sceneDirty || geometryChanged;
if (!syncScene && !contentChanged) {
return;
switch (nextFrameType()) {
case IDLE -> { }
case LAYOUT -> renderLayoutFrame();
case CONTENT -> renderContentFrame();
}
}
// Classify this frame and commit the change trackers. A layout change (size, font,
// tab/pane set, z-order, active pane) needs a full recomposite; otherwise a change to the
// current tab's content version repaints only the panes that changed; otherwise nothing
// changed and the frame is idle.
private FrameType nextFrameType() {
double width = canvas.getWidth();
double height = canvas.getHeight();
long contentVersion = tabs.isEmpty() ? 0 : currentTab().contentVersion();
boolean layoutChanged = width != lastWidth || height != lastHeight
|| metrics.fontSize() != lastFontSize || !Objects.equals(metrics.fontFamily(), lastFontFamily)
|| layoutVersion != lastLayoutVersion;
boolean contentChanged = contentVersion != lastContentVersion;
lastWidth = width;
lastHeight = height;
lastFontFamily = metrics.fontFamily();
lastFontSize = metrics.fontSize();
lastLayoutVersion = layoutVersion;
lastContentVersion = contentVersion;
sceneDirty = false;
if (syncScene) {
syncSceneGraph(width, height);
if (layoutChanged) {
return FrameType.LAYOUT;
}
renderVisiblePanes();
if (contentChanged) {
return FrameType.CONTENT;
}
return FrameType.IDLE;
}
private void markSceneDirty() {
sceneDirty = true;
}
private void syncSceneGraph(double width, double height) {
// Full recomposite onto the retained canvas: lay the tab out, clear to the gap colour,
// draw the tab strip, then paint every pane bottom-to-top (panes() puts the active
// floating pane last == on top).
private void renderLayoutFrame() {
double topInset = tabs.size() > 1 ? TAB_BAR_HEIGHT : 0.0;
paneLayer.resizeRelocate(0.0, 0.0, width, height);
updateTabBar(width, topInset);
if (!tabs.isEmpty()) {
currentTab().layout(width, height, topInset);
currentTab().layout(canvas.getWidth(), canvas.getHeight(), topInset);
}
List<TerminalPane> panes = currentPanes();
retainNodes(allOpenPanes());
List<TerminalPaneNode> orderedNodes = new ArrayList<>(panes.size());
// Sync each pane's ghostty grid to its (possibly new) bounds; a no-op when unchanged.
for (TerminalPane pane : panes) {
pane.fitToBounds();
TerminalPaneNode node = nodeFor(pane);
node.resizeRelocate(Math.round(pane.x()), Math.round(pane.y()), pane.width(), pane.height());
orderedNodes.add(node);
}
paneLayer.getChildren().setAll(orderedNodes);
}
private void renderVisiblePanes() {
for (TerminalPane pane : currentPanes()) {
TerminalPaneNode node = nodes.get(pane);
if (node != null) {
node.renderIncremental(isActive(pane));
GraphicsContext gc = beginFrame();
paneContentVersion.keySet().retainAll(panes);
gc.setFill(GAP_BACKGROUND);
gc.fillRect(0, 0, canvas.getWidth(), canvas.getHeight());
if (topInset > 0.0) {
drawTabBar(gc, canvas.getWidth(), topInset);
}
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
// clips itself to its rect minus the panes above it, so a lower pane's repaint can't bleed
// over one stacked on top — no restore pass needed. Bounds and grids can't have changed
// without a layout frame, so a content frame reuses the existing layout untouched.
private void renderContentFrame() {
List<TerminalPane> panes = currentPanes();
GraphicsContext gc = beginFrame();
for (TerminalPane pane : panes) {
Long drawn = paneContentVersion.get(pane);
if (drawn != null && drawn == pane.contentVersion()) {
continue;
}
paneContentVersion.put(pane, pane.paintIncremental(gc, isActive(pane)));
imageOverlay.updatePane(pane);
}
}
private TerminalPaneNode nodeFor(TerminalPane pane) {
return nodes.computeIfAbsent(pane, this::createNode);
private GraphicsContext beginFrame() {
GraphicsContext gc = canvas.getGraphicsContext2D();
gc.setFontSmoothingType(FontSmoothingType.LCD); // the per-cell renderer relies on LCD
return gc;
}
private TerminalPaneNode createNode(TerminalPane pane) {
TerminalPaneNode node = new TerminalPaneNode(pane, metrics);
node.setOnMousePressed(event -> handleMousePressed(pane, event));
node.setOnMouseReleased(event -> handleMouseReleased(pane, event));
node.setOnMouseDragged(event -> handleMouseDragged(pane, event));
node.setOnMouseMoved(event -> handleMouseMoved(pane, event));
node.setOnScroll(event -> handleScroll(pane, event));
return node;
}
// Thin tab strip: one equal-width segment per tab, the current one highlighted, with a
// small 1-based number centred in each segment.
private void drawTabBar(GraphicsContext gc, double width, double barHeight) {
int count = tabs.size();
Font barFont = Font.font(metrics.fontFamily(), Math.max(9.0, Math.min(13.0, barHeight * 0.62)));
gc.setFont(barFont);
gc.setFontSmoothingType(FontSmoothingType.GRAY);
gc.setTextAlign(TextAlignment.CENTER);
gc.setTextBaseline(VPos.CENTER);
private void retainNodes(List<TerminalPane> openPanes) {
Set<TerminalPane> open = new HashSet<>(openPanes);
nodes.keySet().removeIf(pane -> !open.contains(pane));
}
private void updateTabBar(double width, double barHeight) {
tabBar.setVisible(barHeight > 0.0);
tabBar.setManaged(false);
tabBar.resizeRelocate(0.0, 0.0, width, barHeight);
tabBar.getChildren().clear();
if (barHeight <= 0.0) {
return;
}
double segmentWidth = width / tabs.size();
for (int i = 0; i < tabs.size(); i++) {
Label label = new Label(Integer.toString(i + 1));
double gap = 1.0;
double segmentWidth = width / count;
for (int i = 0; i < count; i++) {
double x = i * segmentWidth;
boolean current = i == currentTabIndex;
label.setAlignment(Pos.CENTER);
label.setTextFill(current ? TAB_TEXT : TAB_INACTIVE_TEXT);
label.setBackground(new Background(new BackgroundFill(
current ? TAB_ACTIVE_BACKGROUND : TAB_INACTIVE_BACKGROUND,
CornerRadii.EMPTY,
null)));
label.setFont(javafx.scene.text.Font.font(metrics.fontFamily(), Math.max(9.0, Math.min(13.0, barHeight * 0.62))));
label.setMinSize(0.0, barHeight);
label.setPrefSize(Math.max(0.0, segmentWidth - 1.0), barHeight);
label.setMaxSize(Double.MAX_VALUE, barHeight);
final int index = i;
label.setOnMousePressed(event -> {
currentTabIndex = index;
markSceneDirty();
root.requestFocus();
event.consume();
});
tabBar.getChildren().add(label);
gc.setFill(current ? Color.rgb(45, 55, 72) : Color.rgb(22, 24, 28));
gc.fillRect(x, 0.0, segmentWidth - gap, barHeight);
gc.setFill(current ? TAB_TEXT : Color.rgb(128, 136, 148));
gc.fillText(Integer.toString(i + 1), x + (segmentWidth - gap) / 2.0, barHeight / 2.0);
}
// Restore the defaults the cell renderer relies on (left-aligned, baseline, LCD).
gc.setTextAlign(TextAlignment.LEFT);
gc.setTextBaseline(VPos.BASELINE);
gc.setFontSmoothingType(FontSmoothingType.LCD);
}
// ---- Input ----------------------------------------------------------------------
private void handleMousePressed(TerminalPane pane, MouseEvent event) {
root.requestFocus();
private void handleMousePressed(MouseEvent event) {
canvas.requestFocus();
TerminalPane pane = paneAt(event.getX(), event.getY());
if (pane == null) {
return;
}
focus(pane);
pressedButton = mouseButton(event);
mouseButtonPressed = true;
@@ -303,38 +388,58 @@ public final class Compositor {
if (target == null) {
return;
}
send(pane, target, MouseInput.press(pressedButton, localX(event.getX(), target), localY(event.getY(), target), modifiers(event)), true, event);
send(pane, target, MouseInput.press(pressedButton, localX(event.getX(), pane, target), localY(event.getY(), pane, target), modifiers(event)), true, event);
}
private void handleMouseReleased(MouseEvent event) {
TerminalPane pane = paneAt(event.getX(), event.getY());
if (pane == null) {
pane = activePane();
}
private void handleMouseReleased(TerminalPane pane, MouseEvent event) {
MouseButton button = pressedButton == MouseButton.UNKNOWN ? mouseButton(event) : pressedButton;
MouseTarget target = mouseTarget(pane);
if (target != null) {
send(pane, target, MouseInput.release(button, localX(event.getX(), target), localY(event.getY(), target), modifiers(event)), false, event);
send(pane, target, MouseInput.release(button, localX(event.getX(), pane, target), localY(event.getY(), pane, target), modifiers(event)), false, event);
}
mouseButtonPressed = false;
pressedButton = MouseButton.UNKNOWN;
}
private void handleMouseDragged(TerminalPane pane, MouseEvent event) {
private void handleMouseDragged(MouseEvent event) {
TerminalPane pane = paneAt(event.getX(), event.getY());
if (pane == null) {
pane = activePane();
}
MouseButton button = pressedButton == MouseButton.UNKNOWN ? mouseButton(event) : pressedButton;
MouseTarget target = mouseTarget(pane);
if (target == null) {
return;
}
send(pane, target, MouseInput.drag(button, localX(event.getX(), target), localY(event.getY(), target), modifiers(event)), true, event);
send(pane, target, MouseInput.drag(button, localX(event.getX(), pane, target), localY(event.getY(), pane, target), modifiers(event)), true, event);
}
private void handleMouseMoved(MouseEvent event) {
TerminalPane pane = paneAt(event.getX(), event.getY());
if (pane == null) {
return;
}
private void handleMouseMoved(TerminalPane pane, MouseEvent event) {
MouseTarget target = mouseTarget(pane);
if (target == null) {
return;
}
send(pane, target, MouseInput.motion(localX(event.getX(), target), localY(event.getY(), target), modifiers(event)), mouseButtonPressed, event);
send(pane, target, MouseInput.motion(localX(event.getX(), pane, target), localY(event.getY(), pane, target), modifiers(event)), mouseButtonPressed, event);
}
private void handleScroll(TerminalPane pane, ScrollEvent event) {
root.requestFocus();
private void handleScroll(ScrollEvent event) {
TerminalPane pane = paneAt(event.getX(), event.getY());
if (pane == null) {
return;
}
canvas.requestFocus();
focus(pane);
int direction = scrollDirection(event);
if (direction == 0) {
@@ -346,8 +451,9 @@ public final class Compositor {
MouseTarget target = mouseTarget(pane);
boolean sent = false;
if (target != null) {
double ex = localX(event.getX(), target);
double ey = localY(event.getY(), target);
// The wheel sends one button press per scrolled row; resolve the position once.
double ex = localX(event.getX(), pane, target);
double ey = localY(event.getY(), pane, target);
KeyModifiers modifiers = modifiers(event);
for (int i = 0; i < rows; i++) {
if (!send(pane, target, MouseInput.press(wheelButton, ex, ey, modifiers), mouseButtonPressed, event)) {
@@ -357,11 +463,14 @@ public final class Compositor {
}
}
if (!sent) {
// Not consumed by the app (e.g. mouse reporting off): scroll the local viewport.
pane.scrollViewport(direction > 0 ? -rows : rows);
event.consume();
}
}
// Forward an already-positioned mouse event to the pane, consuming it if the pane (i.e.
// the app running in it) acted on it. Returns whether it was sent.
private boolean send(TerminalPane pane, MouseTarget target, MouseInput input, boolean anyButtonPressed, InputEvent event) {
boolean sent = pane.sendMouse(input, target.size(), anyButtonPressed);
if (sent) {
@@ -370,6 +479,17 @@ public final class Compositor {
return sent;
}
private TerminalPane paneAt(double x, double y) {
List<TerminalPane> panes = currentPanes();
for (int i = panes.size() - 1; i >= 0; i--) {
TerminalPane pane = panes.get(i);
if (x >= pane.x() && x < pane.x() + pane.width() && y >= pane.y() && y < pane.y() + pane.height()) {
return pane;
}
}
return null;
}
private MouseTarget mouseTarget(TerminalPane pane) {
if (pane.width() <= 2 * TerminalMetrics.PADDING || pane.height() <= 2 * TerminalMetrics.PADDING) {
return null;
@@ -384,12 +504,14 @@ public final class Compositor {
return new MouseTarget(MouseEncoderSize.of(screenWidth, screenHeight, cellWidth, cellHeight), screenWidth, screenHeight);
}
private static double localX(double nodeX, MouseTarget target) {
return clamp(nodeX - TerminalMetrics.PADDING, 0.0, target.screenWidth() - 1.0);
// Resolve a canvas-space pointer position to a pane-local pixel coordinate, clamped to
// the pane's reported screen size (what ghostty's mouse encoder expects).
private static double localX(double canvasX, TerminalPane pane, MouseTarget target) {
return clamp(canvasX - pane.x() - TerminalMetrics.PADDING, 0.0, target.screenWidth() - 1.0);
}
private static double localY(double nodeY, MouseTarget target) {
return clamp(nodeY - TerminalMetrics.PADDING, 0.0, target.screenHeight() - 1.0);
private static double localY(double canvasY, TerminalPane pane, MouseTarget target) {
return clamp(canvasY - pane.y() - TerminalMetrics.PADDING, 0.0, target.screenHeight() - 1.0);
}
private static double clamp(double value, double min, double max) {
@@ -437,6 +559,13 @@ public final class Compositor {
};
}
// What one render() pass should do, decided from the change trackers in nextFrame().
private enum FrameType {
IDLE, // nothing changed since the last frame
LAYOUT, // geometry/font/tab/pane set changed: clear and repaint everything
CONTENT // only terminal content changed: repaint the panes that changed
}
private record MouseTarget(MouseEncoderSize size, long screenWidth, long screenHeight) {
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -26,7 +26,7 @@ final class KeyEncoder {
return switch (code) {
case ENTER -> "\r";
case BACK_SPACE -> "\u007f";
case TAB -> "\t";
case TAB -> event.isShiftDown() ? "\u001b[Z" : "\t";
case ESCAPE -> "\u001b";
case UP -> "\u001b[A";
case DOWN -> "\u001b[B";

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);
@@ -97,6 +101,7 @@ public final class LinuxPty implements AutoCloseable {
private final Arena arena = Arena.ofShared();
private final MemorySegment readBuffer = arena.allocate(65536);
private final MemorySegment writeBuffer = arena.allocate(65536);
private final Object writeLock = new Object();
private final int masterFd;
private final int pid;
@@ -186,19 +191,41 @@ public final class LinuxPty implements AutoCloseable {
return;
}
synchronized (writeLock) {
try (Arena a = Arena.ofConfined()) {
MemorySegment buf = a.allocate(data.length);
MemorySegment.copy(data, 0, buf, ValueLayout.JAVA_BYTE, 0, data.length);
long offset = 0;
int offset = 0;
while (offset < data.length) {
long n = callLong(WRITE, masterFd, buf.asSlice(offset), data.length - offset);
if (n < 0) {
int chunk = (int) Math.min(writeBuffer.byteSize(), data.length - offset);
MemorySegment.copy(data, offset, writeBuffer, ValueLayout.JAVA_BYTE, 0, chunk);
long written = 0;
while (written < chunk) {
long n = callLong(WRITE, masterFd, writeBuffer.asSlice(written), chunk - written);
if (n <= 0) {
throw new IllegalStateException("write to pty failed");
}
offset += n;
written += n;
}
offset += chunk;
}
}
}
/**
* 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. */

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,14 +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;
@@ -30,8 +35,18 @@ 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();
});
Scene scene = new Scene(compositor.node(), config.windowWidth(), config.windowHeight());
StackPane root = new StackPane(compositor.canvas(), compositor.imageOverlay());
compositor.canvas().widthProperty().bind(root.widthProperty());
compositor.canvas().heightProperty().bind(root.heightProperty());
Scene scene = new Scene(root, config.windowWidth(), config.windowHeight());
scene.addEventFilter(KeyEvent.KEY_PRESSED, this::handlePressed);
scene.addEventFilter(KeyEvent.KEY_TYPED, event -> handleTyped(event));
@@ -47,8 +62,35 @@ public final class Main extends Application {
stage.setOnCloseRequest(event -> {
compositor.close();
});
// JavaFX centres a new stage on the primary screen; on X11 there's no "focused monitor"
// to honour, so place it on the screen under the mouse pointer instead.
centreOnActiveScreen(stage, config.windowWidth(), config.windowHeight());
stage.show();
compositor.requestFocus();
// 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) {
@@ -70,14 +112,16 @@ public final class Main extends Application {
} else if (config.keybindings().get("new_pane").matches(event)) {
compositor.createPane();
event.consume();
} 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();
@@ -93,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) {
@@ -114,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");
@@ -153,17 +207,22 @@ public final class Main extends Application {
config = config.withFont(selectedFamily.trim(), selectedSize);
config.save();
compositor.setFont(config.fontFamily(), config.fontSize());
compositor.requestFocus();
compositor.canvas().requestFocus();
});
}
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

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

View File

@@ -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

@@ -1,8 +1,13 @@
package com.gregor.jprototerm;
import javafx.scene.shape.Rectangle;
import javafx.scene.shape.Shape;
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;
/**
@@ -15,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.
@@ -30,13 +41,24 @@ final class Tab implements AutoCloseable {
private double lastTopInset;
// Bumped whenever one of this tab's panes changes content; the compositor reads the current
// tab's value each frame as an O(1) "anything to repaint?" check.
private long contentVersion;
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;
@@ -51,7 +73,7 @@ final class Tab implements AutoCloseable {
}
long contentVersion() {
return contentVersion;
return contentVersion.get();
}
/**
@@ -75,13 +97,6 @@ final class Tab implements AutoCloseable {
return List.copyOf(ordered);
}
List<TerminalPane> allPanes() {
List<TerminalPane> all = new ArrayList<>(tiled.size() + floating.size());
all.addAll(tiled);
all.addAll(floating);
return List.copyOf(all);
}
boolean isActive(TerminalPane pane) {
return pane != null && pane == active;
}
@@ -115,6 +130,51 @@ final class Tab implements AutoCloseable {
floatingWidth,
floatingHeight);
}
assignClips();
}
// Give each pane its clip region for the next paints, so repainting a pane on a content
// frame can never bleed over one stacked on top of it. Each pane is clipped to its rect
// minus the union of the panes above it: floating panes are clipped by the floating panes
// higher in the stack, and tiled panes by the whole floating group. When nothing floats,
// every pane clips to its plain bounds.
private void assignClips() {
if (!floatingVisible || floating.isEmpty()) {
tiled.forEach(pane -> pane.setClip(null));
floating.forEach(pane -> pane.setClip(null));
return;
}
// Floating panes bottom-to-top, matching panes(): insertion order, active pane on top.
List<TerminalPane> order = new ArrayList<>(floating.size());
for (TerminalPane pane : floating) {
if (pane != active) {
order.add(pane);
}
}
if (floating.contains(active)) {
order.add(active);
}
// Walk top-to-bottom, accumulating the union of the panes above each one.
Shape above = null;
for (int i = order.size() - 1; i >= 0; i--) {
Rectangle rect = rectOf(order.get(i));
order.get(i).setClip(above == null ? null : Shape.subtract(rect, above));
above = (above == null) ? rect : Shape.union(above, rect);
}
// `above` is now the union of every floating pane; tiled panes sit under all of them.
for (TerminalPane pane : tiled) {
pane.setClip(Shape.subtract(rectOf(pane), above));
}
}
// Match the renderer's pixel snapping (round the origin, keep width/height) so the clip
// lines up exactly with where the floating panes are drawn.
private static Rectangle rectOf(TerminalPane pane) {
return new Rectangle(Math.round(pane.x()), Math.round(pane.y()), pane.width(), pane.height());
}
boolean navigate(Direction direction) {
@@ -141,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;
@@ -160,20 +220,62 @@ final class Tab implements AutoCloseable {
}
}
void closeActivePane() {
TerminalPane closing = active;
boolean wasFloating = floating.remove(closing);
if (!wasFloating) {
tiled.remove(closing);
void nextFloatingPane() {
if (floating.isEmpty()) {
createFloatingPane();
return;
}
floatingVisible = true;
int current = floating.indexOf(active); // -1 when the active pane is tiled
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() {
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
@@ -185,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) {
@@ -240,22 +365,35 @@ final class Tab implements AutoCloseable {
}
private void markContentChanged() {
contentVersion++;
contentVersion.incrementAndGet();
}
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,28 +12,41 @@ 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;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicLong;
/**
* One terminal: owns its ghostty {@link Terminal}, the {@link ShellSession}/pty driving it,
* and its on-screen geometry and grid. It does not draw itself; {@link TerminalPaneNode}
* reads snapshots from it and represents the visible rows and kitty graphics as JavaFX nodes.
* and its on-screen geometry and grid. It does not draw itself — it is a {@link RenderTarget}
* that a {@link TerminalRenderer} paints. {@link #paintFull}/{@link #paintIncremental} are the
* only rendering API exposed to the {@link Compositor}, and they just delegate to that
* renderer; the compositor decides z-order and which rect each pane occupies.
*/
public final class TerminalPane implements AutoCloseable {
public final class TerminalPane implements AutoCloseable, RenderTarget {
private final Terminal terminal;
private final TerminalMetrics metrics;
private final boolean kittyEnabled;
// Run on every content change so the owning tab can bump its content version — the
// compositor's O(1) "did the current tab change?" gate.
private final Runnable onContentChange;
private final TerminalRenderer renderer;
private final MouseEncoder mouseEncoder = new MouseEncoder();
// A persistent render state (reused across frames) is what makes ghostty's per-row dirty
// tracking meaningful: update() accumulates dirty since the last resetDirty().
private final RenderState renderState = new RenderState();
private RenderStateSnapshot cachedSnapshot;
private ShellSession session;
// 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;
private double x;
private double y;
private double width;
@@ -43,14 +56,15 @@ public final class TerminalPane implements AutoCloseable {
private int pixelWidth;
private int pixelHeight;
private final AtomicLong contentVersion = new AtomicLong();
private volatile long snapshotVersion = -1;
private long snapshotVersion = -1;
private TerminalPane(Terminal terminal, TerminalMetrics metrics, boolean kittyEnabled,
Runnable onContentChange, int columns, int rows) {
Runnable onContentChange, TerminalRenderer renderer, int columns, int rows) {
this.terminal = terminal;
this.metrics = metrics;
this.kittyEnabled = kittyEnabled;
this.onContentChange = onContentChange;
this.renderer = renderer;
this.columns = columns;
this.rows = rows;
}
@@ -60,19 +74,64 @@ public final class TerminalPane implements AutoCloseable {
* 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()));
terminal.setDeviceAttributesProvider(DeviceAttributes::xtermCompatible);
TerminalPane pane = new TerminalPane(terminal, metrics, config.kittyGraphics(), onContentChange, columns, rows);
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 -> {
@@ -105,6 +164,19 @@ public final class TerminalPane implements AutoCloseable {
}
}
/**
* 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);
@@ -144,6 +216,7 @@ public final class TerminalPane implements AutoCloseable {
* Snapshotting is deferred here rather than done in refresh(), so a burst of writes
* between two frames collapses into a single snapshot.
*/
@Override
public RenderStateSnapshot snapshot() {
return takeSnapshot(false);
}
@@ -152,6 +225,7 @@ public final class TerminalPane implements AutoCloseable {
* Full snapshot with every row's cells populated. Used where the whole pane is redrawn
* regardless of dirty state (the kitty-graphics path).
*/
@Override
public RenderStateSnapshot snapshotFull() {
return takeSnapshot(true);
}
@@ -180,37 +254,45 @@ public final class TerminalPane implements AutoCloseable {
}
}
/** 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();
}
long snapshotVersion() {
return snapshotVersion;
}
@Override
public boolean kittyEnabled() {
return kittyEnabled;
}
@Override
public Optional<KittyGraphics> kittyGraphics() {
synchronized (terminal) {
return terminal.kittyGraphics();
}
}
@Override
public double x() {
return x;
}
@Override
public double y() {
return y;
}
@Override
public double width() {
return width;
}
@Override
public double height() {
return height;
}
@@ -222,6 +304,16 @@ public final class TerminalPane implements AutoCloseable {
this.height = height;
}
/** Set the clip region applied on the next paints (see {@link RenderTarget#clip()}). */
public void setClip(Shape clip) {
this.clip = clip;
}
@Override
public Shape clip() {
return clip;
}
/** Recompute the ghostty grid from the current bounds and the shared cell metrics. */
public void fitToBounds() {
int columns = metrics.columnsFor(width);
@@ -258,6 +350,26 @@ public final class TerminalPane implements AutoCloseable {
onContentChange.run();
}
/** Paint the whole pane; see {@link TerminalRenderer#paintFull}. */
public long paintFull(GraphicsContext gc, boolean active) {
renderer.paintFull(gc, this, active);
return snapshotVersion;
}
/** Repaint what changed; see {@link TerminalRenderer#paintIncremental}. */
public long paintIncremental(GraphicsContext gc, boolean active) {
renderer.paintIncremental(gc, this, active);
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

@@ -1,975 +0,0 @@
package com.gregor.jprototerm;
import dev.jlibghostty.KittyImageCompression;
import dev.jlibghostty.KittyImageFormat;
import dev.jlibghostty.KittyImageSnapshot;
import dev.jlibghostty.KittyPlacement;
import dev.jlibghostty.KittyPlacementLayer;
import dev.jlibghostty.KittyPlaceholder;
import dev.jlibghostty.KittyRenderInfo;
import dev.jlibghostty.RenderCell;
import dev.jlibghostty.RenderColor;
import dev.jlibghostty.RenderCursorStyle;
import dev.jlibghostty.RenderRow;
import dev.jlibghostty.RenderStateSnapshot;
import javafx.geometry.Rectangle2D;
import javafx.scene.Node;
import javafx.scene.canvas.Canvas;
import javafx.scene.canvas.GraphicsContext;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.image.PixelFormat;
import javafx.scene.image.WritableImage;
import javafx.scene.layout.Pane;
import javafx.scene.layout.Region;
import javafx.scene.paint.Color;
import javafx.scene.shape.Line;
import javafx.scene.shape.Rectangle;
import javafx.scene.text.FontSmoothingType;
import java.io.ByteArrayInputStream;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* JavaFX node for one terminal pane. The pane is composed from JavaFX primitives: one node per
* terminal row, kitty graphics as ImageView nodes, plus background/cursor/border nodes.
*/
final class TerminalPaneNode extends Region {
private static final int DIRTY_PARTIAL = 1;
private static final int DIRTY_FULL = 2;
private static final Color DEFAULT_FOREGROUND = Color.rgb(225, 229, 235);
private static final Color SELECTED_BACKGROUND = Color.rgb(52, 92, 140);
private static final Color PANE_BACKGROUND = Color.rgb(9, 10, 12);
private static final Map<Integer, Color> COLOR_CACHE = new HashMap<>();
private final TerminalPane pane;
private final TerminalMetrics metrics;
private final Rectangle background = new Rectangle();
private final Pane belowImageLayer = new Pane();
private final Pane rowLayer = new Pane();
private final Pane cursorLayer = new Pane();
private final Pane aboveImageLayer = new Pane();
private final Rectangle topPadding = new Rectangle();
private final Rectangle bottomPadding = new Rectangle();
private final Rectangle border = new Rectangle();
private final Map<Integer, TerminalRowNode> rows = new HashMap<>();
private final Map<Integer, Long> rowFingerprints = new HashMap<>();
private final Map<KittyImageKey, Image> kittyImageCache = new HashMap<>();
private long drawnContentVersion = Long.MIN_VALUE;
private double drawnWidth = -1.0;
private double drawnHeight = -1.0;
TerminalPaneNode(TerminalPane pane, TerminalMetrics metrics) {
this.pane = pane;
this.metrics = metrics;
setPickOnBounds(true);
setClip(new Rectangle());
background.setFill(PANE_BACKGROUND);
border.setFill(Color.TRANSPARENT);
getChildren().setAll(background, belowImageLayer, rowLayer, cursorLayer, aboveImageLayer, border);
rowLayer.getChildren().setAll(topPadding, bottomPadding);
}
void discard() {
drawnContentVersion = Long.MIN_VALUE;
drawnWidth = -1.0;
drawnHeight = -1.0;
rows.clear();
rowFingerprints.clear();
rowLayer.getChildren().setAll(topPadding, bottomPadding);
belowImageLayer.getChildren().clear();
aboveImageLayer.getChildren().clear();
cursorLayer.getChildren().clear();
}
void renderFull(boolean active) {
prepareGeometry();
RenderStateSnapshot snapshot = pane.snapshotFull();
long renderedVersion = pane.snapshotVersion();
boolean withKitty = pane.kittyEnabled() && hasKittyGraphics();
updateRowsFull(snapshot);
updateKittyGraphics(snapshot, withKitty);
updateCursor(snapshot);
updateBorder(active);
markDrawn(renderedVersion);
}
void renderIncremental(boolean active) {
boolean geometryChanged = prepareGeometry();
boolean withKitty = pane.kittyEnabled() && hasKittyGraphics();
if (drawnContentVersion == Long.MIN_VALUE || geometryChanged || withKitty) {
renderFull(active);
return;
}
if (drawnContentVersion == pane.contentVersion()) {
updateBorder(active);
return;
}
RenderStateSnapshot snapshot = pane.snapshot();
long renderedVersion = pane.snapshotVersion();
int dirty = snapshot == null ? DIRTY_FULL : snapshot.dirty();
if (dirty == DIRTY_FULL) {
updateChangedRows(snapshot, snapshot.renderRows());
} else if (dirty == DIRTY_PARTIAL) {
updateDirtyRows(snapshot);
}
updateKittyGraphics(snapshot, false);
updateCursor(snapshot);
updateBorder(active);
markDrawn(renderedVersion);
}
private boolean prepareGeometry() {
double width = Math.max(0.0, pane.width());
double height = Math.max(0.0, pane.height());
boolean changed = drawnWidth != width || drawnHeight != height;
resize(width, height);
background.setWidth(width);
background.setHeight(height);
resizeLayer(belowImageLayer, width, height);
resizeLayer(rowLayer, width, height);
resizeLayer(cursorLayer, width, height);
resizeLayer(aboveImageLayer, width, height);
border.setWidth(Math.max(0.0, width - 1.0));
border.setHeight(Math.max(0.0, height - 1.0));
border.relocate(0.5, 0.5);
Node clip = getClip();
if (clip instanceof Rectangle rectangle) {
rectangle.setWidth(width);
rectangle.setHeight(height);
}
return changed;
}
private static void resizeLayer(Pane layer, double width, double height) {
layer.resizeRelocate(0.0, 0.0, width, height);
}
private void updateRowsFull(RenderStateSnapshot snapshot) {
if (snapshot == null) {
rows.clear();
rowFingerprints.clear();
rowLayer.getChildren().setAll(topPadding, bottomPadding);
return;
}
List<Node> ordered = new ArrayList<>(snapshot.renderRows().size() + 2);
ordered.add(topPadding);
ordered.add(bottomPadding);
Set<Integer> liveRows = new HashSet<>();
for (RenderRow row : snapshot.renderRows()) {
TerminalRowNode node = rowNode(row.row());
long fingerprint = rowFingerprint(row);
node.render(row);
rowFingerprints.put(row.row(), fingerprint);
liveRows.add(row.row());
ordered.add(node);
}
rows.keySet().retainAll(liveRows);
rowFingerprints.keySet().retainAll(liveRows);
rowLayer.getChildren().setAll(ordered);
updateVerticalPadding(snapshot);
}
private void updateDirtyRows(RenderStateSnapshot snapshot) {
List<RenderRow> dirtyRows = snapshot.renderRows().stream()
.filter(RenderRow::dirty)
.toList();
updateChangedRows(snapshot, dirtyRows);
}
private void updateChangedRows(RenderStateSnapshot snapshot, List<RenderRow> changedRows) {
if (snapshot == null || changedRows.isEmpty()) {
return;
}
Set<Integer> movedRows = moveShiftedRows(snapshot, changedRows);
for (RenderRow row : snapshot.renderRows()) {
if (!changedRows.contains(row) || movedRows.contains(row.row())) {
continue;
}
TerminalRowNode node = rowNode(row.row());
long fingerprint = rowFingerprint(row);
node.renderChanged(row);
rowFingerprints.put(row.row(), fingerprint);
}
for (RenderRow row : changedRows) {
updateDirtyVerticalPadding(snapshot, row);
}
syncRowChildren();
}
private Set<Integer> moveShiftedRows(RenderStateSnapshot snapshot, List<RenderRow> changedRows) {
if (rowFingerprints.isEmpty() || changedRows.size() < Math.max(4, snapshot.rows() / 3)) {
return Set.of();
}
ShiftPlan plan = detectShift(snapshot, changedRows);
if (plan == null) {
return Set.of();
}
Map<Integer, TerminalRowNode> oldRows = new HashMap<>(rows);
Map<Integer, Long> oldFingerprints = new HashMap<>(rowFingerprints);
for (RowMove move : plan.moves()) {
rows.remove(move.sourceRow());
rowFingerprints.remove(move.sourceRow());
}
for (RowMove move : plan.moves()) {
TerminalRowNode node = oldRows.get(move.sourceRow());
if (node == null) {
continue;
}
node.moveToRow(move.targetRow());
rows.put(move.targetRow(), node);
rowFingerprints.put(move.targetRow(), oldFingerprints.get(move.sourceRow()));
}
return plan.targetRows();
}
private ShiftPlan detectShift(RenderStateSnapshot snapshot, List<RenderRow> changedRows) {
int bestDelta = 0;
int bestScore = 0;
int rowCount = snapshot.rows();
for (int delta = -rowCount + 1; delta < rowCount; delta++) {
if (delta == 0) {
continue;
}
int score = 0;
for (RenderRow row : changedRows) {
int sourceRow = row.row() + delta;
if (sourceRow < 0 || sourceRow >= rowCount || !rows.containsKey(sourceRow)) {
continue;
}
Long previous = rowFingerprints.get(sourceRow);
if (previous != null && previous == rowFingerprint(row)) {
score++;
}
}
if (score > bestScore) {
bestScore = score;
bestDelta = delta;
}
}
int threshold = Math.max(4, (changedRows.size() * 2 + 2) / 3);
if (bestScore < threshold) {
return null;
}
List<RowMove> moves = new ArrayList<>(bestScore);
Set<Integer> targetRows = new HashSet<>();
for (RenderRow row : changedRows) {
int sourceRow = row.row() + bestDelta;
if (sourceRow < 0 || sourceRow >= rowCount || !rows.containsKey(sourceRow)) {
continue;
}
Long previous = rowFingerprints.get(sourceRow);
if (previous != null && previous == rowFingerprint(row)) {
moves.add(new RowMove(sourceRow, row.row()));
targetRows.add(row.row());
}
}
return new ShiftPlan(moves, targetRows);
}
private void syncRowChildren() {
List<Node> ordered = new ArrayList<>(rows.size() + 2);
ordered.add(topPadding);
ordered.add(bottomPadding);
rows.entrySet().stream()
.sorted(Comparator.comparingInt(Map.Entry::getKey))
.map(Map.Entry::getValue)
.forEach(ordered::add);
rowLayer.getChildren().setAll(ordered);
}
private TerminalRowNode rowNode(int row) {
return rows.computeIfAbsent(row, ignored -> {
TerminalRowNode created = new TerminalRowNode(metrics);
if (!rowLayer.getChildren().contains(created)) {
rowLayer.getChildren().add(created);
}
return created;
});
}
private void updateVerticalPadding(RenderStateSnapshot snapshot) {
List<RenderRow> renderRows = snapshot.renderRows();
if (renderRows.isEmpty()) {
topPadding.setVisible(false);
bottomPadding.setVisible(false);
return;
}
double width = pane.width();
double top = TerminalMetrics.PADDING;
double contentBottom = top + snapshot.rows() * metrics.lineHeight();
topPadding.setVisible(true);
topPadding.setFill(rowEdgeBackground(renderRows.get(0), true));
topPadding.relocate(0.0, 0.0);
topPadding.setWidth(width);
topPadding.setHeight(top);
bottomPadding.setVisible(true);
bottomPadding.setFill(rowEdgeBackground(renderRows.get(renderRows.size() - 1), true));
bottomPadding.relocate(0.0, contentBottom);
bottomPadding.setWidth(width);
bottomPadding.setHeight(Math.max(0.0, pane.height() - contentBottom));
}
private void updateDirtyVerticalPadding(RenderStateSnapshot snapshot, RenderRow row) {
if (row.row() == 0) {
topPadding.setVisible(true);
topPadding.setFill(rowEdgeBackground(row, true));
topPadding.relocate(0.0, 0.0);
topPadding.setWidth(pane.width());
topPadding.setHeight(TerminalMetrics.PADDING);
}
if (row.row() == snapshot.rows() - 1) {
double contentBottom = TerminalMetrics.PADDING + snapshot.rows() * metrics.lineHeight();
bottomPadding.setVisible(true);
bottomPadding.setFill(rowEdgeBackground(row, true));
bottomPadding.relocate(0.0, contentBottom);
bottomPadding.setWidth(pane.width());
bottomPadding.setHeight(Math.max(0.0, pane.height() - contentBottom));
}
}
private void updateCursor(RenderStateSnapshot snapshot) {
cursorLayer.getChildren().clear();
if (snapshot == null || !snapshot.cursorVisible() || !snapshot.cursorViewportHasValue()) {
return;
}
double x = TerminalMetrics.PADDING + (snapshot.cursorViewportX() * metrics.cellWidth());
double y = TerminalMetrics.PADDING + (snapshot.cursorViewportY() * metrics.lineHeight());
double cellWidth = metrics.cellWidth();
double lineHeight = metrics.lineHeight();
RenderCursorStyle style = snapshot.cursorStyle();
if (style == RenderCursorStyle.BAR) {
Line line = new Line(x + 0.5, y + 2.0, x + 0.5, y + lineHeight - 2.0);
line.setStroke(DEFAULT_FOREGROUND);
line.setStrokeWidth(1.5);
cursorLayer.getChildren().add(line);
} else if (style == RenderCursorStyle.UNDERLINE) {
Line line = new Line(x + 1.0, y + lineHeight - 2.0, x + cellWidth - 1.0, y + lineHeight - 2.0);
line.setStroke(DEFAULT_FOREGROUND);
line.setStrokeWidth(1.5);
cursorLayer.getChildren().add(line);
} else if (style == RenderCursorStyle.BLOCK) {
Rectangle rectangle = new Rectangle(x + 0.5, y + 1.0, Math.max(1.0, cellWidth - 1.0), Math.max(1.0, lineHeight - 2.0));
rectangle.setFill(Color.rgb(225, 229, 235, 0.28));
cursorLayer.getChildren().add(rectangle);
} else {
Rectangle rectangle = new Rectangle(x + 0.5, y + 1.0, Math.max(1.0, cellWidth - 1.0), Math.max(1.0, lineHeight - 2.0));
rectangle.setFill(Color.TRANSPARENT);
rectangle.setStroke(DEFAULT_FOREGROUND);
rectangle.setStrokeWidth(1.5);
cursorLayer.getChildren().add(rectangle);
}
}
private void updateBorder(boolean active) {
border.setStroke(active ? Color.rgb(87, 166, 255) : Color.rgb(52, 57, 65));
border.setStrokeWidth(active ? 2.0 : 1.0);
}
private void updateKittyGraphics(RenderStateSnapshot snapshot, boolean withKitty) {
belowImageLayer.getChildren().clear();
aboveImageLayer.getChildren().clear();
if (!withKitty || snapshot == null) {
return;
}
Map<KittyPlaceholderKey, KittyPlaceholderBounds> placeholderBounds = kittyPlaceholderBounds(snapshot);
addKittyGraphics(belowImageLayer, KittyPlacementLayer.BELOW_TEXT, placeholderBounds);
addKittyGraphics(aboveImageLayer, KittyPlacementLayer.ABOVE_TEXT, placeholderBounds);
}
private void addKittyGraphics(Pane layer, KittyPlacementLayer placementLayer, Map<KittyPlaceholderKey, KittyPlaceholderBounds> placeholderBounds) {
pane.kittyGraphics().ifPresent(graphics -> {
for (KittyPlacement placement : graphics.placements(placementLayer)) {
Image image = imageFor(placement);
if (image == null) {
continue;
}
ImageView view = placement.virtual()
? virtualKittyView(placement, image, placeholderBounds)
: pinnedKittyView(placement, image);
if (view != null) {
layer.getChildren().add(view);
}
}
});
}
private ImageView pinnedKittyView(KittyPlacement placement, Image image) {
KittyRenderInfo renderInfo = placement.renderInfo().orElse(null);
if (renderInfo == null || !renderInfo.viewportVisible()) {
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 null;
}
double x = TerminalMetrics.PADDING + (renderInfo.viewportColumn() * metrics.cellWidth()) + placement.xOffset();
double y = TerminalMetrics.PADDING + (renderInfo.viewportRow() * metrics.lineHeight()) + placement.yOffset();
double width = renderInfo.pixelWidth() > 0 ? renderInfo.pixelWidth() : renderInfo.gridColumns() * metrics.cellWidth();
double height = renderInfo.pixelHeight() > 0 ? renderInfo.pixelHeight() : renderInfo.gridRows() * metrics.lineHeight();
return imageView(image, sourceX, sourceY, sourceWidth, sourceHeight, x, y, width, height);
}
private ImageView virtualKittyView(KittyPlacement placement, Image image, Map<KittyPlaceholderKey, KittyPlaceholderBounds> placeholderBounds) {
KittyPlaceholderBounds bounds = placeholderBounds.get(new KittyPlaceholderKey(placement.imageId(), placement.placementId()));
if (bounds == null) {
bounds = placeholderBounds.get(new KittyPlaceholderKey(placement.imageId(), 0));
}
if (bounds == null && placement.placementId() == 0) {
bounds = placeholderBounds.entrySet().stream()
.filter(entry -> entry.getKey().imageId() == placement.imageId())
.map(Map.Entry::getValue)
.findFirst()
.orElse(null);
}
if (bounds == null || bounds.isEmpty()) {
return null;
}
SourceRect source = sourceRect(placement, image);
if (source.width() <= 0.0 || source.height() <= 0.0) {
return null;
}
long gridColumns = gridColumns(placement, bounds);
long gridRows = gridRows(placement, bounds);
double sourceCellWidth = source.width() / Math.max(1L, gridColumns);
double sourceCellHeight = source.height() / Math.max(1L, gridRows);
double sourceX = source.x() + (bounds.minSourceColumn * sourceCellWidth);
double sourceY = source.y() + (bounds.minSourceRow * sourceCellHeight);
double sourceWidth = bounds.sourceColumns() * sourceCellWidth;
double sourceHeight = bounds.sourceRows() * sourceCellHeight;
double x = TerminalMetrics.PADDING + (bounds.minColumn * metrics.cellWidth());
double y = TerminalMetrics.PADDING + (bounds.minRow * metrics.lineHeight());
double availableWidth = bounds.columns() * metrics.cellWidth();
double availableHeight = bounds.rows() * metrics.lineHeight();
if (sourceWidth <= 0.0 || sourceHeight <= 0.0 || availableWidth <= 0.0 || availableHeight <= 0.0) {
return null;
}
double scale = Math.min(availableWidth / sourceWidth, availableHeight / sourceHeight);
return imageView(image, sourceX, sourceY, sourceWidth, sourceHeight, x, y, sourceWidth * scale, sourceHeight * scale);
}
private static ImageView imageView(Image image, double sourceX, double sourceY, double sourceWidth, double sourceHeight,
double x, double y, double width, double height) {
if (width <= 0.0 || height <= 0.0) {
return null;
}
ImageView view = new ImageView(image);
view.setViewport(new Rectangle2D(sourceX, sourceY, sourceWidth, sourceHeight));
view.setFitWidth(width);
view.setFitHeight(height);
view.setPreserveRatio(false);
view.relocate(x, y);
return view;
}
private boolean hasKittyGraphics() {
return pane.kittyGraphics()
.map(graphics -> !graphics.placements().isEmpty())
.orElse(false);
}
private Image imageFor(KittyPlacement placement) {
return placement.image().map(snapshot -> {
byte[] data = snapshot.data();
KittyImageKey key = KittyImageKey.of(snapshot, data);
Image cached = kittyImageCache.get(key);
if (cached != null) {
return cached;
}
kittyImageCache.keySet().removeIf(existing -> existing.id() == snapshot.id());
Image decoded = decodeImage(snapshot, data);
if (decoded != null) {
kittyImageCache.put(key, decoded);
}
return decoded;
}).orElse(null);
}
private static Image decodeImage(KittyImageSnapshot snapshot, byte[] data) {
if (snapshot.compression() != KittyImageCompression.NONE) {
return null;
}
if (snapshot.format() == KittyImageFormat.PNG) {
return new Image(new ByteArrayInputStream(data));
}
int width = Math.toIntExact(snapshot.width());
int height = Math.toIntExact(snapshot.height());
WritableImage image = new WritableImage(width, height);
if (snapshot.format() == KittyImageFormat.RGBA) {
image.getPixelWriter().setPixels(0, 0, width, height, PixelFormat.getByteBgraInstance(), rgbaToBgra(data), 0, width * 4);
} else if (snapshot.format() == KittyImageFormat.RGB) {
image.getPixelWriter().setPixels(0, 0, width, height, PixelFormat.getByteRgbInstance(), data, 0, width * 3);
}
return image;
}
private static byte[] rgbaToBgra(byte[] rgba) {
byte[] bgra = new byte[rgba.length];
for (int i = 0; i + 3 < rgba.length; i += 4) {
bgra[i] = rgba[i + 2];
bgra[i + 1] = rgba[i + 1];
bgra[i + 2] = rgba[i];
bgra[i + 3] = rgba[i + 3];
}
return bgra;
}
private static Map<KittyPlaceholderKey, KittyPlaceholderBounds> kittyPlaceholderBounds(RenderStateSnapshot snapshot) {
Map<KittyPlaceholderKey, KittyPlaceholderBounds> result = new HashMap<>();
for (RenderRow row : snapshot.renderRows()) {
for (RenderCell cell : row.cells()) {
cell.kittyPlaceholder().ifPresent(placeholder -> {
KittyPlaceholderKey key = new KittyPlaceholderKey(placeholder.imageId(), placeholder.placementId());
result.computeIfAbsent(key, ignored -> new KittyPlaceholderBounds()).include(row.row(), cell.column(), placeholder);
});
}
}
return result;
}
private static long gridColumns(KittyPlacement placement, KittyPlaceholderBounds bounds) {
if (placement.columns() > 0) {
return placement.columns();
}
return Math.max(bounds.maxSourceColumn + 1, bounds.sourceColumns());
}
private static long gridRows(KittyPlacement placement, KittyPlaceholderBounds bounds) {
if (placement.rows() > 0) {
return placement.rows();
}
return Math.max(bounds.maxSourceRow + 1, bounds.sourceRows());
}
private static SourceRect sourceRect(KittyPlacement placement, Image image) {
double sourceX = placement.sourceX();
double sourceY = placement.sourceY();
double sourceWidth = placement.sourceWidth() > 0 ? placement.sourceWidth() : image.getWidth() - sourceX;
double sourceHeight = placement.sourceHeight() > 0 ? placement.sourceHeight() : image.getHeight() - sourceY;
return new SourceRect(sourceX, sourceY, Math.min(sourceWidth, image.getWidth() - sourceX), Math.min(sourceHeight, image.getHeight() - sourceY));
}
private void markDrawn(long renderedVersion) {
drawnContentVersion = renderedVersion;
drawnWidth = pane.width();
drawnHeight = pane.height();
}
private static Color cellBackgroundColor(RenderCell cell) {
if (cell.inverse()) {
var fg = cell.foreground();
return fg.isPresent() ? toFxColor(fg.get()) : DEFAULT_FOREGROUND;
}
var bg = cell.background();
return bg.isPresent() ? toFxColor(bg.get()) : PANE_BACKGROUND;
}
private static Color rowEdgeBackground(RenderRow row, boolean firstCell) {
List<RenderCell> cells = row.cells();
if (cells.isEmpty()) {
return PANE_BACKGROUND;
}
return cellBackgroundColor(firstCell ? cells.get(0) : cells.get(cells.size() - 1));
}
private static Color cellBackgroundOverride(RenderCell cell) {
if (cell.inverse()) {
var fg = cell.foreground();
return fg.isPresent() ? toFxColor(fg.get()) : DEFAULT_FOREGROUND;
}
var bg = cell.background();
return bg.isPresent() ? toFxColor(bg.get()) : null;
}
private static Color cellForegroundColor(RenderCell cell) {
var fgOpt = cell.foreground();
var bgOpt = cell.background();
Color fg = fgOpt.isPresent() ? toFxColor(fgOpt.get()) : DEFAULT_FOREGROUND;
Color bg = bgOpt.isPresent() ? toFxColor(bgOpt.get()) : null;
if (cell.inverse()) {
return (bg != null) ? bg : PANE_BACKGROUND;
}
return fg;
}
private static Color toFxColor(RenderColor color) {
int key = (color.red() << 16) | (color.green() << 8) | color.blue();
Color cached = COLOR_CACHE.get(key);
if (cached != null) {
return cached;
}
if (COLOR_CACHE.size() >= 4096) {
COLOR_CACHE.clear();
}
Color created = Color.rgb(color.red(), color.green(), color.blue());
COLOR_CACHE.put(key, created);
return created;
}
private static long rowFingerprint(RenderRow row) {
long hash = 0xcbf29ce484222325L;
hash = mix(hash, row.cells().size());
for (RenderCell cell : row.cells()) {
hash = mix(hash, cellFingerprint(cell));
}
return hash;
}
private static long cellFingerprint(RenderCell cell) {
long hash = 0xcbf29ce484222325L;
hash = mix(hash, cell.column());
hash = mix(hash, cell.inverse() ? 1 : 0);
hash = mix(hash, cell.selected() ? 1 : 0);
hash = mix(hash, colorFingerprint(cell.foreground().orElse(null)));
hash = mix(hash, colorFingerprint(cell.background().orElse(null)));
for (int codepoint : cell.codepoints()) {
hash = mix(hash, codepoint);
}
if (cell.kittyPlaceholder().isPresent()) {
KittyPlaceholder placeholder = cell.kittyPlaceholder().get();
hash = mix(hash, placeholder.imageId());
hash = mix(hash, placeholder.placementId());
hash = mix(hash, placeholder.sourceRow());
hash = mix(hash, placeholder.sourceColumn());
}
return hash;
}
private static long colorFingerprint(RenderColor color) {
if (color == null) {
return -1L;
}
return ((long) color.red() << 16) | ((long) color.green() << 8) | color.blue();
}
private static long mix(long hash, long value) {
hash ^= value;
return hash * 0x100000001b3L;
}
private static final class TerminalRowNode extends Region {
private final TerminalMetrics metrics;
private final Canvas canvas = new Canvas();
private long[] cellFingerprints = new long[0];
private TerminalRowNode(TerminalMetrics metrics) {
this.metrics = metrics;
getChildren().add(canvas);
}
private void render(RenderRow row) {
prepareCanvas(row);
GraphicsContext gc = canvas.getGraphicsContext2D();
gc.clearRect(0.0, 0.0, canvas.getWidth(), canvas.getHeight());
gc.setFontSmoothingType(FontSmoothingType.LCD);
gc.setFont(metrics.font());
paintSidePadding(gc, row, canvas.getWidth(), canvas.getHeight());
drawRow(gc, row, rowTop(row), metrics.cellWidth(), metrics.lineHeight());
cellFingerprints = cellFingerprints(row);
}
private void renderChanged(RenderRow row) {
double oldWidth = canvas.getWidth();
double oldHeight = canvas.getHeight();
prepareCanvas(row);
long[] nextFingerprints = cellFingerprints(row);
if (cellFingerprints.length != nextFingerprints.length
|| oldWidth != canvas.getWidth()
|| oldHeight != canvas.getHeight()) {
render(row);
return;
}
GraphicsContext gc = canvas.getGraphicsContext2D();
gc.setFontSmoothingType(FontSmoothingType.LCD);
gc.setFont(metrics.font());
int runStart = -1;
int runEnd = -1;
for (int column = 0; column < nextFingerprints.length; column++) {
if (cellFingerprints[column] == nextFingerprints[column]) {
continue;
}
int start = Math.max(0, column - 1);
int end = Math.min(nextFingerprints.length - 1, column + 1);
if (runStart < 0) {
runStart = start;
runEnd = end;
} else if (start <= runEnd + 1) {
runEnd = Math.max(runEnd, end);
} else {
repaintColumns(gc, row, runStart, runEnd);
runStart = start;
runEnd = end;
}
}
if (runStart >= 0) {
repaintColumns(gc, row, runStart, runEnd);
}
cellFingerprints = nextFingerprints;
}
private void prepareCanvas(RenderRow row) {
double paneWidth = ((Region) getParent()).getWidth();
double rowTop = rowTop(row);
double rowBottom = rowBottom(row);
double rowHeight = Math.max(1.0, rowBottom - rowTop);
resizeRelocate(0.0, rowTop, paneWidth, rowHeight);
canvas.setWidth(Math.max(0.0, paneWidth));
canvas.setHeight(rowHeight);
}
private void moveToRow(int row) {
double paneWidth = ((Region) getParent()).getWidth();
double rowTop = rowTop(row);
double rowBottom = rowBottom(row);
double rowHeight = Math.max(1.0, rowBottom - rowTop);
resizeRelocate(0.0, rowTop, paneWidth, rowHeight);
canvas.setWidth(Math.max(0.0, paneWidth));
canvas.setHeight(rowHeight);
}
private double rowTop(RenderRow row) {
return rowTop(row.row());
}
private double rowTop(int row) {
return Math.floor(TerminalMetrics.PADDING + row * metrics.lineHeight());
}
private double rowBottom(RenderRow row) {
return rowBottom(row.row());
}
private double rowBottom(int row) {
return Math.ceil(TerminalMetrics.PADDING + (row + 1) * metrics.lineHeight());
}
private void repaintColumns(GraphicsContext gc, RenderRow row, int startColumn, int endColumn) {
if (endColumn < startColumn) {
return;
}
double cellWidth = metrics.cellWidth();
double lineHeight = metrics.lineHeight();
double rowTop = rowTop(row);
double contentTop = TerminalMetrics.PADDING + row.row() * lineHeight;
double localCellTop = contentTop - rowTop;
double baseline = TerminalMetrics.PADDING + metrics.baselineOffset() + row.row() * lineHeight - rowTop;
double x = TerminalMetrics.PADDING + startColumn * cellWidth;
double width = (endColumn - startColumn + 1) * cellWidth;
gc.clearRect(x, 0.0, width, canvas.getHeight());
if (startColumn == 0) {
gc.setFill(rowEdgeBackground(row, true));
gc.fillRect(0.0, 0.0, TerminalMetrics.PADDING, canvas.getHeight());
}
if (endColumn >= row.cells().size() - 1) {
double contentRight = TerminalMetrics.PADDING + row.cells().size() * cellWidth;
gc.setFill(rowEdgeBackground(row, false));
gc.fillRect(contentRight, 0.0, canvas.getWidth() - contentRight, canvas.getHeight());
}
drawRowBackgrounds(gc, row, localCellTop, cellWidth, lineHeight, startColumn, endColumn);
drawRowText(gc, row, baseline, cellWidth, startColumn, endColumn);
}
private void paintSidePadding(GraphicsContext gc, RenderRow row, double paneWidth, double bandHeight) {
int columns = row.cells().size();
if (columns == 0) {
return;
}
double contentLeft = TerminalMetrics.PADDING;
double contentRight = contentLeft + columns * metrics.cellWidth();
gc.setFill(rowEdgeBackground(row, true));
gc.fillRect(0.0, 0.0, contentLeft, bandHeight);
gc.setFill(rowEdgeBackground(row, false));
gc.fillRect(contentRight, 0.0, paneWidth - contentRight, bandHeight);
}
private void drawRow(GraphicsContext gc, RenderRow row, double rowTop, double cellWidth, double lineHeight) {
double contentTop = TerminalMetrics.PADDING + row.row() * lineHeight;
double localCellTop = contentTop - rowTop;
double baseline = TerminalMetrics.PADDING + metrics.baselineOffset() + row.row() * lineHeight - rowTop;
drawRowBackgrounds(gc, row, localCellTop, cellWidth, lineHeight, 0, row.cells().size() - 1);
drawRowText(gc, row, baseline, cellWidth, 0, row.cells().size() - 1);
}
private void drawRowBackgrounds(GraphicsContext gc, RenderRow row, double localCellTop,
double cellWidth, double lineHeight, int startColumn, int endColumn) {
Color runBackground = null;
int runStartColumn = 0;
int previousColumn = -1;
for (RenderCell cell : row.cells()) {
if (cell.column() < startColumn || cell.column() > endColumn) {
continue;
}
if (cell.kittyPlaceholder().isPresent()) {
flushBackgroundRun(gc, runBackground, localCellTop, cellWidth, lineHeight, runStartColumn, previousColumn);
runBackground = null;
previousColumn = -1;
continue;
}
Color background = cell.selected() ? SELECTED_BACKGROUND : cellBackgroundOverride(cell);
if (background == null) {
flushBackgroundRun(gc, runBackground, localCellTop, cellWidth, lineHeight, runStartColumn, previousColumn);
runBackground = null;
previousColumn = -1;
continue;
}
if (runBackground == null || background != runBackground || cell.column() != previousColumn + 1) {
flushBackgroundRun(gc, runBackground, localCellTop, cellWidth, lineHeight, runStartColumn, previousColumn);
runBackground = background;
runStartColumn = cell.column();
}
previousColumn = cell.column();
}
flushBackgroundRun(gc, runBackground, localCellTop, cellWidth, lineHeight, runStartColumn, previousColumn);
}
private void flushBackgroundRun(GraphicsContext gc, Color background, double localCellTop,
double cellWidth, double lineHeight, int startColumn, int endColumn) {
if (background == null || endColumn < startColumn) {
return;
}
gc.setFill(background);
gc.fillRect(
TerminalMetrics.PADDING + startColumn * cellWidth,
localCellTop,
(endColumn - startColumn + 1) * cellWidth,
lineHeight);
}
private void drawRowText(GraphicsContext gc, RenderRow row, double baseline,
double cellWidth, int startColumn, int endColumn) {
for (RenderCell cell : row.cells()) {
if (cell.column() < startColumn || cell.column() > endColumn) {
continue;
}
if (cell.kittyPlaceholder().isPresent() || cell.codepoints().length == 0) {
continue;
}
gc.setFill(cellForegroundColor(cell));
gc.fillText(cell.text(), TerminalMetrics.PADDING + cell.column() * cellWidth, baseline);
}
}
private static long[] cellFingerprints(RenderRow row) {
int columns = row.cells().size();
for (RenderCell cell : row.cells()) {
columns = Math.max(columns, cell.column() + 1);
}
long[] fingerprints = new long[columns];
for (RenderCell cell : row.cells()) {
fingerprints[cell.column()] = cellFingerprint(cell);
}
return fingerprints;
}
}
private record KittyImageKey(long id, long number, long width, long height, KittyImageFormat format, int dataLength) {
private static KittyImageKey of(KittyImageSnapshot snapshot, byte[] data) {
return new KittyImageKey(
snapshot.id(),
snapshot.number(),
snapshot.width(),
snapshot.height(),
snapshot.format(),
data.length
);
}
}
private record KittyPlaceholderKey(long imageId, long placementId) {
}
private record SourceRect(double x, double y, double width, double height) {
}
private record RowMove(int sourceRow, int targetRow) {
}
private record ShiftPlan(List<RowMove> moves, Set<Integer> targetRows) {
}
private static final class KittyPlaceholderBounds {
private int minRow = Integer.MAX_VALUE;
private int maxRow = Integer.MIN_VALUE;
private int minColumn = Integer.MAX_VALUE;
private int maxColumn = Integer.MIN_VALUE;
private long minSourceRow = Long.MAX_VALUE;
private long maxSourceRow = Long.MIN_VALUE;
private long minSourceColumn = Long.MAX_VALUE;
private long maxSourceColumn = Long.MIN_VALUE;
private void include(int row, int column, KittyPlaceholder placeholder) {
minRow = Math.min(minRow, row);
maxRow = Math.max(maxRow, row);
minColumn = Math.min(minColumn, column);
maxColumn = Math.max(maxColumn, column);
minSourceRow = Math.min(minSourceRow, placeholder.sourceRow());
maxSourceRow = Math.max(maxSourceRow, placeholder.sourceRow());
minSourceColumn = Math.min(minSourceColumn, placeholder.sourceColumn());
maxSourceColumn = Math.max(maxSourceColumn, placeholder.sourceColumn());
}
private boolean isEmpty() {
return minRow == Integer.MAX_VALUE;
}
private int rows() {
return maxRow - minRow + 1;
}
private int columns() {
return maxColumn - minColumn + 1;
}
private long sourceRows() {
return maxSourceRow - minSourceRow + 1;
}
private long sourceColumns() {
return maxSourceColumn - minSourceColumn + 1;
}
}
}

View File

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

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