48 Commits

Author SHA1 Message Date
b7adad41f7 Merge branch 'thinner-sync-borders-b' 2026-06-20 14:47:12 +02:00
95eb5ac9bd thinner borders 2026-06-20 14:42:14 +02:00
2c4b89f8f7 Merge branch 'add-worktree-features' 2026-06-19 17:10:46 +02:00
cff4fba81c post create pane sync 2026-06-19 16:44:16 +02:00
5a50ce62f3 post create run commands in panes 2026-06-19 16:41:31 +02:00
27b7ba6904 post create none action 2026-06-19 16:15:10 +02:00
7b3a370371 post worktree creation actions 2026-06-19 16:08:17 +02:00
4e2ed7f99f multiple worktree creation 2026-06-19 16:02:06 +02:00
b8a4f52609 Merge branch 'pane-sync' 2026-06-19 15:26:29 +02:00
7367b2f778 panesync toggle <- panesync commit 2026-06-19 15:26:00 +02:00
a6a700b2c0 panesync start + panesync end = panesync toggle 2026-06-19 15:21:59 +02:00
47b2daa782 pane sync start != pane sync select 2026-06-19 15:19:12 +02:00
8bd8170270 pane sync 2026-06-19 15:15:44 +02:00
c922968376 Merge branch 'add-worktree-shortcut' 2026-06-19 14:36:25 +02:00
64263ee3fb standalone flag 2026-06-19 14:32:52 +02:00
f621366b0c rename action 2026-06-19 14:27:57 +02:00
77fb5e7e11 add worktree shortcut 2026-06-19 14:24:35 +02:00
08fcb93e01 some guard rails 2026-06-12 11:59:25 +02:00
8954482222 refactor config 2026-06-12 11:48:34 +02:00
692511e445 color performance improvements, unify pane ordering in tab 2026-06-12 11:38:48 +02:00
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
25 changed files with 2138 additions and 701 deletions

