28 Commits

Author SHA1 Message Date
897a76d8cf cleanup repo 2026-06-01 00:26:01 +02:00
5799c800d3 untrack 2026-06-01 00:25:16 +02:00
cc9ac43ffa remove obsolete full-row-repaint debug toggle; document diagnostics
The fullRowRepaint toggle existed only to bisect the black-bar repaint artifact,
which is now fixed, so drop it. Document the two remaining render-debug flags
(profile, debugRepaint) in the README.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 22:31:50 +02:00
93d53fcef6 send backtab (ESC [ Z) for Shift+Tab
KeyEncoder mapped TAB to a plain tab regardless of Shift, so Shift+Tab sent the
same byte as Tab. Apps that use backtab for reverse navigation (fish completion
menu, helix theme picker) never saw it. Emit CSI Z when Shift is held.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 22:27:54 +02:00
b0ec6c7014 update jlibghostty 2026-05-31 22:20:56 +02:00
3c913fefd3 fix black seam bars: opaque base fill instead of clearRect
The persistent black bars in the partial-repaint path were clearRect leaving
the run's fractional edge pixels transparent, which showed the near-black pane
background as a seam against the adjacent un-repainted line. Confirmed with the
debugRepaint toggle: filling the span opaque removed the bars entirely.

Fill the repaint run with PANE_BACKGROUND (the default cell background) instead
of clearing to transparent; per-cell backgrounds paint over it as before. Safe
because the per-column path never runs while kitty graphics are present (those
force a full render), so no below-text image needs a transparent row canvas.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 22:08:57 +02:00
263bcf36b7 add debugRepaint toggle that fills cleared spans red
Diagnostic for the persistent black bars: fills each repaint run's cleared span
red instead of clearing to transparent. If the bars turn red they are spans
repaintColumns clears but never refills; if they stay black those pixels are
never touched by the per-column repaint and the cause is elsewhere.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 22:02:41 +02:00
8e060b27ca Revert "snap repaint clear/fill to integer pixels to kill seam bars"
This reverts commit 6613f1f746.
2026-05-31 21:58:15 +02:00
6613f1f746 snap repaint clear/fill to integer pixels to kill seam bars
cellWidth is fractional, so in repaintColumns the clearRect cleared a run's
edge pixel to full transparency while the background fillRect covered that same
pixel only partially (antialiased), leaving a ~1px part-transparent column that
showed the near-black pane background as a thin bar 1-2 cells before the cursor.
Full-row repaint hid it because the highlighted line is then one contiguous
fill with no internal junction.

Route the clear and the background fills through a shared columnX() that rounds
each column boundary to a whole device pixel, so run edges land on integer
pixels with full single coverage and adjacent runs tile seamlessly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 21:56:01 +02:00
5728733f5f worktrees ignores 2026-05-31 21:52:39 +02:00
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
26 changed files with 1479 additions and 2027 deletions

1
.gitattributes vendored
View File

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

6
.gitignore vendored
View File

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

View File

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

View File

@@ -106,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"
@@ -121,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
@@ -134,3 +132,22 @@ open_scrollback = "ALT+S"
Each tab has its own stack of tiled and floating panes; only the active tab is rendered. A Each tab has its own stack of tiled and floating panes; only the active tab is rendered. A
thin tab bar appears at the top when more than one tab is open. Closing the last tiled pane thin tab bar appears at the top when more than one tab is open. Closing the last tiled pane
while floating panes exist promotes the most recently active floating pane to a tiled pane. while floating panes exist promotes the most recently active floating pane to a tiled pane.
## Diagnostics
Two render-debugging flags are off by default and add no overhead unless enabled. Pass them
as JVM system properties (each also has an environment-variable equivalent). With the
packaged binary the JVM picks them up from `JDK_JAVA_OPTIONS`:
```sh
JDK_JAVA_OPTIONS="-Djprototerm.profile=true" ./result/bin/jprototerm
```
- `-Djprototerm.profile=true` (or `JPROTOTERM_PROFILE=1`): print a `[render-profile]` line to
stderr every N renders with the per-frame cost of each render stage — `snapshot` (terminal
state marshalling), `fingerprint` (change detection), `draw` (canvas painting), and the
`frame-total`. Set `-Djprototerm.profile.frames=<N>` to change the dump interval (default
120).
- `-Djprototerm.debugRepaint=true` (or `JPROTOTERM_DEBUG_REPAINT=1`): paint each per-column
repaint run's cleared span red instead of clearing it. Repainted regions flash red, so you
can see exactly which cells are being redrawn each frame.

View File

@@ -1,2 +0,0 @@
jlibghostty - why downcall metadata not propagated ?
jlibghostty - how need to change flake so consuming flakes dont have to depend on same ghostty flake ?

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": 1780272954, "lastModified": 1780258814,
"narHash": "sha256-bVWY60iw8yPIu7I8FuRPf06T0H1TDvQDVUlzeHQs8UA=", "narHash": "sha256-8rxL7xaZ/loYg3zdt0w5+hfNyHFVknDZN360NzrtCsQ=",
"ref": "refs/heads/main", "ref": "refs/heads/main",
"rev": "06a9d5d3ecf11c58f0e41214d1b59900e672dd3a", "rev": "6a3d5aa0b0b1f738c958e2a2f0249574c07d9c4d",
"revCount": 24, "revCount": 23,
"type": "git", "type": "git",
"url": "https://gitea.gregorlohaus.com/gregor/jlibghostty.git" "url": "https://gitea.gregorlohaus.com/gregor/jlibghostty.git"
}, },

