38 Commits

Author SHA1 Message Date
ec769f0f92 trigger gc on a timer to free vram by garbage collecting prism refs 2026-06-05 15:40:47 +02:00
c762e53bf4 make promoteActiveFloating -> toggleActiveFloating (works in reverse) 2026-06-05 11:57:41 +02:00
217e865448 instantly close panes instead of waiting for process to end 2026-06-05 11:10:04 +02:00
55a1f2cf6d fix cursor rendereing regression 2026-06-03 12:46:50 +02:00
fd672f36f7 fix cursor bug, only use java if daemon is not available 2026-06-03 11:53:43 +02:00
281e34e098 retry java tool options sanatization 2026-06-02 13:43:52 +02:00
c6c385c756 sanatize out java tool options 2026-06-02 13:10:29 +02:00
f6b7669798 memory savings 2026-06-02 11:11:33 +02:00
81b26516fe memory savings 2026-06-02 11:09:37 +02:00
0fcba6a97d daemon mode 2026-06-02 10:18:48 +02:00
a5dca9ca46 try caching class loads 2026-06-02 10:00:36 +02:00
96674a3bf5 startup timint 2026-06-02 09:47:23 +02:00
c0ce81f125 shutdown hook 2026-06-02 09:35:41 +02:00
dcb70243aa configurable kill signals 2026-06-02 09:31:14 +02:00
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
33 changed files with 1915 additions and 901 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
.pre-commit-config.yaml .pre-commit-config.yaml
build build
build
.gradle .gradle
bin bin
.settings
.project
.worktrees
.classpath
.codexsession

4
.ignore Normal file
View File

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

View File

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

View File

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

View File

@@ -12,6 +12,8 @@ floating panes and tabs.
> setups, or Mesa/AMD/Intel GPUs. I'm happy to accept pull requests that broaden host > setups, or Mesa/AMD/Intel GPUs. I'm happy to accept pull requests that broaden host
> support. > support.
<video src="https://gitea.gregorlohaus.com/gregor/jprototerm/media/branch/main/demo.mp4" controls></video>
## Build ## Build
```sh ```sh
@@ -60,6 +62,54 @@ gradle run
The Gradle project is the source of truth for the JavaFX build. The Gradle project is the source of truth for the JavaFX build.
## Daemon (optional, faster launches)
Cold start pays for JVM + JavaFX + GL/X11 init every time. The optional daemon keeps one JVM
(one toolkit) running and hosts every window in it, so a `jprototerm` launch just asks the
daemon to open a window — it appears without paying that startup cost again.
Run it once in the background:
```sh
jprototerm --daemon &
```
After that, a bare `jprototerm` connects to the daemon and opens a window in the current
directory. If no daemon is running, `jprototerm` falls back to a standalone in-process window
(today's behavior), so it always works.
To start the daemon automatically with your graphical session, enable the bundled **user**
service (it's a user service, not a system one, because X11 needs a display — which only
exists after you log in):
```sh
mkdir -p ~/.config/systemd/user
ln -sf "$(dirname "$(readlink -f "$(command -v jprototerm)")")/../share/systemd/user/jprototerm.service" \
~/.config/systemd/user/jprototerm.service
systemctl --user enable --now jprototerm.service
```
After upgrading via nix profile upgrade:
```sh
systemctl --user disable jprototerm
ln -sf "$(dirname "$(readlink -f "$(command -v jprototerm)")")/../share/systemd/user/jprototerm.service" \
~/.config/systemd/user/jprototerm.service
systemctl --user enable --now jprototerm.service
systemctl --user restart jprototerm.service
```
If the daemon can't reach your display (e.g. `systemctl --user status jprototerm` shows it
failing to open a window), import the session variables once and restart it:
```sh
systemctl --user import-environment DISPLAY XAUTHORITY
systemctl --user restart jprototerm.service
```
Closing a window (the WM close button, or the close-pane key on the last pane) tears that
window down — its shell processes are signalled with the configured `close_signal` — without
affecting other windows or the daemon. Stop the daemon (and all its windows) with
`systemctl --user stop jprototerm.service`, or `pkill -f 'jprototerm --daemon'`.
## Config ## Config
Configuration is read from: Configuration is read from:

View File

@@ -3,6 +3,9 @@ columns = 100
rows = 30 rows = 30
max_scrollback = 100000 max_scrollback = 100000
shell = "/bin/bash" shell = "/bin/bash"
# Signal sent to a pane's shell process when the pane is closed (e.g. ALT+X).
# Use SIGKILL to force-kill instead of asking the shell to terminate.
close_signal = "SIGTERM"
font_family = "JetBrainsMono Nerd Font" font_family = "JetBrainsMono Nerd Font"
font_size = 15 font_size = 15
@@ -30,3 +33,4 @@ next_floating = "ALT+F12"
close_pane = "ALT+X" close_pane = "ALT+X"
open_font_selector = "ALT+T" open_font_selector = "ALT+T"
open_scrollback = "ALT+S" open_scrollback = "ALT+S"
paste = "CTRL+SHIFT+V"

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

View File