1
.gitattributes vendored Normal file
View File

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

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,57 @@ 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.
For development testing, use `jprototerm --standalone` to skip the daemon even when one is
running.
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:
@@ -96,6 +149,14 @@ enabled = true
[scrollback] [scrollback]
editor_command = "vi {file}" editor_command = "vi {file}"
[worktree]
relative_worktree_path = "./.worktrees"
split_regex = ","
# One of: "none", "cd", "create_panes", "create_panes_floating".
post_create_action = "none"
commands = []
sync_panes = false
[env.override] [env.override]
ZELLIJ_SESSION_NAME = "" ZELLIJ_SESSION_NAME = ""
@@ -113,6 +174,10 @@ previous_tab = "ALT+SHIFT+H"
next_tab = "ALT+SHIFT+L" next_tab = "ALT+SHIFT+L"
open_font_selector = "ALT+T" open_font_selector = "ALT+T"
open_scrollback = "ALT+S" open_scrollback = "ALT+S"
create_worktree = "ALT+W"
pane_sync_toggle = "ALT+Y"
pane_sync_select = "SPACE"
paste = "CTRL+SHIFT+V"
``` ```
## Defaults ## Defaults
@@ -128,6 +193,18 @@ open_scrollback = "ALT+S"
- `Alt+Shift+h` / `Alt+Shift+l`: previous / next tab - `Alt+Shift+h` / `Alt+Shift+l`: previous / next tab
- `Alt+t`: open the font selector - `Alt+t`: open the font selector
- `Alt+s`: open the active pane scrollback in `$EDITOR` - `Alt+s`: open the active pane scrollback in `$EDITOR`
- `Alt+w`: edit one or more worktree names, split by `worktree.split_regex`, then run
`git worktree add <relative_worktree_path>/<name>` for each name from the previously focused
pane's working directory. `worktree.post_create_action` can then do nothing with `none`, `cd` the
previously active pane to the last created worktree, create one tiled pane per worktree with
`create_panes`, or create one floating pane per worktree with `create_panes_floating`.
`worktree.commands`, for example `["npm install", "git status"]`, can run commands in created
panes; commands are assigned in order and repeat when fewer commands than panes are created.
`worktree.sync_panes = true` syncs those created panes after the configured commands are sent.
- `Alt+y`: enter pane-sync selection mode, commit the selection, or stop an active pane sync
- `Space`: toggle the focused pane in the sync set while pane-sync selection mode is active
- Once committed, input typed or pasted into any synced pane is mirrored to the other synced panes
- `Ctrl+Shift+v`: paste
- Font default: `JetBrainsMono Nerd Font` - Font default: `JetBrainsMono Nerd Font`
- Kitty graphics protocol parsing is enabled by default - Kitty graphics protocol parsing is enabled by default

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
@@ -16,6 +19,14 @@ enabled = true
[scrollback] [scrollback]
editor_command = "vi {file}" editor_command = "vi {file}"
[worktree]
relative_worktree_path = "./.worktrees"
split_regex = ","
# One of: "none", "cd", "create_panes", "create_panes_floating".
post_create_action = "none"
commands = []
sync_panes = false
[env.override] [env.override]
ZELLIJ_SESSION_NAME = "" ZELLIJ_SESSION_NAME = ""
@@ -25,8 +36,16 @@ navigate_down = "ALT+J"
navigate_up = "ALT+K" navigate_up = "ALT+K"
navigate_right = "ALT+L" navigate_right = "ALT+L"
toggle_floating = "ALT+F" toggle_floating = "ALT+F"
new_floating = "ALT+SHIFT+F" new_pane = "ALT+N"
next_floating = "ALT+F12" next_floating = "ALT+F12"
promote_floating = "ALT+P"
close_pane = "ALT+X" close_pane = "ALT+X"
new_tab = "ALT+A"
previous_tab = "ALT+SHIFT+H"
next_tab = "ALT+SHIFT+L"
open_font_selector = "ALT+T" open_font_selector = "ALT+T"
open_scrollback = "ALT+S" open_scrollback = "ALT+S"
create_worktree = "ALT+W"
pane_sync_toggle = "ALT+Y"
pane_sync_select = "SPACE"
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": 1780272954, "lastModified": 1780363584,
"narHash": "sha256-bVWY60iw8yPIu7I8FuRPf06T0H1TDvQDVUlzeHQs8UA=", "narHash": "sha256-BN6kwSBnlavuySut6vvLsfwkfKBjrPvFgJeeMMTXNtg=",
"ref": "refs/heads/main", "ref": "refs/heads/main",
"rev": "06a9d5d3ecf11c58f0e41214d1b59900e672dd3a", "rev": "1cd908e5d0070fdc1c86fc6b300cf1d6dbb5d184",
"revCount": 24, "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,19 @@ 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 worktreeRelativePath,
String worktreeSplitRegex,
String worktreePostCreateAction,
List<String> worktreeCommands,
boolean worktreeSyncPanes,
String closeSignal,
Map<String, String> envOverride, Map<String, String> envOverride,
Map<String, KeyBinding> keybindings Map<String, KeyBinding> keybindings
) { ) {
@@ -38,19 +46,24 @@ 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",
"create_worktree",
"pane_sync_toggle",
"pane_sync_select",
"paste"
); );
public static AppConfig load() { public static AppConfig load() {
AppConfig defaults = defaults(); AppConfig defaults = defaults();
Path path = configPath(); Path path = configPath();
if (!Files.isRegularFile(path)) { if (!Files.isRegularFile(path)) {
writeDefaultConfig(path, defaults); save(path, defaults);
return defaults; return defaults;
} }
@@ -60,13 +73,19 @@ 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),
stringValue(document, "worktree.relative_worktree_path", defaults.worktreeRelativePath),
stringValue(document, "worktree.split_regex", defaults.worktreeSplitRegex),
stringValue(document, "worktree.post_create_action", defaults.worktreePostCreateAction),
stringListValue(document, "worktree.commands", defaults.worktreeCommands),
booleanValue(document, "worktree.sync_panes", defaults.worktreeSyncPanes),
closeSignalValue(document, defaults.closeSignal),
envOverride(document, defaults.envOverride), envOverride(document, defaults.envOverride),
keybindings(document, defaults) keybindings(document, defaults)
); );
@@ -88,6 +107,12 @@ public record AppConfig(
760.0, 760.0,
true, true,
defaultScrollbackEditorCommand(), defaultScrollbackEditorCommand(),
"./.worktrees",
",",
"none",
List.of(),
false,
"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 +122,17 @@ 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("create_worktree", KeyBinding.parse("ALT+W")),
Map.entry("pane_sync_toggle", KeyBinding.parse("ALT+Y")),
Map.entry("pane_sync_select", KeyBinding.parse("SPACE")),
Map.entry("paste", KeyBinding.parse("CTRL+SHIFT+V"))
) )
); );
} }
@@ -119,11 +149,27 @@ public record AppConfig(
windowHeight, windowHeight,
kittyGraphics, kittyGraphics,
scrollbackEditorCommand, scrollbackEditorCommand,
worktreeRelativePath,
worktreeSplitRegex,
worktreePostCreateAction,
worktreeCommands,
worktreeSyncPanes,
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 +182,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 +196,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) {
@@ -156,10 +221,6 @@ public record AppConfig(
return Map.copyOf(parsed); return Map.copyOf(parsed);
} }
private static void writeDefaultConfig(Path path, AppConfig defaults) {
save(path, defaults);
}
private static void save(Path path, AppConfig config) { private static void save(Path path, AppConfig config) {
try { try {
Path parent = path.getParent(); Path parent = path.getParent();
@@ -184,7 +245,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");
@@ -194,6 +256,12 @@ public record AppConfig(
builder.append("enabled = ").append(kittyGraphics).append("\n\n"); builder.append("enabled = ").append(kittyGraphics).append("\n\n");
builder.append("[scrollback]\n"); builder.append("[scrollback]\n");
builder.append("editor_command = ").append(quoted(scrollbackEditorCommand)).append("\n\n"); builder.append("editor_command = ").append(quoted(scrollbackEditorCommand)).append("\n\n");
builder.append("[worktree]\n");
builder.append("relative_worktree_path = ").append(quoted(worktreeRelativePath)).append('\n');
builder.append("split_regex = ").append(quoted(worktreeSplitRegex)).append('\n');
builder.append("post_create_action = ").append(quoted(worktreePostCreateAction)).append('\n');
builder.append("commands = ").append(quotedList(worktreeCommands)).append('\n');
builder.append("sync_panes = ").append(worktreeSyncPanes).append("\n\n");
builder.append("[env.override]\n"); builder.append("[env.override]\n");
for (Map.Entry<String, String> entry : envOverride.entrySet()) { for (Map.Entry<String, String> entry : envOverride.entrySet()) {
builder.append(entry.getKey()).append(" = ").append(quoted(entry.getValue())).append('\n'); builder.append(entry.getKey()).append(" = ").append(quoted(entry.getValue())).append('\n');
@@ -209,6 +277,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,16 +347,28 @@ 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); return (int) longValue(table, key, fallback);
if (primitive == null) {
return fallback;
}
try {
return primitive.asInteger();
} catch (RuntimeException ex) {
return fallback;
}
} }
private static long longValue(TomlTable table, String key, long fallback) { private static long longValue(TomlTable table, String key, long fallback) {

View File

@@ -19,9 +19,12 @@ 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.LinkedHashSet;
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:
@@ -35,6 +38,10 @@ public final class Compositor {
// Canvas background shown wherever no pane covers (gaps). Painted on a full recomposite. // Canvas background shown wherever no pane covers (gaps). Painted on a full recomposite.
private static final Color GAP_BACKGROUND = Color.rgb(16, 16, 18); private static final Color GAP_BACKGROUND = Color.rgb(16, 16, 18);
private static final Color TAB_TEXT = Color.rgb(225, 229, 235); private static final Color TAB_TEXT = Color.rgb(225, 229, 235);
private static final Color PANE_SYNC_SELECT_BORDER = Color.rgb(255, 183, 77);
private static final Color PANE_SYNC_COMMITTED_BORDER = Color.rgb(105, 214, 128);
// Keep this narrower than the terminal renderer's active border so focus remains visible.
private static final double PANE_SYNC_BORDER_WIDTH = 1.0;
// Thin tab strip shown at the top when more than one tab is open. // Thin tab strip shown at the top when more than one tab is open.
private static final double TAB_BAR_HEIGHT = 22.0; private static final double TAB_BAR_HEIGHT = 22.0;
@@ -52,6 +59,19 @@ 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<>();
private final Set<TerminalPane> paneSyncSelection = new LinkedHashSet<>();
private final Set<TerminalPane> paneSyncPanes = new LinkedHashSet<>();
private boolean paneSyncSelectMode;
// 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;
@@ -61,11 +81,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);
@@ -78,6 +109,11 @@ 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. */ /** The kitty-image overlay, to be stacked directly above {@link #canvas()} in the window. */
public Node imageOverlay() { public Node imageOverlay() {
return imageOverlay.node(); return imageOverlay.node();
@@ -86,7 +122,7 @@ public final class Compositor {
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();
lastWidth = -1.0; // force a redraw on the next frame layoutVersion++; // recomposite with the new metrics on the next frame
} }
// ---- Tabs and panes ------------------------------------------------------------- // ---- Tabs and panes -------------------------------------------------------------
@@ -95,8 +131,9 @@ public final class Compositor {
return tabs.isEmpty(); return tabs.isEmpty();
} }
/** The active pane of the current tab, or {@code null} when no tab is left. */
public TerminalPane activePane() { public TerminalPane activePane() {
return currentTab().activePane(); return isEmpty() ? null : currentTab().activePane();
} }
public void navigate(Direction direction) { public void navigate(Direction direction) {
@@ -105,58 +142,167 @@ public final class Compositor {
} }
} }
public void toggleFloating() { public boolean isPaneSyncSelecting() {
if (isEmpty()) { return paneSyncSelectMode;
}
public void togglePaneSync() {
if (paneSyncSelectMode) {
paneSyncPanes.clear();
paneSyncPanes.addAll(paneSyncSelection);
paneSyncSelectMode = false;
paneSyncSelection.clear();
prunePaneSyncState();
layoutVersion++;
return; return;
} }
currentTab().toggleFloating(); if (!paneSyncPanes.isEmpty()) {
paneSyncPanes.clear();
layoutVersion++;
return;
}
if (activePane() == null) {
return;
}
paneSyncSelectMode = true;
paneSyncSelection.clear();
layoutVersion++; layoutVersion++;
} }
public void togglePaneSyncSelection() {
TerminalPane active = activePane();
if (active == null || !paneSyncSelectMode) {
return;
}
if (!paneSyncSelection.add(active)) {
paneSyncSelection.remove(active);
}
layoutVersion++;
}
public List<TerminalPane> paneSyncPeers(TerminalPane source) {
prunePaneSyncState();
if (source == null || !paneSyncPanes.contains(source)) {
return List.of();
}
return paneSyncPanes.stream()
.filter(pane -> pane != source)
.toList();
}
public void syncPanes(List<TerminalPane> panes) {
paneSyncSelectMode = false;
paneSyncSelection.clear();
paneSyncPanes.clear();
for (TerminalPane pane : panes) {
if (pane != null) {
paneSyncPanes.add(pane);
}
}
prunePaneSyncState();
layoutVersion++;
}
public void toggleFloating() {
mutateCurrentTab(() -> currentTab().toggleFloating());
}
public void createPane() { public void createPane() {
if (isEmpty()) { mutateCurrentTab(() -> currentTab().createPane());
return;
}
currentTab().createPane();
layoutVersion++;
} }
/** Opens a new floating pane, makes it active, and returns it (null when no tab exists). */ public TerminalPane createTiledPane(String workingDirectory) {
public TerminalPane openFloatingPane() {
if (isEmpty()) { if (isEmpty()) {
return null; return null;
} }
TerminalPane pane = currentTab().createFloatingPane(); TerminalPane pane = currentTab().createTiledPane(workingDirectory);
layoutVersion++;
return pane;
}
public TerminalPane createFloatingPaneInDirectory(String workingDirectory) {
if (isEmpty()) {
return null;
}
TerminalPane pane = currentTab().createFloatingPaneInDirectory(workingDirectory);
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++; layoutVersion++;
return pane; return pane;
} }
public void nextFloatingPane() { public void nextFloatingPane() {
mutateCurrentTab(() -> currentTab().nextFloatingPane());
}
public void toggleActiveFloating() {
mutateCurrentTab(() -> currentTab().toggleActiveFloating());
}
// Run a structural change on the current tab and bump the layout version so the next frame
// recomposites. No-op when no tab is left.
private void mutateCurrentTab(Runnable change) {
if (isEmpty()) { if (isEmpty()) {
return; return;
} }
currentTab().nextFloatingPane(); change.run();
layoutVersion++; layoutVersion++;
} }
public void closeActivePane() { public void closeActivePane() {
if (isEmpty()) { TerminalPane active = activePane();
return; if (active != null) {
closePane(active);
} }
currentTab().closeActivePane(); }
if (currentTab().isEmpty()) {
// Closing a tab's last pane closes the tab. When no tabs remain the surface is /**
// empty and Main quits. * Closes a specific pane, wherever it lives. Driven both by the key-bound close (via
tabs.remove(currentTabIndex); * {@link #closeActivePane()}) and by a pane whose process exited on its own. Drops the owning
if (currentTabIndex >= tabs.size()) { * tab if it becomes empty, and fires {@link #setOnEmpty} when the last pane is gone. Must run on
currentTabIndex = Math.max(0, tabs.size() - 1); * 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)) {
removePaneFromSyncState(pane);
if (tab.isEmpty()) {
// Closing a tab's last pane closes the tab. Keep currentTabIndex pointing at the
// same tab (or clamp it when the current/last tab went away).
tabs.remove(i);
if (i < currentTabIndex) {
currentTabIndex--;
} else if (currentTabIndex >= tabs.size()) {
currentTabIndex = Math.max(0, tabs.size() - 1);
}
}
layoutVersion++;
if (isEmpty()) {
onEmpty.run();
}
return;
} }
} }
layoutVersion++;
} }
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.
TerminalPane active = activePane();
String workingDirectory = active != null ? active.currentWorkingDirectory() : null;
tabs.add(new Tab(config, metrics, workingDirectory, this::closePane));
currentTabIndex = tabs.size() - 1; currentTabIndex = tabs.size() - 1;
layoutVersion++; layoutVersion++;
} }
@@ -180,6 +326,21 @@ public final class Compositor {
tab.close(); tab.close();
} }
tabs.clear(); tabs.clear();
paneSyncSelectMode = false;
paneSyncSelection.clear();
paneSyncPanes.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() {
@@ -203,6 +364,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();
@@ -210,6 +372,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
@@ -264,6 +468,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)));
} }
drawPaneSyncOverlay(gc, panes);
imageOverlay.sync(panes); imageOverlay.sync(panes);
} }
@@ -283,6 +488,7 @@ public final class Compositor {
paneContentVersion.put(pane, pane.paintIncremental(gc, isActive(pane))); paneContentVersion.put(pane, pane.paintIncremental(gc, isActive(pane)));
imageOverlay.updatePane(pane); imageOverlay.updatePane(pane);
} }
drawPaneSyncOverlay(gc, panes);
} }
private GraphicsContext beginFrame() { private GraphicsContext beginFrame() {
@@ -318,6 +524,63 @@ public final class Compositor {
gc.setFontSmoothingType(FontSmoothingType.LCD); gc.setFontSmoothingType(FontSmoothingType.LCD);
} }
private void drawPaneSyncOverlay(GraphicsContext gc, List<TerminalPane> panes) {
Set<TerminalPane> highlighted = paneSyncSelectMode ? paneSyncSelection : paneSyncPanes;
if (highlighted.isEmpty()) {
return;
}
gc.save();
try {
gc.setLineWidth(PANE_SYNC_BORDER_WIDTH);
gc.setStroke(paneSyncSelectMode ? PANE_SYNC_SELECT_BORDER : PANE_SYNC_COMMITTED_BORDER);
for (TerminalPane pane : panes) {
if (!highlighted.contains(pane)) {
continue;
}
gc.save();
double x = Math.round(pane.x()) + 2.0;
double y = Math.round(pane.y()) + 2.0;
double width = Math.max(0.0, pane.width() - 4.0);
double height = Math.max(0.0, pane.height() - 4.0);
TerminalRenderer.clip(gc, Math.round(pane.x()), Math.round(pane.y()), pane.width(), pane.height(), pane.clip());
gc.strokeRect(x, y, width, height);
gc.restore();
}
} finally {
gc.restore();
}
}
private void removePaneFromSyncState(TerminalPane pane) {
boolean changed = paneSyncSelection.remove(pane);
changed |= paneSyncPanes.remove(pane);
if (paneSyncPanes.size() < 2) {
changed |= !paneSyncPanes.isEmpty();
paneSyncPanes.clear();
}
if (changed) {
layoutVersion++;
}
}
private void prunePaneSyncState() {
Set<TerminalPane> live = livePanes();
paneSyncSelection.retainAll(live);
paneSyncPanes.retainAll(live);
if (paneSyncPanes.size() < 2) {
paneSyncPanes.clear();
}
}
private Set<TerminalPane> livePanes() {
Set<TerminalPane> live = new HashSet<>();
for (Tab tab : tabs) {
live.addAll(tab.allPanes());
}
return live;
}
// ---- Input ---------------------------------------------------------------------- // ---- Input ----------------------------------------------------------------------
private void handleMousePressed(MouseEvent event) { private void handleMousePressed(MouseEvent event) {
@@ -340,11 +603,11 @@ public final class Compositor {
private void handleMouseReleased(MouseEvent event) { private void handleMouseReleased(MouseEvent event) {
TerminalPane pane = paneAt(event.getX(), event.getY()); TerminalPane pane = paneAt(event.getX(), event.getY());
if (pane == null) { if (pane == null) {
pane = activePane(); pane = activePane(); // released outside every pane (e.g. mid-drag): route to the active one
} }
MouseButton button = pressedButton == MouseButton.UNKNOWN ? mouseButton(event) : pressedButton; MouseButton button = pressedButton == MouseButton.UNKNOWN ? mouseButton(event) : pressedButton;
MouseTarget target = mouseTarget(pane); MouseTarget target = pane == null ? null : mouseTarget(pane);
if (target != null) { if (target != null) {
send(pane, target, MouseInput.release(button, localX(event.getX(), pane, target), localY(event.getY(), pane, target), modifiers(event)), false, event); send(pane, target, MouseInput.release(button, localX(event.getX(), pane, target), localY(event.getY(), pane, target), modifiers(event)), false, event);
} }
@@ -355,7 +618,10 @@ public final class Compositor {
private void handleMouseDragged(MouseEvent event) { private void handleMouseDragged(MouseEvent event) {
TerminalPane pane = paneAt(event.getX(), event.getY()); TerminalPane pane = paneAt(event.getX(), event.getY());
if (pane == null) { if (pane == null) {
pane = activePane(); pane = activePane(); // dragged outside every pane: route to the active one
}
if (pane == null) {
return;
} }
MouseButton button = pressedButton == MouseButton.UNKNOWN ? mouseButton(event) : pressedButton; MouseButton button = pressedButton == MouseButton.UNKNOWN ? mouseButton(event) : pressedButton;

View File

@@ -0,0 +1,183 @@
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.LinkOption;
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 {
// One request is a single line holding a filesystem path; anything bigger is bogus.
private static final int MAX_REQUEST_BYTES = 4096;
// The accept loop is single-threaded, so a client that stalls must not wedge the daemon.
private static final long READ_TIMEOUT_NANOS = 5_000_000_000L;
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());
secureDir(socket.getParent());
} catch (IOException ex) {
System.err.println("jprototerm: cannot secure 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.configureBlocking(true);
connection.write(ByteBuffer.wrap("OK\n".getBytes(StandardCharsets.UTF_8)));
}
}
// Reads the request line non-blocking with a deadline and a size cap: the accept loop is
// single-threaded, so a client that stalls or never sends a newline must fail the connection
// (an IOException logged by run()) rather than wedge the daemon or grow the buffer unbounded.
private static String readLine(SocketChannel channel) throws IOException {
channel.configureBlocking(false);
long deadline = System.nanoTime() + READ_TIMEOUT_NANOS;
ByteArrayOutputStream out = new ByteArrayOutputStream();
ByteBuffer buffer = ByteBuffer.allocate(4096);
while (true) {
int n = channel.read(buffer);
if (n > 0) {
buffer.flip();
while (buffer.hasRemaining()) {
byte b = buffer.get();
if (b == '\n') {
return out.toString(StandardCharsets.UTF_8);
}
out.write(b);
if (out.size() > MAX_REQUEST_BYTES) {
throw new IOException("request line too long");
}
}
buffer.clear();
} else if (n == -1) {
return out.size() == 0 ? null : out.toString(StandardCharsets.UTF_8);
} else {
if (System.nanoTime() >= deadline) {
throw new IOException("request timed out");
}
try {
Thread.sleep(5);
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
throw new IOException("interrupted while reading request");
}
}
}
}
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");
}
// Make the socket dir private, and refuse to use it if it is not ours. The /tmp fallback
// path is predictable, so another user could have pre-created it (the classic /tmp race);
// binding a socket inside a directory someone else owns would hand them control of it.
private static void secureDir(Path dir) throws IOException {
try {
Files.setPosixFilePermissions(dir, PosixFilePermissions.fromString("rwx------"));
} catch (UnsupportedOperationException ignored) {
return; // not a POSIX filesystem: nothing more we can check
}
String owner = Files.getOwner(dir, LinkOption.NOFOLLOW_LINKS).getName();
String user = System.getProperty("user.name");
if (!owner.equals(user)) {
throw new IOException(dir + " is owned by '" + owner + "', not '" + user + "'");
}
}
}

View File

@@ -13,17 +13,11 @@ 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.text.FontSmoothingType;
import javafx.scene.text.Text;
import java.io.ByteArrayInputStream; import java.io.ByteArrayInputStream;
import java.nio.IntBuffer; import java.nio.IntBuffer;
@@ -45,19 +39,17 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
private static final int DIRTY_PARTIAL = 1; private static final int DIRTY_PARTIAL = 1;
private static final int DIRTY_FULL = 2; private static final int DIRTY_FULL = 2;
private static final Color DEFAULT_FOREGROUND = Color.rgb(225, 229, 235); // All colors are packed ARGB ints (alpha always 0xff): the software backbuffer never needs
private static final Color SELECTED_BACKGROUND = Color.rgb(52, 92, 140); // a javafx Color, so cells go straight from RenderColor to the pixel format.
private static final int DEFAULT_FOREGROUND = 0xffe1e5eb; // rgb(225, 229, 235)
private static final int SELECTED_BACKGROUND = 0xff345c8c; // rgb(52, 92, 140)
// The default cell background (used for cells with no explicit bg, and as the foreground // The default cell background (used for cells with no explicit bg, and as the foreground
// for reverse-video cells whose background is the terminal default). // for reverse-video cells whose background is the terminal default).
private static final Color PANE_BACKGROUND = Color.rgb(9, 10, 12); private static final int PANE_BACKGROUND = 0xff090a0c; // rgb(9, 10, 12)
private static final Color ACTIVE_BORDER = Color.rgb(87, 166, 255); private static final int ACTIVE_BORDER = 0xff57a6ff; // rgb(87, 166, 255)
private static final Color INACTIVE_BORDER = Color.rgb(52, 57, 65); private static final int INACTIVE_BORDER = 0xff343941; // rgb(52, 57, 65)
private static final Color CURSOR_FILL = Color.rgb(225, 229, 235, 0.28); // Block cursor: DEFAULT_FOREGROUND blended at 28% alpha by fillRectAlpha.
private static final int CURSOR_FILL_ALPHA = 71;
// A full-screen redraw asks for one Color per cell; most cells share a handful of colors,
// so cache them by packed RGB instead of allocating a Color each time. Bounded so a
// truecolor gradient can't grow it without limit.
private static final Map<Integer, Color> COLOR_CACHE = new HashMap<>();
private final TerminalMetrics metrics; private final TerminalMetrics metrics;
// 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).
@@ -109,8 +101,16 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
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(); kittyImageNodes = List.of();
} }
@@ -120,18 +120,20 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
return kittyImageNodes; return kittyImageNodes;
} }
// Effective background colour of a cell as it is drawn (reverse video swaps fg/bg, an @Override
// unset colour falls back to the defaults). void release() {
private static Color cellBackgroundColor(RenderCell cell) { software.release();
if (cell.inverse()) { kittyImageNodes = List.of();
var fg = cell.foreground();
return fg.isPresent() ? toFxColor(fg.get()) : DEFAULT_FOREGROUND;
}
var bg = cell.background();
return bg.isPresent() ? toFxColor(bg.get()) : PANE_BACKGROUND;
} }
private static Color rowEdgeBackground(RenderRow row, boolean firstCell) { // Effective background colour of a cell as it is drawn (reverse video swaps fg/bg, an
// unset colour falls back to the defaults).
private static int cellBackgroundColor(RenderCell cell) {
int override = cellBackgroundOverride(cell);
return override != 0 ? override : PANE_BACKGROUND;
}
private static int rowEdgeBackground(RenderRow row, boolean firstCell) {
List<RenderCell> cells = row.cells(); List<RenderCell> cells = row.cells();
if (cells.isEmpty()) { if (cells.isEmpty()) {
return PANE_BACKGROUND; return PANE_BACKGROUND;
@@ -139,41 +141,28 @@ 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));
} }
// Background override for a cell: null means the pane default background already covers it. // Background override for a cell: 0 means the pane default background already covers it
private static Color cellBackgroundOverride(RenderCell cell) { // (real colors always carry 0xff alpha, so 0 is never a valid color).
private static int cellBackgroundOverride(RenderCell cell) {
if (cell.inverse()) { if (cell.inverse()) {
var fg = cell.foreground(); var fg = cell.foreground();
return fg.isPresent() ? toFxColor(fg.get()) : DEFAULT_FOREGROUND; return fg.isPresent() ? packArgb(fg.get()) : DEFAULT_FOREGROUND;
} }
var bgOpt = cell.background(); var bg = cell.background();
Color bg = bgOpt.isPresent() ? toFxColor(bgOpt.get()) : null; return bg.isPresent() ? packArgb(bg.get()) : 0;
return bg;
} }
private static Color cellForegroundColor(RenderCell cell) { private static int cellForegroundColor(RenderCell cell) {
var fgOpt = cell.foreground();
var bgOpt = cell.background();
Color fg = fgOpt.isPresent() ? toFxColor(fgOpt.get()) : DEFAULT_FOREGROUND;
Color bg = bgOpt.isPresent() ? toFxColor(bgOpt.get()) : null;
if (cell.inverse()) { if (cell.inverse()) {
return (bg != null) ? bg : PANE_BACKGROUND; var bg = cell.background();
return bg.isPresent() ? packArgb(bg.get()) : PANE_BACKGROUND;
} }
return fg; var fg = cell.foreground();
return fg.isPresent() ? packArgb(fg.get()) : DEFAULT_FOREGROUND;
} }
private static Color toFxColor(RenderColor color) { private static int packArgb(RenderColor color) {
int key = (color.red() << 16) | (color.green() << 8) | color.blue(); return 0xff000000 | (color.red() << 16) | (color.green() << 8) | color.blue();
Color cached = COLOR_CACHE.get(key);
if (cached != null) {
return cached;
}
if (COLOR_CACHE.size() >= 4096) {
COLOR_CACHE.clear();
}
Color created = Color.rgb(color.red(), color.green(), color.blue());
COLOR_CACHE.put(key, created);
return created;
} }
// ---- Kitty graphics -------------------------------------------------------------- // ---- Kitty graphics --------------------------------------------------------------
@@ -416,7 +405,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;
@@ -425,6 +422,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().
@@ -456,7 +454,7 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
private void paintFull(GraphicsContext gc, RenderStateSnapshot snapshot, private void paintFull(GraphicsContext gc, 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);
fillRect(0, 0, width, height, argbPre(PANE_BACKGROUND)); fillRect(0, 0, width, height, PANE_BACKGROUND);
if (snapshot != null) { if (snapshot != null) {
paintSnapshot(snapshot); paintSnapshot(snapshot);
drawCursor(snapshot); drawCursor(snapshot);
@@ -503,6 +501,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);
@@ -539,11 +554,6 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
return; return;
} }
} }
if (repaintedRowHasCursor(newCursorRow, repainted)
&& !repaintCursorRow(snapshot, newCursorRow, repainted)) {
paintFullOrShifted(gc, target.snapshotFull(), px, py, paneWidth, paneHeight, active);
return;
}
lastCursor = cursor; lastCursor = cursor;
if (needsCursorDraw) { if (needsCursorDraw) {
drawCursor(snapshot); drawCursor(snapshot);
@@ -566,10 +576,6 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
return true; return true;
} }
private boolean repaintedRowHasCursor(int rowIndex, boolean[] repainted) {
return rowIndex >= 0 && rowIndex < repainted.length && repainted[rowIndex];
}
private RenderRow rowByIndex(RenderStateSnapshot snapshot, int rowIndex) { private RenderRow rowByIndex(RenderStateSnapshot snapshot, int rowIndex) {
for (RenderRow row : snapshot.renderRows()) { for (RenderRow row : snapshot.renderRows()) {
if (row.row() == rowIndex) { if (row.row() == rowIndex) {
@@ -583,7 +589,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;
} }
@@ -593,17 +598,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) {
@@ -615,6 +622,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();
} }
@@ -695,7 +708,7 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
// per-strip fillRect calls don't touch, so mark the full content band for upload. // per-strip fillRect calls don't touch, so mark the full content band for upload.
markDirtyRows(top, top + contentHeight); markDirtyRows(top, top + contentHeight);
if (dy == 0 || Math.abs(dy) >= contentHeight) { if (dy == 0 || Math.abs(dy) >= contentHeight) {
fillRect(0, top, width, contentHeight, argbPre(PANE_BACKGROUND)); fillRect(0, top, width, contentHeight, PANE_BACKGROUND);
return; return;
} }
@@ -706,7 +719,7 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
for (int y = 0; y < copyHeight; y++) { for (int y = 0; y < copyHeight; y++) {
System.arraycopy(pixels, (srcY + y) * width, pixels, (dstY + y) * width, width); System.arraycopy(pixels, (srcY + y) * width, pixels, (dstY + y) * width, width);
} }
fillRect(0, top + copyHeight, width, -dy, argbPre(PANE_BACKGROUND)); fillRect(0, top + copyHeight, width, -dy, PANE_BACKGROUND);
} else { } else {
int srcY = top; int srcY = top;
int dstY = top + dy; int dstY = top + dy;
@@ -714,7 +727,7 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
for (int y = copyHeight - 1; y >= 0; y--) { for (int y = copyHeight - 1; y >= 0; y--) {
System.arraycopy(pixels, (srcY + y) * width, pixels, (dstY + y) * width, width); System.arraycopy(pixels, (srcY + y) * width, pixels, (dstY + y) * width, width);
} }
fillRect(0, top, width, dy, argbPre(PANE_BACKGROUND)); fillRect(0, top, width, dy, PANE_BACKGROUND);
} }
} }
@@ -725,8 +738,8 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
} }
int top = contentTop(); int top = contentTop();
int contentBottom = top + snapshot.rows() * lineHeight(); int contentBottom = top + snapshot.rows() * lineHeight();
fillRect(0, 0, width, top, argbPre(rowEdgeBackground(rows.get(0), true))); fillRect(0, 0, width, top, rowEdgeBackground(rows.get(0), true));
fillRect(0, contentBottom, width, height - contentBottom, argbPre(rowEdgeBackground(rows.get(rows.size() - 1), true))); fillRect(0, contentBottom, width, height - contentBottom, rowEdgeBackground(rows.get(rows.size() - 1), true));
for (RenderRow row : rows) { for (RenderRow row : rows) {
paintRow(row); paintRow(row);
} }
@@ -735,13 +748,13 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
private void paintRow(RenderRow row) { private void paintRow(RenderRow row) {
int rowTop = contentTop() + (row.row() * lineHeight()); int rowTop = contentTop() + (row.row() * lineHeight());
int rowHeight = lineHeight(); int rowHeight = lineHeight();
fillRect(0, rowTop, width, rowHeight, argbPre(PANE_BACKGROUND)); fillRect(0, rowTop, width, rowHeight, PANE_BACKGROUND);
if (row.row() == 0) { if (row.row() == 0) {
fillRect(0, 0, width, contentTop(), argbPre(rowEdgeBackground(row, true))); fillRect(0, 0, width, contentTop(), rowEdgeBackground(row, true));
} }
if (row.row() == rowHashes.length - 1) { if (row.row() == rowHashes.length - 1) {
int bottom = contentTop() + (rowHashes.length * lineHeight()); int bottom = contentTop() + (rowHashes.length * lineHeight());
fillRect(0, bottom, width, height - bottom, argbPre(rowEdgeBackground(row, true))); fillRect(0, bottom, width, height - bottom, rowEdgeBackground(row, true));
} }
paintRowSidePadding(row, rowTop, rowHeight); paintRowSidePadding(row, rowTop, rowHeight);
paintRowBackgrounds(row, rowTop, rowHeight); paintRowBackgrounds(row, rowTop, rowHeight);
@@ -755,31 +768,30 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
} }
int left = contentLeft(); int left = contentLeft();
int contentRight = left + (cells.size() * cellWidth()); int contentRight = left + (cells.size() * cellWidth());
fillRect(0, rowTop, left, rowHeight, argbPre(rowEdgeBackground(row, true))); fillRect(0, rowTop, left, rowHeight, rowEdgeBackground(row, true));
fillRect(contentRight, rowTop, width - contentRight, rowHeight, argbPre(rowEdgeBackground(row, false))); fillRect(contentRight, rowTop, width - contentRight, rowHeight, rowEdgeBackground(row, false));
} }
private void paintRowBackgrounds(RenderRow row, int rowTop, int rowHeight) { private void paintRowBackgrounds(RenderRow row, int rowTop, int rowHeight) {
int cellWidth = cellWidth(); int runBackground = 0;
Color runBackground = null;
int runStartColumn = 0; int runStartColumn = 0;
int previousColumn = -1; int previousColumn = -1;
for (RenderCell cell : row.cells()) { for (RenderCell cell : row.cells()) {
if (cell.kittyPlaceholder().isPresent()) { if (cell.kittyPlaceholder().isPresent()) {
flushBackground(runBackground, runStartColumn, previousColumn, rowTop, rowHeight); flushBackground(runBackground, runStartColumn, previousColumn, rowTop, rowHeight);
runBackground = null; runBackground = 0;
previousColumn = -1; previousColumn = -1;
continue; continue;
} }
Color bg = cell.selected() ? SELECTED_BACKGROUND : cellBackgroundOverride(cell); int bg = cell.selected() ? SELECTED_BACKGROUND : cellBackgroundOverride(cell);
if (bg == null) { if (bg == 0) {
flushBackground(runBackground, runStartColumn, previousColumn, rowTop, rowHeight); flushBackground(runBackground, runStartColumn, previousColumn, rowTop, rowHeight);
runBackground = null; runBackground = 0;
previousColumn = -1; previousColumn = -1;
continue; continue;
} }
if (runBackground == null || bg != runBackground || cell.column() != previousColumn + 1) { if (bg != runBackground || cell.column() != previousColumn + 1) {
flushBackground(runBackground, runStartColumn, previousColumn, rowTop, rowHeight); flushBackground(runBackground, runStartColumn, previousColumn, rowTop, rowHeight);
runBackground = bg; runBackground = bg;
runStartColumn = cell.column(); runStartColumn = cell.column();
@@ -789,12 +801,12 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
flushBackground(runBackground, runStartColumn, previousColumn, rowTop, rowHeight); flushBackground(runBackground, runStartColumn, previousColumn, rowTop, rowHeight);
} }
private void flushBackground(Color background, int startColumn, int endColumn, int rowTop, int rowHeight) { private void flushBackground(int background, int startColumn, int endColumn, int rowTop, int rowHeight) {
if (background == null || endColumn < startColumn) { if (background == 0 || endColumn < startColumn) {
return; return;
} }
fillRect(contentLeft() + (startColumn * cellWidth()), rowTop, fillRect(contentLeft() + (startColumn * cellWidth()), rowTop,
(endColumn - startColumn + 1) * cellWidth(), rowHeight, argbPre(background)); (endColumn - startColumn + 1) * cellWidth(), rowHeight, background);
} }
private void paintRowText(RenderRow row, int rowTop) { private void paintRowText(RenderRow row, int rowTop) {
@@ -804,30 +816,31 @@ 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)); blitGlyph(glyph, x0 + (cell.column() * cellWidth), rowTop, cellForegroundColor(cell));
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;
} }
@@ -857,20 +870,63 @@ 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), DEFAULT_FOREGROUND);
} else if (style == RenderCursorStyle.UNDERLINE) { } else if (style == RenderCursorStyle.UNDERLINE) {
fillRect(x + 1, y + lh - 2, Math.max(1, cw - 2), 1, argbPre(DEFAULT_FOREGROUND)); fillRect(x + 1, y + lh - 2, Math.max(1, cw - 2), 1, DEFAULT_FOREGROUND);
} else if (style == RenderCursorStyle.BLOCK) { } else if (style == RenderCursorStyle.BLOCK) {
fillRectAlpha(x, y + 1, Math.max(1, cw - 1), Math.max(1, lh - 2), CURSOR_FILL); fillRectAlpha(x, y + 1, Math.max(1, cw - 1), Math.max(1, lh - 2), DEFAULT_FOREGROUND, CURSOR_FILL_ALPHA);
} else { } else {
strokeRect(x, y + 1, Math.max(1, cw - 1), Math.max(1, lh - 2), argbPre(DEFAULT_FOREGROUND), 1); strokeRect(x, y + 1, Math.max(1, cw - 1), Math.max(1, lh - 2), DEFAULT_FOREGROUND, 1);
} }
} }
// 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, active ? ACTIVE_BORDER : INACTIVE_BORDER, active ? 2 : 1);
} }
private void strokeRect(int x, int y, int w, int h, int color, int lineWidth) { private void strokeRect(int x, int y, int w, int h, int color, int lineWidth) {
@@ -887,9 +943,7 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
} }
} }
private void fillRectAlpha(int x, int y, int w, int h, Color color) { private void fillRectAlpha(int x, int y, int w, int h, int rgb, int alpha) {
int alpha = (int) Math.round(color.getOpacity() * 255.0);
int rgb = rgb(color);
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;
@@ -947,53 +1001,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);
@@ -1042,21 +1049,6 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
return hash * 0x100000001b3L; return hash * 0x100000001b3L;
} }
private static int rgb(Color color) {
int red = (int) Math.round(color.getRed() * 255.0);
int green = (int) Math.round(color.getGreen() * 255.0);
int blue = (int) Math.round(color.getBlue() * 255.0);
return (red << 16) | (green << 8) | blue;
}
private static int argbPre(Color color) {
int alpha = (int) Math.round(color.getOpacity() * 255.0);
int red = (int) Math.round(color.getRed() * alpha);
int green = (int) Math.round(color.getGreen() * alpha);
int blue = (int) Math.round(color.getBlue() * alpha);
return (alpha << 24) | (red << 16) | (green << 8) | blue;
}
// A kitty image is immutable for a given (id, number); re-transmitting under the same id // A kitty image is immutable for a given (id, number); re-transmitting under the same id
// changes the number (and the snapshot below evicts stale entries by id anyway). So the // changes the number (and the snapshot below evicts stale entries by id anyway). So the
// 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