View File

@@ -37,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() {
@@ -98,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"))
) )
); );
} }

View File

@@ -4,60 +4,52 @@ 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;
@@ -66,27 +58,25 @@ public final class Compositor {
this.config = config; this.config = config;
this.metrics = metrics; this.metrics = metrics;
tabs.add(new Tab(config, metrics)); 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;
} }
/** The kitty-image overlay, to be stacked directly above {@link #canvas()} in the window. */ public void requestFocus() {
public Node imageOverlay() { root.requestFocus();
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 -------------------------------------------------------------
@@ -101,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();
} }
} }
@@ -110,7 +100,7 @@ public final class Compositor {
return; return;
} }
currentTab().toggleFloating(); currentTab().toggleFloating();
layoutVersion++; markSceneDirty();
} }
public void createPane() { public void createPane() {
@@ -118,33 +108,7 @@ 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;
}
public void nextFloatingPane() {
if (isEmpty()) {
return;
}
currentTab().nextFloatingPane();
layoutVersion++;
}
public void promoteActiveFloating() {
if (isEmpty()) {
return;
}
currentTab().promoteActiveFloating();
layoutVersion++;
} }
public void closeActivePane() { public void closeActivePane() {
@@ -153,36 +117,31 @@ public final class Compositor {
} }
currentTab().closeActivePane(); currentTab().closeActivePane();
if (currentTab().isEmpty()) { if (currentTab().isEmpty()) {
// Closing a tab's last pane closes the tab. When no tabs remain the surface is
// empty and Main quits.
tabs.remove(currentTabIndex); tabs.remove(currentTabIndex);
if (currentTabIndex >= tabs.size()) { if (currentTabIndex >= tabs.size()) {
currentTabIndex = Math.max(0, tabs.size() - 1); currentTabIndex = Math.max(0, tabs.size() - 1);
} }
} }
layoutVersion++; markSceneDirty();
} }
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));
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();
} }
} }
@@ -191,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() {
@@ -201,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;
@@ -345,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) {
@@ -408,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)) {
@@ -420,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) {
@@ -436,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;
@@ -461,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) {
@@ -516,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

@@ -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;
@@ -36,11 +31,7 @@ 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);
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));
@@ -56,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) {
@@ -106,12 +70,6 @@ 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)) {
compositor.closeActivePane(); compositor.closeActivePane();
event.consume(); event.consume();
@@ -135,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) {
@@ -159,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");
@@ -205,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();
TerminalPane pane = compositor.openFloatingPane(); compositor.activePane().send(scrollbackEditorCommand(file) + "\r");
if (pane != null) {
pane.send(scrollbackEditorCommand(file) + "\r");
}
} 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

