33 Commits

Author SHA1 Message Date
86dd79d5b1 thinner borders 2026-06-20 14:42:19 +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
19 changed files with 1808 additions and 555 deletions

View File

@@ -62,6 +62,57 @@ gradle run
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
Configuration is read from:
@@ -98,6 +149,14 @@ enabled = true
[scrollback]
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]
ZELLIJ_SESSION_NAME = ""
@@ -115,6 +174,10 @@ previous_tab = "ALT+SHIFT+H"
next_tab = "ALT+SHIFT+L"
open_font_selector = "ALT+T"
open_scrollback = "ALT+S"
create_worktree = "ALT+W"
pane_sync_toggle = "ALT+Y"
pane_sync_select = "SPACE"
paste = "CTRL+SHIFT+V"
```
## Defaults
@@ -130,6 +193,18 @@ open_scrollback = "ALT+S"
- `Alt+Shift+h` / `Alt+Shift+l`: previous / next tab
- `Alt+t`: open the font selector
- `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`
- Kitty graphics protocol parsing is enabled by default

View File

@@ -3,6 +3,9 @@ columns = 100
rows = 30
max_scrollback = 100000
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_size = 15
@@ -16,6 +19,14 @@ enabled = true
[scrollback]
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]
ZELLIJ_SESSION_NAME = ""
@@ -25,9 +36,16 @@ navigate_down = "ALT+J"
navigate_up = "ALT+K"
navigate_right = "ALT+L"
toggle_floating = "ALT+F"
new_floating = "ALT+SHIFT+F"
new_pane = "ALT+N"
next_floating = "ALT+F12"
promote_floating = "ALT+P"
close_pane = "ALT+X"
new_tab = "ALT+A"
previous_tab = "ALT+SHIFT+H"
next_tab = "ALT+SHIFT+L"
open_font_selector = "ALT+T"
open_scrollback = "ALT+S"
create_worktree = "ALT+W"
pane_sync_toggle = "ALT+Y"
pane_sync_select = "SPACE"
paste = "CTRL+SHIFT+V"

View File

@@ -107,8 +107,35 @@
classpath="$classpath''${classpath:+:}$jar"
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" \
--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 '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 "--module-path $out/share/jprototerm/javafx" \
--add-flags "--add-modules javafx.controls,javafx.fxml" \
@@ -119,6 +146,25 @@
--set JLIBGHOSTTY_LIBRARY "${ghosttyVt}/lib/libghostty-vt.so" \
--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
'';
});

View File

@@ -29,6 +29,12 @@ public record AppConfig(
double windowHeight,
boolean kittyGraphics,
String scrollbackEditorCommand,
String worktreeRelativePath,
String worktreeSplitRegex,
String worktreePostCreateAction,
List<String> worktreeCommands,
boolean worktreeSyncPanes,
String closeSignal,
Map<String, String> envOverride,
Map<String, KeyBinding> keybindings
) {
@@ -47,6 +53,9 @@ public record AppConfig(
"next_tab",
"open_font_selector",
"open_scrollback",
"create_worktree",
"pane_sync_toggle",
"pane_sync_select",
"paste"
);
@@ -54,7 +63,7 @@ public record AppConfig(
AppConfig defaults = defaults();
Path path = configPath();
if (!Files.isRegularFile(path)) {
writeDefaultConfig(path, defaults);
save(path, defaults);
return defaults;
}
@@ -71,6 +80,12 @@ public record AppConfig(
doubleValue(document, "window.height", defaults.windowHeight),
booleanValue(document, "kitty_graphics.enabled", defaults.kittyGraphics),
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),
keybindings(document, defaults)
);
@@ -92,6 +107,12 @@ public record AppConfig(
760.0,
true,
defaultScrollbackEditorCommand(),
"./.worktrees",
",",
"none",
List.of(),
false,
"SIGTERM",
Map.of(),
Map.ofEntries(
Map.entry("navigate_left", KeyBinding.parse("ALT+H")),
@@ -108,6 +129,9 @@ public record AppConfig(
Map.entry("next_tab", KeyBinding.parse("ALT+SHIFT+L")),
Map.entry("open_font_selector", KeyBinding.parse("ALT+T")),
Map.entry("open_scrollback", KeyBinding.parse("ALT+S")),
Map.entry("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"))
)
);
@@ -125,11 +149,27 @@ public record AppConfig(
windowHeight,
kittyGraphics,
scrollbackEditorCommand,
worktreeRelativePath,
worktreeSplitRegex,
worktreePostCreateAction,
worktreeCommands,
worktreeSyncPanes,
closeSignal,
envOverride,
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() {
save(configPath(), this);
}
@@ -156,6 +196,23 @@ public record AppConfig(
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) {
Map<String, KeyBinding> parsed = new LinkedHashMap<>();
for (String key : KEYBINDING_KEYS) {
@@ -164,10 +221,6 @@ public record AppConfig(
return Map.copyOf(parsed);
}
private static void writeDefaultConfig(Path path, AppConfig defaults) {
save(path, defaults);
}
private static void save(Path path, AppConfig config) {
try {
Path parent = path.getParent();
@@ -193,6 +246,7 @@ public record AppConfig(
builder.append("rows = ").append(rows).append('\n');
builder.append("max_scrollback = ").append(maxScrollback).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_size = ").append(trimDouble(fontSize)).append("\n\n");
builder.append("[window]\n");
@@ -202,6 +256,12 @@ public record AppConfig(
builder.append("enabled = ").append(kittyGraphics).append("\n\n");
builder.append("[scrollback]\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");
for (Map.Entry<String, String> entry : envOverride.entrySet()) {
builder.append(entry.getKey()).append(" = ").append(quoted(entry.getValue())).append('\n');
@@ -308,15 +368,7 @@ public record AppConfig(
}
private static int intValue(TomlTable table, String key, int fallback) {
TomlPrimitive primitive = primitive(table, key);
if (primitive == null) {
return fallback;
}
try {
return primitive.asInteger();
} catch (RuntimeException ex) {
return fallback;
}
return (int) longValue(table, key, 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.HashMap;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
/**
* Owns the window's tabs and drives rendering and input. It composites only the current tab:
@@ -35,6 +38,8 @@ public final class Compositor {
// Canvas background shown wherever no pane covers (gaps). Painted on a full recomposite.
private static final Color GAP_BACKGROUND = Color.rgb(16, 16, 18);
private static final Color TAB_TEXT = Color.rgb(225, 229, 235);
private static final Color PANE_SYNC_SELECT_BORDER = Color.rgb(255, 183, 77);
private static final Color PANE_SYNC_COMMITTED_BORDER = Color.rgb(105, 214, 128);
// Thin tab strip shown at the top when more than one tab is open.
private static final double TAB_BAR_HEIGHT = 22.0;
@@ -52,6 +57,19 @@ public final class Compositor {
// Last content version drawn to the canvas per pane, so a content frame repaints only
// the panes that actually changed.
private final Map<TerminalPane, Long> paneContentVersion = new HashMap<>();
// 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.
private double lastWidth = -1.0;
private double lastHeight = -1.0;
@@ -65,9 +83,18 @@ public final class Compositor {
private Runnable onEmpty = () -> {};
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.metrics = metrics;
tabs.add(new Tab(config, metrics, this::closePane));
tabs.add(new Tab(config, metrics, workingDirectory, this::closePane));
canvas.setFocusTraversable(true);
canvas.setOnMousePressed(this::handleMousePressed);
canvas.setOnMouseReleased(this::handleMouseReleased);
@@ -93,7 +120,7 @@ public final class Compositor {
public void setFont(String family, double size) {
metrics.setFont(family, size);
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 -------------------------------------------------------------
@@ -102,8 +129,9 @@ public final class Compositor {
return tabs.isEmpty();
}
/** The active pane of the current tab, or {@code null} when no tab is left. */
public TerminalPane activePane() {
return currentTab().activePane();
return isEmpty() ? null : currentTab().activePane();
}
public void navigate(Direction direction) {
@@ -112,28 +140,89 @@ public final class Compositor {
}
}
public void toggleFloating() {
if (isEmpty()) {
public boolean isPaneSyncSelecting() {
return paneSyncSelectMode;
}
public void togglePaneSync() {
if (paneSyncSelectMode) {
paneSyncPanes.clear();
paneSyncPanes.addAll(paneSyncSelection);
paneSyncSelectMode = false;
paneSyncSelection.clear();
prunePaneSyncState();
layoutVersion++;
return;
}
currentTab().toggleFloating();
if (!paneSyncPanes.isEmpty()) {
paneSyncPanes.clear();
layoutVersion++;
return;
}
if (activePane() == null) {
return;
}
paneSyncSelectMode = true;
paneSyncSelection.clear();
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() {
if (isEmpty()) {
return;
}
currentTab().createPane();
layoutVersion++;
mutateCurrentTab(() -> currentTab().createPane());
}
/** Opens a new floating pane, makes it active, and returns it (null when no tab exists). */
public TerminalPane openFloatingPane() {
public TerminalPane createTiledPane(String workingDirectory) {
if (isEmpty()) {
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;
}
@@ -152,26 +241,25 @@ public final class Compositor {
}
public void nextFloatingPane() {
if (isEmpty()) {
return;
}
currentTab().nextFloatingPane();
layoutVersion++;
mutateCurrentTab(() -> currentTab().nextFloatingPane());
}
public void promoteActiveFloating() {
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()) {
return;
}
currentTab().promoteActiveFloating();
change.run();
layoutVersion++;
}
public void closeActivePane() {
if (isEmpty()) {
return;
}
TerminalPane active = currentTab().activePane();
TerminalPane active = activePane();
if (active != null) {
closePane(active);
}
@@ -187,6 +275,7 @@ public final class Compositor {
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).
@@ -209,7 +298,8 @@ public final class Compositor {
public void newTab() {
// Open the new tab in the currently active pane's working directory, so it lands where the
// user currently is rather than always in home.
String workingDirectory = isEmpty() ? null : currentTab().activePane().currentWorkingDirectory();
TerminalPane active = activePane();
String workingDirectory = active != null ? active.currentWorkingDirectory() : null;
tabs.add(new Tab(config, metrics, workingDirectory, this::closePane));
currentTabIndex = tabs.size() - 1;
layoutVersion++;
@@ -234,6 +324,21 @@ public final class Compositor {
tab.close();
}
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() {
@@ -257,6 +362,7 @@ public final class Compositor {
// ---- Rendering ------------------------------------------------------------------
public void render() {
sweepHiddenPanes();
switch (nextFrameType()) {
case IDLE -> { }
case LAYOUT -> renderLayoutFrame();
@@ -264,6 +370,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,
// 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
@@ -318,6 +466,7 @@ public final class Compositor {
for (TerminalPane pane : panes) {
paneContentVersion.put(pane, pane.paintFull(gc, isActive(pane)));
}
drawPaneSyncOverlay(gc, panes);
imageOverlay.sync(panes);
}
@@ -337,6 +486,7 @@ public final class Compositor {
paneContentVersion.put(pane, pane.paintIncremental(gc, isActive(pane)));
imageOverlay.updatePane(pane);
}
drawPaneSyncOverlay(gc, panes);
}
private GraphicsContext beginFrame() {
@@ -372,6 +522,68 @@ public final class Compositor {
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 {
// Draw a thin sync border inset just inside the pane's own edge border, so the
// renderer's active (blue) / inactive border stays visible and you can still tell
// which synced pane is focused.
double lineWidth = 2.0;
double inset = 2.0 + lineWidth / 2.0; // clear the renderer's 2px active border
gc.setLineWidth(lineWidth);
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()) + inset;
double y = Math.round(pane.y()) + inset;
double width = Math.max(0.0, pane.width() - 2.0 * inset);
double height = Math.max(0.0, pane.height() - 2.0 * inset);
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 ----------------------------------------------------------------------
private void handleMousePressed(MouseEvent event) {
@@ -394,11 +606,11 @@ public final class Compositor {
private void handleMouseReleased(MouseEvent event) {
TerminalPane pane = paneAt(event.getX(), event.getY());
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;
MouseTarget target = mouseTarget(pane);
MouseTarget target = pane == null ? null : mouseTarget(pane);
if (target != null) {
send(pane, target, MouseInput.release(button, localX(event.getX(), pane, target), localY(event.getY(), pane, target), modifiers(event)), false, event);
}
@@ -409,7 +621,10 @@ public final class Compositor {
private void handleMouseDragged(MouseEvent event) {
TerminalPane pane = paneAt(event.getX(), event.getY());
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;

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.RenderStateSnapshot;
import javafx.geometry.Rectangle2D;
import javafx.scene.SnapshotParameters;
import javafx.scene.canvas.Canvas;
import javafx.scene.canvas.GraphicsContext;
import javafx.scene.image.Image;
import javafx.scene.image.PixelFormat;
import javafx.scene.image.PixelBuffer;
import javafx.scene.image.PixelReader;
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.nio.IntBuffer;
@@ -45,19 +39,17 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
private static final int DIRTY_PARTIAL = 1;
private static final int DIRTY_FULL = 2;
private static final Color DEFAULT_FOREGROUND = Color.rgb(225, 229, 235);
private static final Color SELECTED_BACKGROUND = Color.rgb(52, 92, 140);
// All colors are packed ARGB ints (alpha always 0xff): the software backbuffer never needs
// 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
// for reverse-video cells whose background is the terminal default).
private static final Color PANE_BACKGROUND = Color.rgb(9, 10, 12);
private static final Color ACTIVE_BORDER = Color.rgb(87, 166, 255);
private static final Color INACTIVE_BORDER = Color.rgb(52, 57, 65);
private static final Color CURSOR_FILL = Color.rgb(225, 229, 235, 0.28);
// 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 static final int PANE_BACKGROUND = 0xff090a0c; // rgb(9, 10, 12)
private static final int ACTIVE_BORDER = 0xff57a6ff; // rgb(87, 166, 255)
private static final int INACTIVE_BORDER = 0xff343941; // rgb(52, 57, 65)
// Block cursor: DEFAULT_FOREGROUND blended at 28% alpha by fillRectAlpha.
private static final int CURSOR_FILL_ALPHA = 71;
private final TerminalMetrics metrics;
// 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);
} else if (dirty == DIRTY_PARTIAL) {
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();
kittyImageNodes = List.of();
}
@@ -120,18 +120,20 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
return kittyImageNodes;
}
// 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 Color cellBackgroundColor(RenderCell cell) {
if (cell.inverse()) {
var fg = cell.foreground();
return fg.isPresent() ? toFxColor(fg.get()) : DEFAULT_FOREGROUND;
}
var bg = cell.background();
return bg.isPresent() ? toFxColor(bg.get()) : PANE_BACKGROUND;
@Override
void release() {
software.release();
kittyImageNodes = List.of();
}
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();
if (cells.isEmpty()) {
return PANE_BACKGROUND;
@@ -139,41 +141,28 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
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.
private static Color cellBackgroundOverride(RenderCell cell) {
// Background override for a cell: 0 means the pane default background already covers it
// (real colors always carry 0xff alpha, so 0 is never a valid color).
private static int cellBackgroundOverride(RenderCell cell) {
if (cell.inverse()) {
var fg = cell.foreground();
return fg.isPresent() ? toFxColor(fg.get()) : DEFAULT_FOREGROUND;
return fg.isPresent() ? packArgb(fg.get()) : DEFAULT_FOREGROUND;
}
var bgOpt = cell.background();
Color bg = bgOpt.isPresent() ? toFxColor(bgOpt.get()) : null;
return bg;
var bg = cell.background();
return bg.isPresent() ? packArgb(bg.get()) : 0;
}
private static Color cellForegroundColor(RenderCell cell) {
var fgOpt = cell.foreground();
var bgOpt = cell.background();
Color fg = fgOpt.isPresent() ? toFxColor(fgOpt.get()) : DEFAULT_FOREGROUND;
Color bg = bgOpt.isPresent() ? toFxColor(bgOpt.get()) : null;
private static int cellForegroundColor(RenderCell cell) {
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) {
int key = (color.red() << 16) | (color.green() << 8) | color.blue();
Color cached = COLOR_CACHE.get(key);
if (cached != null) {
return cached;
}
if (COLOR_CACHE.size() >= 4096) {
COLOR_CACHE.clear();
}
Color created = Color.rgb(color.red(), color.green(), color.blue());
COLOR_CACHE.put(key, created);
return created;
private static int packArgb(RenderColor color) {
return 0xff000000 | (color.red() << 16) | (color.green() << 8) | color.blue();
}
// ---- Kitty graphics --------------------------------------------------------------
@@ -416,7 +405,15 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
private WritableImage image;
private long[] rowHashes = new long[0];
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
// present() can upload only that band to the GPU instead of the whole pane texture.
private int dirtyMinY = Integer.MAX_VALUE;
@@ -425,6 +422,7 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
private void invalidate() {
rowHashes = new long[0];
lastCursor = CursorState.none();
cursorSaved = false;
}
// 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,
double px, double py, double paneWidth, double paneHeight, boolean active) {
ensure(paneWidth, paneHeight);
fillRect(0, 0, width, height, argbPre(PANE_BACKGROUND));
fillRect(0, 0, width, height, PANE_BACKGROUND);
if (snapshot != null) {
paintSnapshot(snapshot);
drawCursor(snapshot);
@@ -503,6 +501,23 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
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,
double px, double py, double paneWidth, double paneHeight, boolean active) {
ensure(paneWidth, paneHeight);
@@ -539,11 +554,6 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
return;
}
}
if (repaintedRowHasCursor(newCursorRow, repainted)
&& !repaintCursorRow(snapshot, newCursorRow, repainted)) {
paintFullOrShifted(gc, target.snapshotFull(), px, py, paneWidth, paneHeight, active);
return;
}
lastCursor = cursor;
if (needsCursorDraw) {
drawCursor(snapshot);
@@ -566,10 +576,6 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
return true;
}
private boolean repaintedRowHasCursor(int rowIndex, boolean[] repainted) {
return rowIndex >= 0 && rowIndex < repainted.length && repainted[rowIndex];
}
private RenderRow rowByIndex(RenderStateSnapshot snapshot, int rowIndex) {
for (RenderRow row : snapshot.renderRows()) {
if (row.row() == rowIndex) {
@@ -583,7 +589,6 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
int nextWidth = Math.max(1, (int) Math.round(paneWidth));
int nextHeight = Math.max(1, (int) Math.round(paneHeight));
if (nextWidth == width && nextHeight == height && image != null) {
ensureGlyphs();
return;
}
@@ -593,17 +598,19 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
pixelBuffer = new PixelBuffer<>(width, height, IntBuffer.wrap(pixels), PixelFormat.getIntArgbPreInstance());
image = new WritableImage(pixelBuffer);
invalidate();
ensureGlyphs();
}
private void ensureGlyphs() {
int cellWidth = cellWidth();
int lineHeight = lineHeight();
double baseline = metrics.baselineOffset();
if (glyphs == null || glyphs.font != metrics.font()
|| glyphs.cellWidth != cellWidth || glyphs.lineHeight != lineHeight || glyphs.baseline != baseline) {
glyphs = new GlyphCache(metrics.font(), cellWidth, lineHeight, baseline);
}
// Drop the full-resolution pixel buffer and its GPU-backed image. The next ensure() rebuilds
// them (and a layout frame's paintFull repaints from scratch), so this is safe to call when
// the pane goes off-screen; only the shared glyph atlas (in TerminalMetrics) survives.
private void release() {
pixels = new int[0];
pixelBuffer = null;
image = null;
width = 0;
height = 0;
invalidate();
resetDirty();
}
private void present(GraphicsContext gc, double px, double py) {
@@ -615,6 +622,12 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
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) {
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.
markDirtyRows(top, top + contentHeight);
if (dy == 0 || Math.abs(dy) >= contentHeight) {
fillRect(0, top, width, contentHeight, argbPre(PANE_BACKGROUND));
fillRect(0, top, width, contentHeight, PANE_BACKGROUND);
return;
}
@@ -706,7 +719,7 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
for (int y = 0; y < copyHeight; y++) {
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 {
int srcY = top;
int dstY = top + dy;
@@ -714,7 +727,7 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
for (int y = copyHeight - 1; y >= 0; y--) {
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 contentBottom = top + snapshot.rows() * lineHeight();
fillRect(0, 0, width, top, argbPre(rowEdgeBackground(rows.get(0), true)));
fillRect(0, contentBottom, width, height - contentBottom, argbPre(rowEdgeBackground(rows.get(rows.size() - 1), true)));
fillRect(0, 0, width, top, rowEdgeBackground(rows.get(0), true));
fillRect(0, contentBottom, width, height - contentBottom, rowEdgeBackground(rows.get(rows.size() - 1), true));
for (RenderRow row : rows) {
paintRow(row);
}
@@ -735,13 +748,13 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
private void paintRow(RenderRow row) {
int rowTop = contentTop() + (row.row() * lineHeight());
int rowHeight = lineHeight();
fillRect(0, rowTop, width, rowHeight, argbPre(PANE_BACKGROUND));
fillRect(0, rowTop, width, rowHeight, PANE_BACKGROUND);
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) {
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);
paintRowBackgrounds(row, rowTop, rowHeight);
@@ -755,31 +768,30 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
}
int left = contentLeft();
int contentRight = left + (cells.size() * cellWidth());
fillRect(0, rowTop, left, rowHeight, argbPre(rowEdgeBackground(row, true)));
fillRect(contentRight, rowTop, width - contentRight, rowHeight, argbPre(rowEdgeBackground(row, false)));
fillRect(0, rowTop, left, rowHeight, rowEdgeBackground(row, true));
fillRect(contentRight, rowTop, width - contentRight, rowHeight, rowEdgeBackground(row, false));
}
private void paintRowBackgrounds(RenderRow row, int rowTop, int rowHeight) {
int cellWidth = cellWidth();
Color runBackground = null;
int runBackground = 0;
int runStartColumn = 0;
int previousColumn = -1;
for (RenderCell cell : row.cells()) {
if (cell.kittyPlaceholder().isPresent()) {
flushBackground(runBackground, runStartColumn, previousColumn, rowTop, rowHeight);
runBackground = null;
runBackground = 0;
previousColumn = -1;
continue;
}
Color bg = cell.selected() ? SELECTED_BACKGROUND : cellBackgroundOverride(cell);
if (bg == null) {
int bg = cell.selected() ? SELECTED_BACKGROUND : cellBackgroundOverride(cell);
if (bg == 0) {
flushBackground(runBackground, runStartColumn, previousColumn, rowTop, rowHeight);
runBackground = null;
runBackground = 0;
previousColumn = -1;
continue;
}
if (runBackground == null || bg != runBackground || cell.column() != previousColumn + 1) {
if (bg != runBackground || cell.column() != previousColumn + 1) {
flushBackground(runBackground, runStartColumn, previousColumn, rowTop, rowHeight);
runBackground = bg;
runStartColumn = cell.column();
@@ -789,12 +801,12 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
flushBackground(runBackground, runStartColumn, previousColumn, rowTop, rowHeight);
}
private void flushBackground(Color background, int startColumn, int endColumn, int rowTop, int rowHeight) {
if (background == null || endColumn < startColumn) {
private void flushBackground(int background, int startColumn, int endColumn, int rowTop, int rowHeight) {
if (background == 0 || endColumn < startColumn) {
return;
}
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) {
@@ -804,30 +816,31 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
if (cell.kittyPlaceholder().isPresent() || cell.codepoints().length == 0) {
continue;
}
Glyph glyph = glyphs.glyph(cell.text());
int color = rgb(cellForegroundColor(cell));
blitGlyph(glyph, x0 + (cell.column() * cellWidth), rowTop, color);
GlyphCache.Glyph glyph = metrics.glyphCache().glyph(cell.text());
blitGlyph(glyph, x0 + (cell.column() * cellWidth), rowTop, cellForegroundColor(cell));
}
}
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 green = (rgb >> 8) & 0xff;
int blue = rgb & 0xff;
// 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).
int glyphWidth = glyph.width();
byte[] glyphAlpha = glyph.alpha();
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 gxEnd = Math.min(glyph.width, width - x);
int gxEnd = Math.min(glyphWidth, width - x);
if (gyStart >= gyEnd || gxStart >= gxEnd) {
return;
}
for (int gy = gyStart; gy < gyEnd; gy++) {
int rowOffset = (y + gy) * width;
int glyphOffset = gy * glyph.width;
int glyphOffset = gy * glyphWidth;
for (int gx = gxStart; gx < gxEnd; gx++) {
int alpha = glyph.alpha[glyphOffset + gx] & 0xff;
int alpha = glyphAlpha[glyphOffset + gx] & 0xff;
if (alpha == 0) {
continue;
}
@@ -857,20 +870,63 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
int y = contentTop() + ((int) snapshot.cursorViewportY() * lineHeight());
int cw = cellWidth();
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();
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) {
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) {
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 {
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) {
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) {
@@ -887,9 +943,7 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
}
}
private void fillRectAlpha(int x, int y, int w, int h, Color color) {
int alpha = (int) Math.round(color.getOpacity() * 255.0);
int rgb = rgb(color);
private void fillRectAlpha(int x, int y, int w, int h, int rgb, int alpha) {
int red = (rgb >> 16) & 0xff;
int green = (rgb >> 8) & 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 static CursorState none() {
return new CursorState(false, false, -1, -1, null);
@@ -1042,21 +1049,6 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
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
// 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

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;
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) {
boolean alt = false;
boolean control = false;
boolean shift = false;
boolean meta = false;
KeyCode code = null;
for (String part : value.split("\\+")) {
String token = part.trim().toUpperCase(Locale.ROOT);
switch (token) {
case "ALT", "META" -> alt = true;
case "ALT" -> alt = true;
case "META", "SUPER" -> meta = true;
case "CTRL", "CONTROL" -> control = true;
case "SHIFT" -> shift = true;
default -> code = keyCode(token);
@@ -25,13 +27,14 @@ public record KeyBinding(boolean alt, boolean control, boolean shift, KeyCode co
if (code == null) {
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) {
return event.isAltDown() == alt
&& event.isControlDown() == control
&& event.isShiftDown() == shift
&& event.isMetaDown() == meta
&& event.getCode() == code;
}
@@ -44,6 +47,9 @@ public record KeyBinding(boolean alt, boolean control, boolean shift, KeyCode co
if (alt) {
builder.append("ALT+");
}
if (meta) {
builder.append("META+");
}
if (shift) {
builder.append("SHIFT+");
}

View File

@@ -15,6 +15,8 @@ import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
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.
@@ -33,6 +35,11 @@ import java.util.Map;
public final class LinuxPty implements AutoCloseable {
static final Linker LINKER = Linker.nativeLinker();
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 ValueLayout.OfShort C_SHORT = (ValueLayout.OfShort) LINKER.canonicalLayouts().get("short");
@@ -63,7 +70,10 @@ public final class LinuxPty implements AutoCloseable {
private static final long TIOCSWINSZ = 0x5414L;
private static final short POSIX_SPAWN_SETSID = 0x80;
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 SIGTERM = 15;
private static final int WNOHANG = 1;
// struct winsize { unsigned short ws_row, ws_col, ws_xpixel, ws_ypixel; }
@@ -105,11 +115,36 @@ public final class LinuxPty implements AutoCloseable {
private final Object writeLock = new Object();
private final int masterFd;
private final int pid;
private final int closeSignal;
private volatile boolean closed;
private LinuxPty(int masterFd, int pid) {
private LinuxPty(int masterFd, int pid, int closeSignal) {
this.masterFd = masterFd;
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;
};
}
/**
@@ -118,8 +153,10 @@ public final class LinuxPty implements AutoCloseable {
* @param argv command and arguments (e.g. {@code {"/bin/zsh", "-i"}})
* @param environment environment for the child, as KEY=VALUE pairs
* @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();
try {
int master = check(callInt(POSIX_OPENPT, O_RDWR | O_NOCTTY), "posix_openpt");
@@ -158,7 +195,7 @@ public final class LinuxPty implements AutoCloseable {
if (rc != 0) {
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 {
callInt(ATTR_DESTROY, attr);
callInt(FA_DESTROY, actions);
@@ -245,14 +282,47 @@ public final class LinuxPty implements AutoCloseable {
@Override
public void close() {
if (closed) {
if (!markClosed()) {
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;
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);
reap();
arena.close();
}
private void reap() {

View File

@@ -1,251 +1,36 @@
package com.gregor.jprototerm;
import javafx.animation.AnimationTimer;
import javafx.application.Application;
import javafx.application.Platform;
import javafx.geometry.Rectangle2D;
import javafx.scene.Scene;
import javafx.scene.control.ButtonType;
import javafx.scene.control.ComboBox;
import javafx.scene.control.Dialog;
import javafx.scene.control.Label;
import javafx.scene.control.Spinner;
import javafx.scene.control.SpinnerValueFactory;
import javafx.scene.input.Clipboard;
import javafx.scene.input.KeyEvent;
import javafx.scene.layout.GridPane;
import javafx.scene.layout.StackPane;
import javafx.scene.text.Font;
import javafx.stage.Screen;
import javafx.stage.Stage;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
public final class Main extends Application {
private Compositor compositor;
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);
// When the last pane closes — whether via the close-pane key or because a pane's process
// exited on its own — tear down and quit.
compositor.setOnEmpty(() -> {
compositor.close();
Platform.exit();
});
StackPane root = new StackPane(compositor.canvas(), 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();
// Ask the window manager to raise and focus the new window so the user can type right
// away; the canvas requestFocus() below only routes events within the scene.
stage.toFront();
stage.requestFocus();
compositor.canvas().requestFocus();
}
// Centre the stage within the screen the mouse pointer is on (the best proxy for the
// "active" monitor on X11, which exposes no focused-monitor concept to JavaFX).
private static void centreOnActiveScreen(Stage stage, double width, double height) {
Rectangle2D bounds = activeScreen().getVisualBounds();
stage.setX(bounds.getMinX() + ((bounds.getWidth() - width) / 2.0));
stage.setY(bounds.getMinY() + ((bounds.getHeight() - height) / 2.0));
}
private static Screen activeScreen() {
int[] at = X11Pointer.query();
if (at != null) {
// libX11 and JavaFX share a coordinate space on the X11 virtual screen.
List<Screen> screens = Screen.getScreensForRectangle(at[0], at[1], 1.0, 1.0);
if (!screens.isEmpty()) {
return screens.get(0);
}
}
return Screen.getPrimary();
}
private void handlePressed(KeyEvent event) {
if (config.keybindings().get("navigate_left").matches(event)) {
compositor.navigate(Direction.LEFT);
event.consume();
} else if (config.keybindings().get("navigate_down").matches(event)) {
compositor.navigate(Direction.DOWN);
event.consume();
} else if (config.keybindings().get("navigate_up").matches(event)) {
compositor.navigate(Direction.UP);
event.consume();
} else if (config.keybindings().get("navigate_right").matches(event)) {
compositor.navigate(Direction.RIGHT);
event.consume();
} else if (config.keybindings().get("toggle_floating").matches(event)) {
compositor.toggleFloating();
event.consume();
} else if (config.keybindings().get("new_pane").matches(event)) {
compositor.createPane();
event.consume();
} else if (config.keybindings().get("next_floating").matches(event)) {
compositor.nextFloatingPane();
event.consume();
} else if (config.keybindings().get("promote_floating").matches(event)) {
compositor.promoteActiveFloating();
event.consume();
} else if (config.keybindings().get("close_pane").matches(event)) {
// Closing the last pane quits the app, via the compositor's onEmpty hook.
compositor.closeActivePane();
event.consume();
} else if (config.keybindings().get("new_tab").matches(event)) {
compositor.newTab();
event.consume();
} else if (config.keybindings().get("previous_tab").matches(event)) {
compositor.previousTab();
event.consume();
} else if (config.keybindings().get("next_tab").matches(event)) {
compositor.nextTab();
event.consume();
} else if (config.keybindings().get("open_font_selector").matches(event)) {
openFontSelector();
event.consume();
} else if (config.keybindings().get("open_scrollback").matches(event)) {
openScrollbackInEditor();
event.consume();
} else if (config.keybindings().get("paste").matches(event)) {
pasteFromClipboard();
event.consume();
} else {
String encoded = KeyEncoder.encode(event);
if (encoded != null) {
compositor.activePane().send(encoded);
event.consume();
}
}
}
private void handleTyped(KeyEvent event) {
if (event.isAltDown() || event.isControlDown() || event.isMetaDown()) {
return;
}
String text = event.getCharacter();
if (text != null && !text.isEmpty() && text.charAt(0) >= 0x20 && text.charAt(0) != 0x7f) {
compositor.activePane().send(text);
event.consume();
}
}
private void pasteFromClipboard() {
Clipboard clipboard = Clipboard.getSystemClipboard();
if (clipboard.hasString()) {
compositor.activePane().paste(clipboard.getString());
}
}
private void openFontSelector() {
Dialog<ButtonType> dialog = new Dialog<>();
dialog.setTitle("Font");
dialog.getDialogPane().getButtonTypes().addAll(ButtonType.OK, ButtonType.CANCEL);
ComboBox<String> family = new ComboBox<>();
family.getItems().setAll(Font.getFamilies());
family.setEditable(true);
family.setMaxWidth(Double.MAX_VALUE);
family.setValue(config.fontFamily());
Spinner<Double> size = new Spinner<>();
size.setEditable(true);
size.setValueFactory(new SpinnerValueFactory.DoubleSpinnerValueFactory(6.0, 48.0, config.fontSize(), 0.5));
GridPane content = new GridPane();
content.setHgap(10.0);
content.setVgap(10.0);
content.add(new Label("Family"), 0, 0);
content.add(family, 1, 0);
content.add(new Label("Size"), 0, 1);
content.add(size, 1, 1);
dialog.getDialogPane().setContent(content);
dialog.showAndWait()
.filter(button -> button == ButtonType.OK)
.ifPresent(ignored -> {
String selectedFamily = family.getEditor().getText();
if (selectedFamily == null || selectedFamily.isBlank()) {
selectedFamily = family.getValue();
}
if (selectedFamily == null || selectedFamily.isBlank()) {
return;
}
double selectedSize = size.getValue();
config = config.withFont(selectedFamily.trim(), selectedSize);
config.save();
compositor.setFont(config.fontFamily(), config.fontSize());
compositor.canvas().requestFocus();
});
}
private void openScrollbackInEditor() {
try {
// Capture the active pane's scrollback before opening the floating pane, since that
// makes the new pane active.
Path file = Files.createTempFile("jprototerm-scrollback-", ".txt");
Files.writeString(file, compositor.activePane().scrollbackText());
file.toFile().deleteOnExit();
// Run the editor as the floating pane's process (via /bin/sh -c) rather than typing the
// command into an interactive shell. The command runs deterministically from the start
// — no shell startup/rc race — and the pane auto-closes when the editor exits.
compositor.openFloatingPane(scrollbackEditorCommand(file));
} catch (IOException ex) {
System.err.println("Could not open scrollback in editor: " + ex.getMessage());
}
}
private String scrollbackEditorCommand(Path file) {
String quotedFile = shellQuote(file.toString());
String command = config.scrollbackEditorCommand();
if (command == null || command.isBlank()) {
command = "vi {file}";
}
if (command.contains("{file}")) {
return command.replace("{file}", quotedFile);
}
return command + " " + quotedFile;
}
private static String shellQuote(String value) {
return "'" + value.replace("'", "'\"'\"'") + "'";
/**
* Entry point and mode dispatch. A bare invocation is a thin client: it hands the request to a
* running {@link Daemon}, or, if none is reachable, opens a single standalone window in this process
* (today's behavior). {@code --daemon} runs the long-lived server that hosts every window in one
* JVM, so client launches skip cold JVM/JavaFX/GL startup. {@code --standalone} skips daemon client
* mode and always opens an in-process window, which is useful while testing development builds.
*/
public final class Main {
private Main() {
}
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"));
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

@@ -27,9 +27,10 @@ public final class ShellSession implements AutoCloseable {
* config, not assumed here.
*/
public static ShellSession start(List<String> shellCommand, Map<String, String> envOverride, TerminalPane pane,
int columns, int rows, String workingDirectory) {
int columns, int rows, String workingDirectory, int closeSignal) {
try {
return spawn(shellCommand.toArray(new String[0]), envOverride, columns, rows, workingDirectory);
return spawn(shellCommand.toArray(new String[0]), envOverride, columns, rows, workingDirectory,
closeSignal);
} catch (RuntimeException ex) {
pane.write("failed to start shell: " + ex.getMessage() + "\r\n");
throw new IllegalStateException("Could not start shell " + String.join(" ", shellCommand), ex);
@@ -45,9 +46,10 @@ public final class ShellSession implements AutoCloseable {
* 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 columns, int rows, String workingDirectory, String command, int closeSignal) {
try {
return spawn(new String[] {"/bin/sh", "-c", command}, envOverride, columns, rows, workingDirectory);
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);
@@ -55,7 +57,7 @@ public final class ShellSession implements AutoCloseable {
}
private static ShellSession spawn(String[] argv, Map<String, String> envOverride,
int columns, int rows, String workingDirectory) {
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");
@@ -65,7 +67,8 @@ public final class ShellSession implements AutoCloseable {
LinuxPty pty = LinuxPty.spawn(
argv,
environment,
workingDirectory != null ? workingDirectory : System.getProperty("user.home"));
workingDirectory != null ? workingDirectory : System.getProperty("user.home"),
closeSignal);
ShellSession session = new ShellSession(pty);
session.resize(columns, rows);
return session;
@@ -93,6 +96,26 @@ public final class ShellSession implements AutoCloseable {
// These are jprototerm's own runtime settings, not the user's shell environment.
environment.remove("GDK_BACKEND");
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) {
@@ -157,8 +180,27 @@ public final class ShellSession implements AutoCloseable {
@Override
public void close() {
closed = true;
if (!markClosed()) {
return;
}
reader.shutdownNow();
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

@@ -18,6 +18,14 @@ import java.util.stream.Stream;
* return whether they actually changed anything so it can bump its layout version.
*/
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 TerminalMetrics metrics;
// Notified (on the FX thread) when one of this tab's panes' process exits on its own, so the
@@ -86,21 +94,43 @@ final class Tab implements AutoCloseable {
}
List<TerminalPane> ordered = new ArrayList<>(tiled.size() + floating.size());
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) {
if (pane != active) {
ordered.add(pane);
order.add(pane);
}
}
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) {
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) {
if (pane == active || !isFocusable(pane)) {
return false;
@@ -120,13 +150,15 @@ final class Tab implements AutoCloseable {
tiled.get(i).bounds(i * tileWidth, topInset, tileWidth, availHeight);
}
double floatingWidth = Math.max(420, width * 0.58);
double floatingHeight = Math.max(260, availHeight * 0.58);
double floatingWidth = Math.max(FLOATING_MIN_WIDTH, width * FLOATING_SIZE_FRACTION);
double floatingHeight = Math.max(FLOATING_MIN_HEIGHT, availHeight * FLOATING_SIZE_FRACTION);
for (int i = 0; i < floating.size(); i++) {
double offset = i * 28.0;
double offset = i * FLOATING_CASCADE_OFFSET;
floating.get(i).bounds(
Math.min(width - floatingWidth - 12.0, ((width - floatingWidth) / 2.0) + offset),
Math.min(height - floatingHeight - 12.0, topInset + ((availHeight - floatingHeight) / 2.0) + offset),
Math.min(width - floatingWidth - FLOATING_EDGE_MARGIN,
((width - floatingWidth) / 2.0) + offset),
Math.min(height - floatingHeight - FLOATING_EDGE_MARGIN,
topInset + ((availHeight - floatingHeight) / 2.0) + offset),
floatingWidth,
floatingHeight);
}
@@ -141,27 +173,18 @@ final class Tab implements AutoCloseable {
// every pane clips to its plain bounds.
private void assignClips() {
if (!floatingVisible || floating.isEmpty()) {
tiled.forEach(pane -> pane.setClip(null));
floating.forEach(pane -> pane.setClip(null));
allPanes().forEach(pane -> pane.setClip(null));
return;
}
// Floating panes bottom-to-top, matching panes(): insertion order, active pane on top.
List<TerminalPane> order = new ArrayList<>(floating.size());
for (TerminalPane pane : floating) {
if (pane != active) {
order.add(pane);
}
}
if (floating.contains(active)) {
order.add(active);
}
// Walk top-to-bottom, accumulating the union of the panes above each one.
// Walk the floating stack top-to-bottom, accumulating the union of the panes above
// each one. The topmost pane has nothing above it and keeps an unclipped bounds.
List<TerminalPane> order = floatingOrder();
Shape above = null;
for (int i = order.size() - 1; i >= 0; i--) {
Rectangle rect = rectOf(order.get(i));
order.get(i).setClip(above == null ? null : Shape.subtract(rect, above));
TerminalPane pane = order.get(i);
Rectangle rect = rectOf(pane);
pane.setClip(above == null ? null : Shape.subtract(rect, above));
above = (above == null) ? rect : Shape.union(above, rect);
}
@@ -214,12 +237,21 @@ final class Tab implements AutoCloseable {
if (floatingVisible) {
createFloatingPane();
} else {
TerminalPane pane = openPane(false);
tiled.add(pane);
setActive(pane);
createTiledPane(paneWorkingDirectory());
}
}
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() {
if (floating.isEmpty()) {
createFloatingPane();
@@ -231,24 +263,18 @@ final class Tab implements AutoCloseable {
}
/** Promotes the active floating pane to a tiled pane, joining the tiled row. No-op otherwise. */
void promoteActiveFloating() {
TerminalPane promote = active;
if (!floating.remove(promote)) {
return; // active pane is tiled (or there is none); nothing to promote
}
if (promote == lastFocusedFloating) {
void toggleActiveFloating() {
TerminalPane toggled = active;
if (floating.remove(toggled)) {
lastFocusedFloating = floating.isEmpty() ? null : floating.get(floating.size() - 1);
}
tiled.add(promote);
if (floating.isEmpty()) {
tiled.add(toggled);
floatingVisible = false;
}
setActive(promote);
}
void closeActivePane() {
if (active != null) {
closePane(active);
setActive(toggled);
} else if (tiled.remove(toggled)) {
lastFocusedTiled = tiled.isEmpty() ? null : tiled.get(tiled.size() -1);
floating.add(toggled);
floatingVisible = true;
setActive(toggled);
}
}
@@ -316,8 +342,8 @@ final class Tab implements AutoCloseable {
}
}
TerminalPane createFloatingPane() {
return addFloating(openPane(true));
private void createFloatingPane() {
addFloating(openPane(true, paneWorkingDirectory()));
}
/**
@@ -369,15 +395,21 @@ final class Tab implements AutoCloseable {
}
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], paneWorkingDirectory()));
config, metrics, this::markContentChanged, size[0], size[1], workingDirectory));
}
private double[] paneSize(boolean asFloating) {
double availHeight = lastHeight - lastTopInset;
if (asFloating) {
return new double[] {Math.max(420, lastWidth * 0.58), Math.max(260, availHeight * 0.58)};
return new double[] {
Math.max(FLOATING_MIN_WIDTH, lastWidth * FLOATING_SIZE_FRACTION),
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.
return new double[] {lastWidth / (tiled.size() + 1), availHeight};
@@ -423,4 +455,14 @@ final class Tab implements AutoCloseable {
tiled.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 lineHeight;
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) {
setFont(fontFamily, fontSize);
@@ -59,6 +62,11 @@ public final class TerminalMetrics {
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). */
public int columnsFor(double widthPx) {
return Math.max(1, (int) ((widthPx - 2 * PADDING) / cellWidth));

View File

@@ -16,8 +16,11 @@ import javafx.application.Platform;
import javafx.scene.canvas.GraphicsContext;
import javafx.scene.shape.Shape;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicLong;
import java.util.function.Supplier;
/**
* One terminal: owns its ghostty {@link Terminal}, the {@link ShellSession}/pty driving it,
@@ -39,10 +42,10 @@ public final class TerminalPane implements AutoCloseable, RenderTarget {
// tracking meaningful: update() accumulates dirty since the last resetDirty().
private final RenderState renderState = new RenderState();
private RenderStateSnapshot cachedSnapshot;
private ShellSession session;
private volatile ShellSession session;
// Run once (on the FX thread) when this pane's process exits on its own, so the owning tab can
// remove it. Set by the Tab that creates the pane; null until then.
private Runnable onExit;
// 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;
// null means clip to the plain bounds. See RenderTarget#clip().
@@ -80,8 +83,8 @@ public final class TerminalPane implements AutoCloseable, RenderTarget {
public static TerminalPane create(AppConfig config, TerminalMetrics metrics, Runnable onContentChange,
double widthPx, double heightPx, String workingDirectory) {
TerminalPane pane = newPane(config, metrics, onContentChange, widthPx, heightPx);
pane.attach(ShellSession.start(config.shell(), config.envOverride(), pane, pane.columns, pane.rows,
workingDirectory));
attachOrShowError(pane, () -> ShellSession.start(config.shell(), config.envOverride(), pane,
pane.columns, pane.rows, workingDirectory, config.closeSignalNumber()));
return pane;
}
@@ -93,11 +96,23 @@ public final class TerminalPane implements AutoCloseable, RenderTarget {
public static TerminalPane createWithCommand(AppConfig config, TerminalMetrics metrics, Runnable onContentChange,
double widthPx, double heightPx, String workingDirectory, String command) {
TerminalPane pane = newPane(config, metrics, onContentChange, widthPx, heightPx);
pane.attach(ShellSession.startCommand(config.envOverride(), pane, pane.columns, pane.rows,
workingDirectory, command));
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();
@@ -112,12 +127,20 @@ public final class TerminalPane implements AutoCloseable, RenderTarget {
/** Sets the callback run when this pane's process exits on its own (see {@link #handleSessionExit}). */
public void setOnExit(Runnable onExit) {
this.onExit = 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 {@link #onExit} once, so tab/compositor
* 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() {
@@ -126,8 +149,8 @@ public final class TerminalPane implements AutoCloseable, RenderTarget {
return;
}
exited = true;
if (onExit != null) {
onExit.run();
for (Runnable handler : List.copyOf(onExitHandlers)) {
handler.run();
}
});
}
@@ -350,6 +373,16 @@ public final class TerminalPane implements AutoCloseable, RenderTarget {
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}. */
public long paintFull(GraphicsContext gc, boolean active) {
renderer.paintFull(gc, this, active);
@@ -373,11 +406,24 @@ public final class TerminalPane implements AutoCloseable, RenderTarget {
@Override
public void close() {
if (session != null) {
session.close();
session.closeDetached();
session = null;
}
mouseEncoder.close();
renderState.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();
}
protected static void clipRect(GraphicsContext gc, double x, double y, double width, double height) {
gc.beginPath();
gc.rect(x, y, width, height);
gc.clip();
/**
* Release any large, rebuildable render buffers (e.g. a pane's pixel backbuffer) because the
* target is no longer being composited. A no-op by default; the next paint must rebuild whatever
* was dropped. Called off the paint path, so it must not assume a frame is in progress.
*/
void release() {
}
/**
@@ -45,22 +47,22 @@ abstract class TerminalRenderer {
* path, so it replays onto the canvas as move/line/close segments.
*/
protected static void clip(GraphicsContext gc, double x, double y, double width, double height, Shape region) {
if (region == null) {
clipRect(gc, x, y, width, height);
return;
}
var elements = ((Path) region).getElements();
gc.beginPath();
if (elements.isEmpty()) {
gc.rect(x, y, 0.0, 0.0); // fully covered: clip to nothing
}
for (PathElement element : elements) {
if (element instanceof MoveTo moveTo) {
gc.moveTo(moveTo.getX(), moveTo.getY());
} else if (element instanceof LineTo lineTo) {
gc.lineTo(lineTo.getX(), lineTo.getY());
} else if (element instanceof ClosePath) {
gc.closePath();
if (region == null) {
gc.rect(x, y, width, height);
} else {
var elements = ((Path) region).getElements();
if (elements.isEmpty()) {
gc.rect(x, y, 0.0, 0.0); // fully covered: clip to nothing
}
for (PathElement element : elements) {
if (element instanceof MoveTo moveTo) {
gc.moveTo(moveTo.getX(), moveTo.getY());
} else if (element instanceof LineTo lineTo) {
gc.lineTo(lineTo.getX(), lineTo.getY());
} else if (element instanceof ClosePath) {
gc.closePath();
}
}
}
gc.clip();

View File

@@ -0,0 +1,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();
}
}
}