View File

@@ -0,0 +1,104 @@
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) {
}
// Bounds the atlas so pathological glyph diversity (e.g. a dump of distinct CJK/emoji cells)
// can't grow it without limit; on overflow it clears and rebuilds on demand, like any
// metrics change.
private static final int MAX_GLYPHS = 4096;
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();
Glyph cached = glyphs.get(text);
if (cached != null) {
return cached;
}
if (glyphs.size() >= MAX_GLYPHS) {
glyphs.clear();
}
Glyph rendered = renderGlyph(text);
glyphs.put(text, rendered);
return rendered;
}
// 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

@@ -5,17 +5,19 @@ import javafx.scene.input.KeyEvent;
import java.util.Locale; import java.util.Locale;
public record KeyBinding(boolean alt, boolean control, boolean shift, KeyCode code) { public record KeyBinding(boolean alt, boolean control, boolean shift, boolean meta, KeyCode code) {
public static KeyBinding parse(String value) { public static KeyBinding parse(String value) {
boolean alt = false; boolean alt = false;
boolean control = false; boolean control = false;
boolean shift = false; boolean shift = false;
boolean meta = false;
KeyCode code = null; KeyCode code = null;
for (String part : value.split("\\+")) { for (String part : value.split("\\+")) {
String token = part.trim().toUpperCase(Locale.ROOT); String token = part.trim().toUpperCase(Locale.ROOT);
switch (token) { switch (token) {
case "ALT", "META" -> alt = true; case "ALT" -> alt = true;
case "META", "SUPER" -> meta = true;
case "CTRL", "CONTROL" -> control = true; case "CTRL", "CONTROL" -> control = true;
case "SHIFT" -> shift = true; case "SHIFT" -> shift = true;
default -> code = keyCode(token); default -> code = keyCode(token);
@@ -25,13 +27,14 @@ public record KeyBinding(boolean alt, boolean control, boolean shift, KeyCode co
if (code == null) { if (code == null) {
throw new IllegalArgumentException("Key binding has no key code: " + value); throw new IllegalArgumentException("Key binding has no key code: " + value);
} }
return new KeyBinding(alt, control, shift, code); return new KeyBinding(alt, control, shift, meta, code);
} }
public boolean matches(KeyEvent event) { public boolean matches(KeyEvent event) {
return event.isAltDown() == alt return event.isAltDown() == alt
&& event.isControlDown() == control && event.isControlDown() == control
&& event.isShiftDown() == shift && event.isShiftDown() == shift
&& event.isMetaDown() == meta
&& event.getCode() == code; && event.getCode() == code;
} }
@@ -44,6 +47,9 @@ public record KeyBinding(boolean alt, boolean control, boolean shift, KeyCode co
if (alt) { if (alt) {
builder.append("ALT+"); builder.append("ALT+");
} }
if (meta) {
builder.append("META+");
}
if (shift) { if (shift) {
builder.append("SHIFT+"); builder.append("SHIFT+");
} }

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,14 +282,47 @@ public final class LinuxPty implements AutoCloseable {
@Override @Override
public void close() { public void close() {
if (closed) { if (!markClosed()) {
return; return;
} }
closeMaster();
try {
reap();
} finally {
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; closed = true;
callKill(pid, SIGHUP); return true;
}
private void closeMaster() {
// Note: closing the master fd does NOT wake a reader thread blocked in read() on it —
// the reader unblocks via EOF when the child exits and the slave end closes. The signal
// here usually does that; if the child ignores it, the SIGKILL escalation in reap()
// guarantees it shortly after.
callKill(pid, closeSignal);
callInt(CLOSE, masterFd); callInt(CLOSE, masterFd);
reap();
arena.close();
} }
private void reap() { private void reap() {

View File

@@ -1,231 +1,36 @@
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.geometry.Rectangle2D; * (today's behavior). {@code --daemon} runs the long-lived server that hosts every window in one
import javafx.scene.Scene; * JVM, so client launches skip cold JVM/JavaFX/GL startup. {@code --standalone} skips daemon client
import javafx.scene.control.ButtonType; * mode and always opens an in-process window, which is useful while testing development builds.
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.Screen;
import javafx.stage.Stage;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
public final class Main extends Application {
private Compositor compositor;
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.imageOverlay());
compositor.canvas().widthProperty().bind(root.widthProperty());
compositor.canvas().heightProperty().bind(root.heightProperty());
Scene scene = new Scene(root, config.windowWidth(), config.windowHeight());
scene.addEventFilter(KeyEvent.KEY_PRESSED, this::handlePressed);
scene.addEventFilter(KeyEvent.KEY_TYPED, event -> handleTyped(event));
new AnimationTimer() {
@Override
public void handle(long now) {
compositor.render();
}
}.start();
stage.setTitle("jprototerm");
stage.setScene(scene);
stage.setOnCloseRequest(event -> {
compositor.close();
});
// JavaFX centres a new stage on the primary screen; on X11 there's no "focused monitor"
// to honour, so place it on the screen under the mouse pointer instead.
centreOnActiveScreen(stage, config.windowWidth(), config.windowHeight());
stage.show();
compositor.canvas().requestFocus();
}
// Centre the stage within the screen the mouse pointer is on (the best proxy for the
// "active" monitor on X11, which exposes no focused-monitor concept to JavaFX).
private static void centreOnActiveScreen(Stage stage, double width, double height) {
Rectangle2D bounds = activeScreen().getVisualBounds();
stage.setX(bounds.getMinX() + ((bounds.getWidth() - width) / 2.0));
stage.setY(bounds.getMinY() + ((bounds.getHeight() - height) / 2.0));
}
private static Screen activeScreen() {
int[] at = X11Pointer.query();
if (at != null) {
// libX11 and JavaFX share a coordinate space on the X11 virtual screen.
List<Screen> screens = Screen.getScreensForRectangle(at[0], at[1], 1.0, 1.0);
if (!screens.isEmpty()) {
return screens.get(0);
}
}
return Screen.getPrimary();
}
private void handlePressed(KeyEvent event) {
if (config.keybindings().get("navigate_left").matches(event)) {
compositor.navigate(Direction.LEFT);
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 {
// 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();
TerminalPane pane = compositor.openFloatingPane();
if (pane != null) {
pane.send(scrollbackEditorCommand(file) + "\r");
}
} catch (IOException ex) {
System.err.println("Could not open scrollback in editor: " + ex.getMessage());
}
}
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);
boolean standalone = false;
for (String arg : args) {
if (arg.equals("--daemon")) {
Daemon.run();
return;
}
if (arg.equals("--standalone")) {
standalone = true;
}
}
String workingDirectory = System.getProperty("user.dir");
if (!standalone && 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,27 +21,59 @@ 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 {
Map<String, String> environment = new HashMap<>(System.getenv()); return spawn(shellCommand.toArray(new String[0]), envOverride, columns, rows, workingDirectory,
environment.put("TERM", "xterm-kitty"); closeSignal);
environment.put("COLORTERM", "truecolor");
sanitizeWrapperEnvironment(environment);
environment.putAll(envOverride);
LinuxPty pty = LinuxPty.spawn(
new String[] {shell, "-i"},
environment,
System.getProperty("user.home"));
ShellSession session = new ShellSession(pty);
session.resize(columns, rows);
return session;
} catch (RuntimeException ex) { } catch (RuntimeException ex) {
pane.write("failed to start shell: " + ex.getMessage() + "\r\n"); pane.write("failed to start shell: " + ex.getMessage() + "\r\n");
throw new IllegalStateException("Could not start shell " + shell, ex); 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());
environment.put("TERM", "xterm-kitty");
environment.put("COLORTERM", "truecolor");
sanitizeWrapperEnvironment(environment);
environment.putAll(envOverride);
LinuxPty pty = LinuxPty.spawn(
argv,
environment,
workingDirectory != null ? workingDirectory : System.getProperty("user.home"),
closeSignal);
ShellSession session = new ShellSession(pty);
session.resize(columns, rows);
return session;
}
/** /**
* Strips the variables injected by the Nix launcher wrapper from the shell's * Strips the variables injected by the Nix launcher wrapper from the shell's
* environment so they do not leak into terminal subprocesses. * environment so they do not leak into terminal subprocesses.
@@ -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,51 @@
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.
* Synchronized because marks come from both the launcher thread and the FX thread.
*/
static synchronized 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 synchronized 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;
/** /**
@@ -17,15 +18,29 @@ import java.util.stream.Stream;
* return whether they actually changed anything so it can bump its layout version. * return whether they actually changed anything so it can bump its layout version.
*/ */
final class Tab implements AutoCloseable { final class Tab implements AutoCloseable {
// Floating-pane sizing policy: a fraction of the tab's size with a floor so panes stay
// usable in small windows, cascaded diagonally per pane, kept off the window edge.
private static final double FLOATING_SIZE_FRACTION = 0.58;
private static final double FLOATING_MIN_WIDTH = 420.0;
private static final double FLOATING_MIN_HEIGHT = 260.0;
private static final double FLOATING_CASCADE_OFFSET = 28.0;
private static final double FLOATING_EDGE_MARGIN = 12.0;
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 +51,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;
@@ -68,21 +94,43 @@ final class Tab implements AutoCloseable {
} }
List<TerminalPane> ordered = new ArrayList<>(tiled.size() + floating.size()); List<TerminalPane> ordered = new ArrayList<>(tiled.size() + floating.size());
ordered.addAll(tiled); ordered.addAll(tiled);
ordered.addAll(floatingOrder());
return List.copyOf(ordered);
}
// Floating panes bottom-to-top: insertion order, with the active pane moved to the top.
// Single source of the stacking order, so the clips assigned in assignClips() always match
// the compositing order in panes().
private List<TerminalPane> floatingOrder() {
List<TerminalPane> order = new ArrayList<>(floating.size());
for (TerminalPane pane : floating) { for (TerminalPane pane : floating) {
if (pane != active) { if (pane != active) {
ordered.add(pane); order.add(pane);
} }
} }
if (floating.contains(active)) { if (floating.contains(active)) {
ordered.add(active); // active floating pane on top order.add(active);
} }
return List.copyOf(ordered); return order;
} }
boolean isActive(TerminalPane pane) { boolean isActive(TerminalPane pane) {
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;
@@ -102,13 +150,15 @@ final class Tab implements AutoCloseable {
tiled.get(i).bounds(i * tileWidth, topInset, tileWidth, availHeight); tiled.get(i).bounds(i * tileWidth, topInset, tileWidth, availHeight);
} }
double floatingWidth = Math.max(420, width * 0.58); double floatingWidth = Math.max(FLOATING_MIN_WIDTH, width * FLOATING_SIZE_FRACTION);
double floatingHeight = Math.max(260, availHeight * 0.58); double floatingHeight = Math.max(FLOATING_MIN_HEIGHT, availHeight * FLOATING_SIZE_FRACTION);
for (int i = 0; i < floating.size(); i++) { for (int i = 0; i < floating.size(); i++) {
double offset = i * 28.0; double offset = i * FLOATING_CASCADE_OFFSET;
floating.get(i).bounds( floating.get(i).bounds(
Math.min(width - floatingWidth - 12.0, ((width - floatingWidth) / 2.0) + offset), Math.min(width - floatingWidth - FLOATING_EDGE_MARGIN,
Math.min(height - floatingHeight - 12.0, topInset + ((availHeight - floatingHeight) / 2.0) + offset), ((width - floatingWidth) / 2.0) + offset),
Math.min(height - floatingHeight - FLOATING_EDGE_MARGIN,
topInset + ((availHeight - floatingHeight) / 2.0) + offset),
floatingWidth, floatingWidth,
floatingHeight); floatingHeight);
} }
@@ -123,27 +173,18 @@ final class Tab implements AutoCloseable {
// every pane clips to its plain bounds. // every pane clips to its plain bounds.
private void assignClips() { private void assignClips() {
if (!floatingVisible || floating.isEmpty()) { if (!floatingVisible || floating.isEmpty()) {
tiled.forEach(pane -> pane.setClip(null)); allPanes().forEach(pane -> pane.setClip(null));
floating.forEach(pane -> pane.setClip(null));
return; return;
} }
// Floating panes bottom-to-top, matching panes(): insertion order, active pane on top. // Walk the floating stack top-to-bottom, accumulating the union of the panes above
List<TerminalPane> order = new ArrayList<>(floating.size()); // each one. The topmost pane has nothing above it and keeps an unclipped bounds.
for (TerminalPane pane : floating) { List<TerminalPane> order = floatingOrder();
if (pane != active) {
order.add(pane);
}
}
if (floating.contains(active)) {
order.add(active);
}
// Walk top-to-bottom, accumulating the union of the panes above each one.
Shape above = null; Shape above = null;
for (int i = order.size() - 1; i >= 0; i--) { for (int i = order.size() - 1; i >= 0; i--) {
Rectangle rect = rectOf(order.get(i)); TerminalPane pane = order.get(i);
order.get(i).setClip(above == null ? null : Shape.subtract(rect, above)); Rectangle rect = rectOf(pane);
pane.setClip(above == null ? null : Shape.subtract(rect, above));
above = (above == null) ? rect : Shape.union(above, rect); above = (above == null) ? rect : Shape.union(above, rect);
} }
@@ -183,7 +224,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;
@@ -196,12 +237,21 @@ final class Tab implements AutoCloseable {
if (floatingVisible) { if (floatingVisible) {
createFloatingPane(); createFloatingPane();
} else { } else {
TerminalPane pane = openPane(false); createTiledPane(paneWorkingDirectory());
tiled.add(pane);
setActive(pane);
} }
} }
TerminalPane createTiledPane(String workingDirectory) {
TerminalPane pane = openPane(false, workingDirectory);
tiled.add(pane);
setActive(pane);
return pane;
}
TerminalPane createFloatingPaneInDirectory(String workingDirectory) {
return addFloating(openPane(true, workingDirectory));
}
void nextFloatingPane() { void nextFloatingPane() {
if (floating.isEmpty()) { if (floating.isEmpty()) {
createFloatingPane(); createFloatingPane();
@@ -212,20 +262,46 @@ 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);
} }
}
/**
* 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,28 +313,50 @@ 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; lastFocusedFloating = floating.isEmpty() ? null : floating.get(nextFocussed);
if (!floating.isEmpty()) {
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;
} }
} }
TerminalPane createFloatingPane() { private void createFloatingPane() {
TerminalPane pane = openPane(true); addFloating(openPane(true, paneWorkingDirectory()));
}
/**
* 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);
@@ -297,18 +395,37 @@ final class Tab implements AutoCloseable {
} }
private TerminalPane openPane(boolean asFloating) { private TerminalPane openPane(boolean asFloating) {
return openPane(asFloating, paneWorkingDirectory());
}
private TerminalPane openPane(boolean asFloating, String workingDirectory) {
double[] size = paneSize(asFloating);
return register(TerminalPane.create(
config, metrics, this::markContentChanged, size[0], size[1], workingDirectory));
}
private double[] paneSize(boolean asFloating) {
double availHeight = lastHeight - lastTopInset; double availHeight = lastHeight - lastTopInset;
double widthPx;
double heightPx;
if (asFloating) { if (asFloating) {
widthPx = Math.max(420, lastWidth * 0.58); return new double[] {
heightPx = Math.max(260, availHeight * 0.58); Math.max(FLOATING_MIN_WIDTH, lastWidth * FLOATING_SIZE_FRACTION),
} else { Math.max(FLOATING_MIN_HEIGHT, availHeight * FLOATING_SIZE_FRACTION)};
// 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); // 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) {
@@ -338,4 +455,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,11 +12,15 @@ 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;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.AtomicLong;
import java.util.function.Supplier;
/** /**
* One terminal: owns its ghostty {@link Terminal}, the {@link ShellSession}/pty driving it, * One terminal: owns its ghostty {@link Terminal}, the {@link ShellSession}/pty driving it,
@@ -38,7 +42,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 and command panes can run follow-up actions.
private final List<Runnable> onExitHandlers = new ArrayList<>();
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 +77,44 @@ 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);
attachOrShowError(pane, () -> 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);
attachOrShowError(pane, () -> ShellSession.startCommand(config.envOverride(), pane,
pane.columns, pane.rows, workingDirectory, command, config.closeSignalNumber()));
return pane;
}
// Start the pane's process, but never let a spawn failure (e.g. a bad `shell` in config)
// propagate and crash window/pane creation. ShellSession has already written the error into
// the pane, so the pane opens showing it; with no session attached it is inert (sends are
// dropped) and the user closes it with the close-pane key.
private static void attachOrShowError(TerminalPane pane, Supplier<ShellSession> start) {
try {
pane.attach(start.get());
} catch (RuntimeException ex) {
System.err.println("jprototerm: " + ex.getMessage());
}
}
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 +122,39 @@ 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) {
onExitHandlers.clear();
addOnExit(onExit);
}
/** Adds a callback run when this pane's process exits on its own (see {@link #handleSessionExit}). */
public void addOnExit(Runnable onExit) {
if (onExit != null) {
onExitHandlers.add(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 the exit handlers once, so tab/compositor
* mutation happens on the thread that owns the layout.
*/
void handleSessionExit() {
Platform.runLater(() -> {
if (exited) {
return;
}
exited = true;
for (Runnable handler : List.copyOf(onExitHandlers)) {
handler.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 +187,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 +277,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 +373,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);
@@ -305,11 +406,24 @@ public final class TerminalPane implements AutoCloseable, RenderTarget {
@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

@@ -33,10 +33,12 @@ abstract class TerminalRenderer {
return java.util.List.of(); return java.util.List.of();
} }
protected static void clipRect(GraphicsContext gc, double x, double y, double width, double height) { /**
gc.beginPath(); * Release any large, rebuildable render buffers (e.g. a pane's pixel backbuffer) because the
gc.rect(x, y, width, height); * target is no longer being composited. A no-op by default; the next paint must rebuild whatever
gc.clip(); * was dropped. Called off the paint path, so it must not assume a frame is in progress.
*/
void release() {
} }
/** /**
@@ -45,22 +47,22 @@ abstract class TerminalRenderer {
* path, so it replays onto the canvas as move/line/close segments. * path, so it replays onto the canvas as move/line/close segments.
*/ */
protected static void clip(GraphicsContext gc, double x, double y, double width, double height, Shape region) { protected static void clip(GraphicsContext gc, double x, double y, double width, double height, Shape region) {
if (region == null) {
clipRect(gc, x, y, width, height);
return;
}
var elements = ((Path) region).getElements();
gc.beginPath(); gc.beginPath();
if (elements.isEmpty()) { if (region == null) {
gc.rect(x, y, 0.0, 0.0); // fully covered: clip to nothing gc.rect(x, y, width, height);
} } else {
for (PathElement element : elements) { var elements = ((Path) region).getElements();
if (element instanceof MoveTo moveTo) { if (elements.isEmpty()) {
gc.moveTo(moveTo.getX(), moveTo.getY()); gc.rect(x, y, 0.0, 0.0); // fully covered: clip to nothing
} else if (element instanceof LineTo lineTo) { }
gc.lineTo(lineTo.getX(), lineTo.getY()); for (PathElement element : elements) {
} else if (element instanceof ClosePath) { if (element instanceof MoveTo moveTo) {
gc.closePath(); gc.moveTo(moveTo.getX(), moveTo.getY());
} else if (element instanceof LineTo lineTo) {
gc.lineTo(lineTo.getX(), lineTo.getY());
} else if (element instanceof ClosePath) {
gc.closePath();
}
} }
} }
gc.clip(); gc.clip();

View File

@@ -0,0 +1,447 @@
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.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
/**
* 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;
// Key-bound actions by their config keybinding name, checked in this order on each key press
// (the keys must match AppConfig's keybinding keys).
private final Map<String, Runnable> keyActions = new LinkedHashMap<>();
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);
keyActions.put("navigate_left", () -> compositor.navigate(Direction.LEFT));
keyActions.put("navigate_down", () -> compositor.navigate(Direction.DOWN));
keyActions.put("navigate_up", () -> compositor.navigate(Direction.UP));
keyActions.put("navigate_right", () -> compositor.navigate(Direction.RIGHT));
keyActions.put("toggle_floating", compositor::toggleFloating);
keyActions.put("new_pane", compositor::createPane);
keyActions.put("next_floating", compositor::nextFloatingPane);
keyActions.put("promote_floating", compositor::toggleActiveFloating);
// Closing the last pane closes this window, via the compositor's onEmpty hook.
keyActions.put("close_pane", compositor::closeActivePane);
keyActions.put("new_tab", compositor::newTab);
keyActions.put("previous_tab", compositor::previousTab);
keyActions.put("next_tab", compositor::nextTab);
keyActions.put("open_font_selector", this::openFontSelector);
keyActions.put("open_scrollback", this::openScrollbackInEditor);
keyActions.put("create_worktree", this::createWorktreeInEditor);
keyActions.put("pane_sync_toggle", compositor::togglePaneSync);
keyActions.put("pane_sync_select", compositor::togglePaneSyncSelection);
keyActions.put("paste", this::pasteFromClipboard);
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) {
for (Map.Entry<String, Runnable> action : keyActions.entrySet()) {
String actionName = action.getKey();
if (config.keybindings().get(actionName).matches(event)) {
if (actionName.equals("pane_sync_select") && !compositor.isPaneSyncSelecting()) {
continue;
}
if (compositor.isPaneSyncSelecting() && !allowedDuringPaneSyncSelection(actionName)) {
event.consume();
return;
}
action.getValue().run();
event.consume();
return;
}
}
if (compositor.isPaneSyncSelecting()) {
event.consume();
return;
}
String encoded = KeyEncoder.encode(event);
if (encoded != null) {
sendToActivePane(encoded, event);
}
}
private static boolean allowedDuringPaneSyncSelection(String action) {
return switch (action) {
case "navigate_left",
"navigate_down",
"navigate_up",
"navigate_right",
"toggle_floating",
"next_floating",
"previous_tab",
"next_tab",
"pane_sync_toggle",
"pane_sync_select" -> true;
default -> false;
};
}
private void handleTyped(KeyEvent event) {
if (compositor.isPaneSyncSelecting()) {
event.consume();
return;
}
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) {
sendToActivePane(text, event);
}
}
// Key handlers run on every keystroke, including any that race the window's teardown, so
// tolerate the no-pane-left state instead of assuming one exists.
private void sendToActivePane(String text, KeyEvent event) {
TerminalPane active = compositor.activePane();
if (active != null) {
active.send(text);
for (TerminalPane peer : compositor.paneSyncPeers(active)) {
peer.send(text);
}
event.consume();
}
}
private void pasteFromClipboard() {
TerminalPane active = compositor.activePane();
Clipboard clipboard = Clipboard.getSystemClipboard();
if (active != null && clipboard.hasString()) {
String text = clipboard.getString();
active.paste(text);
for (TerminalPane peer : compositor.paneSyncPeers(active)) {
peer.paste(text);
}
}
}
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() {
// Capture the active pane's scrollback before opening the floating pane, since that
// makes the new pane active.
TerminalPane active = compositor.activePane();
if (active == null) {
return;
}
try {
Path file = Files.createTempFile("jprototerm-scrollback-", ".txt");
Files.writeString(file, active.scrollbackText());
// 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. The
// trailing rm removes the file (which holds terminal contents) when the editor exits;
// deleteOnExit would leak files for the JVM's whole lifetime in daemon mode.
compositor.openFloatingPane(editorCommand(file) + "; rm -f " + shellQuote(file.toString()));
} catch (IOException ex) {
System.err.println("Could not open scrollback in editor: " + ex.getMessage());
}
}
private void createWorktreeInEditor() {
// The floating pane command inherits the active pane's cwd at creation time, so the git
// worktree command runs from the pane that was focused before this shortcut opened.
TerminalPane active = compositor.activePane();
if (active == null) {
return;
}
try {
Path file = Files.createTempFile("jprototerm-worktree-", ".txt");
Path createdFile = Files.createTempFile("jprototerm-worktree-created-", ".txt");
Files.writeString(file, "");
TerminalPane commandPane = compositor.openFloatingPane(worktreeEditorCommand(file, createdFile));
if (commandPane != null) {
commandPane.addOnExit(() -> runPostWorktreeAction(active, createdFile));
} else {
Files.deleteIfExists(createdFile);
}
} catch (IOException ex) {
System.err.println("Could not create worktree from editor input: " + ex.getMessage());
}
}
private String editorCommand(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 String worktreeEditorCommand(Path file, Path createdFile) {
String quotedFile = shellQuote(file.toString());
String quotedCreatedFile = shellQuote(createdFile.toString());
String relativePath = config.worktreeRelativePath();
if (relativePath == null || relativePath.isBlank()) {
relativePath = "./.worktrees";
}
String splitRegex = config.worktreeSplitRegex();
if (splitRegex == null || splitRegex.isBlank()) {
splitRegex = ",";
}
return editorCommand(file)
+ "; editor_status=$?"
+ "; git_status=$editor_status"
+ "; if [ \"$editor_status\" -eq 0 ]; then"
+ " if names_file=$(mktemp); then"
+ " if awk -v re=" + shellQuote(splitRegex)
+ " '{ text = text $0 \"\\n\" }"
+ " END { n = split(text, names, re); for (i = 1; i <= n; i++)"
+ " { name = names[i]; sub(/^[[:space:]]+/, \"\", name);"
+ " sub(/[[:space:]]+$/, \"\", name); if (name != \"\") print name; } }'"
+ " " + quotedFile + " > \"$names_file\"; then"
+ " git_status=0"
+ "; while IFS= read -r name; do"
+ " worktree_path=" + shellQuote(relativePath) + "/\"$name\""
+ "; git worktree add \"$worktree_path\""
+ " || { git_status=$?; break; }"
+ "; created_path=$(cd \"$worktree_path\" && pwd -P)"
+ " || { git_status=$?; break; }"
+ "; printf '%s\\n' \"$created_path\" >> " + quotedCreatedFile
+ " || { git_status=$?; break; }"
+ "; done < \"$names_file\""
+ "; else git_status=$?"
+ "; fi"
+ "; rm -f \"$names_file\""
+ "; else git_status=$?"
+ "; fi"
+ "; fi"
+ "; rm -f " + quotedFile
+ "; exit \"$git_status\"";
}
private void runPostWorktreeAction(TerminalPane lastActivePane, Path createdFile) {
List<String> worktreePaths = readCreatedWorktreePaths(createdFile);
try {
Files.deleteIfExists(createdFile);
} catch (IOException ex) {
System.err.println("Could not remove worktree result file " + createdFile + ": " + ex.getMessage());
}
if (worktreePaths.isEmpty()) {
return;
}
String action = config.worktreePostCreateAction();
if (action == null || action.isBlank()) {
return;
}
switch (action.trim().toLowerCase(Locale.ROOT)) {
case "none" -> { }
case "cd" -> lastActivePane.send("cd " + shellQuote(worktreePaths.get(worktreePaths.size() - 1)) + "\r");
case "create_panes" -> createWorktreePanes(worktreePaths, false);
case "create_panes_floating" -> createWorktreePanes(worktreePaths, true);
default -> System.err.println("Unknown worktree.post_create_action '" + action + "'");
}
}
private void createWorktreePanes(List<String> worktreePaths, boolean floating) {
List<String> commands = config.worktreeCommands();
List<TerminalPane> createdPanes = new ArrayList<>();
for (int i = 0; i < worktreePaths.size(); i++) {
TerminalPane pane = floating
? compositor.createFloatingPaneInDirectory(worktreePaths.get(i))
: compositor.createTiledPane(worktreePaths.get(i));
if (pane != null) {
createdPanes.add(pane);
}
if (pane != null && !commands.isEmpty()) {
String command = commands.get(i % commands.size());
if (command != null && !command.isBlank()) {
pane.send(command + "\r");
}
}
}
if (config.worktreeSyncPanes() && !createdPanes.isEmpty()) {
compositor.syncPanes(createdPanes);
}
}
private List<String> readCreatedWorktreePaths(Path createdFile) {
try {
List<String> paths = new ArrayList<>();
for (String line : Files.readAllLines(createdFile)) {
String path = line.trim();
if (!path.isEmpty()) {
paths.add(path);
}
}
return paths;
} catch (IOException ex) {
System.err.println("Could not read created worktree paths from " + createdFile + ": " + ex.getMessage());
return List.of();
}
}
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();
}
}
}