@@ -20,8 +20,7 @@ public final class ShellSession implements AutoCloseable {
}); });
} }
public static ShellSession start(String shell, Map<String, String> envOverride, TerminalPane pane, public static ShellSession start(String shell, Map<String, String> envOverride, TerminalPane pane, int columns, int rows) {
int columns, int rows, String workingDirectory) {
try { try {
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");
@@ -32,7 +31,7 @@ public final class ShellSession implements AutoCloseable {
LinuxPty pty = LinuxPty.spawn( LinuxPty pty = LinuxPty.spawn(
new String[] {shell, "-i"}, 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;
@@ -70,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;

View File

@@ -1,12 +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.stream.Stream; import java.util.stream.Stream;
/** /**
@@ -23,7 +19,6 @@ final class Tab implements AutoCloseable {
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;
@@ -35,22 +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) { Tab(AppConfig config, TerminalMetrics metrics) {
this(config, metrics, null);
}
/**
* 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) {
this.config = config; this.config = config;
this.metrics = metrics; this.metrics = metrics;
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;
@@ -65,7 +51,7 @@ final class Tab implements AutoCloseable {
} }
long contentVersion() { long contentVersion() {
return contentVersion.get(); return contentVersion;
} }
/** /**
@@ -89,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;
} }
@@ -122,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) {
@@ -212,32 +160,6 @@ 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() {
TerminalPane closing = active; TerminalPane closing = active;
boolean wasFloating = floating.remove(closing); boolean wasFloating = floating.remove(closing);
@@ -283,12 +205,11 @@ final class Tab implements AutoCloseable {
} }
} }
TerminalPane createFloatingPane() { private void createFloatingPane() {
TerminalPane pane = openPane(true); TerminalPane pane = openPane(true);
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) {
@@ -319,7 +240,7 @@ final class Tab implements AutoCloseable {
} }
private void markContentChanged() { private void markContentChanged() {
contentVersion.incrementAndGet(); contentVersion++;
} }
private TerminalPane openPane(boolean asFloating) { private TerminalPane openPane(boolean asFloating) {
@@ -334,11 +255,7 @@ final class Tab implements AutoCloseable {
widthPx = lastWidth / (tiled.size() + 1); widthPx = lastWidth / (tiled.size() + 1);
heightPx = availHeight; heightPx = availHeight;
} }
// Open the new pane in the active pane's working directory, so a split/new pane lands return TerminalPane.create(config, metrics, this::markContentChanged, widthPx, heightPx);
// 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.
String workingDirectory = active != null ? active.currentWorkingDirectory() : initialWorkingDirectory;
return TerminalPane.create(config, metrics, this::markContentChanged, widthPx, heightPx, workingDirectory);
} }
private static boolean directionFilter(Direction direction, TerminalPane current, TerminalPane candidate) { private static boolean directionFilter(Direction direction, TerminalPane current, TerminalPane candidate) {

View File

@@ -12,36 +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.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;
// 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;
@@ -51,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;
} }
@@ -69,19 +60,16 @@ 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) {
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, workingDirectory)); pane.attach(ShellSession.start(config.shell(), config.envOverride(), pane, columns, rows));
return pane; return pane;
} }
@@ -117,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);
@@ -169,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);
} }
@@ -178,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);
} }
@@ -187,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;
} }
@@ -207,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;
} }
@@ -257,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);
@@ -303,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;
}
}
}