18 Commits

Author SHA1 Message Date
6e3e88919e Revert "fix glyph-overhang artifacts in partial row repaint"
This reverts commit 57103bb98b.
2026-05-31 21:50:17 +02:00
57103bb98b fix glyph-overhang artifacts in partial row repaint
repaintColumns cleared and redrew only [start,end], but a neighbouring cell's
glyph can overhang into that span. The clearRect erased the overhang and the
neighbour was never redrawn, leaving black notches through the line 1-2 cells
before the cursor that survived until a full rerender.

Redraw text for a couple of extra cells on each side, clipped to the cleared
span, so overhang from just-outside cells is restored without touching their
own cell areas. Keeps the per-column repaint efficiency (vs the full-row
repaint debug toggle, which fixed the bars but repainted every dirty cell).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 21:48:34 +02:00
cb95a7188d update jlibghostty 2026-05-31 21:39:18 +02:00
Gregor Lohaus
5ca192b7be add full-row-repaint debug toggle
-Djprototerm.fullRowRepaint=true (or JPROTOTERM_FULL_ROW_REPAINT=1) bypasses the
per-column repaint in renderChanged and repaints the whole row, to bisect the
stale black-bar artifact that appears near the cursor and survives until a full
rerender.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 21:25:13 +02:00
Gregor Lohaus
e99a6ee33e split snapshot profiler bucket into update vs marshal
The snapshot bucket lumped ghostty's native dirty-state update together with
the Java-side cell marshaling. Time them separately to see which half of the
~7ms/frame snapshot cost (now the dominant frame cost after the detectShift
hoist) is the real target.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 21:18:07 +02:00
Gregor Lohaus
4923ea5527 hoist row hash out of detectShift delta scan
rowFingerprint(row) is invariant across the delta loop but was recomputed for
every candidate delta, making shift detection O(rows^2 x cols) on large changes
(full-screen scroll). Precompute each changed row's hash once, dropping it to
O(rows x cols). Profiling showed fingerprint hashing at ~74% of frame time under
heavy scroll, dominated by this loop.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 21:09:54 +02:00
Gregor Lohaus
1f7394d75a add opt-in render profiler instrumentation
Gated behind -Djprototerm.profile=true (or JPROTOTERM_PROFILE=1), accumulates
per-frame nanos into snapshot/fingerprint/draw/frame-total buckets and dumps
to stderr every N renders. Splits the three suspected render costs: native
snapshot marshaling, fingerprint hashing, and canvas draw recording.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 21:04:00 +02:00
Gregor Lohaus
50641d0a6a per-row cell-run repaint 2026-05-31 20:12:43 +02:00
Gregor Lohaus
51f64e7ca8 cache hidden panes 2026-05-31 19:56:09 +02:00
Gregor Lohaus
528afafcda no next floating pane keyboard shortcut 2026-05-31 19:52:24 +02:00
Gregor Lohaus
093a09da39 frame classifiaction not needed anymore 2026-05-31 19:46:55 +02:00
Gregor Lohaus
59ab33bc01 dont rerender full on every layout frame 2026-05-31 19:45:38 +02:00
Gregor Lohaus
d8447d9e29 port feasable performance improvements 2026-05-31 19:38:06 +02:00
Gregor Lohaus
dba6474491 apply race condition fix 2026-05-31 19:30:36 +02:00
Gregor Lohaus
743f312921 move unchanged rows 2026-05-31 18:55:53 +02:00
Gregor Lohaus
3054b3ec77 cleanup 2026-05-31 18:51:16 +02:00
Gregor Lohaus
2bcaf951df Render terminal rows as JavaFX nodes 2026-05-31 18:40:33 +02:00
Gregor Lohaus
beba14c3ea scene graph 2026-05-31 18:27:52 +02:00
30 changed files with 1569 additions and 2295 deletions

18
.classpath Normal file
View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<classpath>
<classpathentry kind="src" output="bin/main" path="src/main/java">
<attributes>
<attribute name="gradle_scope" value="main"/>
<attribute name="gradle_used_by_scope" value="main,test"/>
</attributes>
</classpathentry>
<classpathentry kind="src" output="bin/main" path="src/main/resources">
<attributes>
<attribute name="gradle_scope" value="main"/>
<attribute name="gradle_used_by_scope" value="main,test"/>
</attributes>
</classpathentry>
<classpathentry kind="con" path="org.eclipse.jdt.launching.JRE_CONTAINER/org.eclipse.jdt.internal.debug.ui.launcher.StandardVMType/JavaSE-21/"/>
<classpathentry kind="con" path="org.eclipse.buildship.core.gradleclasspathcontainer"/>
<classpathentry kind="output" path="bin/default"/>
</classpath>

1
.codexsession Normal file
View File

@@ -0,0 +1 @@
019e6999-b7c8-7591-a8aa-ea51b89a7f7e

1
.gitattributes vendored
View File

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

6
.gitignore vendored
View File

@@ -11,10 +11,6 @@ devenv.local.yaml
# pre-commit # pre-commit
.pre-commit-config.yaml .pre-commit-config.yaml
build build
build
.gradle .gradle
bin bin
.settings
.project
.worktrees
.classpath
.codexsession

View File

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

34
.project Normal file
View File

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

View File

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

View File

@@ -12,8 +12,6 @@ floating panes and tabs.
> setups, or Mesa/AMD/Intel GPUs. I'm happy to accept pull requests that broaden host > setups, or Mesa/AMD/Intel GPUs. I'm happy to accept pull requests that broaden host
> support. > support.
<video src="https://gitea.gregorlohaus.com/gregor/jprototerm/media/branch/main/demo.mp4" controls></video>
## Build ## Build
```sh ```sh
@@ -108,7 +106,6 @@ navigate_up = "ALT+K"
navigate_right = "ALT+L" navigate_right = "ALT+L"
toggle_floating = "ALT+F" toggle_floating = "ALT+F"
new_pane = "ALT+N" new_pane = "ALT+N"
next_floating = "ALT+F12"
close_pane = "ALT+X" close_pane = "ALT+X"
new_tab = "ALT+A" new_tab = "ALT+A"
previous_tab = "ALT+SHIFT+H" previous_tab = "ALT+SHIFT+H"
@@ -123,7 +120,6 @@ open_scrollback = "ALT+S"
- `Alt+n`: new pane — a floating pane when floating panes are shown, otherwise a new tiled - `Alt+n`: new pane — a floating pane when floating panes are shown, otherwise a new tiled
pane (tiled panes are split equally across the width) pane (tiled panes are split equally across the width)
- `Alt+f`: show or hide all floating panes - `Alt+f`: show or hide all floating panes
- `Alt+F12`: cycle floating panes
- `Alt+x`: close the active pane; closing a tab's last pane closes the tab, and closing the - `Alt+x`: close the active pane; closing a tab's last pane closes the tab, and closing the
last pane of the last tab quits last pane of the last tab quits
- `Alt+a`: new tab - `Alt+a`: new tab

View File

@@ -25,9 +25,7 @@ navigate_down = "ALT+J"
navigate_up = "ALT+K" navigate_up = "ALT+K"
navigate_right = "ALT+L" navigate_right = "ALT+L"
toggle_floating = "ALT+F" toggle_floating = "ALT+F"
new_floating = "ALT+SHIFT+F" new_pane = "ALT+N"
next_floating = "ALT+F12"
close_pane = "ALT+X" close_pane = "ALT+X"
open_font_selector = "ALT+T" open_font_selector = "ALT+T"
open_scrollback = "ALT+S" open_scrollback = "ALT+S"
paste = "CTRL+SHIFT+V"

BIN
demo.mp4 LFS

Binary file not shown.

65
devenv.lock Normal file
View File