@@ -107,8 +107,35 @@
classpath="$classpath''${classpath:+:}$jar" classpath="$classpath''${classpath:+:}$jar"
done done
# The CDS archive records launch-time module/classpath properties, including
# Nix store paths. Key it by this build's launch shape so stale archives from a
# previous package path cannot be reused, and pass the flags on java's command
# line so terminal child processes do not inherit them through JAVA_TOOL_OPTIONS.
cdsArchive="app-$(printf '%s\n' \
"${pkgs.jdk25}" \
"$out" \
"$classpath" \
"$out/share/jprototerm/javafx" \
"--enable-native-access=ALL-UNNAMED,javafx.graphics" \
"--add-modules=javafx.controls,javafx.fxml" \
"com.gregor.jprototerm.Main" \
| sha256sum | cut -c1-16).jsa"
# Prism frees GPU textures (and the X11 pixmaps behind them) only from phantom-ref
# Disposers that run during a GC. This daemon is one long-lived JVM whose ~140MB heap
# never nears the multi-GB default ceiling, so G1 almost never collects, the Disposers
# never run, and orphaned render resources (closed panes/windows, resized backbuffers)
# pile up until the X server's VRAM is exhausted and the whole session freezes. -Xmx
# makes output churn drive GC; G1PeriodicGCInterval runs a concurrent (low-pause) GC on
# an idle timer so an idle daemon still reclaims. The live heap is tiny, so GC is cheap.
makeWrapper "${pkgs.jdk25}/bin/java" "$out/bin/jprototerm" \ makeWrapper "${pkgs.jdk25}/bin/java" "$out/bin/jprototerm" \
--run 'if [ "$#" -eq 0 ]; then if [ -n "''${XDG_RUNTIME_DIR:-}" ]; then jprototermSock="$XDG_RUNTIME_DIR/jprototerm/daemon.sock"; else jprototermSock="/tmp/jprototerm-''${USER:-user}/daemon.sock"; fi; if [ -S "$jprototermSock" ] && printf "%s\n" "$(pwd)" | ${pkgs.socat}/bin/socat - UNIX-CONNECT:"$jprototermSock" >/dev/null 2>&1; then exit 0; fi; fi' \
--run 'export JPROTOTERM_HOST_LD_LIBRARY_PATH="''${LD_LIBRARY_PATH:-}"' \ --run 'export JPROTOTERM_HOST_LD_LIBRARY_PATH="''${LD_LIBRARY_PATH:-}"' \
--run 'cdsDir="''${XDG_CACHE_HOME:-$HOME/.cache}/jprototerm"; mkdir -p "$cdsDir"' \
--add-flags "-XX:+AutoCreateSharedArchive" \
--add-flags "-XX:SharedArchiveFile=\$cdsDir/$cdsArchive" \
--add-flags "-Xmx512m" \
--add-flags "-XX:G1PeriodicGCInterval=5000" \
--add-flags "--enable-native-access=ALL-UNNAMED,javafx.graphics" \ --add-flags "--enable-native-access=ALL-UNNAMED,javafx.graphics" \
--add-flags "--module-path $out/share/jprototerm/javafx" \ --add-flags "--module-path $out/share/jprototerm/javafx" \
--add-flags "--add-modules javafx.controls,javafx.fxml" \ --add-flags "--add-modules javafx.controls,javafx.fxml" \
@@ -119,6 +146,25 @@
--set JLIBGHOSTTY_LIBRARY "${ghosttyVt}/lib/libghostty-vt.so" \ --set JLIBGHOSTTY_LIBRARY "${ghosttyVt}/lib/libghostty-vt.so" \
--set GDK_BACKEND x11 --set GDK_BACKEND x11
# Optional background daemon: one JVM hosts every window, so client launches skip
# cold JVM/JavaFX/GL startup. A *user* service tied to graphical-session.target (X11
# needs a display, which only exists after login). Enable instructions are in README.
mkdir -p "$out/share/systemd/user"
cat > "$out/share/systemd/user/jprototerm.service" <<EOF
[Unit]
Description=jprototerm terminal daemon
PartOf=graphical-session.target
After=graphical-session.target
[Service]
Type=simple
ExecStart=$out/bin/jprototerm --daemon
Restart=on-failure
[Install]
WantedBy=graphical-session.target
EOF
runHook postInstall runHook postInstall
''; '';
}); });

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.except.TomlException;
import io.github.wasabithumb.jtoml.key.TomlKey; import io.github.wasabithumb.jtoml.key.TomlKey;
import io.github.wasabithumb.jtoml.value.TomlValue; 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.primitive.TomlPrimitive;
import io.github.wasabithumb.jtoml.value.table.TomlTable; import io.github.wasabithumb.jtoml.value.table.TomlTable;
@@ -12,6 +13,7 @@ import java.nio.file.Files;
import java.io.IOException; import java.io.IOException;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.StandardOpenOption; import java.nio.file.StandardOpenOption;
import java.util.ArrayList;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@@ -20,13 +22,14 @@ public record AppConfig(
int columns, int columns,
int rows, int rows,
long maxScrollback, long maxScrollback,
String shell, List<String> shell,
String fontFamily, String fontFamily,
double fontSize, double fontSize,
double windowWidth, double windowWidth,
double windowHeight, double windowHeight,
boolean kittyGraphics, boolean kittyGraphics,
String scrollbackEditorCommand, String scrollbackEditorCommand,
String closeSignal,
Map<String, String> envOverride, Map<String, String> envOverride,
Map<String, KeyBinding> keybindings Map<String, KeyBinding> keybindings
) { ) {
@@ -38,12 +41,14 @@ public record AppConfig(
"toggle_floating", "toggle_floating",
"new_pane", "new_pane",
"next_floating", "next_floating",
"promote_floating",
"close_pane", "close_pane",
"new_tab", "new_tab",
"previous_tab", "previous_tab",
"next_tab", "next_tab",
"open_font_selector", "open_font_selector",
"open_scrollback" "open_scrollback",
"paste"
); );
public static AppConfig load() { public static AppConfig load() {
@@ -60,13 +65,14 @@ public record AppConfig(
intValue(document, "terminal.columns", defaults.columns), intValue(document, "terminal.columns", defaults.columns),
intValue(document, "terminal.rows", defaults.rows), intValue(document, "terminal.rows", defaults.rows),
longValue(document, "terminal.max_scrollback", defaults.maxScrollback), 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), stringValue(document, "terminal.font_family", defaults.fontFamily),
doubleValue(document, "terminal.font_size", defaults.fontSize), doubleValue(document, "terminal.font_size", defaults.fontSize),
doubleValue(document, "window.width", defaults.windowWidth), doubleValue(document, "window.width", defaults.windowWidth),
doubleValue(document, "window.height", defaults.windowHeight), doubleValue(document, "window.height", defaults.windowHeight),
booleanValue(document, "kitty_graphics.enabled", defaults.kittyGraphics), booleanValue(document, "kitty_graphics.enabled", defaults.kittyGraphics),
stringValue(document, "scrollback.editor_command", defaults.scrollbackEditorCommand), stringValue(document, "scrollback.editor_command", defaults.scrollbackEditorCommand),
closeSignalValue(document, defaults.closeSignal),
envOverride(document, defaults.envOverride), envOverride(document, defaults.envOverride),
keybindings(document, defaults) keybindings(document, defaults)
); );
@@ -88,6 +94,7 @@ public record AppConfig(
760.0, 760.0,
true, true,
defaultScrollbackEditorCommand(), defaultScrollbackEditorCommand(),
"SIGTERM",
Map.of(), Map.of(),
Map.ofEntries( Map.ofEntries(
Map.entry("navigate_left", KeyBinding.parse("ALT+H")), Map.entry("navigate_left", KeyBinding.parse("ALT+H")),
@@ -97,12 +104,14 @@ public record AppConfig(
Map.entry("toggle_floating", KeyBinding.parse("ALT+F")), Map.entry("toggle_floating", KeyBinding.parse("ALT+F")),
Map.entry("new_pane", KeyBinding.parse("ALT+N")), Map.entry("new_pane", KeyBinding.parse("ALT+N")),
Map.entry("next_floating", KeyBinding.parse("ALT+F12")), Map.entry("next_floating", KeyBinding.parse("ALT+F12")),
Map.entry("promote_floating", KeyBinding.parse("ALT+P")),
Map.entry("close_pane", KeyBinding.parse("ALT+X")), Map.entry("close_pane", KeyBinding.parse("ALT+X")),
Map.entry("new_tab", KeyBinding.parse("ALT+A")), Map.entry("new_tab", KeyBinding.parse("ALT+A")),
Map.entry("previous_tab", KeyBinding.parse("ALT+SHIFT+H")), Map.entry("previous_tab", KeyBinding.parse("ALT+SHIFT+H")),
Map.entry("next_tab", KeyBinding.parse("ALT+SHIFT+L")), Map.entry("next_tab", KeyBinding.parse("ALT+SHIFT+L")),
Map.entry("open_font_selector", KeyBinding.parse("ALT+T")), Map.entry("open_font_selector", KeyBinding.parse("ALT+T")),
Map.entry("open_scrollback", KeyBinding.parse("ALT+S")) Map.entry("open_scrollback", KeyBinding.parse("ALT+S")),
Map.entry("paste", KeyBinding.parse("CTRL+SHIFT+V"))
) )
); );
} }
@@ -119,11 +128,22 @@ public record AppConfig(
windowHeight, windowHeight,
kittyGraphics, kittyGraphics,
scrollbackEditorCommand, scrollbackEditorCommand,
closeSignal,
envOverride, envOverride,
keybindings keybindings
); );
} }
/**
* The {@link #closeSignal} as a Linux signal number, sent to a pane's shell process when the
* pane is closed (e.g. via the close-pane key). Falls back to SIGTERM (15) if the configured
* name is somehow unresolvable.
*/
public int closeSignalNumber() {
int number = LinuxPty.signalNumber(closeSignal);
return number < 0 ? 15 : number;
}
public void save() { public void save() {
save(configPath(), this); save(configPath(), this);
} }
@@ -136,8 +156,10 @@ public record AppConfig(
return Path.of(System.getProperty("user.home"), ".config", "jprototerm", "config.toml"); return Path.of(System.getProperty("user.home"), ".config", "jprototerm", "config.toml");
} }
private static String defaultShell() { private static List<String> defaultShell() {
return "/bin/bash"; // 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() { private static String defaultScrollbackEditorCommand() {
@@ -148,6 +170,23 @@ public record AppConfig(
return editor.trim() + " {file}"; return editor.trim() + " {file}";
} }
/**
* Reads {@code terminal.close_signal}, normalising it to a canonical {@code SIG*} name. An
* unknown or unset value keeps {@code fallback} so a typo can't leave a pane unkillable.
*/
private static String closeSignalValue(TomlTable table, String fallback) {
String value = stringValue(table, "terminal.close_signal", null);
if (value == null) {
return fallback;
}
if (LinuxPty.signalNumber(value) < 0) {
System.err.println("Unknown terminal.close_signal '" + value + "', using " + fallback);
return fallback;
}
String normalized = value.trim().toUpperCase(java.util.Locale.ROOT);
return normalized.startsWith("SIG") ? normalized : "SIG" + normalized;
}
private static Map<String, KeyBinding> keybindings(TomlTable table, AppConfig defaults) { private static Map<String, KeyBinding> keybindings(TomlTable table, AppConfig defaults) {
Map<String, KeyBinding> parsed = new LinkedHashMap<>(); Map<String, KeyBinding> parsed = new LinkedHashMap<>();
for (String key : KEYBINDING_KEYS) { for (String key : KEYBINDING_KEYS) {
@@ -184,7 +223,8 @@ public record AppConfig(
builder.append("columns = ").append(columns).append('\n'); builder.append("columns = ").append(columns).append('\n');
builder.append("rows = ").append(rows).append('\n'); builder.append("rows = ").append(rows).append('\n');
builder.append("max_scrollback = ").append(maxScrollback).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("close_signal = ").append(quoted(closeSignal)).append('\n');
builder.append("font_family = ").append(quoted(fontFamily)).append('\n'); builder.append("font_family = ").append(quoted(fontFamily)).append('\n');
builder.append("font_size = ").append(trimDouble(fontSize)).append("\n\n"); builder.append("font_size = ").append(trimDouble(fontSize)).append("\n\n");
builder.append("[window]\n"); builder.append("[window]\n");
@@ -209,6 +249,17 @@ public record AppConfig(
return builder.toString(); 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) { private static String quoted(String value) {
return "\"" + value return "\"" + value
.replace("\\", "\\\\") .replace("\\", "\\\\")
@@ -268,6 +319,26 @@ public record AppConfig(
return primitive == null ? fallback : primitive.asString(); 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) { private static int intValue(TomlTable table, String key, int fallback) {
TomlPrimitive primitive = primitive(table, key); TomlPrimitive primitive = primitive(table, key);
if (primitive == null) { if (primitive == null) {

View File

@@ -5,6 +5,7 @@ import dev.jlibghostty.MouseButton;
import dev.jlibghostty.MouseEncoderSize; import dev.jlibghostty.MouseEncoderSize;
import dev.jlibghostty.MouseInput; import dev.jlibghostty.MouseInput;
import javafx.geometry.VPos; import javafx.geometry.VPos;
import javafx.scene.Node;
import javafx.scene.canvas.Canvas; import javafx.scene.canvas.Canvas;
import javafx.scene.canvas.GraphicsContext; import javafx.scene.canvas.GraphicsContext;
import javafx.scene.input.InputEvent; import javafx.scene.input.InputEvent;
@@ -18,9 +19,11 @@ import javafx.scene.text.TextAlignment;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashMap; import java.util.HashMap;
import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.Set;
/** /**
* Owns the window's tabs and drives rendering and input. It composites only the current tab: * Owns the window's tabs and drives rendering and input. It composites only the current tab:
@@ -38,6 +41,8 @@ public final class Compositor {
private static final double TAB_BAR_HEIGHT = 22.0; private static final double TAB_BAR_HEIGHT = 22.0;
private final Canvas canvas = new Canvas(); private final Canvas canvas = new Canvas();
// Kitty images are drawn as retained nodes layered over the canvas, not composited onto it.
private final KittyImageOverlay imageOverlay = new KittyImageOverlay();
private final AppConfig config; private final AppConfig config;
private final TerminalMetrics metrics; private final TerminalMetrics metrics;
private final List<Tab> tabs = new ArrayList<>(); private final List<Tab> tabs = new ArrayList<>();
@@ -49,6 +54,16 @@ public final class Compositor {
// Last content version drawn to the canvas per pane, so a content frame repaints only // Last content version drawn to the canvas per pane, so a content frame repaints only
// the panes that actually changed. // the panes that actually changed.
private final Map<TerminalPane, Long> paneContentVersion = new HashMap<>(); private final Map<TerminalPane, Long> paneContentVersion = new HashMap<>();
// Off-screen panes (background tabs, hidden floating groups) keep their full-resolution pixel
// backbuffer + GPU image until released. We free them after a short grace period rather than the
// instant they're hidden, so rapidly flipping through tabs never thrashes the realloc/upload.
private static final long RELEASE_DELAY_NANOS = 750_000_000L;
// Hidden pane -> nanoTime it became hidden (the release timer); removed once released or shown.
private final Map<TerminalPane, Long> hiddenSince = new HashMap<>();
// Panes whose backbuffer is currently released, so we don't release again every frame.
private final Set<TerminalPane> released = new HashSet<>();
// layoutVersion at the last sweep: lets an idle, all-released steady state skip the scan.
private long lastSweepLayoutVersion = Long.MIN_VALUE;
// Cheap per-frame dirty signal: skip the whole render when none of these changed. // Cheap per-frame dirty signal: skip the whole render when none of these changed.
private double lastWidth = -1.0; private double lastWidth = -1.0;
private double lastHeight = -1.0; private double lastHeight = -1.0;
@@ -58,11 +73,22 @@ public final class Compositor {
private long lastContentVersion = Long.MIN_VALUE; private long lastContentVersion = Long.MIN_VALUE;
private boolean mouseButtonPressed; private boolean mouseButtonPressed;
private MouseButton pressedButton = MouseButton.UNKNOWN; 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) { public Compositor(AppConfig config, TerminalMetrics metrics) {
this(config, metrics, null);
}
/**
* Creates a compositor whose first tab's first pane starts in {@code workingDirectory} (e.g. the
* cwd a client passed when asking the daemon to open this window), or the user's home when
* {@code null}.
*/
public Compositor(AppConfig config, TerminalMetrics metrics, String workingDirectory) {
this.config = config; this.config = config;
this.metrics = metrics; this.metrics = metrics;
tabs.add(new Tab(config, metrics)); tabs.add(new Tab(config, metrics, workingDirectory, this::closePane));
canvas.setFocusTraversable(true); canvas.setFocusTraversable(true);
canvas.setOnMousePressed(this::handleMousePressed); canvas.setOnMousePressed(this::handleMousePressed);
canvas.setOnMouseReleased(this::handleMouseReleased); canvas.setOnMouseReleased(this::handleMouseReleased);
@@ -75,6 +101,16 @@ public final class Compositor {
return canvas; return canvas;
} }
/** Sets the callback run when the last pane closes (e.g. to quit the application). */
public void setOnEmpty(Runnable onEmpty) {
this.onEmpty = onEmpty;
}
/** The kitty-image overlay, to be stacked directly above {@link #canvas()} in the window. */
public Node imageOverlay() {
return imageOverlay.node();
}
public void setFont(String family, double size) { public void setFont(String family, double size) {
metrics.setFont(family, size); metrics.setFont(family, size);
paneContentVersion.clear(); paneContentVersion.clear();
@@ -113,6 +149,29 @@ public final class Compositor {
layoutVersion++; layoutVersion++;
} }
/** Opens a new floating pane, makes it active, and returns it (null when no tab exists). */
public TerminalPane openFloatingPane() {
if (isEmpty()) {
return null;
}
TerminalPane pane = currentTab().createFloatingPane();
layoutVersion++;
return pane;
}
/**
* 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() { public void nextFloatingPane() {
if (isEmpty()) { if (isEmpty()) {
return; return;
@@ -121,24 +180,58 @@ public final class Compositor {
layoutVersion++; layoutVersion++;
} }
public void toggleActiveFloating() {
if (isEmpty()) {
return;
}
currentTab().toggleActiveFloating();
layoutVersion++;
}
public void closeActivePane() { public void closeActivePane() {
if (isEmpty()) { if (isEmpty()) {
return; return;
} }
currentTab().closeActivePane(); TerminalPane active = currentTab().activePane();
if (currentTab().isEmpty()) { if (active != null) {
// Closing a tab's last pane closes the tab. When no tabs remain the surface is closePane(active);
// empty and Main quits. }
tabs.remove(currentTabIndex); }
if (currentTabIndex >= tabs.size()) {
/**
* 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); currentTabIndex = Math.max(0, tabs.size() - 1);
} }
} }
layoutVersion++; layoutVersion++;
if (isEmpty()) {
onEmpty.run();
}
return;
}
}
} }
public void newTab() { public void newTab() {
tabs.add(new Tab(config, metrics)); // Open the new tab in the currently active pane's working directory, so it lands where the
// user currently is rather than always in home.
String workingDirectory = isEmpty() ? null : currentTab().activePane().currentWorkingDirectory();
tabs.add(new Tab(config, metrics, workingDirectory, this::closePane));
currentTabIndex = tabs.size() - 1; currentTabIndex = tabs.size() - 1;
layoutVersion++; layoutVersion++;
} }
@@ -164,6 +257,18 @@ public final class Compositor {
tabs.clear(); tabs.clear();
} }
/**
* Signals and reaps every pane's shell process across all tabs, without tearing down render
* state. Intended for a JVM shutdown hook (SIGTERM/SIGINT/SIGHUP), so child shells get the
* configured close signal instead of being orphaned when jprototerm itself is killed. Safe to
* call off the FX thread and idempotent; see {@link TerminalPane#terminateSession()}.
*/
public void terminateSessions() {
for (Tab tab : List.copyOf(tabs)) {
tab.terminateSessions();
}
}
private Tab currentTab() { private Tab currentTab() {
return tabs.get(currentTabIndex); return tabs.get(currentTabIndex);
} }
@@ -185,6 +290,7 @@ public final class Compositor {
// ---- Rendering ------------------------------------------------------------------ // ---- Rendering ------------------------------------------------------------------
public void render() { public void render() {
sweepHiddenPanes();
switch (nextFrameType()) { switch (nextFrameType()) {
case IDLE -> { } case IDLE -> { }
case LAYOUT -> renderLayoutFrame(); case LAYOUT -> renderLayoutFrame();
@@ -192,6 +298,48 @@ public final class Compositor {
} }
} }
// Free the backbuffer of any pane that has been off-screen past the grace period, and re-arm the
// timer for newly hidden panes. The next layout frame rebuilds a released pane (paintFull goes
// through ensure()), so showing a tab again is the only cost. Skips entirely once everything that
// can be hidden is already released and the layout hasn't changed, so an idle multi-tab window
// does no per-frame work here.
private void sweepHiddenPanes() {
if (layoutVersion == lastSweepLayoutVersion && hiddenSince.isEmpty()) {
return;
}
lastSweepLayoutVersion = layoutVersion;
// Fast path: a single tab compositing all of its panes has nothing off-screen.
if (tabs.size() <= 1 && (tabs.isEmpty() || !currentTab().hasHiddenPanes())) {
hiddenSince.clear();
released.clear();
return;
}
Set<TerminalPane> visible = new HashSet<>(currentPanes());
Set<TerminalPane> live = new HashSet<>();
long now = System.nanoTime();
for (Tab tab : tabs) {
for (TerminalPane pane : tab.allPanes()) {
live.add(pane);
if (visible.contains(pane)) {
hiddenSince.remove(pane);
released.remove(pane);
} else if (!released.contains(pane)) {
Long since = hiddenSince.putIfAbsent(pane, now);
if (since != null && now - since >= RELEASE_DELAY_NANOS) {
pane.releaseRenderResources();
released.add(pane);
hiddenSince.remove(pane);
}
}
}
}
// Forget panes that have since closed.
hiddenSince.keySet().retainAll(live);
released.retainAll(live);
}
// Classify this frame and commit the change trackers. A layout change (size, font, // 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 // 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 // current tab's content version repaints only the panes that changed; otherwise nothing
@@ -246,6 +394,7 @@ public final class Compositor {
for (TerminalPane pane : panes) { for (TerminalPane pane : panes) {
paneContentVersion.put(pane, pane.paintFull(gc, isActive(pane))); paneContentVersion.put(pane, pane.paintFull(gc, isActive(pane)));
} }
imageOverlay.sync(panes);
} }
// Repaint just the panes whose content changed, directly on the retained canvas. Each pane // Repaint just the panes whose content changed, directly on the retained canvas. Each pane
@@ -262,6 +411,7 @@ public final class Compositor {
continue; continue;
} }
paneContentVersion.put(pane, pane.paintIncremental(gc, isActive(pane))); paneContentVersion.put(pane, pane.paintIncremental(gc, isActive(pane)));
imageOverlay.updatePane(pane);
} }
} }

View File

@@ -0,0 +1,146 @@
package com.gregor.jprototerm;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.net.StandardProtocolFamily;
import java.net.UnixDomainSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.attribute.PosixFilePermissions;
/**
* Single-instance server and its thin client, over a per-user Unix domain socket. The server hosts
* every window in one JVM (see {@link WindowManager}); a client invocation just asks it to open a
* window in the client's working directory and exits, so the window appears without paying cold
* JVM/JavaFX/GL startup.
*
* <p>Protocol is deliberately trivial: the client writes one UTF-8 line — the absolute working
* directory — and the server replies {@code OK\n}. The socket lives under {@code XDG_RUNTIME_DIR}
* (mode 0700), so only the owning user can connect.
*/
public final class Daemon {
private Daemon() {
}
/** Runs the server: brings up the toolkit, binds the socket, and serves window-open requests. */
public static void run() {
Path socket = socketPath();
try {
Files.createDirectories(socket.getParent());
trySecureDir(socket.getParent());
} catch (IOException ex) {
System.err.println("jprototerm: cannot create socket dir " + socket.getParent() + ": " + ex.getMessage());
return;
}
WindowManager manager = WindowManager.start(WindowManager.Mode.DAEMON);
try (ServerSocketChannel server = bind(socket)) {
while (true) {
try {
handle(server.accept(), manager);
} catch (IOException ex) {
System.err.println("jprototerm: connection error: " + ex.getMessage());
}
}
} catch (IOException ex) {
System.err.println("jprototerm: daemon socket error: " + ex.getMessage());
}
}
/**
* Client side: connect to a running daemon and ask it to open a window in {@code workingDirectory}.
* Returns {@code true} if the daemon handled it, {@code false} if none is reachable (the caller
* then falls back to a standalone in-process window).
*/
public static boolean tryClient(String workingDirectory) {
Path socket = socketPath();
if (!Files.exists(socket)) {
return false;
}
try (SocketChannel channel = SocketChannel.open(UnixDomainSocketAddress.of(socket))) {
channel.write(ByteBuffer.wrap((workingDirectory + "\n").getBytes(StandardCharsets.UTF_8)));
// Best-effort wait for the ack so we don't race ahead of the window opening.
channel.read(ByteBuffer.allocate(16));
return true;
} catch (IOException ex) {
return false; // no daemon, or a stale socket file — fall back to standalone
}
}
private static ServerSocketChannel bind(Path socket) throws IOException {
UnixDomainSocketAddress address = UnixDomainSocketAddress.of(socket);
ServerSocketChannel channel = ServerSocketChannel.open(StandardProtocolFamily.UNIX);
try {
channel.bind(address);
return channel;
} catch (IOException firstTry) {
// The path is taken. If a live daemon answers, this invocation lost the race; otherwise
// it's a stale socket from a crashed daemon, so remove it and rebind.
if (tryClient0(socket)) {
channel.close();
System.err.println("jprototerm: a daemon is already running");
System.exit(0);
}
Files.deleteIfExists(socket);
channel.bind(address);
return channel;
}
}
/** A bare connect probe used by {@link #bind} to tell a live daemon from a stale socket file. */
private static boolean tryClient0(Path socket) {
try (SocketChannel channel = SocketChannel.open(UnixDomainSocketAddress.of(socket))) {
return true;
} catch (IOException ex) {
return false;
}
}
private static void handle(SocketChannel connection, WindowManager manager) throws IOException {
try (connection) {
String workingDirectory = readLine(connection);
manager.openWindow(workingDirectory == null || workingDirectory.isBlank()
? null
: workingDirectory.trim());
connection.write(ByteBuffer.wrap("OK\n".getBytes(StandardCharsets.UTF_8)));
}
}
private static String readLine(SocketChannel channel) throws IOException {
ByteArrayOutputStream out = new ByteArrayOutputStream();
ByteBuffer buffer = ByteBuffer.allocate(4096);
while (channel.read(buffer) != -1) {
buffer.flip();
while (buffer.hasRemaining()) {
byte b = buffer.get();
if (b == '\n') {
return out.toString(StandardCharsets.UTF_8);
}
out.write(b);
}
buffer.clear();
}
return out.size() == 0 ? null : out.toString(StandardCharsets.UTF_8);
}
private static Path socketPath() {
String runtimeDir = System.getenv("XDG_RUNTIME_DIR");
Path dir = runtimeDir != null && !runtimeDir.isBlank()
? Path.of(runtimeDir, "jprototerm")
: Path.of("/tmp", "jprototerm-" + System.getProperty("user.name", "user"));
return dir.resolve("daemon.sock");
}
private static void trySecureDir(Path dir) {
try {
Files.setPosixFilePermissions(dir, PosixFilePermissions.fromString("rwx------"));
} catch (IOException | UnsupportedOperationException ignored) {
// Best effort: XDG_RUNTIME_DIR is already user-private; the /tmp fallback we try to lock.
}
}
}

View File

@@ -1,8 +1,8 @@
package com.gregor.jprototerm; package com.gregor.jprototerm;
import dev.jlibghostty.KittyImage;
import dev.jlibghostty.KittyImageCompression; import dev.jlibghostty.KittyImageCompression;
import dev.jlibghostty.KittyImageFormat; import dev.jlibghostty.KittyImageFormat;
import dev.jlibghostty.KittyImageSnapshot;
import dev.jlibghostty.KittyPlacement; import dev.jlibghostty.KittyPlacement;
import dev.jlibghostty.KittyPlacementLayer; import dev.jlibghostty.KittyPlacementLayer;
import dev.jlibghostty.KittyPlaceholder; import dev.jlibghostty.KittyPlaceholder;
@@ -13,29 +13,27 @@ import dev.jlibghostty.RenderCursorStyle;
import dev.jlibghostty.RenderRow; import dev.jlibghostty.RenderRow;
import dev.jlibghostty.RenderStateSnapshot; import dev.jlibghostty.RenderStateSnapshot;
import javafx.geometry.Rectangle2D; import javafx.geometry.Rectangle2D;
import javafx.scene.SnapshotParameters;
import javafx.scene.canvas.Canvas;
import javafx.scene.canvas.GraphicsContext; import javafx.scene.canvas.GraphicsContext;
import javafx.scene.image.Image; import javafx.scene.image.Image;
import javafx.scene.image.PixelFormat; import javafx.scene.image.PixelFormat;
import javafx.scene.image.PixelBuffer; import javafx.scene.image.PixelBuffer;
import javafx.scene.image.PixelReader;
import javafx.scene.image.WritableImage; import javafx.scene.image.WritableImage;
import javafx.scene.paint.Color; import javafx.scene.paint.Color;
import javafx.scene.text.FontSmoothingType;
import javafx.scene.text.Text;
import java.io.ByteArrayInputStream; import java.io.ByteArrayInputStream;
import java.nio.IntBuffer; import java.nio.IntBuffer;
import java.util.ArrayList;
import java.util.Arrays; import java.util.Arrays;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
/** /**
* The real terminal renderer: paints a pane's background, cell rows, cursor, border, padding * The real terminal renderer: rasterizes a pane's background, cell rows, cursor, border and
* and (when enabled) kitty graphics. One instance per pane, since it caches that pane's * padding into the {@link SoftwareBackbuffer} and blits it to the canvas. Kitty images are not
* decoded kitty images. * drawn here — each paint collects their placements (see {@link #kittyImages()}) for the
* compositor to render as overlay nodes above the canvas. One instance per pane, since it caches
* that pane's decoded kitty images.
*/ */
final class GhosttyTerminalRenderer extends TerminalRenderer { final class GhosttyTerminalRenderer extends TerminalRenderer {
// GhosttyRenderStateDirty values (stable C ABI; see ghostty/vt/render.h). // GhosttyRenderStateDirty values (stable C ABI; see ghostty/vt/render.h).
@@ -60,6 +58,9 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
// Decoded kitty images for this renderer's pane (kitty graphics state is per-terminal). // Decoded kitty images for this renderer's pane (kitty graphics state is per-terminal).
private final Map<KittyImageKey, Image> kittyImageCache = new HashMap<>(); private final Map<KittyImageKey, Image> kittyImageCache = new HashMap<>();
private final SoftwareBackbuffer software = new SoftwareBackbuffer(); private final SoftwareBackbuffer software = new SoftwareBackbuffer();
// Image placements produced by the last paint; the compositor reads these and renders them
// as overlay nodes rather than compositing them onto the canvas. Empty for non-kitty panes.
private List<KittyImageNode> kittyImageNodes = List.of();
GhosttyTerminalRenderer(TerminalMetrics metrics) { GhosttyTerminalRenderer(TerminalMetrics metrics) {
this.metrics = metrics; this.metrics = metrics;
@@ -73,15 +74,10 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
double height = target.height(); double height = target.height();
gc.save(); gc.save();
clip(gc, px, py, width, height, target.clip()); clip(gc, px, py, width, height, target.clip());
boolean withKitty = target.kittyEnabled() && hasKittyGraphics(target);
RenderStateSnapshot snapshot = target.snapshotFull(); RenderStateSnapshot snapshot = target.snapshotFull();
if (withKitty) {
drawContent(gc, target, snapshot, px, py, width, height, active, true);
software.invalidate();
} else {
software.paintFull(gc, snapshot, px, py, width, height, active); software.paintFull(gc, snapshot, px, py, width, height, active);
}
gc.restore(); gc.restore();
collectKittyImages(target, snapshot, px, py);
} }
@Override @Override
@@ -93,171 +89,44 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
gc.save(); gc.save();
clip(gc, px, py, width, height, target.clip()); clip(gc, px, py, width, height, target.clip());
if (target.kittyEnabled() && hasKittyGraphics(target)) { if (target.kittyEnabled() && hasKittyGraphics(target)) {
// Kitty placements can move without a per-row dirty flag, so always redraw whole. // Images render as overlay nodes, not on the canvas, but their positions track the
drawContent(gc, target, target.snapshotFull(), px, py, width, height, active, true); // grid (scroll/cursor), so we need a full snapshot to locate placeholder cells. The
software.invalidate(); // software path itself still repaints only the text rows whose hash changed.
} else { RenderStateSnapshot snapshot = target.snapshotFull();
software.paintFullOrShifted(gc, snapshot, px, py, width, height, active);
gc.restore();
collectKittyImages(target, snapshot, px, py);
return;
}
RenderStateSnapshot snapshot = target.snapshot(); RenderStateSnapshot snapshot = target.snapshot();
int dirty = snapshot == null ? DIRTY_FULL : snapshot.dirty(); int dirty = snapshot == null ? DIRTY_FULL : snapshot.dirty();
if (dirty == DIRTY_FULL) { if (dirty == DIRTY_FULL) {
software.paintFullOrShifted(gc, target.snapshotFull(), px, py, width, height, active); software.paintFullOrShifted(gc, target.snapshotFull(), px, py, width, height, active);
} else if (dirty == DIRTY_PARTIAL) { } else if (dirty == DIRTY_PARTIAL) {
software.paintDirty(gc, target, snapshot, px, py, width, height, active); software.paintDirty(gc, target, snapshot, px, py, width, height, active);
} else if (software.cursorChanged(snapshot)) {
// dirty == FALSE means no cell content changed, but the cursor can still have moved,
// changed style, or toggled visibility on its own (e.g. plain cursor-left/right, or the
// hide/redraw/show dance fish does around a line edit). No row was marshalled, and we
// must not force a full snapshot here — that would re-marshal every cell on every
// cursor move. Instead restore the pixels saved beneath the old cursor (erasing it)
// and redraw the cursor at its new spot, touching only those two cell rects.
software.paintCursorOnly(gc, snapshot, px, py, width, height);
} }
// dirty == FALSE: nothing visible changed. // dirty == FALSE with an unchanged cursor: nothing visible changed.
}
gc.restore(); gc.restore();
kittyImageNodes = List.of();
} }
// Full content render: background, border, all rows, cursor, and (when enabled) kitty @Override
// graphics. Used by the kitty direct path and by full redraws. List<KittyImageNode> kittyImages() {
private void drawContent( return kittyImageNodes;
GraphicsContext gc,
RenderTarget target,
RenderStateSnapshot snapshot,
double x,
double y,
double width,
double height,
boolean active,
boolean withKitty
) {
double cellWidth = metrics.cellWidth();
double lineHeight = metrics.lineHeight();
gc.setFontSmoothingType(FontSmoothingType.LCD);
gc.setFill(PANE_BACKGROUND);
gc.fillRect(x, y, width, height);
gc.setFont(metrics.font());
double left = x + TerminalMetrics.PADDING;
double top = y + TerminalMetrics.PADDING;
double baseline = top + metrics.baselineOffset();
Map<KittyPlaceholderKey, KittyPlaceholderBounds> placeholderBounds = withKitty
? kittyPlaceholderBounds(snapshot)
: Map.of();
if (withKitty) {
drawKittyGraphics(gc, target, KittyPlacementLayer.BELOW_TEXT, placeholderBounds, left, top, cellWidth, lineHeight);
} }
if (snapshot != null) { @Override
double contentBottom = top + snapshot.rows() * lineHeight; void release() {
fillVerticalPadding(gc, snapshot, x, y, width, height, top, contentBottom); software.release();
for (RenderRow row : snapshot.renderRows()) { kittyImageNodes = List.of();
double y0 = Math.floor(top + (row.row() * lineHeight));
double y1 = Math.ceil(top + ((row.row() + 1) * lineHeight));
paintSidePadding(gc, row, x, width, left, cellWidth, y0, y1 - y0);
drawRow(gc, row, left, top, baseline, cellWidth, lineHeight);
}
drawCursor(gc, snapshot, left, top, cellWidth, lineHeight);
}
if (withKitty) {
drawKittyGraphics(gc, target, KittyPlacementLayer.ABOVE_TEXT, placeholderBounds, left, top, cellWidth, lineHeight);
}
drawBorder(gc, x, y, width, height, active);
}
// Incremental render: repaint only the rows ghostty flagged dirty, then restore the
// cursor and border. The local band tracks the repainted span only so the border redraw
// can be limited to it.
private void drawDirtyRows(
GraphicsContext gc,
RenderStateSnapshot snapshot,
double px,
double py,
double pw,
double ph,
boolean active
) {
double cellWidth = metrics.cellWidth();
double lineHeight = metrics.lineHeight();
gc.setFontSmoothingType(FontSmoothingType.LCD);
gc.setFont(metrics.font());
double left = px + TerminalMetrics.PADDING;
double top = py + TerminalMetrics.PADDING;
double baseline = top + metrics.baselineOffset();
double contentBottom = top + snapshot.rows() * lineHeight;
int lastRow = snapshot.rows() - 1;
List<RenderRow> rows = snapshot.renderRows();
boolean allRowsDirty = allRowsDirty(snapshot, rows);
if (allRowsDirty) {
gc.setFill(PANE_BACKGROUND);
gc.fillRect(px, py, pw, ph);
}
boolean cursorRowDirty = false;
double bandMin = Double.POSITIVE_INFINITY;
double bandMax = Double.NEGATIVE_INFINITY;
for (RenderRow row : rows) {
if (!row.dirty()) {
continue;
}
// Snap the row band to integer pixels and paint opaque: a fractional-height fill
// would leave sub-pixel seams between rows.
double y0 = Math.floor(top + (row.row() * lineHeight));
double y1 = Math.ceil(top + ((row.row() + 1) * lineHeight));
if (!allRowsDirty) {
gc.setFill(PANE_BACKGROUND);
gc.fillRect(px, y0, pw, y1 - y0);
}
paintSidePadding(gc, row, px, pw, left, cellWidth, y0, y1 - y0);
drawRow(gc, row, left, top, baseline, cellWidth, lineHeight);
bandMin = Math.min(bandMin, y0);
bandMax = Math.max(bandMax, y1);
// Edge rows also own the top/bottom padding strip; repaint it and extend the
// band so panes stacked above get restored over it too.
if (row.row() == 0) {
gc.setFill(rowEdgeBackground(row, true));
gc.fillRect(px, py, pw, top - py);
bandMin = Math.min(bandMin, py);
}
if (row.row() == lastRow) {
gc.setFill(rowEdgeBackground(row, true));
gc.fillRect(px, contentBottom, pw, py + ph - contentBottom);
bandMax = Math.max(bandMax, py + ph);
}
if (snapshot.cursorViewportHasValue() && row.row() == snapshot.cursorViewportY()) {
cursorRowDirty = true;
}
}
if (bandMin > bandMax) {
return;
}
// The cursor overlays its cell; redraw it only when its row was repainted, so we
// neither leave a stale cursor nor stack the translucent overlay on itself.
if (cursorRowDirty) {
drawCursor(gc, snapshot, left, top, cellWidth, lineHeight);
}
// Repainting rows clears the side borders within the band; restore just those
// segments, clipped to the band so we don't redraw the whole outline.
gc.save();
clipRect(gc, px, bandMin, pw, bandMax - bandMin);
drawBorder(gc, px, py, pw, ph, active);
gc.restore();
}
private static boolean allRowsDirty(RenderStateSnapshot snapshot, List<RenderRow> rows) {
if (rows.size() != snapshot.rows()) {
return false;
}
for (int i = 0; i < rows.size(); i++) {
RenderRow row = rows.get(i);
if (!row.dirty() || row.row() != i) {
return false;
}
}
return true;
}
private void drawBorder(GraphicsContext gc, double x, double y, double width, double height, boolean active) {
gc.setStroke(active ? ACTIVE_BORDER : INACTIVE_BORDER);
gc.setLineWidth(active ? 2.0 : 1.0);
gc.strokeRect(x + 0.5, y + 0.5, width - 1.0, height - 1.0);
} }
// Effective background colour of a cell as it is drawn (reverse video swaps fg/bg, an // Effective background colour of a cell as it is drawn (reverse video swaps fg/bg, an
@@ -279,124 +148,6 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
return cellBackgroundColor(firstCell ? cells.get(0) : cells.get(cells.size() - 1)); return cellBackgroundColor(firstCell ? cells.get(0) : cells.get(cells.size() - 1));
} }
// Extend the row's edge-cell backgrounds into the left/right padding (the margin and the
// right-edge rounding sliver), so the unused space matches the rendered content.
private void paintSidePadding(GraphicsContext gc, RenderRow row, double paneX, double paneWidth,
double contentLeft, double cellWidth, double yTop, double bandHeight) {
int columns = row.cells().size();
if (columns == 0) {
return;
}
double contentRight = contentLeft + (columns * cellWidth);
gc.setFill(rowEdgeBackground(row, true));
gc.fillRect(paneX, yTop, contentLeft - paneX, bandHeight);
gc.setFill(rowEdgeBackground(row, false));
gc.fillRect(contentRight, yTop, paneX + paneWidth - contentRight, bandHeight);
}
// Fill the top/bottom padding strips with the top/bottom row's edge colour.
private void fillVerticalPadding(GraphicsContext gc, RenderStateSnapshot snapshot,
double paneX, double paneY, double paneWidth, double paneHeight, double contentTop, double contentBottom) {
List<RenderRow> rows = snapshot.renderRows();
if (rows.isEmpty()) {
return;
}
gc.setFill(rowEdgeBackground(rows.get(0), true));
gc.fillRect(paneX, paneY, paneWidth, contentTop - paneY);
gc.setFill(rowEdgeBackground(rows.get(rows.size() - 1), true));
gc.fillRect(paneX, contentBottom, paneWidth, paneY + paneHeight - contentBottom);
}
private void drawRow(
GraphicsContext gc,
RenderRow row,
double left,
double top,
double baseline,
double cellWidth,
double lineHeight
) {
drawRowBackgrounds(gc, row, left, top, cellWidth, lineHeight);
drawRowText(gc, row, left, baseline, cellWidth, lineHeight);
}
private static void drawRowBackgrounds(
GraphicsContext gc,
RenderRow row,
double left,
double top,
double cellWidth,
double lineHeight
) {
Color runBackground = null;
int runStartColumn = 0;
int previousColumn = -1;
for (RenderCell cell : row.cells()) {
if (cell.kittyPlaceholder().isPresent()) {
flushBackgroundRun(gc, runBackground, left, top, cellWidth, lineHeight, row.row(), runStartColumn, previousColumn);
runBackground = null;
previousColumn = -1;
continue;
}
Color bg = cell.selected() ? SELECTED_BACKGROUND : cellBackgroundOverride(cell);
if (bg == null) {
flushBackgroundRun(gc, runBackground, left, top, cellWidth, lineHeight, row.row(), runStartColumn, previousColumn);
runBackground = null;
previousColumn = -1;
continue;
}
if (runBackground == null || bg != runBackground || cell.column() != previousColumn + 1) {
flushBackgroundRun(gc, runBackground, left, top, cellWidth, lineHeight, row.row(), runStartColumn, previousColumn);
runBackground = bg;
runStartColumn = cell.column();
}
previousColumn = cell.column();
}
flushBackgroundRun(gc, runBackground, left, top, cellWidth, lineHeight, row.row(), runStartColumn, previousColumn);
}
private static void flushBackgroundRun(
GraphicsContext gc,
Color background,
double left,
double top,
double cellWidth,
double lineHeight,
int row,
int startColumn,
int endColumn
) {
if (background == null || endColumn < startColumn) {
return;
}
gc.setFill(background);
gc.fillRect(
left + (startColumn * cellWidth),
top + (row * lineHeight),
(endColumn - startColumn + 1) * cellWidth,
lineHeight);
}
private void drawRowText(
GraphicsContext gc,
RenderRow row,
double left,
double baseline,
double cellWidth,
double lineHeight
) {
for (RenderCell cell : row.cells()) {
if (cell.kittyPlaceholder().isPresent() || cell.codepoints().length == 0) {
continue;
}
gc.setFill(cellForegroundColor(cell));
gc.fillText(cell.text(), left + (cell.column() * cellWidth), baseline + (row.row() * lineHeight));
}
}
// Background override for a cell: null means the pane default background already covers it. // Background override for a cell: null means the pane default background already covers it.
private static Color cellBackgroundOverride(RenderCell cell) { private static Color cellBackgroundOverride(RenderCell cell) {
if (cell.inverse()) { if (cell.inverse()) {
@@ -434,46 +185,42 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
return created; return created;
} }
private static void drawCursor(GraphicsContext gc, RenderStateSnapshot snapshot, double left, double top, double cellWidth, double lineHeight) {
if (!snapshot.cursorVisible() || !snapshot.cursorViewportHasValue()) {
return;
}
double x = left + (snapshot.cursorViewportX() * cellWidth);
double y = top + (snapshot.cursorViewportY() * lineHeight);
gc.setStroke(DEFAULT_FOREGROUND);
gc.setFill(CURSOR_FILL);
gc.setLineWidth(1.5);
RenderCursorStyle style = snapshot.cursorStyle();
if (style == RenderCursorStyle.BAR) {
gc.strokeLine(x + 0.5, y + 2.0, x + 0.5, y + lineHeight - 2.0);
} else if (style == RenderCursorStyle.UNDERLINE) {
gc.strokeLine(x + 1.0, y + lineHeight - 2.0, x + cellWidth - 1.0, y + lineHeight - 2.0);
} else if (style == RenderCursorStyle.BLOCK) {
gc.fillRect(x + 0.5, y + 1.0, Math.max(1.0, cellWidth - 1.0), Math.max(1.0, lineHeight - 2.0));
} else {
gc.strokeRect(x + 0.5, y + 1.0, Math.max(1.0, cellWidth - 1.0), Math.max(1.0, lineHeight - 2.0));
}
}
// ---- Kitty graphics -------------------------------------------------------------- // ---- Kitty graphics --------------------------------------------------------------
private static boolean hasKittyGraphics(RenderTarget target) { private static boolean hasKittyGraphics(RenderTarget target) {
return target.kittyGraphics() return target.kittyGraphics()
.map(graphics -> !graphics.placements().isEmpty()) .map(graphics -> !graphics.isEmpty())
.orElse(false); .orElse(false);
} }
private void drawKittyGraphics( // Build the image-node descriptors for the current frame. Reads the decoded-image cache and
GraphicsContext gc, // each placement's geometry, but draws nothing — the compositor turns these into overlay
// nodes clipped to the pane.
private void collectKittyImages(RenderTarget target, RenderStateSnapshot snapshot, double px, double py) {
if (!target.kittyEnabled() || !hasKittyGraphics(target)) {
kittyImageNodes = List.of();
return;
}
double cellWidth = metrics.cellWidth();
double lineHeight = metrics.lineHeight();
double originX = px + TerminalMetrics.PADDING;
double originY = py + TerminalMetrics.PADDING;
Map<KittyPlaceholderKey, KittyPlaceholderBounds> placeholderBounds = kittyPlaceholderBounds(snapshot);
List<KittyImageNode> nodes = new ArrayList<>();
collectLayer(target, KittyPlacementLayer.BELOW_TEXT, placeholderBounds, originX, originY, cellWidth, lineHeight, nodes);
collectLayer(target, KittyPlacementLayer.ABOVE_TEXT, placeholderBounds, originX, originY, cellWidth, lineHeight, nodes);
kittyImageNodes = nodes;
}
private void collectLayer(
RenderTarget target, RenderTarget target,
KittyPlacementLayer layer, KittyPlacementLayer layer,
Map<KittyPlaceholderKey, KittyPlaceholderBounds> placeholderBounds, Map<KittyPlaceholderKey, KittyPlaceholderBounds> placeholderBounds,
double originX, double originX,
double originY, double originY,
double cellWidth, double cellWidth,
double lineHeight double lineHeight,
List<KittyImageNode> out
) { ) {
target.kittyGraphics().ifPresent(graphics -> { target.kittyGraphics().ifPresent(graphics -> {
for (KittyPlacement placement : graphics.placements(layer)) { for (KittyPlacement placement : graphics.placements(layer)) {
@@ -482,17 +229,17 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
continue; continue;
} }
if (placement.virtual()) { KittyImageNode node = placement.virtual()
drawVirtualKittyPlacement(gc, placement, image, placeholderBounds, originX, originY, cellWidth, lineHeight); ? virtualNode(placement, image, placeholderBounds, originX, originY, cellWidth, lineHeight)
} else { : pinnedNode(placement, image, originX, originY, cellWidth, lineHeight);
drawPinnedKittyPlacement(gc, placement, image, originX, originY, cellWidth, lineHeight); if (node != null) {
out.add(node);
} }
} }
}); });
} }
private static void drawPinnedKittyPlacement( private static KittyImageNode pinnedNode(
GraphicsContext gc,
KittyPlacement placement, KittyPlacement placement,
Image image, Image image,
double originX, double originX,
@@ -502,15 +249,13 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
) { ) {
KittyRenderInfo renderInfo = placement.renderInfo().orElse(null); KittyRenderInfo renderInfo = placement.renderInfo().orElse(null);
if (renderInfo == null || !renderInfo.viewportVisible()) { if (renderInfo == null || !renderInfo.viewportVisible()) {
return; return null;
} }
double sourceX = renderInfo.sourceX();
double sourceY = renderInfo.sourceY();
double sourceWidth = renderInfo.sourceWidth(); double sourceWidth = renderInfo.sourceWidth();
double sourceHeight = renderInfo.sourceHeight(); double sourceHeight = renderInfo.sourceHeight();
if (sourceWidth <= 0.0 || sourceHeight <= 0.0) { if (sourceWidth <= 0.0 || sourceHeight <= 0.0) {
return; return null;
} }
double x = originX + (renderInfo.viewportColumn() * cellWidth) + placement.xOffset(); double x = originX + (renderInfo.viewportColumn() * cellWidth) + placement.xOffset();
@@ -518,14 +263,14 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
double width = renderInfo.pixelWidth() > 0 ? renderInfo.pixelWidth() : renderInfo.gridColumns() * cellWidth; double width = renderInfo.pixelWidth() > 0 ? renderInfo.pixelWidth() : renderInfo.gridColumns() * cellWidth;
double height = renderInfo.pixelHeight() > 0 ? renderInfo.pixelHeight() : renderInfo.gridRows() * lineHeight; double height = renderInfo.pixelHeight() > 0 ? renderInfo.pixelHeight() : renderInfo.gridRows() * lineHeight;
if (width <= 0.0 || height <= 0.0) { if (width <= 0.0 || height <= 0.0) {
return; return null;
} }
gc.drawImage(image, sourceX, sourceY, sourceWidth, sourceHeight, x, y, width, height); return new KittyImageNode(placement.imageId(), placement.placementId(), image,
renderInfo.sourceX(), renderInfo.sourceY(), sourceWidth, sourceHeight, x, y, width, height);
} }
private static void drawVirtualKittyPlacement( private static KittyImageNode virtualNode(
GraphicsContext gc,
KittyPlacement placement, KittyPlacement placement,
Image image, Image image,
Map<KittyPlaceholderKey, KittyPlaceholderBounds> placeholderBounds, Map<KittyPlaceholderKey, KittyPlaceholderBounds> placeholderBounds,
@@ -546,12 +291,12 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
.orElse(null); .orElse(null);
} }
if (bounds == null || bounds.isEmpty()) { if (bounds == null || bounds.isEmpty()) {
return; return null;
} }
SourceRect source = sourceRect(placement, image); SourceRect source = sourceRect(placement, image);
if (source.width() <= 0.0 || source.height() <= 0.0) { if (source.width() <= 0.0 || source.height() <= 0.0) {
return; return null;
} }
long gridColumns = gridColumns(placement, bounds); long gridColumns = gridColumns(placement, bounds);
@@ -569,13 +314,14 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
double availableHeight = bounds.rows() * lineHeight; double availableHeight = bounds.rows() * lineHeight;
if (sourceWidth <= 0.0 || sourceHeight <= 0.0 || availableWidth <= 0.0 || availableHeight <= 0.0) { if (sourceWidth <= 0.0 || sourceHeight <= 0.0 || availableWidth <= 0.0 || availableHeight <= 0.0) {
return; return null;
} }
double scale = Math.min(availableWidth / sourceWidth, availableHeight / sourceHeight); double scale = Math.min(availableWidth / sourceWidth, availableHeight / sourceHeight);
double width = sourceWidth * scale; double width = sourceWidth * scale;
double height = sourceHeight * scale; double height = sourceHeight * scale;
gc.drawImage(image, sourceX, sourceY, sourceWidth, sourceHeight, x, y, width, height); return new KittyImageNode(placement.imageId(), placement.placementId(), image,
sourceX, sourceY, sourceWidth, sourceHeight, x, y, width, height);
} }
private static long gridColumns(KittyPlacement placement, KittyPlaceholderBounds bounds) { private static long gridColumns(KittyPlacement placement, KittyPlaceholderBounds bounds) {
@@ -601,16 +347,17 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
} }
private Image imageFor(KittyPlacement placement) { private Image imageFor(KittyPlacement placement) {
return placement.image().map(snapshot -> { return placement.image().map(image -> {
byte[] data = snapshot.data(); // Build the cache key from cheap metadata only — the pixel buffer is never copied out
KittyImageKey key = KittyImageKey.of(snapshot, data); // of native memory on a cache hit (the common per-frame case).
KittyImageKey key = KittyImageKey.of(image);
Image cached = kittyImageCache.get(key); Image cached = kittyImageCache.get(key);
if (cached != null) { if (cached != null) {
return cached; return cached;
} }
kittyImageCache.keySet().removeIf(existing -> existing.id() == snapshot.id()); kittyImageCache.keySet().removeIf(existing -> existing.id() == image.id());
Image decoded = decodeImage(snapshot, data); Image decoded = decodeImage(image);
if (decoded != null) { if (decoded != null) {
kittyImageCache.put(key, decoded); kittyImageCache.put(key, decoded);
} }
@@ -618,22 +365,25 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
}).orElse(null); }).orElse(null);
} }
private Image decodeImage(KittyImageSnapshot snapshot, byte[] data) { private Image decodeImage(KittyImage source) {
if (snapshot.compression() != KittyImageCompression.NONE) { if (source.compression() != KittyImageCompression.NONE) {
return null; return null;
} }
if (snapshot.format() == KittyImageFormat.PNG) { // Only now — on a cache miss — do we pull the raw bytes across the native boundary.
byte[] data = source.data();
KittyImageFormat format = source.format();
if (format == KittyImageFormat.PNG) {
return new Image(new ByteArrayInputStream(data)); return new Image(new ByteArrayInputStream(data));
} }
int width = Math.toIntExact(snapshot.width()); int width = Math.toIntExact(source.width());
int height = Math.toIntExact(snapshot.height()); int height = Math.toIntExact(source.height());
WritableImage image = new WritableImage(width, height); WritableImage image = new WritableImage(width, height);
if (snapshot.format() == KittyImageFormat.RGBA) { if (format == KittyImageFormat.RGBA) {
image.getPixelWriter().setPixels(0, 0, width, height, PixelFormat.getByteBgraInstance(), rgbaToBgra(data), 0, width * 4); image.getPixelWriter().setPixels(0, 0, width, height, PixelFormat.getByteBgraInstance(), rgbaToBgra(data), 0, width * 4);
} else if (snapshot.format() == KittyImageFormat.RGB) { } else if (format == KittyImageFormat.RGB) {
image.getPixelWriter().setPixels(0, 0, width, height, PixelFormat.getByteRgbInstance(), data, 0, width * 3); image.getPixelWriter().setPixels(0, 0, width, height, PixelFormat.getByteRgbInstance(), data, 0, width * 3);
} }
return image; return image;
@@ -675,7 +425,15 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
private WritableImage image; private WritableImage image;
private long[] rowHashes = new long[0]; private long[] rowHashes = new long[0];
private CursorState lastCursor = CursorState.none(); private CursorState lastCursor = CursorState.none();
private GlyphCache glyphs; // Pixels captured from beneath the cursor just before it was drawn, plus the clamped rect
// they came from. A cursor-only frame restores these to erase the old cursor without
// repainting the row's cells (which would need a full, freshly-marshalled snapshot).
private int[] cursorUnder = new int[0];
private int cursorUnderX;
private int cursorUnderY;
private int cursorUnderW;
private int cursorUnderH;
private boolean cursorSaved;
// Half-open [min, max) vertical span of buffer rows written since the last present, so // Half-open [min, max) vertical span of buffer rows written since the last present, so
// present() can upload only that band to the GPU instead of the whole pane texture. // present() can upload only that band to the GPU instead of the whole pane texture.
private int dirtyMinY = Integer.MAX_VALUE; private int dirtyMinY = Integer.MAX_VALUE;
@@ -684,6 +442,7 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
private void invalidate() { private void invalidate() {
rowHashes = new long[0]; rowHashes = new long[0];
lastCursor = CursorState.none(); lastCursor = CursorState.none();
cursorSaved = false;
} }
// Record that buffer rows [y0, y1) changed; clamped to the buffer in dirtyRegion(). // Record that buffer rows [y0, y1) changed; clamped to the buffer in dirtyRegion().
@@ -762,6 +521,23 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
present(gc, px, py); present(gc, px, py);
} }
// Repaint nothing but the cursor: restore the pixels beneath the old cursor to erase it,
// then draw the cursor at its new position (which re-saves the pixels under it). Used when
// the global dirty flag is FALSE but the cursor alone moved, styled or toggled visibility.
// Touches at most the two cursor cell rects, so it never marshals or hashes a full snapshot.
private void paintCursorOnly(GraphicsContext gc, RenderStateSnapshot snapshot,
double px, double py, double paneWidth, double paneHeight) {
ensure(paneWidth, paneHeight);
eraseCursorUnder();
if (snapshot != null) {
drawCursor(snapshot);
lastCursor = CursorState.from(snapshot);
} else {
lastCursor = CursorState.none();
}
present(gc, px, py);
}
private void paintDirty(GraphicsContext gc, RenderTarget target, RenderStateSnapshot snapshot, private void paintDirty(GraphicsContext gc, RenderTarget target, RenderStateSnapshot snapshot,
double px, double py, double paneWidth, double paneHeight, boolean active) { double px, double py, double paneWidth, double paneHeight, boolean active) {
ensure(paneWidth, paneHeight); ensure(paneWidth, paneHeight);
@@ -842,7 +618,6 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
int nextWidth = Math.max(1, (int) Math.round(paneWidth)); int nextWidth = Math.max(1, (int) Math.round(paneWidth));
int nextHeight = Math.max(1, (int) Math.round(paneHeight)); int nextHeight = Math.max(1, (int) Math.round(paneHeight));
if (nextWidth == width && nextHeight == height && image != null) { if (nextWidth == width && nextHeight == height && image != null) {
ensureGlyphs();
return; return;
} }
@@ -852,17 +627,19 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
pixelBuffer = new PixelBuffer<>(width, height, IntBuffer.wrap(pixels), PixelFormat.getIntArgbPreInstance()); pixelBuffer = new PixelBuffer<>(width, height, IntBuffer.wrap(pixels), PixelFormat.getIntArgbPreInstance());
image = new WritableImage(pixelBuffer); image = new WritableImage(pixelBuffer);
invalidate(); invalidate();
ensureGlyphs();
} }
private void ensureGlyphs() { // Drop the full-resolution pixel buffer and its GPU-backed image. The next ensure() rebuilds
int cellWidth = cellWidth(); // them (and a layout frame's paintFull repaints from scratch), so this is safe to call when
int lineHeight = lineHeight(); // the pane goes off-screen; only the shared glyph atlas (in TerminalMetrics) survives.
double baseline = metrics.baselineOffset(); private void release() {
if (glyphs == null || glyphs.font != metrics.font() pixels = new int[0];
|| glyphs.cellWidth != cellWidth || glyphs.lineHeight != lineHeight || glyphs.baseline != baseline) { pixelBuffer = null;
glyphs = new GlyphCache(metrics.font(), cellWidth, lineHeight, baseline); image = null;
} width = 0;
height = 0;
invalidate();
resetDirty();
} }
private void present(GraphicsContext gc, double px, double py) { private void present(GraphicsContext gc, double px, double py) {
@@ -874,6 +651,12 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
gc.drawImage(image, px, py); gc.drawImage(image, px, py);
} }
// Whether the cursor differs from what we last drew (position, style or visibility). Used to
// catch cursor-only updates that arrive with a FALSE global dirty flag.
private boolean cursorChanged(RenderStateSnapshot snapshot) {
return !CursorState.from(snapshot).equals(lastCursor);
}
private boolean canDiff(RenderStateSnapshot snapshot) { private boolean canDiff(RenderStateSnapshot snapshot) {
return rowHashes.length == snapshot.rows() && snapshot.renderRows().size() == snapshot.rows(); return rowHashes.length == snapshot.rows() && snapshot.renderRows().size() == snapshot.rows();
} }
@@ -1063,30 +846,32 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
if (cell.kittyPlaceholder().isPresent() || cell.codepoints().length == 0) { if (cell.kittyPlaceholder().isPresent() || cell.codepoints().length == 0) {
continue; continue;
} }
Glyph glyph = glyphs.glyph(cell.text()); GlyphCache.Glyph glyph = metrics.glyphCache().glyph(cell.text());
int color = rgb(cellForegroundColor(cell)); int color = rgb(cellForegroundColor(cell));
blitGlyph(glyph, x0 + (cell.column() * cellWidth), rowTop, color); blitGlyph(glyph, x0 + (cell.column() * cellWidth), rowTop, color);
} }
} }
private void blitGlyph(Glyph glyph, int x, int y, int rgb) { private void blitGlyph(GlyphCache.Glyph glyph, int x, int y, int rgb) {
int red = (rgb >> 16) & 0xff; int red = (rgb >> 16) & 0xff;
int green = (rgb >> 8) & 0xff; int green = (rgb >> 8) & 0xff;
int blue = rgb & 0xff; int blue = rgb & 0xff;
// Clamp the glyph rectangle to the buffer once, so the inner loops carry no // Clamp the glyph rectangle to the buffer once, so the inner loops carry no
// per-pixel bounds check (this is the hottest pixel loop on a text repaint). // per-pixel bounds check (this is the hottest pixel loop on a text repaint).
int glyphWidth = glyph.width();
byte[] glyphAlpha = glyph.alpha();
int gyStart = Math.max(0, -y); int gyStart = Math.max(0, -y);
int gyEnd = Math.min(glyph.height, height - y); int gyEnd = Math.min(glyph.height(), height - y);
int gxStart = Math.max(0, -x); int gxStart = Math.max(0, -x);
int gxEnd = Math.min(glyph.width, width - x); int gxEnd = Math.min(glyphWidth, width - x);
if (gyStart >= gyEnd || gxStart >= gxEnd) { if (gyStart >= gyEnd || gxStart >= gxEnd) {
return; return;
} }
for (int gy = gyStart; gy < gyEnd; gy++) { for (int gy = gyStart; gy < gyEnd; gy++) {
int rowOffset = (y + gy) * width; int rowOffset = (y + gy) * width;
int glyphOffset = gy * glyph.width; int glyphOffset = gy * glyphWidth;
for (int gx = gxStart; gx < gxEnd; gx++) { for (int gx = gxStart; gx < gxEnd; gx++) {
int alpha = glyph.alpha[glyphOffset + gx] & 0xff; int alpha = glyphAlpha[glyphOffset + gx] & 0xff;
if (alpha == 0) { if (alpha == 0) {
continue; continue;
} }
@@ -1116,6 +901,10 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
int y = contentTop() + ((int) snapshot.cursorViewportY() * lineHeight()); int y = contentTop() + ((int) snapshot.cursorViewportY() * lineHeight());
int cw = cellWidth(); int cw = cellWidth();
int lh = lineHeight(); int lh = lineHeight();
// Capture the cell box under the cursor before drawing, so a later cursor-only frame
// can restore it without repainting the row. The box is a superset of every style's
// footprint below, so the restore erases whichever style was actually drawn.
saveCursorUnder(x, y, cw, lh);
RenderCursorStyle style = snapshot.cursorStyle(); RenderCursorStyle style = snapshot.cursorStyle();
if (style == RenderCursorStyle.BAR) { if (style == RenderCursorStyle.BAR) {
fillRect(x, y + 2, 1, Math.max(1, lh - 4), argbPre(DEFAULT_FOREGROUND)); fillRect(x, y + 2, 1, Math.max(1, lh - 4), argbPre(DEFAULT_FOREGROUND));
@@ -1128,6 +917,45 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
} }
} }
// Copy the clamped cell box under the cursor into the save-under buffer. Marks no dirty
// band — saving reads pixels, it doesn't change them.
private void saveCursorUnder(int x, int y, int w, int h) {
int x0 = Math.max(0, x);
int y0 = Math.max(0, y);
int x1 = Math.min(width, x + w);
int y1 = Math.min(height, y + h);
if (x0 >= x1 || y0 >= y1) {
cursorSaved = false;
return;
}
int rw = x1 - x0;
int rh = y1 - y0;
if (cursorUnder.length < rw * rh) {
cursorUnder = new int[rw * rh];
}
for (int row = 0; row < rh; row++) {
System.arraycopy(pixels, ((y0 + row) * width) + x0, cursorUnder, row * rw, rw);
}
cursorUnderX = x0;
cursorUnderY = y0;
cursorUnderW = rw;
cursorUnderH = rh;
cursorSaved = true;
}
// Restore the saved cell box, erasing the cursor that was drawn over it. No-op if nothing
// is currently saved (cursor hidden, or buffer reset since the last draw).
private void eraseCursorUnder() {
if (!cursorSaved) {
return;
}
for (int row = 0; row < cursorUnderH; row++) {
System.arraycopy(cursorUnder, row * cursorUnderW, pixels, ((cursorUnderY + row) * width) + cursorUnderX, cursorUnderW);
}
markDirtyRows(cursorUnderY, cursorUnderY + cursorUnderH);
cursorSaved = false;
}
private void drawBorder(boolean active) { private void drawBorder(boolean active) {
strokeRect(0, 0, width, height, argbPre(active ? ACTIVE_BORDER : INACTIVE_BORDER), active ? 2 : 1); strokeRect(0, 0, width, height, argbPre(active ? ACTIVE_BORDER : INACTIVE_BORDER), active ? 2 : 1);
} }
@@ -1206,53 +1034,6 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
} }
} }
private final class GlyphCache {
private final javafx.scene.text.Font font;
private final int cellWidth;
private final int lineHeight;
private final double baseline;
private final Map<String, Glyph> glyphs = new HashMap<>();
private GlyphCache(javafx.scene.text.Font font, int cellWidth, int lineHeight, double baseline) {
this.font = font;
this.cellWidth = cellWidth;
this.lineHeight = lineHeight;
this.baseline = baseline;
}
private Glyph glyph(String text) {
return glyphs.computeIfAbsent(text, this::renderGlyph);
}
private Glyph renderGlyph(String value) {
Text measured = new Text(value);
measured.setFont(font);
int glyphWidth = Math.max(cellWidth, (int) Math.ceil(measured.getLayoutBounds().getWidth()) + 2);
Canvas canvas = new Canvas(glyphWidth, lineHeight);
GraphicsContext gc = canvas.getGraphicsContext2D();
gc.setFontSmoothingType(FontSmoothingType.GRAY);
gc.setFont(font);
gc.setFill(Color.WHITE);
gc.fillText(value, 0.0, baseline);
SnapshotParameters parameters = new SnapshotParameters();
parameters.setFill(Color.TRANSPARENT);
WritableImage snapshot = canvas.snapshot(parameters, null);
PixelReader reader = snapshot.getPixelReader();
byte[] alpha = new byte[glyphWidth * lineHeight];
for (int y = 0; y < lineHeight; y++) {
int offset = y * glyphWidth;
for (int x = 0; x < glyphWidth; x++) {
alpha[offset + x] = (byte) ((reader.getArgb(x, y) >>> 24) & 0xff);
}
}
return new Glyph(glyphWidth, lineHeight, alpha);
}
}
private record Glyph(int width, int height, byte[] alpha) {
}
private record CursorState(boolean visible, boolean hasViewport, int column, int row, RenderCursorStyle style) { private record CursorState(boolean visible, boolean hasViewport, int column, int row, RenderCursorStyle style) {
private static CursorState none() { private static CursorState none() {
return new CursorState(false, false, -1, -1, null); return new CursorState(false, false, -1, -1, null);
@@ -1321,15 +1102,15 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
// identity + dimensions + payload length are enough to key the decoded-image cache, and // identity + dimensions + payload length are enough to key the decoded-image cache, and
// we avoid fingerprinting the whole payload — which previously ran once per frame per // we avoid fingerprinting the whole payload — which previously ran once per frame per
// placement (O(image size)) just to look the image up. // placement (O(image size)) just to look the image up.
private record KittyImageKey(long id, long number, long width, long height, KittyImageFormat format, int dataLength) { private record KittyImageKey(long id, long number, long width, long height, KittyImageFormat format, long dataLength) {
private static KittyImageKey of(KittyImageSnapshot snapshot, byte[] data) { private static KittyImageKey of(KittyImage image) {
return new KittyImageKey( return new KittyImageKey(
snapshot.id(), image.id(),
snapshot.number(), image.number(),
snapshot.width(), image.width(),
snapshot.height(), image.height(),
snapshot.format(), image.format(),
data.length image.dataLength()
); );
} }
} }

View File

@@ -0,0 +1,90 @@
package com.gregor.jprototerm;
import javafx.scene.SnapshotParameters;
import javafx.scene.canvas.Canvas;
import javafx.scene.canvas.GraphicsContext;
import javafx.scene.image.PixelReader;
import javafx.scene.image.WritableImage;
import javafx.scene.paint.Color;
import javafx.scene.text.Font;
import javafx.scene.text.FontSmoothingType;
import javafx.scene.text.Text;
import java.util.HashMap;
import java.util.Map;
/**
* Rasterized glyph alpha masks for one window's font, shared by every pane's renderer. The atlas is
* a pure function of the window's {@link TerminalMetrics} (font family/size, snapped cell geometry,
* baseline), and all panes in a window observe the same metrics, so a single shared cache lets N
* panes reuse one copy of each glyph instead of each rasterizing and retaining its own. It also
* means a pane whose backbuffer was released (see {@link GhosttyTerminalRenderer}) does not have to
* re-rasterize glyphs when it is shown again.
*
* <p>Rasterizing goes through JavaFX ({@link Canvas#snapshot}), so {@link #glyph} must be called on
* the FX thread — which is where all rendering happens. The cache self-invalidates when the metrics
* change (e.g. a font switch): the next lookup notices and clears.
*/
final class GlyphCache {
record Glyph(int width, int height, byte[] alpha) {
}
private final TerminalMetrics metrics;
private final Map<String, Glyph> glyphs = new HashMap<>();
// The metrics snapshot the cached glyphs were rasterized for; a mismatch clears the cache.
private Font font;
private int cellWidth;
private int lineHeight;
private double baseline;
GlyphCache(TerminalMetrics metrics) {
this.metrics = metrics;
}
Glyph glyph(String text) {
ensureCurrent();
return glyphs.computeIfAbsent(text, this::renderGlyph);
}
// Drop the rasterized masks if the font/cell geometry changed since they were built. Cheap to
// call per lookup: a no-op unless the window's metrics actually changed under us.
private void ensureCurrent() {
Font currentFont = metrics.font();
int currentCellWidth = Math.max(1, (int) Math.round(metrics.cellWidth()));
int currentLineHeight = Math.max(1, (int) Math.round(metrics.lineHeight()));
double currentBaseline = metrics.baselineOffset();
if (currentFont != font || currentCellWidth != cellWidth
|| currentLineHeight != lineHeight || currentBaseline != baseline) {
font = currentFont;
cellWidth = currentCellWidth;
lineHeight = currentLineHeight;
baseline = currentBaseline;
glyphs.clear();
}
}
private Glyph renderGlyph(String value) {
Text measured = new Text(value);
measured.setFont(font);
int glyphWidth = Math.max(cellWidth, (int) Math.ceil(measured.getLayoutBounds().getWidth()) + 2);
Canvas canvas = new Canvas(glyphWidth, lineHeight);
GraphicsContext gc = canvas.getGraphicsContext2D();
gc.setFontSmoothingType(FontSmoothingType.GRAY);
gc.setFont(font);
gc.setFill(Color.WHITE);
gc.fillText(value, 0.0, baseline);
SnapshotParameters parameters = new SnapshotParameters();
parameters.setFill(Color.TRANSPARENT);
WritableImage snapshot = canvas.snapshot(parameters, null);
PixelReader reader = snapshot.getPixelReader();
byte[] alpha = new byte[glyphWidth * lineHeight];
for (int y = 0; y < lineHeight; y++) {
int offset = y * glyphWidth;
for (int x = 0; x < glyphWidth; x++) {
alpha[offset + x] = (byte) ((reader.getArgb(x, y) >>> 24) & 0xff);
}
}
return new Glyph(glyphWidth, lineHeight, alpha);
}
}

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,9 +9,14 @@ import java.lang.foreign.MemorySegment;
import java.lang.foreign.SymbolLookup; import java.lang.foreign.SymbolLookup;
import java.lang.foreign.ValueLayout; import java.lang.foreign.ValueLayout;
import java.lang.invoke.MethodHandle; import java.lang.invoke.MethodHandle;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/** /**
* A Linux PTY backed by libc via the Foreign Function & Memory API. * A Linux PTY backed by libc via the Foreign Function & Memory API.
@@ -30,6 +35,11 @@ import java.util.Map;
public final class LinuxPty implements AutoCloseable { public final class LinuxPty implements AutoCloseable {
static final Linker LINKER = Linker.nativeLinker(); static final Linker LINKER = Linker.nativeLinker();
private static final SymbolLookup LIBC = LINKER.defaultLookup(); private static final SymbolLookup LIBC = LINKER.defaultLookup();
private static final ExecutorService REAPER = Executors.newCachedThreadPool(runnable -> {
Thread thread = new Thread(runnable, "pty-reaper");
thread.setDaemon(true);
return thread;
});
static final AddressLayout C_POINTER = (AddressLayout) LINKER.canonicalLayouts().get("void*"); static final AddressLayout C_POINTER = (AddressLayout) LINKER.canonicalLayouts().get("void*");
static final ValueLayout.OfShort C_SHORT = (ValueLayout.OfShort) LINKER.canonicalLayouts().get("short"); static final ValueLayout.OfShort C_SHORT = (ValueLayout.OfShort) LINKER.canonicalLayouts().get("short");
@@ -60,7 +70,10 @@ public final class LinuxPty implements AutoCloseable {
private static final long TIOCSWINSZ = 0x5414L; private static final long TIOCSWINSZ = 0x5414L;
private static final short POSIX_SPAWN_SETSID = 0x80; private static final short POSIX_SPAWN_SETSID = 0x80;
private static final int SIGHUP = 1; private static final int SIGHUP = 1;
private static final int SIGINT = 2;
private static final int SIGQUIT = 3;
private static final int SIGKILL = 9; private static final int SIGKILL = 9;
private static final int SIGTERM = 15;
private static final int WNOHANG = 1; private static final int WNOHANG = 1;
// struct winsize { unsigned short ws_row, ws_col, ws_xpixel, ws_ypixel; } // struct winsize { unsigned short ws_row, ws_col, ws_xpixel, ws_ypixel; }
@@ -74,6 +87,7 @@ public final class LinuxPty implements AutoCloseable {
private static final long SPAWN_ACTIONS_SIZE = 256; private static final long SPAWN_ACTIONS_SIZE = 256;
private static final long SPAWN_ATTR_SIZE = 512; private static final long SPAWN_ATTR_SIZE = 512;
private static final MethodHandle TCGETPGRP = handle("tcgetpgrp", FD_INT_INT);
private static final MethodHandle POSIX_OPENPT = handle("posix_openpt", FD_INT_INT); private static final MethodHandle POSIX_OPENPT = handle("posix_openpt", FD_INT_INT);
private static final MethodHandle GRANTPT = handle("grantpt", FD_INT_INT); private static final MethodHandle GRANTPT = handle("grantpt", FD_INT_INT);
private static final MethodHandle UNLOCKPT = handle("unlockpt", FD_INT_INT); private static final MethodHandle UNLOCKPT = handle("unlockpt", FD_INT_INT);
@@ -101,11 +115,36 @@ public final class LinuxPty implements AutoCloseable {
private final Object writeLock = new Object(); private final Object writeLock = new Object();
private final int masterFd; private final int masterFd;
private final int pid; private final int pid;
private final int closeSignal;
private volatile boolean closed; private volatile boolean closed;
private LinuxPty(int masterFd, int pid) { private LinuxPty(int masterFd, int pid, int closeSignal) {
this.masterFd = masterFd; this.masterFd = masterFd;
this.pid = pid; this.pid = pid;
this.closeSignal = closeSignal;
}
/**
* Resolves a signal name (e.g. {@code "SIGTERM"}, {@code "TERM"}, {@code "SIGKILL"}) to its
* Linux signal number, or {@code -1} if the name is not one we recognise. Case-insensitive and
* tolerant of a missing {@code SIG} prefix.
*/
public static int signalNumber(String name) {
if (name == null) {
return -1;
}
String normalized = name.trim().toUpperCase(java.util.Locale.ROOT);
if (normalized.startsWith("SIG")) {
normalized = normalized.substring(3);
}
return switch (normalized) {
case "HUP" -> SIGHUP;
case "INT" -> SIGINT;
case "QUIT" -> SIGQUIT;
case "KILL" -> SIGKILL;
case "TERM" -> SIGTERM;
default -> -1;
};
} }
/** /**
@@ -114,8 +153,10 @@ public final class LinuxPty implements AutoCloseable {
* @param argv command and arguments (e.g. {@code {"/bin/zsh", "-i"}}) * @param argv command and arguments (e.g. {@code {"/bin/zsh", "-i"}})
* @param environment environment for the child, as KEY=VALUE pairs * @param environment environment for the child, as KEY=VALUE pairs
* @param workingDirectory directory the child starts in, or {@code null} to inherit * @param workingDirectory directory the child starts in, or {@code null} to inherit
* @param closeSignal signal number sent to the child on {@link #close()} (e.g. SIGTERM)
*/ */
public static LinuxPty spawn(String[] argv, Map<String, String> environment, String workingDirectory) { public static LinuxPty spawn(String[] argv, Map<String, String> environment, String workingDirectory,
int closeSignal) {
Arena setup = Arena.ofConfined(); Arena setup = Arena.ofConfined();
try { try {
int master = check(callInt(POSIX_OPENPT, O_RDWR | O_NOCTTY), "posix_openpt"); int master = check(callInt(POSIX_OPENPT, O_RDWR | O_NOCTTY), "posix_openpt");
@@ -154,7 +195,7 @@ public final class LinuxPty implements AutoCloseable {
if (rc != 0) { if (rc != 0) {
throw new IllegalStateException("posix_spawnp failed for " + argv[0] + " (rc=" + rc + ")"); throw new IllegalStateException("posix_spawnp failed for " + argv[0] + " (rc=" + rc + ")");
} }
return new LinuxPty(master, pidOut.get(C_INT, 0)); return new LinuxPty(master, pidOut.get(C_INT, 0), closeSignal);
} finally { } finally {
callInt(ATTR_DESTROY, attr); callInt(ATTR_DESTROY, attr);
callInt(FA_DESTROY, actions); callInt(FA_DESTROY, actions);
@@ -205,6 +246,25 @@ public final class LinuxPty implements AutoCloseable {
} }
} }
/**
* Best-effort current working directory of the terminal's foreground process group, read from
* {@code /proc}. This tracks the directory the user is actually in (a {@code cd} in the shell,
* or a child program that changed dir), so a newly opened pane can start there. Falls back to
* the shell's own pid, and returns {@code null} if it cannot be determined.
*/
public String currentWorkingDirectory() {
if (closed) {
return null;
}
int pgid = callInt(TCGETPGRP, masterFd);
int target = pgid > 0 ? pgid : pid;
try {
return Files.readSymbolicLink(Path.of("/proc", Integer.toString(target), "cwd")).toString();
} catch (IOException | RuntimeException ex) {
return null;
}
}
/** Resizes the terminal window. */ /** Resizes the terminal window. */
public void setWinSize(int columns, int rows) { public void setWinSize(int columns, int rows) {
if (closed) { if (closed) {
@@ -222,15 +282,44 @@ public final class LinuxPty implements AutoCloseable {
@Override @Override
public void close() { public void close() {
if (closed) { if (!markClosed()) {
return; return;
} }
closed = true; closeMaster();
callKill(pid, SIGHUP); try {
callInt(CLOSE, masterFd);
reap(); reap();
} finally {
arena.close(); arena.close();
} }
}
/** Send the configured close signal and close the master fd now; reap off the caller thread. */
public void closeDetached() {
if (!markClosed()) {
return;
}
closeMaster();
REAPER.submit(() -> {
try {
reap();
} finally {
arena.close();
}
});
}
private synchronized boolean markClosed() {
if (closed) {
return false;
}
closed = true;
return true;
}
private void closeMaster() {
callKill(pid, closeSignal);
callInt(CLOSE, masterFd);
}
private void reap() { private void reap() {
try (Arena a = Arena.ofConfined()) { try (Arena a = Arena.ofConfined()) {

View File

@@ -1,200 +1,31 @@
package com.gregor.jprototerm; package com.gregor.jprototerm;
import javafx.animation.AnimationTimer; /**
import javafx.application.Application; * Entry point and mode dispatch. A bare invocation is a thin client: it hands the request to a
import javafx.application.Platform; * running {@link Daemon}, or, if none is reachable, opens a single standalone window in this process
import javafx.scene.Scene; * (today's behavior). {@code --daemon} runs the long-lived server that hosts every window in one
import javafx.scene.control.ButtonType; * JVM, so client launches skip cold JVM/JavaFX/GL startup.
import javafx.scene.control.ComboBox; */
import javafx.scene.control.Dialog; public final class Main {
import javafx.scene.control.Label; private Main() {
import javafx.scene.control.Spinner;
import javafx.scene.control.SpinnerValueFactory;
import javafx.scene.input.KeyEvent;
import javafx.scene.layout.GridPane;
import javafx.scene.layout.StackPane;
import javafx.scene.text.Font;
import javafx.stage.Stage;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
public final class Main extends Application {
private Compositor compositor;
private TerminalMetrics metrics;
private AppConfig config;
@Override
public void start(Stage stage) {
config = AppConfig.load();
metrics = new TerminalMetrics(config.fontFamily(), config.fontSize());
compositor = new Compositor(config, metrics);
StackPane root = new StackPane(compositor.canvas());
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));
new AnimationTimer() {
@Override
public void handle(long now) {
compositor.render();
}
}.start();
stage.setTitle("jprototerm");
stage.setScene(scene);
stage.setOnCloseRequest(event -> {
compositor.close();
});
stage.show();
compositor.canvas().requestFocus();
}
private void handlePressed(KeyEvent event) {
if (config.keybindings().get("navigate_left").matches(event)) {
compositor.navigate(Direction.LEFT);
event.consume();
} else if (config.keybindings().get("navigate_down").matches(event)) {
compositor.navigate(Direction.DOWN);
event.consume();
} else if (config.keybindings().get("navigate_up").matches(event)) {
compositor.navigate(Direction.UP);
event.consume();
} else if (config.keybindings().get("navigate_right").matches(event)) {
compositor.navigate(Direction.RIGHT);
event.consume();
} else if (config.keybindings().get("toggle_floating").matches(event)) {
compositor.toggleFloating();
event.consume();
} 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("close_pane").matches(event)) {
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();
} else if (config.keybindings().get("previous_tab").matches(event)) {
compositor.previousTab();
event.consume();
} else if (config.keybindings().get("next_tab").matches(event)) {
compositor.nextTab();
event.consume();
} else if (config.keybindings().get("open_font_selector").matches(event)) {
openFontSelector();
event.consume();
} else if (config.keybindings().get("open_scrollback").matches(event)) {
openScrollbackInEditor();
event.consume();
} else {
String encoded = KeyEncoder.encode(event);
if (encoded != null) {
compositor.activePane().send(encoded);
event.consume();
}
}
}
private void handleTyped(KeyEvent event) {
if (event.isAltDown() || event.isControlDown() || event.isMetaDown()) {
return;
}
String text = event.getCharacter();
if (text != null && !text.isEmpty() && text.charAt(0) >= 0x20 && text.charAt(0) != 0x7f) {
compositor.activePane().send(text);
event.consume();
}
}
private void openFontSelector() {
Dialog<ButtonType> dialog = new Dialog<>();
dialog.setTitle("Font");
dialog.getDialogPane().getButtonTypes().addAll(ButtonType.OK, ButtonType.CANCEL);
ComboBox<String> family = new ComboBox<>();
family.getItems().setAll(Font.getFamilies());
family.setEditable(true);
family.setMaxWidth(Double.MAX_VALUE);
family.setValue(config.fontFamily());
Spinner<Double> size = new Spinner<>();
size.setEditable(true);
size.setValueFactory(new SpinnerValueFactory.DoubleSpinnerValueFactory(6.0, 48.0, config.fontSize(), 0.5));
GridPane content = new GridPane();
content.setHgap(10.0);
content.setVgap(10.0);
content.add(new Label("Family"), 0, 0);
content.add(family, 1, 0);
content.add(new Label("Size"), 0, 1);
content.add(size, 1, 1);
dialog.getDialogPane().setContent(content);
dialog.showAndWait()
.filter(button -> button == ButtonType.OK)
.ifPresent(ignored -> {
String selectedFamily = family.getEditor().getText();
if (selectedFamily == null || selectedFamily.isBlank()) {
selectedFamily = family.getValue();
}
if (selectedFamily == null || selectedFamily.isBlank()) {
return;
}
double selectedSize = size.getValue();
config = config.withFont(selectedFamily.trim(), selectedSize);
config.save();
compositor.setFont(config.fontFamily(), config.fontSize());
compositor.canvas().requestFocus();
});
}
private void openScrollbackInEditor() {
try {
Path file = Files.createTempFile("jprototerm-scrollback-", ".txt");
Files.writeString(file, compositor.activePane().scrollbackText());
file.toFile().deleteOnExit();
compositor.activePane().send(scrollbackEditorCommand(file) + "\r");
} catch (IOException ex) {
System.err.println("Could not open scrollback in editor: " + ex.getMessage());
}
}
private String scrollbackEditorCommand(Path file) {
String quotedFile = shellQuote(file.toString());
String command = config.scrollbackEditorCommand();
if (command == null || command.isBlank()) {
command = "vi {file}";
}
if (command.contains("{file}")) {
return command.replace("{file}", quotedFile);
}
return command + " " + quotedFile;
}
private static String shellQuote(String value) {
return "'" + value.replace("'", "'\"'\"'") + "'";
} }
public static void main(String[] args) { public static void main(String[] args) {
// Match the renderer order the app was tuned for; honor an explicit override if present.
System.setProperty("prism.order", System.getProperty("prism.order", "es2,sw")); System.setProperty("prism.order", System.getProperty("prism.order", "es2,sw"));
launch(Main.class, args);
for (String arg : args) {
if (arg.equals("--daemon")) {
Daemon.run();
return;
}
}
String workingDirectory = System.getProperty("user.dir");
if (Daemon.tryClient(workingDirectory)) {
return; // a running daemon opened the window
}
// No daemon reachable: fall back to a standalone window; the JVM exits when it closes.
WindowManager.start(WindowManager.Mode.STANDALONE).openWindow(workingDirectory);
} }
} }

View File

@@ -2,6 +2,7 @@ package com.gregor.jprototerm;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.HashMap; import java.util.HashMap;
import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.concurrent.ExecutorService; import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors; import java.util.concurrent.Executors;
@@ -20,8 +21,43 @@ 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, int closeSignal) {
try { try {
return spawn(shellCommand.toArray(new String[0]), envOverride, columns, rows, workingDirectory,
closeSignal);
} 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, int closeSignal) {
try {
return spawn(new String[] {"/bin/sh", "-c", command}, envOverride, columns, rows, workingDirectory,
closeSignal);
} 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, int closeSignal) {
Map<String, String> environment = new HashMap<>(System.getenv()); Map<String, String> environment = new HashMap<>(System.getenv());
environment.put("TERM", "xterm-kitty"); environment.put("TERM", "xterm-kitty");
environment.put("COLORTERM", "truecolor"); environment.put("COLORTERM", "truecolor");
@@ -29,16 +65,13 @@ public final class ShellSession implements AutoCloseable {
environment.putAll(envOverride); environment.putAll(envOverride);
LinuxPty pty = LinuxPty.spawn( LinuxPty pty = LinuxPty.spawn(
new String[] {shell, "-i"}, argv,
environment, environment,
System.getProperty("user.home")); workingDirectory != null ? workingDirectory : System.getProperty("user.home"),
closeSignal);
ShellSession session = new ShellSession(pty); ShellSession session = new ShellSession(pty);
session.resize(columns, rows); session.resize(columns, rows);
return session; return session;
} catch (RuntimeException ex) {
pane.write("failed to start shell: " + ex.getMessage() + "\r\n");
throw new IllegalStateException("Could not start shell " + shell, ex);
}
} }
/** /**
@@ -63,12 +96,37 @@ public final class ShellSession implements AutoCloseable {
// These are jprototerm's own runtime settings, not the user's shell environment. // These are jprototerm's own runtime settings, not the user's shell environment.
environment.remove("GDK_BACKEND"); environment.remove("GDK_BACKEND");
environment.remove("JLIBGHOSTTY_LIBRARY"); environment.remove("JLIBGHOSTTY_LIBRARY");
sanitizeJavaToolOptions(environment);
}
private static void sanitizeJavaToolOptions(Map<String, String> environment) {
String javaToolOptions = environment.get("JAVA_TOOL_OPTIONS");
if (javaToolOptions == null
|| !javaToolOptions.contains("-XX:SharedArchiveFile=")
|| !javaToolOptions.contains("/jprototerm/app")) {
return;
}
String sanitized = javaToolOptions
.replaceAll("(^|\\s)-XX:\\+AutoCreateSharedArchive(?=\\s|$)", " ")
.replaceAll("(^|\\s)-XX:SharedArchiveFile=\\S*/jprototerm/app\\S*(?=\\s|$)", " ")
.trim();
if (sanitized.isEmpty()) {
environment.remove("JAVA_TOOL_OPTIONS");
} else {
environment.put("JAVA_TOOL_OPTIONS", sanitized);
}
} }
public void startReading(TerminalPane pane) { public void startReading(TerminalPane pane) {
reader.submit(() -> readOutput(pane)); reader.submit(() -> readOutput(pane));
} }
/** Best-effort current working directory of the running shell, or {@code null} if unknown. */
public String currentWorkingDirectory() {
return closed ? null : pty.currentWorkingDirectory();
}
public void resize(int columns, int rows) { public void resize(int columns, int rows) {
if (closed) { if (closed) {
return; return;
@@ -113,12 +171,36 @@ public final class ShellSession implements AutoCloseable {
pane.write("\r\nshell output stopped: " + ex.getMessage() + "\r\n"); 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 @Override
public void close() { public void close() {
closed = true; if (!markClosed()) {
return;
}
reader.shutdownNow(); reader.shutdownNow();
pty.close(); pty.close();
} }
/** Signal and disconnect the pty immediately, but leave child reaping to a background thread. */
public void closeDetached() {
if (!markClosed()) {
return;
}
reader.shutdownNow();
pty.closeDetached();
}
private synchronized boolean markClosed() {
if (closed) {
return false;
}
closed = true;
return true;
}
} }

View File

@@ -0,0 +1,48 @@
package com.gregor.jprototerm;
import java.lang.management.ManagementFactory;
/**
* Opt-in startup phase timing, enabled with {@code -Djprototerm.timing=true} (e.g. via
* {@code JAVA_TOOL_OPTIONS}); otherwise every method is a cheap no-op and prints nothing.
*
* <p>Each {@link #mark(String)} prints one line to stderr with the time since the previous mark and
* the total since JVM start, so a cold launch breaks down into its phases — toolkit/GL init vs
* config load vs font loading vs first frame. The anchor is the JVM's own start time (the closest
* proxy we have to "process start"), so the first mark includes JVM bootstrap and JavaFX toolkit
* init, which is usually the dominant cost.
*/
final class StartupTiming {
private static final boolean ENABLED = Boolean.getBoolean("jprototerm.timing");
// Epoch millis; getStartTime() is the JVM's start, the earliest timestamp we can anchor to.
private static final long JVM_START_MILLIS = ManagementFactory.getRuntimeMXBean().getStartTime();
private static long lastMillis = -1;
private static boolean firstFrameSeen;
private StartupTiming() {
}
/** Records a phase boundary, printing the delta since the previous mark and since JVM start. */
static void mark(String phase) {
if (!ENABLED) {
return;
}
long now = System.currentTimeMillis();
long sinceStart = now - JVM_START_MILLIS;
long sinceLast = lastMillis < 0 ? sinceStart : now - lastMillis;
lastMillis = now;
System.err.printf("[timing] %-22s +%5d ms (%5d ms since JVM start)%n", phase, sinceLast, sinceStart);
}
/**
* Records the first rendered frame exactly once, then becomes a no-op. Safe and cheap to call
* from the render loop every frame (it only ever touches FX-thread state).
*/
static void firstFrame() {
if (!ENABLED || firstFrameSeen) {
return;
}
firstFrameSeen = true;
mark("first frame");
}
}

View File

@@ -7,6 +7,7 @@ import java.util.ArrayList;
import java.util.Comparator; import java.util.Comparator;
import java.util.List; import java.util.List;
import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.AtomicLong;
import java.util.function.Consumer;
import java.util.stream.Stream; import java.util.stream.Stream;
/** /**
@@ -19,13 +20,19 @@ import java.util.stream.Stream;
final class Tab implements AutoCloseable { final class Tab implements AutoCloseable {
private final AppConfig config; private final AppConfig config;
private final TerminalMetrics metrics; 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> tiled = new ArrayList<>();
private final List<TerminalPane> floating = new ArrayList<>(); private final List<TerminalPane> floating = new ArrayList<>();
private boolean floatingVisible; private boolean floatingVisible;
private TerminalPane active; private TerminalPane active;
private final String initialWorkingDirectory;
// The floating pane to re-focus when the group is shown again, and to prefer when promoting // The floating pane to re-focus when the group is shown again, and to prefer when promoting
// after the last tiled pane closes. // after the last tiled pane closes.
private TerminalPane lastFocusedFloating; private TerminalPane lastFocusedFloating;
// 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 // 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 // (and thus grid). Seeded from the configured window size for the first pane, which is
// opened before any layout pass runs. // opened before any layout pass runs.
@@ -36,11 +43,22 @@ final class Tab implements AutoCloseable {
// tab's value each frame as an O(1) "anything to repaint?" check. // tab's value each frame as an O(1) "anything to repaint?" check.
private final AtomicLong contentVersion = new AtomicLong(); 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.config = config;
this.metrics = metrics; this.metrics = metrics;
this.onPaneExit = onPaneExit;
this.lastWidth = config.windowWidth(); this.lastWidth = config.windowWidth();
this.lastHeight = config.windowHeight(); this.lastHeight = config.windowHeight();
this.initialWorkingDirectory = initialWorkingDirectory;
TerminalPane first = openPane(false); TerminalPane first = openPane(false);
tiled.add(first); tiled.add(first);
active = first; active = first;
@@ -83,6 +101,19 @@ final class Tab implements AutoCloseable {
return pane != null && pane == active; return pane != null && pane == active;
} }
/** Every pane this tab owns, composited or not (tiled then floating). */
List<TerminalPane> allPanes() {
List<TerminalPane> all = new ArrayList<>(tiled.size() + floating.size());
all.addAll(tiled);
all.addAll(floating);
return all;
}
/** Whether this tab owns panes that {@link #panes()} does not currently composite. */
boolean hasHiddenPanes() {
return !floatingVisible && !floating.isEmpty();
}
boolean focus(TerminalPane pane) { boolean focus(TerminalPane pane) {
if (pane == active || !isFocusable(pane)) { if (pane == active || !isFocusable(pane)) {
return false; return false;
@@ -183,7 +214,7 @@ final class Tab implements AutoCloseable {
if (floatingVisible) { if (floatingVisible) {
floatingVisible = false; floatingVisible = false;
if (floating.contains(active)) { if (floating.contains(active)) {
setActive(tiled.get(0)); setActive(tiled.contains(lastFocusedTiled) ? lastFocusedTiled : tiled.get(0));
} }
} else { } else {
floatingVisible = true; floatingVisible = true;
@@ -212,20 +243,52 @@ final class Tab implements AutoCloseable {
setActive(floating.get((current + 1 + floating.size()) % floating.size())); setActive(floating.get((current + 1 + floating.size()) % floating.size()));
} }
void closeActivePane() { /** Promotes the active floating pane to a tiled pane, joining the tiled row. No-op otherwise. */
TerminalPane closing = active; void toggleActiveFloating() {
boolean wasFloating = floating.remove(closing); TerminalPane toggled = active;
if (!wasFloating) { if (floating.remove(toggled)) {
tiled.remove(closing); lastFocusedFloating = floating.isEmpty() ? null : floating.get(floating.size() - 1);
tiled.add(toggled);
floatingVisible = false;
setActive(toggled);
} else if (tiled.remove(toggled)) {
lastFocusedTiled = tiled.isEmpty() ? null : tiled.get(tiled.size() -1);
floating.add(toggled);
floatingVisible = true;
setActive(toggled);
} }
}
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) { if (closing == lastFocusedFloating) {
lastFocusedFloating = null; lastFocusedFloating = null;
} }
if (closing == lastFocusedTiled) {
lastFocusedTiled = null;
}
closing.close(); closing.close();
if (tiled.isEmpty() && floating.isEmpty()) { if (tiled.isEmpty() && floating.isEmpty()) {
active = null; // tab is now empty; the compositor drops it 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 // Always keep a tiled base: if the last tiled pane just closed, promote a floating one
@@ -237,31 +300,54 @@ final class Tab implements AutoCloseable {
floating.remove(promote); floating.remove(promote);
tiled.add(promote); tiled.add(promote);
if (promote == lastFocusedFloating) { if (promote == lastFocusedFloating) {
lastFocusedFloating = null;
if (!floating.isEmpty()) {
lastFocusedFloating = floating.isEmpty() ? null : floating.get(nextFocussed); lastFocusedFloating = floating.isEmpty() ? null : floating.get(nextFocussed);
} }
} }
}
if (floating.isEmpty()) { if (floating.isEmpty()) {
floatingVisible = false; 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) { private void setActive(TerminalPane pane) {
active = pane; active = pane;
if (floating.contains(pane)) { if (floating.contains(pane)) {
lastFocusedFloating = 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 createFloatingPane() {
TerminalPane pane = openPane(true); 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); floating.add(pane);
floatingVisible = true; floatingVisible = true;
setActive(pane); setActive(pane);
return pane;
} }
private boolean navigateFloatingStack(Direction direction) { private boolean navigateFloatingStack(Direction direction) {
@@ -296,18 +382,31 @@ final class Tab implements AutoCloseable {
} }
private TerminalPane openPane(boolean asFloating) { private TerminalPane openPane(boolean asFloating) {
double availHeight = lastHeight - lastTopInset; double[] size = paneSize(asFloating);
double widthPx; return register(TerminalPane.create(
double heightPx; config, metrics, this::markContentChanged, size[0], size[1], paneWorkingDirectory()));
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;
} }
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) { private static boolean directionFilter(Direction direction, TerminalPane current, TerminalPane candidate) {
@@ -337,4 +436,14 @@ final class Tab implements AutoCloseable {
tiled.clear(); tiled.clear();
floating.clear(); floating.clear();
} }
/**
* Signals and reaps every pane's shell process without tearing down render state. Safe to call
* off the FX thread (see {@link TerminalPane#terminateSession()}); iterates snapshots so a
* concurrent close on the FX thread can't trigger a {@link java.util.ConcurrentModificationException}.
*/
public void terminateSessions() {
List.copyOf(tiled).forEach(TerminalPane::terminateSession);
List.copyOf(floating).forEach(TerminalPane::terminateSession);
}
} }

View File

@@ -23,6 +23,9 @@ public final class TerminalMetrics {
private double cellWidth; private double cellWidth;
private double lineHeight; private double lineHeight;
private double baselineOffset; private double baselineOffset;
// One rasterized-glyph atlas per window, shared by every pane's renderer (the masks are a pure
// function of the font geometry below). It self-invalidates when these metrics change.
private final GlyphCache glyphCache = new GlyphCache(this);
public TerminalMetrics(String fontFamily, double fontSize) { public TerminalMetrics(String fontFamily, double fontSize) {
setFont(fontFamily, fontSize); setFont(fontFamily, fontSize);
@@ -59,6 +62,11 @@ public final class TerminalMetrics {
return baselineOffset; return baselineOffset;
} }
/** The window's shared glyph atlas (see {@link GlyphCache}). */
public GlyphCache glyphCache() {
return glyphCache;
}
/** Columns that fit in a pane of the given pixel width (after subtracting the padding). */ /** Columns that fit in a pane of the given pixel width (after subtracting the padding). */
public int columnsFor(double widthPx) { public int columnsFor(double widthPx) {
return Math.max(1, (int) ((widthPx - 2 * PADDING) / cellWidth)); return Math.max(1, (int) ((widthPx - 2 * PADDING) / cellWidth));

View File

@@ -12,6 +12,7 @@ import dev.jlibghostty.RenderStateSnapshot;
import dev.jlibghostty.ScrollViewport; import dev.jlibghostty.ScrollViewport;
import dev.jlibghostty.Terminal; import dev.jlibghostty.Terminal;
import dev.jlibghostty.TerminalOptions; import dev.jlibghostty.TerminalOptions;
import javafx.application.Platform;
import javafx.scene.canvas.GraphicsContext; import javafx.scene.canvas.GraphicsContext;
import javafx.scene.shape.Shape; import javafx.scene.shape.Shape;
@@ -38,7 +39,11 @@ public final class TerminalPane implements AutoCloseable, RenderTarget {
// tracking meaningful: update() accumulates dirty since the last resetDirty(). // tracking meaningful: update() accumulates dirty since the last resetDirty().
private final RenderState renderState = new RenderState(); private final RenderState renderState = new RenderState();
private RenderStateSnapshot cachedSnapshot; private RenderStateSnapshot cachedSnapshot;
private ShellSession session; private volatile 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; // 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(). // null means clip to the plain bounds. See RenderTarget#clip().
private Shape clip; private Shape clip;
@@ -69,9 +74,32 @@ public final class TerminalPane implements AutoCloseable, RenderTarget {
* columns and rows fit, and that grid is handed to ghostty and the shell at start-up. A * columns and rows fit, and that grid is handed to ghostty and the shell at start-up. A
* non-positive size falls back to the configured default grid (used before the first * non-positive size falls back to the configured default grid (used before the first
* layout, when no rect is known yet). The pane owns the shell session it starts and runs * layout, when no rect is known yet). The pane owns the shell session it starts and runs
* {@code onContentChange} on every content change. * {@code onContentChange} on every content change. The shell starts in {@code workingDirectory}
* (e.g. the active pane's cwd), or the user's home when {@code null}.
*/ */
public static TerminalPane create(AppConfig config, TerminalMetrics metrics, Runnable onContentChange, double widthPx, double heightPx) { public static TerminalPane create(AppConfig config, TerminalMetrics metrics, Runnable onContentChange,
double widthPx, double heightPx, String workingDirectory) {
TerminalPane pane = newPane(config, metrics, onContentChange, widthPx, heightPx);
pane.attach(ShellSession.start(config.shell(), config.envOverride(), pane, pane.columns, pane.rows,
workingDirectory, config.closeSignalNumber()));
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, config.closeSignalNumber()));
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 columns = widthPx > 0 ? metrics.columnsFor(widthPx) : config.columns();
int rows = heightPx > 0 ? metrics.rowsFor(heightPx) : config.rows(); int rows = heightPx > 0 ? metrics.rowsFor(heightPx) : config.rows();
Terminal terminal = Ghostty.open(new TerminalOptions(columns, rows, config.maxScrollback())); Terminal terminal = Ghostty.open(new TerminalOptions(columns, rows, config.maxScrollback()));
@@ -79,10 +107,31 @@ public final class TerminalPane implements AutoCloseable, RenderTarget {
TerminalPane pane = new TerminalPane(terminal, metrics, config.kittyGraphics(), onContentChange, TerminalPane pane = new TerminalPane(terminal, metrics, config.kittyGraphics(), onContentChange,
new GhosttyTerminalRenderer(metrics), columns, rows); new GhosttyTerminalRenderer(metrics), columns, rows);
pane.refresh(); pane.refresh();
pane.attach(ShellSession.start(config.shell(), config.envOverride(), pane, columns, rows));
return pane; 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) { private void attach(ShellSession session) {
this.session = session; this.session = session;
terminal.setPtyWriter(bytes -> { terminal.setPtyWriter(bytes -> {
@@ -115,6 +164,19 @@ public final class TerminalPane implements AutoCloseable, RenderTarget {
} }
} }
/**
* Paste text to the shell: ghostty sanitises it (stripping anything that could smuggle in
* control sequences) and wraps it in the bracketed-paste markers, then it goes to the pty
* like typed input. We always request bracketed mode — every modern shell and editor enables
* DECSET 2004, and the jlibghostty API does not expose querying the terminal's live mode.
*/
public void paste(String text) {
if (text == null || text.isEmpty()) {
return;
}
send(Ghostty.encodePaste(text, true));
}
public boolean sendMouse(MouseInput input, MouseEncoderSize size, boolean anyButtonPressed) { public boolean sendMouse(MouseInput input, MouseEncoderSize size, boolean anyButtonPressed) {
synchronized (terminal) { synchronized (terminal) {
mouseEncoder.syncFromTerminal(terminal); mouseEncoder.syncFromTerminal(terminal);
@@ -192,6 +254,12 @@ public final class TerminalPane implements AutoCloseable, RenderTarget {
} }
} }
/** Best-effort current working directory of this pane's shell, or {@code null} if unknown. */
public String currentWorkingDirectory() {
ShellSession current = session;
return current != null ? current.currentWorkingDirectory() : null;
}
/** This pane's own content revision, bumped on every change (see {@link #refresh()}). */ /** This pane's own content revision, bumped on every change (see {@link #refresh()}). */
public long contentVersion() { public long contentVersion() {
return contentVersion.get(); return contentVersion.get();
@@ -282,6 +350,16 @@ public final class TerminalPane implements AutoCloseable, RenderTarget {
onContentChange.run(); onContentChange.run();
} }
/**
* Drop this pane's large, rebuildable render buffers because it is no longer being composited
* (e.g. it sits in a background tab or a hidden floating group). The pixel backbuffer and its GPU
* image are freed; the shell, ghostty terminal and shared glyph atlas are untouched, and the next
* {@link #paintFull} rebuilds the buffer. Safe to call repeatedly. See {@link TerminalRenderer#release}.
*/
public void releaseRenderResources() {
renderer.release();
}
/** Paint the whole pane; see {@link TerminalRenderer#paintFull}. */ /** Paint the whole pane; see {@link TerminalRenderer#paintFull}. */
public long paintFull(GraphicsContext gc, boolean active) { public long paintFull(GraphicsContext gc, boolean active) {
renderer.paintFull(gc, this, active); renderer.paintFull(gc, this, active);
@@ -294,14 +372,35 @@ public final class TerminalPane implements AutoCloseable, RenderTarget {
return snapshotVersion; return snapshotVersion;
} }
/**
* Kitty image placements from the most recent paint, in scene coordinates. The compositor
* renders these as overlay nodes clipped to this pane (see {@link KittyImageOverlay}).
*/
public java.util.List<KittyImageNode> kittyImages() {
return renderer.kittyImages();
}
@Override @Override
public void close() { public void close() {
if (session != null) { if (session != null) {
session.close(); session.closeDetached();
session = null; session = null;
} }
mouseEncoder.close(); mouseEncoder.close();
renderState.close(); renderState.close();
terminal.close(); terminal.close();
} }
/**
* Signals and reaps just the shell process, leaving the render/native state untouched. Unlike
* {@link #close()} this is safe to call off the FX thread — notably from a JVM shutdown hook,
* which runs concurrently with the live render loop — because it only touches the pty (a child
* process and fd), not ghostty's terminal handles. Idempotent; the OS reclaims the rest on exit.
*/
public void terminateSession() {
ShellSession current = session;
if (current != null) {
current.close();
}
}
} }

View File

@@ -25,6 +25,22 @@ abstract class TerminalRenderer {
/** Repaint only what changed since the last frame, clipped to the target's clip region. */ /** Repaint only what changed since the last frame, clipped to the target's clip region. */
abstract void paintIncremental(GraphicsContext gc, RenderTarget target, boolean active); abstract void paintIncremental(GraphicsContext gc, RenderTarget target, boolean active);
/**
* The kitty image placements produced by the most recent paint, for the compositor to render
* as overlay nodes above the canvas. Empty unless the last paint found visible images.
*/
java.util.List<KittyImageNode> kittyImages() {
return java.util.List.of();
}
/**
* Release any large, rebuildable render buffers (e.g. a pane's pixel backbuffer) because the
* target is no longer being composited. A no-op by default; the next paint must rebuild whatever
* was dropped. Called off the paint path, so it must not assume a frame is in progress.
*/
void release() {
}
protected static void clipRect(GraphicsContext gc, double x, double y, double width, double height) { protected static void clipRect(GraphicsContext gc, double x, double y, double width, double height) {
gc.beginPath(); gc.beginPath();
gc.rect(x, y, width, height); gc.rect(x, y, width, height);

View File

@@ -0,0 +1,279 @@
package com.gregor.jprototerm;
import javafx.animation.AnimationTimer;
import javafx.geometry.Rectangle2D;
import javafx.scene.Scene;
import javafx.scene.control.ButtonType;
import javafx.scene.control.ComboBox;
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;
/**
* One top-level terminal window: its own {@link Stage}, {@link Compositor}, config/metrics, render
* loop and input handling. Many of these live in a single JVM under a {@link WindowManager}; closing
* one tears down only that window (its shells, its Stage) and leaves the rest — and, in daemon mode,
* the JVM — running. Built on the FX thread.
*/
final class TerminalWindow {
private final WindowManager manager;
private final TerminalMetrics metrics;
private final Compositor compositor;
private final Stage stage;
private final AnimationTimer renderLoop;
private AppConfig config;
private boolean closed;
TerminalWindow(WindowManager manager, String workingDirectory) {
this.manager = manager;
// Each window loads config independently, so edits (and per-window font changes) apply to
// newly opened windows without disturbing existing ones.
config = AppConfig.load();
StartupTiming.mark("config loaded");
metrics = new TerminalMetrics(config.fontFamily(), config.fontSize());
StartupTiming.mark("fonts loaded");
compositor = new Compositor(config, metrics, workingDirectory);
StartupTiming.mark("compositor ready");
// The last pane closing closes this window (not the JVM); see teardown().
compositor.setOnEmpty(this::teardown);
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, this::handleTyped);
renderLoop = new AnimationTimer() {
@Override
public void handle(long now) {
compositor.render();
StartupTiming.firstFrame();
}
};
renderLoop.start();
stage = new Stage();
stage.setTitle("jprototerm");
stage.setScene(scene);
// The X11 WM close button: tear this window down explicitly. With implicit exit disabled
// (see WindowManager) nothing else would reap the shells or drop the window otherwise.
stage.setOnCloseRequest(event -> teardown());
centreOnActiveScreen(stage, config.windowWidth(), config.windowHeight());
stage.show();
StartupTiming.mark("stage shown");
// 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();
}
/**
* Fully tears this window down (FX thread, idempotent): stops rendering, closes the compositor —
* which signals pane shells via the configured {@code close_signal} — disposes the Stage, and
* notifies the manager so it can drop the window (and, in standalone mode, exit the JVM). Both
* the WM close button and the last-pane-closed hook route through here.
*/
void teardown() {
if (closed) {
return;
}
closed = true;
renderLoop.stop();
compositor.close();
stage.close();
manager.onWindowClosed(this);
}
/** Signals and reaps this window's shell processes without touching render state (off-FX safe). */
void terminateSessions() {
compositor.terminateSessions();
}
private void handlePressed(KeyEvent event) {
if (config.keybindings().get("navigate_left").matches(event)) {
compositor.navigate(Direction.LEFT);
event.consume();
} else if (config.keybindings().get("navigate_down").matches(event)) {
compositor.navigate(Direction.DOWN);
event.consume();
} else if (config.keybindings().get("navigate_up").matches(event)) {
compositor.navigate(Direction.UP);
event.consume();
} else if (config.keybindings().get("navigate_right").matches(event)) {
compositor.navigate(Direction.RIGHT);
event.consume();
} else if (config.keybindings().get("toggle_floating").matches(event)) {
compositor.toggleFloating();
event.consume();
} 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.toggleActiveFloating();
event.consume();
} else if (config.keybindings().get("close_pane").matches(event)) {
// Closing the last pane closes this window, via the compositor's onEmpty hook.
compositor.closeActivePane();
event.consume();
} else if (config.keybindings().get("new_tab").matches(event)) {
compositor.newTab();
event.consume();
} else if (config.keybindings().get("previous_tab").matches(event)) {
compositor.previousTab();
event.consume();
} else if (config.keybindings().get("next_tab").matches(event)) {
compositor.nextTab();
event.consume();
} else if (config.keybindings().get("open_font_selector").matches(event)) {
openFontSelector();
event.consume();
} 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) {
compositor.activePane().send(encoded);
event.consume();
}
}
}
private void handleTyped(KeyEvent event) {
if (event.isAltDown() || event.isControlDown() || event.isMetaDown()) {
return;
}
String text = event.getCharacter();
if (text != null && !text.isEmpty() && text.charAt(0) >= 0x20 && text.charAt(0) != 0x7f) {
compositor.activePane().send(text);
event.consume();
}
}
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");
dialog.getDialogPane().getButtonTypes().addAll(ButtonType.OK, ButtonType.CANCEL);
ComboBox<String> family = new ComboBox<>();
family.getItems().setAll(Font.getFamilies());
family.setEditable(true);
family.setMaxWidth(Double.MAX_VALUE);
family.setValue(config.fontFamily());
Spinner<Double> size = new Spinner<>();
size.setEditable(true);
size.setValueFactory(new SpinnerValueFactory.DoubleSpinnerValueFactory(6.0, 48.0, config.fontSize(), 0.5));
GridPane content = new GridPane();
content.setHgap(10.0);
content.setVgap(10.0);
content.add(new Label("Family"), 0, 0);
content.add(family, 1, 0);
content.add(new Label("Size"), 0, 1);
content.add(size, 1, 1);
dialog.getDialogPane().setContent(content);
dialog.showAndWait()
.filter(button -> button == ButtonType.OK)
.ifPresent(ignored -> {
String selectedFamily = family.getEditor().getText();
if (selectedFamily == null || selectedFamily.isBlank()) {
selectedFamily = family.getValue();
}
if (selectedFamily == null || selectedFamily.isBlank()) {
return;
}
double selectedSize = size.getValue();
config = config.withFont(selectedFamily.trim(), selectedSize);
config.save();
compositor.setFont(config.fontFamily(), config.fontSize());
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();
// 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());
}
}
private String scrollbackEditorCommand(Path file) {
String quotedFile = shellQuote(file.toString());
String command = config.scrollbackEditorCommand();
if (command == null || command.isBlank()) {
command = "vi {file}";
}
if (command.contains("{file}")) {
return command.replace("{file}", quotedFile);
}
return command + " " + quotedFile;
}
private static String shellQuote(String value) {
return "'" + value.replace("'", "'\"'\"'") + "'";
}
// 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();
}
}

View File

@@ -0,0 +1,69 @@
package com.gregor.jprototerm;
import javafx.application.Platform;
import java.util.Set;
import java.util.concurrent.CopyOnWriteArraySet;
/**
* Owns the JavaFX toolkit lifecycle and the set of live {@link TerminalWindow}s for one JVM. A
* single JVM hosts every window, so the expensive toolkit/GL init is paid once; opening another
* window is then just a new {@link javafx.stage.Stage}.
*
* <p>Two modes differ only in the empty policy: in {@link Mode#STANDALONE} (today's behavior, used
* as the fallback when no daemon is reachable) the JVM exits once the last window closes; in
* {@link Mode#DAEMON} the toolkit stays alive with zero windows, waiting for the next client
* request. {@link Platform#setImplicitExit(boolean) implicit exit} is disabled in both so the
* toolkit never tears itself down behind our back — every exit is an explicit decision here.
*/
public final class WindowManager {
public enum Mode {
STANDALONE,
DAEMON
}
private final Mode mode;
// Mutated on the FX thread (register/deregister), iterated from the shutdown-hook thread.
private final Set<TerminalWindow> windows = new CopyOnWriteArraySet<>();
private WindowManager(Mode mode) {
this.mode = mode;
}
/**
* Brings up the JavaFX toolkit (once per JVM) and returns a manager in {@code mode}. Registers a
* shutdown hook that reaps every window's shell processes, so child shells are terminated rather
* than orphaned if the JVM is killed (SIGTERM/SIGINT/SIGHUP) — see
* {@link Compositor#terminateSessions()}.
*/
public static WindowManager start(Mode mode) {
WindowManager manager = new WindowManager(mode);
Platform.setImplicitExit(false);
Platform.startup(() -> StartupTiming.mark("toolkit ready"));
Runtime.getRuntime().addShutdownHook(new Thread(manager::terminateAllSessions, "shell-cleanup"));
return manager;
}
/** Opens a new window (on the FX thread) whose first pane starts in {@code workingDirectory}. */
public void openWindow(String workingDirectory) {
Platform.runLater(() -> windows.add(new TerminalWindow(this, workingDirectory)));
}
/**
* Called by a window when it has finished tearing down (FX thread). Drops it from the registry
* and, in standalone mode, exits the JVM once none remain.
*/
void onWindowClosed(TerminalWindow window) {
windows.remove(window);
if (mode == Mode.STANDALONE && windows.isEmpty()) {
Platform.exit();
}
}
/** Signals and reaps every live window's shell processes. Safe to call off the FX thread. */
void terminateAllSessions() {
for (TerminalWindow window : windows) {
window.terminateSessions();
}
}
}

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