@@ -0,0 +1,65 @@
{
"nodes": {
"devenv": {
"locked": {
"dir": "src/modules",
"lastModified": 1779749056,
"narHash": "sha256-AtocdrunzuxTvSDn+82RntEhrs6TicM6Z4/zNQS9KKg=",
"owner": "cachix",
"repo": "devenv",
"rev": "099ac65fcef79e88127bdc06adbd1ea94255274a",
"type": "github"
},
"original": {
"dir": "src/modules",
"owner": "cachix",
"repo": "devenv",
"type": "github"
}
},
"nixpkgs": {
"inputs": {
"nixpkgs-src": "nixpkgs-src"
},
"locked": {
"lastModified": 1778507786,
"narHash": "sha256-HzSQCKMsMr8r55LwM1JuzIOB+8bzk0FEv6sItKvsfoY=",
"owner": "cachix",
"repo": "devenv-nixpkgs",
"rev": "8f24a228a782e24576b155d1e39f0d914b380691",
"type": "github"
},
"original": {
"owner": "cachix",
"ref": "rolling",
"repo": "devenv-nixpkgs",
"type": "github"
}
},
"nixpkgs-src": {
"flake": false,
"locked": {
"lastModified": 1778274207,
"narHash": "sha256-I4puXmX1iovcCHZlRmztO3vW0mAbbRvq4F8wgIMQ1MM=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "b3da656039dc7a6240f27b2ef8cc6a3ef3bccae7",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"devenv": "devenv",
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}

66
devenv.nix Normal file
View File

@@ -0,0 +1,66 @@
{ pkgs, lib, config, inputs, ... }:
let
system = pkgs.stdenv.hostPlatform.system;
jlibghostty = builtins.getFlake
"git+https://gitea.gregorlohaus.com/gregor/jlibghostty.git";
jlib = jlibghostty.packages.${system}.jlibghostty;
hostNvidiaLibs = ".devenv/host-nvidia-libs";
in
{
packages = [
pkgs.git
pkgs.gradle_9
pkgs.jdk25
pkgs.jdt-language-server
pkgs.openjfx
pkgs.glib
pkgs.xorg.libXxf86vm
pkgs.xorg.libXrender
pkgs.xorg.libXtst
pkgs.xorg.libXi
pkgs.xorg.libXrandr
pkgs.libGL
pkgs.gtk3
pkgs.alsa-lib
pkgs.mesa-demos
];
env.LD_LIBRARY_PATH = "${hostNvidiaLibs}:" + lib.makeLibraryPath [
pkgs.openjfx
pkgs.glib
pkgs.xorg.libXxf86vm
pkgs.xorg.libXrender
pkgs.xorg.libXtst
pkgs.xorg.libXi
pkgs.xorg.libXrandr
pkgs.libGL
pkgs.gtk3
pkgs.alsa-lib
] + ":/usr/lib/x86_64-linux-gnu/nvidia/current";
env.__GLX_VENDOR_LIBRARY_NAME = "nvidia";
env.__EGL_VENDOR_LIBRARY_FILENAMES = "/usr/share/glvnd/egl_vendor.d/10_nvidia.json";
env.JLIBGHOSTTY_MAVEN_REPO = "${jlib}/maven";
enterShell = ''
mkdir -p ${hostNvidiaLibs}
for lib in \
/usr/lib/x86_64-linux-gnu/libnvidia*.so* \
/usr/lib/x86_64-linux-gnu/libGLX_nvidia.so* \
/usr/lib/x86_64-linux-gnu/libEGL_nvidia.so* \
/usr/lib/x86_64-linux-gnu/nvidia/current/libnvidia*.so* \
/usr/lib/x86_64-linux-gnu/nvidia/current/libGLX_nvidia.so* \
/usr/lib/x86_64-linux-gnu/nvidia/current/libEGL_nvidia.so*
do
if [ -e "$lib" ]; then
ln -sfn "$lib" ${hostNvidiaLibs}/"$(basename "$lib")"
fi
done
'';
}

18
devenv.yaml Normal file
View File

@@ -0,0 +1,18 @@
# yaml-language-server: $schema=https://devenv.sh/devenv.schema.json
inputs:
nixpkgs:
url: github:cachix/devenv-nixpkgs/rolling
# If you're using non-OSS software, you can set allowUnfree to true.
# allowUnfree: true
# If you're not willing to allow unsupported packages:
# allowUnsupportedSystem: false
# If you're willing to use a package that's vulnerable
# permittedInsecurePackages:
# - "openssl-1.1.1w"
# If you have more than one devenv you can merge them
#imports:
# - ./backend

8
flake.lock generated
View File

@@ -70,11 +70,11 @@
"nixpkgs": "nixpkgs" "nixpkgs": "nixpkgs"
}, },
"locked": { "locked": {
"lastModified": 1780363584, "lastModified": 1780256181,
"narHash": "sha256-BN6kwSBnlavuySut6vvLsfwkfKBjrPvFgJeeMMTXNtg=", "narHash": "sha256-/saXdnYMbAMfP7u6USSqtNkBIgqZhU+CPr3F8tUQhHU=",
"ref": "refs/heads/main", "ref": "refs/heads/main",
"rev": "1cd908e5d0070fdc1c86fc6b300cf1d6dbb5d184", "rev": "db5ee5d20daf8855de3a3b2fa9349eced70946f0",
"revCount": 25, "revCount": 21,
"type": "git", "type": "git",
"url": "https://gitea.gregorlohaus.com/gregor/jlibghostty.git" "url": "https://gitea.gregorlohaus.com/gregor/jlibghostty.git"
}, },

View File

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

View File

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

View File

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

View File

@@ -1,32 +0,0 @@
package com.gregor.jprototerm;
import javafx.scene.image.Image;
/**
* A single kitty image to display, produced by the renderer and consumed by {@link
* KittyImageOverlay}. Images are not painted onto the canvas; each becomes a retained
* {@code ImageView} node positioned over the pane. The {@code source*} fields are the region of
* {@link #image()} to show (in image pixels); the {@code x/y/width/height} are where to put it,
* in scene coordinates (the same space the pane's clip {@code Shape} lives in).
*
* <p>{@code imageId}+{@code placementId} identify the placement so the overlay can reuse the
* same node across frames instead of recreating it.
*/
record KittyImageNode(
long imageId,
long placementId,
Image image,
double sourceX,
double sourceY,
double sourceWidth,
double sourceHeight,
double x,
double y,
double width,
double height
) {
/** Stable per-pane key for node reuse. Packs the two u32 ids without collision. */
long key() {
return (imageId << 32) | (placementId & 0xffffffffL);
}
}

View File

@@ -1,151 +0,0 @@
package com.gregor.jprototerm;
import javafx.geometry.Rectangle2D;
import javafx.scene.Group;
import javafx.scene.Node;
import javafx.scene.image.ImageView;
import javafx.scene.layout.Pane;
import javafx.scene.shape.Rectangle;
import javafx.scene.shape.Shape;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* Renders kitty graphics images as retained scene-graph nodes layered over the {@link Compositor}
* canvas, instead of compositing them onto the canvas. Each pane gets a {@link Group} clipped to
* that pane's region (the same clip {@code Shape} the canvas renderer uses), and each visible
* image placement is an {@link ImageView} inside it, reused across frames so an unchanged image
* costs nothing to redraw.
*
* <p>The overlay {@link #node()} is mouse-transparent and sits above the canvas in the window's
* {@code StackPane}; its children use scene coordinates, which line up with the canvas because
* both fill the same root.
*/
final class KittyImageOverlay {
private final Pane root = new Pane();
private final Map<TerminalPane, PaneOverlay> overlays = new HashMap<>();
KittyImageOverlay() {
// Input belongs to the canvas underneath; the overlay only shows pixels.
root.setMouseTransparent(true);
root.setManaged(false);
}
Node node() {
return root;
}
/**
* Full reconcile to {@code panes} (bottom-to-top): drop overlays for panes that went away,
* refresh each surviving/added pane's images and clip, and order the per-pane groups to match
* the pane z-order. Called on layout frames, after the panes have painted.
*/
void sync(List<TerminalPane> panes) {
Iterator<Map.Entry<TerminalPane, PaneOverlay>> it = overlays.entrySet().iterator();
while (it.hasNext()) {
Map.Entry<TerminalPane, PaneOverlay> entry = it.next();
if (!panes.contains(entry.getKey())) {
root.getChildren().remove(entry.getValue().group);
it.remove();
}
}
for (TerminalPane pane : panes) {
updatePane(pane);
}
// Only panes that actually have images get a group; order those to match pane z-order.
List<Node> ordered = new ArrayList<>(panes.size());
for (TerminalPane pane : panes) {
PaneOverlay overlay = overlays.get(pane);
if (overlay != null) {
ordered.add(overlay.group);
}
}
if (!root.getChildren().equals(ordered)) {
root.getChildren().setAll(ordered);
}
}
/**
* Refresh one pane's images and clip (called on content frames for each repainted pane).
* Creates the pane's group if this is the first time it has shown an image.
*/
void updatePane(TerminalPane pane) {
List<KittyImageNode> images = pane.kittyImages();
PaneOverlay overlay = overlays.get(pane);
if (overlay == null) {
if (images.isEmpty()) {
return;
}
overlay = new PaneOverlay();
overlays.put(pane, overlay);
root.getChildren().add(overlay.group);
}
overlay.group.setClip(clipFor(pane));
reconcile(overlay, images);
}
private static void reconcile(PaneOverlay overlay, List<KittyImageNode> images) {
Set<Long> seen = new HashSet<>();
for (KittyImageNode node : images) {
long key = node.key();
seen.add(key);
ImageView view = overlay.views.get(key);
if (view == null) {
view = new ImageView();
view.setManaged(false);
view.setSmooth(true);
view.setPreserveRatio(false);
overlay.views.put(key, view);
overlay.group.getChildren().add(view);
}
apply(view, node);
}
if (overlay.views.size() == seen.size()) {
return;
}
Iterator<Map.Entry<Long, ImageView>> it = overlay.views.entrySet().iterator();
while (it.hasNext()) {
Map.Entry<Long, ImageView> entry = it.next();
if (!seen.contains(entry.getKey())) {
overlay.group.getChildren().remove(entry.getValue());
it.remove();
}
}
}
private static void apply(ImageView view, KittyImageNode node) {
if (view.getImage() != node.image()) {
view.setImage(node.image());
}
view.setViewport(new Rectangle2D(node.sourceX(), node.sourceY(), node.sourceWidth(), node.sourceHeight()));
view.setFitWidth(node.width());
view.setFitHeight(node.height());
view.setLayoutX(node.x());
view.setLayoutY(node.y());
}
// The pane's occlusion clip when one is set (rect minus covering panes), else the pane's
// plain bounds so an image can't spill outside its pane. Matches Tab's pixel snapping.
private static Shape clipFor(TerminalPane pane) {
Shape clip = pane.clip();
if (clip != null) {
return clip;
}
return new Rectangle(Math.round(pane.x()), Math.round(pane.y()), pane.width(), pane.height());
}
private static final class PaneOverlay {
private final Group group = new Group();
private final Map<Long, ImageView> views = new HashMap<>();
private PaneOverlay() {
group.setManaged(false);
}
}
}

View File

@@ -9,9 +9,6 @@ import java.lang.foreign.MemorySegment;
import java.lang.foreign.SymbolLookup; import java.lang.foreign.SymbolLookup;
import java.lang.foreign.ValueLayout; import java.lang.foreign.ValueLayout;
import java.lang.invoke.MethodHandle; import java.lang.invoke.MethodHandle;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@@ -77,7 +74,6 @@ public final class LinuxPty implements AutoCloseable {
private static final long SPAWN_ACTIONS_SIZE = 256; private static final long SPAWN_ACTIONS_SIZE = 256;
private static final long SPAWN_ATTR_SIZE = 512; private static final long SPAWN_ATTR_SIZE = 512;
private static final MethodHandle TCGETPGRP = handle("tcgetpgrp", FD_INT_INT);
private static final MethodHandle POSIX_OPENPT = handle("posix_openpt", FD_INT_INT); private static final MethodHandle POSIX_OPENPT = handle("posix_openpt", FD_INT_INT);
private static final MethodHandle GRANTPT = handle("grantpt", FD_INT_INT); private static final MethodHandle GRANTPT = handle("grantpt", FD_INT_INT);
private static final MethodHandle UNLOCKPT = handle("unlockpt", FD_INT_INT); private static final MethodHandle UNLOCKPT = handle("unlockpt", FD_INT_INT);
@@ -101,7 +97,6 @@ public final class LinuxPty implements AutoCloseable {
private final Arena arena = Arena.ofShared(); private final Arena arena = Arena.ofShared();
private final MemorySegment readBuffer = arena.allocate(65536); private final MemorySegment readBuffer = arena.allocate(65536);
private final MemorySegment writeBuffer = arena.allocate(65536);
private final Object writeLock = new Object(); private final Object writeLock = new Object();
private final int masterFd; private final int masterFd;
private final int pid; private final int pid;
@@ -191,41 +186,19 @@ public final class LinuxPty implements AutoCloseable {
return; return;
} }
synchronized (writeLock) { synchronized (writeLock) {
int offset = 0; try (Arena a = Arena.ofConfined()) {
MemorySegment buf = a.allocate(data.length);
MemorySegment.copy(data, 0, buf, ValueLayout.JAVA_BYTE, 0, data.length);
long offset = 0;
while (offset < data.length) { while (offset < data.length) {
int chunk = (int) Math.min(writeBuffer.byteSize(), data.length - offset); long n = callLong(WRITE, masterFd, buf.asSlice(offset), data.length - offset);
MemorySegment.copy(data, offset, writeBuffer, ValueLayout.JAVA_BYTE, 0, chunk); if (n < 0) {
long written = 0;
while (written < chunk) {
long n = callLong(WRITE, masterFd, writeBuffer.asSlice(written), chunk - written);
if (n <= 0) {
throw new IllegalStateException("write to pty failed"); throw new IllegalStateException("write to pty failed");
} }
written += n; offset += n;
}
offset += chunk;
} }
} }
} }
/**
* Best-effort current working directory of the terminal's foreground process group, read from
* {@code /proc}. This tracks the directory the user is actually in (a {@code cd} in the shell,
* or a child program that changed dir), so a newly opened pane can start there. Falls back to
* the shell's own pid, and returns {@code null} if it cannot be determined.
*/
public String currentWorkingDirectory() {
if (closed) {
return null;
}
int pgid = callInt(TCGETPGRP, masterFd);
int target = pgid > 0 ? pgid : pid;
try {
return Files.readSymbolicLink(Path.of("/proc", Integer.toString(target), "cwd")).toString();
} catch (IOException | RuntimeException ex) {
return null;
}
} }
/** Resizes the terminal window. */ /** Resizes the terminal window. */

View File

@@ -3,7 +3,6 @@ package com.gregor.jprototerm;
import javafx.animation.AnimationTimer; import javafx.animation.AnimationTimer;
import javafx.application.Application; import javafx.application.Application;
import javafx.application.Platform; import javafx.application.Platform;
import javafx.geometry.Rectangle2D;
import javafx.scene.Scene; import javafx.scene.Scene;
import javafx.scene.control.ButtonType; import javafx.scene.control.ButtonType;
import javafx.scene.control.ComboBox; import javafx.scene.control.ComboBox;
@@ -11,18 +10,14 @@ import javafx.scene.control.Dialog;
import javafx.scene.control.Label; import javafx.scene.control.Label;
import javafx.scene.control.Spinner; import javafx.scene.control.Spinner;
import javafx.scene.control.SpinnerValueFactory; import javafx.scene.control.SpinnerValueFactory;
import javafx.scene.input.Clipboard;
import javafx.scene.input.KeyEvent; import javafx.scene.input.KeyEvent;
import javafx.scene.layout.GridPane; import javafx.scene.layout.GridPane;
import javafx.scene.layout.StackPane;
import javafx.scene.text.Font; import javafx.scene.text.Font;
import javafx.stage.Screen;
import javafx.stage.Stage; import javafx.stage.Stage;
import java.io.IOException; import java.io.IOException;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.List;
public final class Main extends Application { public final class Main extends Application {
private Compositor compositor; private Compositor compositor;
@@ -35,18 +30,8 @@ public final class Main extends Application {
metrics = new TerminalMetrics(config.fontFamily(), config.fontSize()); metrics = new TerminalMetrics(config.fontFamily(), config.fontSize());
compositor = new Compositor(config, metrics); 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()); Scene scene = new Scene(compositor.node(), config.windowWidth(), config.windowHeight());
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_PRESSED, this::handlePressed);
scene.addEventFilter(KeyEvent.KEY_TYPED, event -> handleTyped(event)); scene.addEventFilter(KeyEvent.KEY_TYPED, event -> handleTyped(event));
@@ -62,35 +47,8 @@ public final class Main extends Application {
stage.setOnCloseRequest(event -> { stage.setOnCloseRequest(event -> {
compositor.close(); 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(); stage.show();
// Ask the window manager to raise and focus the new window so the user can type right compositor.requestFocus();
// 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) { private void handlePressed(KeyEvent event) {
@@ -112,16 +70,14 @@ public final class Main extends Application {
} else if (config.keybindings().get("new_pane").matches(event)) { } else if (config.keybindings().get("new_pane").matches(event)) {
compositor.createPane(); compositor.createPane();
event.consume(); 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)) { } else if (config.keybindings().get("close_pane").matches(event)) {
// Closing the last pane quits the app, via the compositor's onEmpty hook.
compositor.closeActivePane(); compositor.closeActivePane();
event.consume(); event.consume();
if (compositor.isEmpty()) {
// Closing the last pane quits the app.
compositor.close();
Platform.exit();
}
} else if (config.keybindings().get("new_tab").matches(event)) { } else if (config.keybindings().get("new_tab").matches(event)) {
compositor.newTab(); compositor.newTab();
event.consume(); event.consume();
@@ -137,9 +93,6 @@ public final class Main extends Application {
} else if (config.keybindings().get("open_scrollback").matches(event)) { } else if (config.keybindings().get("open_scrollback").matches(event)) {
openScrollbackInEditor(); openScrollbackInEditor();
event.consume(); event.consume();
} else if (config.keybindings().get("paste").matches(event)) {
pasteFromClipboard();
event.consume();
} else { } else {
String encoded = KeyEncoder.encode(event); String encoded = KeyEncoder.encode(event);
if (encoded != null) { if (encoded != null) {
@@ -161,13 +114,6 @@ public final class Main extends Application {
} }
} }
private void pasteFromClipboard() {
Clipboard clipboard = Clipboard.getSystemClipboard();
if (clipboard.hasString()) {
compositor.activePane().paste(clipboard.getString());
}
}
private void openFontSelector() { private void openFontSelector() {
Dialog<ButtonType> dialog = new Dialog<>(); Dialog<ButtonType> dialog = new Dialog<>();
dialog.setTitle("Font"); dialog.setTitle("Font");
@@ -207,22 +153,17 @@ public final class Main extends Application {
config = config.withFont(selectedFamily.trim(), selectedSize); config = config.withFont(selectedFamily.trim(), selectedSize);
config.save(); config.save();
compositor.setFont(config.fontFamily(), config.fontSize()); compositor.setFont(config.fontFamily(), config.fontSize());
compositor.canvas().requestFocus(); compositor.requestFocus();
}); });
} }
private void openScrollbackInEditor() { private void openScrollbackInEditor() {
try { 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"); Path file = Files.createTempFile("jprototerm-scrollback-", ".txt");
Files.writeString(file, compositor.activePane().scrollbackText()); Files.writeString(file, compositor.activePane().scrollbackText());
file.toFile().deleteOnExit(); file.toFile().deleteOnExit();
// Run the editor as the floating pane's process (via /bin/sh -c) rather than typing the compositor.activePane().send(scrollbackEditorCommand(file) + "\r");
// 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) { } catch (IOException ex) {
System.err.println("Could not open scrollback in editor: " + ex.getMessage()); System.err.println("Could not open scrollback in editor: " + ex.getMessage());
} }

View File

@@ -0,0 +1,79 @@
package com.gregor.jprototerm;
/**
* Lightweight render profiler, disabled unless {@code -Djprototerm.profile=true} (or the
* {@code JPROTOTERM_PROFILE=1} environment variable) is set. It accumulates wall-clock nanos
* into a handful of buckets and prints aggregate per-frame stats to stderr every
* {@code jprototerm.profile.frames} render invocations (default 120).
*
* <p>All render work runs on the JavaFX application thread, so the accumulators are plain
* fields with no synchronization.
*
* <p>Caveat: JavaFX canvas drawing is deferred to the QuantumRenderer thread, so the
* {@link #DRAW} bucket measures only the cost of <em>recording</em> draw commands, not the
* GPU paint. Pair this with {@code -Djavafx.pulseLogger=true} to see the render-thread side.
*/
final class RenderProfiler {
static final int SNAPSHOT = 0;
static final int FINGERPRINT = 1;
static final int DRAW = 2;
static final int FRAME = 3;
static final int UPDATE = 4;
static final int MARSHAL = 5;
private static final int BUCKETS = 6;
private static final String[] NAMES =
{"snapshot", "fingerprint", "draw", "frame-total", "update", "marshal"};
private static final boolean ENABLED =
Boolean.getBoolean("jprototerm.profile") || "1".equals(System.getenv("JPROTOTERM_PROFILE"));
private static final int DUMP_FRAMES = Integer.getInteger("jprototerm.profile.frames", 120);
private static final long[] totalNanos = new long[BUCKETS];
private static final long[] counts = new long[BUCKETS];
private static int frames;
private RenderProfiler() {
}
static boolean enabled() {
return ENABLED;
}
/** Returns a start timestamp, or 0 when profiling is disabled. */
static long start() {
return ENABLED ? System.nanoTime() : 0L;
}
/** Records the time elapsed since {@code startNanos} into {@code bucket}. */
static void stop(int bucket, long startNanos) {
if (!ENABLED) {
return;
}
totalNanos[bucket] += System.nanoTime() - startNanos;
counts[bucket]++;
}
/** Marks the end of one render invocation; dumps and resets every {@code DUMP_FRAMES}. */
static void frame() {
if (!ENABLED) {
return;
}
if (++frames < DUMP_FRAMES) {
return;
}
dump();
}
private static void dump() {
StringBuilder sb = new StringBuilder(192);
sb.append("[render-profile] ").append(frames).append(" renders");
for (int i = 0; i < BUCKETS; i++) {
double totalMs = totalNanos[i] / 1_000_000.0;
sb.append(String.format(" | %s %.3fms/f (n=%d)", NAMES[i], totalMs / frames, counts[i]));
totalNanos[i] = 0;
counts[i] = 0;
}
System.err.println(sb);
frames = 0;
}
}

View File

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

View File

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

View File

@@ -1,13 +1,8 @@
package com.gregor.jprototerm; package com.gregor.jprototerm;
import javafx.scene.shape.Rectangle;
import javafx.scene.shape.Shape;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Comparator; import java.util.Comparator;
import java.util.List; import java.util.List;
import java.util.concurrent.atomic.AtomicLong;
import java.util.function.Consumer;
import java.util.stream.Stream; import java.util.stream.Stream;
/** /**
@@ -20,19 +15,13 @@ import java.util.stream.Stream;
final class Tab implements AutoCloseable { final class Tab implements AutoCloseable {
private final AppConfig config; private final AppConfig config;
private final TerminalMetrics metrics; private final TerminalMetrics metrics;
// Notified (on the FX thread) when one of this tab's panes' process exits on its own, so the
// compositor can close that pane and reap the tab/app if it was the last one.
private final Consumer<TerminalPane> onPaneExit;
private final List<TerminalPane> tiled = new ArrayList<>(); private final List<TerminalPane> tiled = new ArrayList<>();
private final List<TerminalPane> floating = new ArrayList<>(); private final List<TerminalPane> floating = new ArrayList<>();
private boolean floatingVisible; private boolean floatingVisible;
private TerminalPane active; private TerminalPane active;
private final String initialWorkingDirectory;
// The floating pane to re-focus when the group is shown again, and to prefer when promoting // The floating pane to re-focus when the group is shown again, and to prefer when promoting
// after the last tiled pane closes. // after the last tiled pane closes.
private TerminalPane lastFocusedFloating; private TerminalPane lastFocusedFloating;
// The tiled pane to re-focus when the floating group is hidden.
private TerminalPane lastFocusedTiled;
// Last laid-out size, so a newly opened pane can be created at roughly its eventual rect // Last laid-out size, so a newly opened pane can be created at roughly its eventual rect
// (and thus grid). Seeded from the configured window size for the first pane, which is // (and thus grid). Seeded from the configured window size for the first pane, which is
// opened before any layout pass runs. // opened before any layout pass runs.
@@ -41,24 +30,13 @@ final class Tab implements AutoCloseable {
private double lastTopInset; private double lastTopInset;
// Bumped whenever one of this tab's panes changes content; the compositor reads the current // Bumped whenever one of this tab's panes changes content; the compositor reads the current
// tab's value each frame as an O(1) "anything to repaint?" check. // tab's value each frame as an O(1) "anything to repaint?" check.
private final AtomicLong contentVersion = new AtomicLong(); private long contentVersion;
Tab(AppConfig config, TerminalMetrics metrics, Consumer<TerminalPane> onPaneExit) { Tab(AppConfig config, TerminalMetrics metrics) {
this(config, metrics, null, onPaneExit);
}
/**
* Creates a tab whose first pane starts in {@code initialWorkingDirectory} (e.g. the cwd of the
* pane that was active when this tab was opened), or the user's home when {@code null}.
*/
Tab(AppConfig config, TerminalMetrics metrics, String initialWorkingDirectory,
Consumer<TerminalPane> onPaneExit) {
this.config = config; this.config = config;
this.metrics = metrics; this.metrics = metrics;
this.onPaneExit = onPaneExit;
this.lastWidth = config.windowWidth(); this.lastWidth = config.windowWidth();
this.lastHeight = config.windowHeight(); this.lastHeight = config.windowHeight();
this.initialWorkingDirectory = initialWorkingDirectory;
TerminalPane first = openPane(false); TerminalPane first = openPane(false);
tiled.add(first); tiled.add(first);
active = first; active = first;
@@ -73,7 +51,7 @@ final class Tab implements AutoCloseable {
} }
long contentVersion() { long contentVersion() {
return contentVersion.get(); return contentVersion;
} }
/** /**
@@ -97,6 +75,13 @@ final class Tab implements AutoCloseable {
return List.copyOf(ordered); return List.copyOf(ordered);
} }
List<TerminalPane> allPanes() {
List<TerminalPane> all = new ArrayList<>(tiled.size() + floating.size());
all.addAll(tiled);
all.addAll(floating);
return List.copyOf(all);
}
boolean isActive(TerminalPane pane) { boolean isActive(TerminalPane pane) {
return pane != null && pane == active; return pane != null && pane == active;
} }
@@ -130,51 +115,6 @@ final class Tab implements AutoCloseable {
floatingWidth, floatingWidth,
floatingHeight); floatingHeight);
} }
assignClips();
}
// Give each pane its clip region for the next paints, so repainting a pane on a content
// frame can never bleed over one stacked on top of it. Each pane is clipped to its rect
// minus the union of the panes above it: floating panes are clipped by the floating panes
// higher in the stack, and tiled panes by the whole floating group. When nothing floats,
// every pane clips to its plain bounds.
private void assignClips() {
if (!floatingVisible || floating.isEmpty()) {
tiled.forEach(pane -> pane.setClip(null));
floating.forEach(pane -> pane.setClip(null));
return;
}
// Floating panes bottom-to-top, matching panes(): insertion order, active pane on top.
List<TerminalPane> order = new ArrayList<>(floating.size());
for (TerminalPane pane : floating) {
if (pane != active) {
order.add(pane);
}
}
if (floating.contains(active)) {
order.add(active);
}
// Walk top-to-bottom, accumulating the union of the panes above each one.
Shape above = null;
for (int i = order.size() - 1; i >= 0; i--) {
Rectangle rect = rectOf(order.get(i));
order.get(i).setClip(above == null ? null : Shape.subtract(rect, above));
above = (above == null) ? rect : Shape.union(above, rect);
}
// `above` is now the union of every floating pane; tiled panes sit under all of them.
for (TerminalPane pane : tiled) {
pane.setClip(Shape.subtract(rectOf(pane), above));
}
}
// Match the renderer's pixel snapping (round the origin, keep width/height) so the clip
// lines up exactly with where the floating panes are drawn.
private static Rectangle rectOf(TerminalPane pane) {
return new Rectangle(Math.round(pane.x()), Math.round(pane.y()), pane.width(), pane.height());
} }
boolean navigate(Direction direction) { boolean navigate(Direction direction) {
@@ -201,7 +141,7 @@ final class Tab implements AutoCloseable {
if (floatingVisible) { if (floatingVisible) {
floatingVisible = false; floatingVisible = false;
if (floating.contains(active)) { if (floating.contains(active)) {
setActive(tiled.contains(lastFocusedTiled) ? lastFocusedTiled : tiled.get(0)); setActive(tiled.get(0));
} }
} else { } else {
floatingVisible = true; floatingVisible = true;
@@ -220,62 +160,20 @@ final class Tab implements AutoCloseable {
} }
} }
void nextFloatingPane() {
if (floating.isEmpty()) {
createFloatingPane();
return;
}
floatingVisible = true;
int current = floating.indexOf(active); // -1 when the active pane is tiled
setActive(floating.get((current + 1 + floating.size()) % floating.size()));
}
/** Promotes the active floating pane to a tiled pane, joining the tiled row. No-op otherwise. */
void promoteActiveFloating() {
TerminalPane promote = active;
if (!floating.remove(promote)) {
return; // active pane is tiled (or there is none); nothing to promote
}
if (promote == lastFocusedFloating) {
lastFocusedFloating = floating.isEmpty() ? null : floating.get(floating.size() - 1);
}
tiled.add(promote);
if (floating.isEmpty()) {
floatingVisible = false;
}
setActive(promote);
}
void closeActivePane() { void closeActivePane() {
if (active != null) { TerminalPane closing = active;
closePane(active);
}
}
/**
* Closes {@code closing} (the active pane on a key-bound close, or any pane whose process just
* exited) and re-selects the active pane only when the one that closed was active. Returns
* false when the pane is not in this tab. Leaves the tab empty ({@code active == null}) when its
* last pane closes, so the compositor can drop it.
*/
boolean closePane(TerminalPane closing) {
boolean wasFloating = floating.remove(closing); boolean wasFloating = floating.remove(closing);
boolean wasTiled = !wasFloating && tiled.remove(closing); if (!wasFloating) {
if (!wasFloating && !wasTiled) { tiled.remove(closing);
return false; // not one of this tab's panes (already gone)
} }
boolean wasActive = closing == active;
if (closing == lastFocusedFloating) { if (closing == lastFocusedFloating) {
lastFocusedFloating = null; lastFocusedFloating = null;
} }
if (closing == lastFocusedTiled) {
lastFocusedTiled = null;
}
closing.close(); closing.close();
if (tiled.isEmpty() && floating.isEmpty()) { if (tiled.isEmpty() && floating.isEmpty()) {
active = null; // tab is now empty; the compositor drops it active = null; // tab is now empty; the compositor drops it
return true; return;
} }
// Always keep a tiled base: if the last tiled pane just closed, promote a floating one // Always keep a tiled base: if the last tiled pane just closed, promote a floating one
@@ -287,54 +185,31 @@ final class Tab implements AutoCloseable {
floating.remove(promote); floating.remove(promote);
tiled.add(promote); tiled.add(promote);
if (promote == lastFocusedFloating) { if (promote == lastFocusedFloating) {
lastFocusedFloating = null;
if (!floating.isEmpty()) {
lastFocusedFloating = floating.isEmpty() ? null : floating.get(nextFocussed); lastFocusedFloating = floating.isEmpty() ? null : floating.get(nextFocussed);
} }
} }
}
if (floating.isEmpty()) { if (floating.isEmpty()) {
floatingVisible = false; floatingVisible = false;
} }
// Only the active pane closing forces a re-selection; closing a background pane (e.g. one setActive(wasFloating && floatingVisible ? floating.get(floating.size() - 1) : tiled.get(0));
// whose process exited while another is focused) leaves focus where it is.
if (wasActive) {
setActive(wasFloating && floatingVisible
? floating.get(floating.size() - 1)
: tiled.contains(lastFocusedTiled) ? lastFocusedTiled : tiled.get(0));
}
return true;
} }
private void setActive(TerminalPane pane) { private void setActive(TerminalPane pane) {
active = pane; active = pane;
if (floating.contains(pane)) { if (floating.contains(pane)) {
lastFocusedFloating = pane; lastFocusedFloating = pane;
} else if (tiled.contains(pane)) {
lastFocusedTiled = pane;
// A tiled pane gaining focus hides the floating group: leaving it shown while a tiled
// pane is active strands focus behind the overlay and disables navigation.
floatingVisible = false;
} }
} }
TerminalPane createFloatingPane() { private void createFloatingPane() {
return addFloating(openPane(true)); TerminalPane pane = openPane(true);
}
/**
* Opens a floating pane whose process runs {@code command} directly (auto-closing when it
* exits), rather than an interactive shell. Used for one-shot panes like the scrollback editor.
*/
TerminalPane createFloatingPane(String command) {
double[] size = paneSize(true);
return addFloating(register(TerminalPane.createWithCommand(
config, metrics, this::markContentChanged, size[0], size[1], paneWorkingDirectory(), command)));
}
private TerminalPane addFloating(TerminalPane pane) {
floating.add(pane); floating.add(pane);
floatingVisible = true; floatingVisible = true;
setActive(pane); setActive(pane);
return pane;
} }
private boolean navigateFloatingStack(Direction direction) { private boolean navigateFloatingStack(Direction direction) {
@@ -365,35 +240,22 @@ final class Tab implements AutoCloseable {
} }
private void markContentChanged() { private void markContentChanged() {
contentVersion.incrementAndGet(); contentVersion++;
} }
private TerminalPane openPane(boolean asFloating) { private TerminalPane openPane(boolean asFloating) {
double[] size = paneSize(asFloating);
return register(TerminalPane.create(
config, metrics, this::markContentChanged, size[0], size[1], paneWorkingDirectory()));
}
private double[] paneSize(boolean asFloating) {
double availHeight = lastHeight - lastTopInset; double availHeight = lastHeight - lastTopInset;
double widthPx;
double heightPx;
if (asFloating) { if (asFloating) {
return new double[] {Math.max(420, lastWidth * 0.58), Math.max(260, availHeight * 0.58)}; widthPx = Math.max(420, lastWidth * 0.58);
} heightPx = Math.max(260, availHeight * 0.58);
} else {
// A new tiled pane joins the row, so each gets 1/(n+1) of the width. // A new tiled pane joins the row, so each gets 1/(n+1) of the width.
return new double[] {lastWidth / (tiled.size() + 1), availHeight}; widthPx = lastWidth / (tiled.size() + 1);
heightPx = availHeight;
} }
return TerminalPane.create(config, metrics, this::markContentChanged, widthPx, heightPx);
// Open a new pane in the active pane's working directory, so a split/new pane lands where the
// user currently is. With no active pane yet (the tab's first pane), fall back to the directory
// this tab was opened in. null (cwd unknown) falls back to home downstream.
private String paneWorkingDirectory() {
return active != null ? active.currentWorkingDirectory() : initialWorkingDirectory;
}
// Wire the pane's self-exit (process ended) back to the compositor so it gets reaped.
private TerminalPane register(TerminalPane pane) {
pane.setOnExit(() -> onPaneExit.accept(pane));
return pane;
} }
private static boolean directionFilter(Direction direction, TerminalPane current, TerminalPane candidate) { private static boolean directionFilter(Direction direction, TerminalPane current, TerminalPane candidate) {

View File

@@ -12,41 +12,28 @@ import dev.jlibghostty.RenderStateSnapshot;
import dev.jlibghostty.ScrollViewport; import dev.jlibghostty.ScrollViewport;
import dev.jlibghostty.Terminal; import dev.jlibghostty.Terminal;
import dev.jlibghostty.TerminalOptions; import dev.jlibghostty.TerminalOptions;
import javafx.application.Platform;
import javafx.scene.canvas.GraphicsContext;
import javafx.scene.shape.Shape;
import java.util.Optional; import java.util.Optional;
import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.AtomicLong;
/** /**
* One terminal: owns its ghostty {@link Terminal}, the {@link ShellSession}/pty driving it, * One terminal: owns its ghostty {@link Terminal}, the {@link ShellSession}/pty driving it,
* and its on-screen geometry and grid. It does not draw itself — it is a {@link RenderTarget} * and its on-screen geometry and grid. It does not draw itself; {@link TerminalPaneNode}
* that a {@link TerminalRenderer} paints. {@link #paintFull}/{@link #paintIncremental} are the * reads snapshots from it and represents the visible rows and kitty graphics as JavaFX nodes.
* only rendering API exposed to the {@link Compositor}, and they just delegate to that
* renderer; the compositor decides z-order and which rect each pane occupies.
*/ */
public final class TerminalPane implements AutoCloseable, RenderTarget { public final class TerminalPane implements AutoCloseable {
private final Terminal terminal; private final Terminal terminal;
private final TerminalMetrics metrics; private final TerminalMetrics metrics;
private final boolean kittyEnabled; private final boolean kittyEnabled;
// Run on every content change so the owning tab can bump its content version — the // Run on every content change so the owning tab can bump its content version — the
// compositor's O(1) "did the current tab change?" gate. // compositor's O(1) "did the current tab change?" gate.
private final Runnable onContentChange; private final Runnable onContentChange;
private final TerminalRenderer renderer;
private final MouseEncoder mouseEncoder = new MouseEncoder(); private final MouseEncoder mouseEncoder = new MouseEncoder();
// A persistent render state (reused across frames) is what makes ghostty's per-row dirty // A persistent render state (reused across frames) is what makes ghostty's per-row dirty
// tracking meaningful: update() accumulates dirty since the last resetDirty(). // tracking meaningful: update() accumulates dirty since the last resetDirty().
private final RenderState renderState = new RenderState(); private final RenderState renderState = new RenderState();
private RenderStateSnapshot cachedSnapshot; private RenderStateSnapshot cachedSnapshot;
private ShellSession session; private ShellSession session;
// Run once (on the FX thread) when this pane's process exits on its own, so the owning tab can
// remove it. Set by the Tab that creates the pane; null until then.
private Runnable onExit;
private boolean exited;
// Clip region for rendering (rect minus the panes covering this one), set at layout time;
// null means clip to the plain bounds. See RenderTarget#clip().
private Shape clip;
private double x; private double x;
private double y; private double y;
private double width; private double width;
@@ -56,15 +43,14 @@ public final class TerminalPane implements AutoCloseable, RenderTarget {
private int pixelWidth; private int pixelWidth;
private int pixelHeight; private int pixelHeight;
private final AtomicLong contentVersion = new AtomicLong(); private final AtomicLong contentVersion = new AtomicLong();
private long snapshotVersion = -1; private volatile long snapshotVersion = -1;
private TerminalPane(Terminal terminal, TerminalMetrics metrics, boolean kittyEnabled, private TerminalPane(Terminal terminal, TerminalMetrics metrics, boolean kittyEnabled,
Runnable onContentChange, TerminalRenderer renderer, int columns, int rows) { Runnable onContentChange, int columns, int rows) {
this.terminal = terminal; this.terminal = terminal;
this.metrics = metrics; this.metrics = metrics;
this.kittyEnabled = kittyEnabled; this.kittyEnabled = kittyEnabled;
this.onContentChange = onContentChange; this.onContentChange = onContentChange;
this.renderer = renderer;
this.columns = columns; this.columns = columns;
this.rows = rows; this.rows = rows;
} }
@@ -74,64 +60,19 @@ public final class TerminalPane implements AutoCloseable, RenderTarget {
* columns and rows fit, and that grid is handed to ghostty and the shell at start-up. A * columns and rows fit, and that grid is handed to ghostty and the shell at start-up. A
* non-positive size falls back to the configured default grid (used before the first * non-positive size falls back to the configured default grid (used before the first
* layout, when no rect is known yet). The pane owns the shell session it starts and runs * layout, when no rect is known yet). The pane owns the shell session it starts and runs
* {@code onContentChange} on every content change. The shell starts in {@code workingDirectory} * {@code onContentChange} on every content change.
* (e.g. the active pane's cwd), or the user's home when {@code null}.
*/ */
public static TerminalPane create(AppConfig config, TerminalMetrics metrics, Runnable onContentChange, public static TerminalPane create(AppConfig config, TerminalMetrics metrics, Runnable onContentChange, double widthPx, double heightPx) {
double widthPx, double heightPx, String workingDirectory) {
TerminalPane pane = newPane(config, metrics, onContentChange, widthPx, heightPx);
pane.attach(ShellSession.start(config.shell(), config.envOverride(), pane, pane.columns, pane.rows,
workingDirectory));
return pane;
}
/**
* Opens a pane whose process runs {@code command} directly (via {@code /bin/sh -c}) instead of
* an interactive shell. The pane auto-closes when the command exits. See
* {@link ShellSession#startCommand}.
*/
public static TerminalPane createWithCommand(AppConfig config, TerminalMetrics metrics, Runnable onContentChange,
double widthPx, double heightPx, String workingDirectory, String command) {
TerminalPane pane = newPane(config, metrics, onContentChange, widthPx, heightPx);
pane.attach(ShellSession.startCommand(config.envOverride(), pane, pane.columns, pane.rows,
workingDirectory, command));
return pane;
}
private static TerminalPane newPane(AppConfig config, TerminalMetrics metrics, Runnable onContentChange,
double widthPx, double heightPx) {
int columns = widthPx > 0 ? metrics.columnsFor(widthPx) : config.columns(); int columns = widthPx > 0 ? metrics.columnsFor(widthPx) : config.columns();
int rows = heightPx > 0 ? metrics.rowsFor(heightPx) : config.rows(); int rows = heightPx > 0 ? metrics.rowsFor(heightPx) : config.rows();
Terminal terminal = Ghostty.open(new TerminalOptions(columns, rows, config.maxScrollback())); Terminal terminal = Ghostty.open(new TerminalOptions(columns, rows, config.maxScrollback()));
terminal.setDeviceAttributesProvider(DeviceAttributes::xtermCompatible); terminal.setDeviceAttributesProvider(DeviceAttributes::xtermCompatible);
TerminalPane pane = new TerminalPane(terminal, metrics, config.kittyGraphics(), onContentChange, TerminalPane pane = new TerminalPane(terminal, metrics, config.kittyGraphics(), onContentChange, columns, rows);
new GhosttyTerminalRenderer(metrics), columns, rows);
pane.refresh(); pane.refresh();
pane.attach(ShellSession.start(config.shell(), config.envOverride(), pane, columns, rows));
return pane; return pane;
} }
/** Sets the callback run when this pane's process exits on its own (see {@link #handleSessionExit}). */
public void setOnExit(Runnable onExit) {
this.onExit = onExit;
}
/**
* Called from the shell reader thread when the pty stream ends without us closing it (the
* process exited). Hops to the FX thread and fires {@link #onExit} once, so tab/compositor
* mutation happens on the thread that owns the layout.
*/
void handleSessionExit() {
Platform.runLater(() -> {
if (exited) {
return;
}
exited = true;
if (onExit != null) {
onExit.run();
}
});
}
private void attach(ShellSession session) { private void attach(ShellSession session) {
this.session = session; this.session = session;
terminal.setPtyWriter(bytes -> { terminal.setPtyWriter(bytes -> {
@@ -164,19 +105,6 @@ public final class TerminalPane implements AutoCloseable, RenderTarget {
} }
} }
/**
* Paste text to the shell: ghostty sanitises it (stripping anything that could smuggle in
* control sequences) and wraps it in the bracketed-paste markers, then it goes to the pty
* like typed input. We always request bracketed mode — every modern shell and editor enables
* DECSET 2004, and the jlibghostty API does not expose querying the terminal's live mode.
*/
public void paste(String text) {
if (text == null || text.isEmpty()) {
return;
}
send(Ghostty.encodePaste(text, true));
}
public boolean sendMouse(MouseInput input, MouseEncoderSize size, boolean anyButtonPressed) { public boolean sendMouse(MouseInput input, MouseEncoderSize size, boolean anyButtonPressed) {
synchronized (terminal) { synchronized (terminal) {
mouseEncoder.syncFromTerminal(terminal); mouseEncoder.syncFromTerminal(terminal);
@@ -216,7 +144,6 @@ public final class TerminalPane implements AutoCloseable, RenderTarget {
* Snapshotting is deferred here rather than done in refresh(), so a burst of writes * Snapshotting is deferred here rather than done in refresh(), so a burst of writes
* between two frames collapses into a single snapshot. * between two frames collapses into a single snapshot.
*/ */
@Override
public RenderStateSnapshot snapshot() { public RenderStateSnapshot snapshot() {
return takeSnapshot(false); return takeSnapshot(false);
} }
@@ -225,7 +152,6 @@ public final class TerminalPane implements AutoCloseable, RenderTarget {
* Full snapshot with every row's cells populated. Used where the whole pane is redrawn * Full snapshot with every row's cells populated. Used where the whole pane is redrawn
* regardless of dirty state (the kitty-graphics path). * regardless of dirty state (the kitty-graphics path).
*/ */
@Override
public RenderStateSnapshot snapshotFull() { public RenderStateSnapshot snapshotFull() {
return takeSnapshot(true); return takeSnapshot(true);
} }
@@ -234,13 +160,21 @@ public final class TerminalPane implements AutoCloseable, RenderTarget {
synchronized (terminal) { synchronized (terminal) {
long version = contentVersion.get(); long version = contentVersion.get();
if (full) { if (full) {
long updateStart = RenderProfiler.start();
renderState.update(terminal); renderState.update(terminal);
RenderProfiler.stop(RenderProfiler.UPDATE, updateStart);
long marshalStart = RenderProfiler.start();
cachedSnapshot = renderState.snapshot(); cachedSnapshot = renderState.snapshot();
RenderProfiler.stop(RenderProfiler.MARSHAL, marshalStart);
renderState.resetDirty(); renderState.resetDirty();
snapshotVersion = version; snapshotVersion = version;
} else if (snapshotVersion != version) { } else if (snapshotVersion != version) {
long updateStart = RenderProfiler.start();
renderState.update(terminal); renderState.update(terminal);
RenderProfiler.stop(RenderProfiler.UPDATE, updateStart);
long marshalStart = RenderProfiler.start();
cachedSnapshot = renderState.snapshotIncremental(); cachedSnapshot = renderState.snapshotIncremental();
RenderProfiler.stop(RenderProfiler.MARSHAL, marshalStart);
renderState.resetDirty(); renderState.resetDirty();
snapshotVersion = version; snapshotVersion = version;
} }
@@ -254,45 +188,37 @@ public final class TerminalPane implements AutoCloseable, RenderTarget {
} }
} }
/** Best-effort current working directory of this pane's shell, or {@code null} if unknown. */
public String currentWorkingDirectory() {
ShellSession current = session;
return current != null ? current.currentWorkingDirectory() : null;
}
/** This pane's own content revision, bumped on every change (see {@link #refresh()}). */ /** This pane's own content revision, bumped on every change (see {@link #refresh()}). */
public long contentVersion() { public long contentVersion() {
return contentVersion.get(); return contentVersion.get();
} }
@Override long snapshotVersion() {
return snapshotVersion;
}
public boolean kittyEnabled() { public boolean kittyEnabled() {
return kittyEnabled; return kittyEnabled;
} }
@Override
public Optional<KittyGraphics> kittyGraphics() { public Optional<KittyGraphics> kittyGraphics() {
synchronized (terminal) { synchronized (terminal) {
return terminal.kittyGraphics(); return terminal.kittyGraphics();
} }
} }
@Override
public double x() { public double x() {
return x; return x;
} }
@Override
public double y() { public double y() {
return y; return y;
} }
@Override
public double width() { public double width() {
return width; return width;
} }
@Override
public double height() { public double height() {
return height; return height;
} }
@@ -304,16 +230,6 @@ public final class TerminalPane implements AutoCloseable, RenderTarget {
this.height = height; this.height = height;
} }
/** Set the clip region applied on the next paints (see {@link RenderTarget#clip()}). */
public void setClip(Shape clip) {
this.clip = clip;
}
@Override
public Shape clip() {
return clip;
}
/** Recompute the ghostty grid from the current bounds and the shared cell metrics. */ /** Recompute the ghostty grid from the current bounds and the shared cell metrics. */
public void fitToBounds() { public void fitToBounds() {
int columns = metrics.columnsFor(width); int columns = metrics.columnsFor(width);
@@ -350,26 +266,6 @@ public final class TerminalPane implements AutoCloseable, RenderTarget {
onContentChange.run(); onContentChange.run();
} }
/** Paint the whole pane; see {@link TerminalRenderer#paintFull}. */
public long paintFull(GraphicsContext gc, boolean active) {
renderer.paintFull(gc, this, active);
return snapshotVersion;
}
/** Repaint what changed; see {@link TerminalRenderer#paintIncremental}. */
public long paintIncremental(GraphicsContext gc, boolean active) {
renderer.paintIncremental(gc, this, active);
return snapshotVersion;
}
/**
* Kitty image placements from the most recent paint, in scene coordinates. The compositor
* renders these as overlay nodes clipped to this pane (see {@link KittyImageOverlay}).
*/
public java.util.List<KittyImageNode> kittyImages() {
return renderer.kittyImages();
}
@Override @Override
public void close() { public void close() {
if (session != null) { if (session != null) {

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,66 +0,0 @@
package com.gregor.jprototerm;
import java.lang.foreign.Arena;
import java.lang.foreign.FunctionDescriptor;
import java.lang.foreign.Linker;
import java.lang.foreign.MemorySegment;
import java.lang.foreign.SymbolLookup;
import java.lang.invoke.MethodHandle;
import static java.lang.foreign.ValueLayout.ADDRESS;
import static java.lang.foreign.ValueLayout.JAVA_INT;
import static java.lang.foreign.ValueLayout.JAVA_LONG;
/**
* Reads the X11 pointer location directly via libX11 ({@code XQueryPointer}). Unlike AWT's
* {@code MouseInfo}, this never calls {@code XSetErrorHandler}, so it doesn't trip GDK's
* "XSetErrorHandler called with a GDK error trap pushed" warning when JavaFX's GTK backend is
* already up. Returns {@code null} when not on X11 or libX11 can't be loaded.
*/
final class X11Pointer {
private X11Pointer() {
}
/** {@code {x, y}} of the pointer in X root-window (virtual screen) space, or {@code null}. */
static int[] query() {
try (Arena arena = Arena.ofConfined()) {
Linker linker = Linker.nativeLinker();
SymbolLookup x11 = SymbolLookup.libraryLookup("libX11.so.6", arena);
MethodHandle openDisplay = linker.downcallHandle(x11.find("XOpenDisplay").orElseThrow(),
FunctionDescriptor.of(ADDRESS, ADDRESS));
MethodHandle defaultRootWindow = linker.downcallHandle(x11.find("XDefaultRootWindow").orElseThrow(),
FunctionDescriptor.of(JAVA_LONG, ADDRESS));
MethodHandle queryPointer = linker.downcallHandle(x11.find("XQueryPointer").orElseThrow(),
FunctionDescriptor.of(JAVA_INT, ADDRESS, JAVA_LONG,
ADDRESS, ADDRESS, ADDRESS, ADDRESS, ADDRESS, ADDRESS, ADDRESS));
MethodHandle closeDisplay = linker.downcallHandle(x11.find("XCloseDisplay").orElseThrow(),
FunctionDescriptor.of(JAVA_INT, ADDRESS));
MemorySegment display = (MemorySegment) openDisplay.invoke(MemorySegment.NULL);
if (display.address() == 0) {
return null;
}
try {
long root = (long) defaultRootWindow.invoke(display);
MemorySegment rootReturn = arena.allocate(JAVA_LONG);
MemorySegment childReturn = arena.allocate(JAVA_LONG);
MemorySegment rootX = arena.allocate(JAVA_INT);
MemorySegment rootY = arena.allocate(JAVA_INT);
MemorySegment winX = arena.allocate(JAVA_INT);
MemorySegment winY = arena.allocate(JAVA_INT);
MemorySegment mask = arena.allocate(JAVA_INT);
int onSameScreen = (int) queryPointer.invoke(display, root,
rootReturn, childReturn, rootX, rootY, winX, winY, mask);
if (onSameScreen == 0) {
return null;
}
return new int[] { rootX.get(JAVA_INT, 0), rootY.get(JAVA_INT, 0) };
} finally {
closeDisplay.invoke(display);
}
} catch (Throwable ignored) {
// Not X11, libX11 missing, or the call failed — caller falls back to the primary screen.
return null;
}
}
}