36 Commits

Author SHA1 Message Date
44edff25d9 demo video 2026-06-01 14:42:23 +02:00
cc694a257a lfs conf 2026-06-01 14:42:04 +02:00
1895a48550 promote floating 2026-06-01 14:39:00 +02:00
40230dd8f7 new tab inherits cwd 2026-06-01 14:03:45 +02:00
a31cf06cbd new panes gain active pwd 2026-06-01 13:35:28 +02:00
6cf9afd664 remove unused devenv files 2026-06-01 13:31:20 +02:00
75cbea61dd paste 2026-06-01 03:47:16 +02:00
dbb5dc350b grap focus on launch 2026-06-01 03:30:45 +02:00
1776aa251a fix call xseterrorhandler while gdk error trap is up 2026-06-01 03:13:59 +02:00
0be3662a93 fix call xseterrorhandler while gdk error trap is up 2026-06-01 03:09:54 +02:00
8f70c4bf45 open on active monitor 2026-06-01 03:03:42 +02:00
6738051da1 fix null pointer access 2026-06-01 02:58:20 +02:00
65f69d5c75 remove dead code 2026-06-01 02:50:21 +02:00
85f2d86c09 hybrig image rendering 2026-06-01 02:45:46 +02:00
5f0edcbe31 try to fix graphics path 2026-06-01 02:18:01 +02:00
ebf87c0bff scrollback opens in floating pane 2026-06-01 00:46:28 +02:00
a51bee3b43 cleanup repo 2026-06-01 00:35:51 +02:00
aa5ca0451c Merge branch 'codex-performance-improvements' 2026-05-31 23:24:06 +02:00
8ac07218fe 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>
(cherry picked from commit 93d53fcef6)
2026-05-31 22:34:45 +02:00
6bf69e8572 update jlibghostty 2026-05-31 22:23:14 +02:00
07585a314c Upload only changed rows to GPU and hoist glyph bounds checks
paintIncremental's per-row dirty work was negated by present() calling
PixelBuffer.updateBuffer(null), which re-uploads the whole pane texture
every frame. Track the vertical band of buffer rows written since the
last present and hand that to updateBuffer so only changed rows upload.
The border is now drawn without extending the dirty band (its pixels are
unchanged between incremental frames). Also clamp blitGlyph's rectangle
once instead of bounds-checking every glyph pixel in the inner loop.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 22:12:45 +02:00
bdb33450f1 update jlibghostty 2026-05-31 21:51:57 +02:00
Gregor Lohaus
2c020bb6cb fix race condition 2026-05-31 18:12:44 +02:00
Gregor Lohaus
71a533ec34 clear context new fix 2026-05-31 18:05:57 +02:00
Gregor Lohaus
54b08c7eca revert failed fix 2026-05-31 18:00:49 +02:00
Gregor Lohaus
2fcdb286af Fixed the partial-dirty blanking regression 2026-05-31 17:59:26 +02:00
Gregor Lohaus
e6848ec684 revert failed fixed 2026-05-31 17:56:36 +02:00
Gregor Lohaus
38822d66b8 Fixed the partial-dirty blanking regression 2026-05-31 17:51:53 +02:00
Gregor Lohaus
586150de59 Fixed the partial-dirty blanking regression 2026-05-31 17:48:04 +02:00
Gregor Lohaus
494d2c40cf pixel buffer, scroll inference 2026-05-31 17:41:33 +02:00
Gregor Lohaus
a99cbdc61a revert row diffing 2026-05-31 17:20:13 +02:00
Gregor Lohaus
86f7174eee row diffing 2026-05-31 17:14:07 +02:00
Gregor Lohaus
137db24023 refert safe batching 2026-05-31 17:04:17 +02:00
Gregor Lohaus
d8faf8d6df safe batching 2026-05-31 17:02:44 +02:00
Gregor Lohaus
9903e9174f fix cell shifting regression 2026-05-31 16:58:11 +02:00
Gregor Lohaus
9b7247a4e0 small improvements 2026-05-31 16:50:12 +02:00
26 changed files with 1268 additions and 536 deletions

View File

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

View File

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

1
.gitattributes vendored Normal file
View File

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

6
.gitignore vendored
View File

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

4
.ignore Normal file
View File

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

View File

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

View File

@@ -1,13 +0,0 @@
arguments=--init-script /home/anon/Src/eclipse.jdt.ls/org.eclipse.jdt.ls.product/target/repository/configuration/org.eclipse.osgi/58/0/.cp/gradle/init/init.gradle
auto.sync=false
build.scans.enabled=false
connection.gradle.distribution=GRADLE_DISTRIBUTION(LOCAL_INSTALLATION(/home/anon/.sdkman/candidates/gradle/current))
connection.project.dir=
eclipse.preferences.version=1
gradle.user.home=
java.home=/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

@@ -30,3 +30,4 @@ next_floating = "ALT+F12"
close_pane = "ALT+X"
open_font_selector = "ALT+T"
open_scrollback = "ALT+S"
paste = "CTRL+SHIFT+V"

BIN
demo.mp4 LFS Normal file

Binary file not shown.

View File

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

View File

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

View File

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

8
flake.lock generated
View File

@@ -70,11 +70,11 @@
"nixpkgs": "nixpkgs"
},
"locked": {
"lastModified": 1780079529,
"narHash": "sha256-AxlGTL8c5xSLcQHvWlm994IdOqxsN8iKrA02Cpv7vso=",
"lastModified": 1780272954,
"narHash": "sha256-bVWY60iw8yPIu7I8FuRPf06T0H1TDvQDVUlzeHQs8UA=",
"ref": "refs/heads/main",
"rev": "68121d50b52fb56038871c97c97e7a12ffe987c2",
"revCount": 20,
"rev": "06a9d5d3ecf11c58f0e41214d1b59900e672dd3a",
"revCount": 24,
"type": "git",
"url": "https://gitea.gregorlohaus.com/gregor/jlibghostty.git"
},

View File

@@ -38,12 +38,14 @@ public record AppConfig(
"toggle_floating",
"new_pane",
"next_floating",
"promote_floating",
"close_pane",
"new_tab",
"previous_tab",
"next_tab",
"open_font_selector",
"open_scrollback"
"open_scrollback",
"paste"
);
public static AppConfig load() {
@@ -97,12 +99,14 @@ public record AppConfig(
Map.entry("toggle_floating", KeyBinding.parse("ALT+F")),
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("new_tab", KeyBinding.parse("ALT+A")),
Map.entry("previous_tab", KeyBinding.parse("ALT+SHIFT+H")),
Map.entry("next_tab", KeyBinding.parse("ALT+SHIFT+L")),
Map.entry("open_font_selector", KeyBinding.parse("ALT+T")),
Map.entry("open_scrollback", KeyBinding.parse("ALT+S"))
Map.entry("open_scrollback", KeyBinding.parse("ALT+S")),
Map.entry("paste", KeyBinding.parse("CTRL+SHIFT+V"))
)
);
}

View File

@@ -5,6 +5,7 @@ import dev.jlibghostty.MouseButton;
import dev.jlibghostty.MouseEncoderSize;
import dev.jlibghostty.MouseInput;
import javafx.geometry.VPos;
import javafx.scene.Node;
import javafx.scene.canvas.Canvas;
import javafx.scene.canvas.GraphicsContext;
import javafx.scene.input.InputEvent;
@@ -38,6 +39,8 @@ public final class Compositor {
private static final double TAB_BAR_HEIGHT = 22.0;
private final Canvas canvas = new Canvas();
// Kitty images are drawn as retained nodes layered over the canvas, not composited onto it.
private final KittyImageOverlay imageOverlay = new KittyImageOverlay();
private final AppConfig config;
private final TerminalMetrics metrics;
private final List<Tab> tabs = new ArrayList<>();
@@ -75,6 +78,11 @@ public final class Compositor {
return canvas;
}
/** 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) {
metrics.setFont(family, size);
paneContentVersion.clear();
@@ -113,6 +121,16 @@ public final class Compositor {
layoutVersion++;
}
/** Opens a new floating pane, makes it active, and returns it (null when no tab exists). */
public TerminalPane openFloatingPane() {
if (isEmpty()) {
return null;
}
TerminalPane pane = currentTab().createFloatingPane();
layoutVersion++;
return pane;
}
public void nextFloatingPane() {
if (isEmpty()) {
return;
@@ -121,6 +139,14 @@ public final class Compositor {
layoutVersion++;
}
public void promoteActiveFloating() {
if (isEmpty()) {
return;
}
currentTab().promoteActiveFloating();
layoutVersion++;
}
public void closeActivePane() {
if (isEmpty()) {
return;
@@ -138,7 +164,10 @@ public final class Compositor {
}
public void newTab() {
tabs.add(new Tab(config, metrics));
// Open the new tab in the currently active pane's working directory, so it lands where the
// user currently is rather than always in home.
String workingDirectory = isEmpty() ? null : currentTab().activePane().currentWorkingDirectory();
tabs.add(new Tab(config, metrics, workingDirectory));
currentTabIndex = tabs.size() - 1;
layoutVersion++;
}
@@ -244,9 +273,9 @@ public final class Compositor {
drawTabBar(gc, canvas.getWidth(), topInset);
}
for (TerminalPane pane : panes) {
pane.paintFull(gc, isActive(pane));
paneContentVersion.put(pane, pane.contentVersion());
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
@@ -262,8 +291,8 @@ public final class Compositor {
if (drawn != null && drawn == pane.contentVersion()) {
continue;
}
pane.paintIncremental(gc, isActive(pane));
paneContentVersion.put(pane, pane.contentVersion());
paneContentVersion.put(pane, pane.paintIncremental(gc, isActive(pane)));
imageOverlay.updatePane(pane);
}
}
@@ -384,7 +413,10 @@ public final class Compositor {
double ey = localY(event.getY(), pane, target);
KeyModifiers modifiers = modifiers(event);
for (int i = 0; i < rows; i++) {
sent |= send(pane, target, MouseInput.press(wheelButton, ex, ey, modifiers), mouseButtonPressed, event);
if (!send(pane, target, MouseInput.press(wheelButton, ex, ey, modifiers), mouseButtonPressed, event)) {
break;
}
sent = true;
}
}
if (!sent) {

View File

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

View File

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

View File

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

View File

@@ -9,6 +9,9 @@ import java.lang.foreign.MemorySegment;
import java.lang.foreign.SymbolLookup;
import java.lang.foreign.ValueLayout;
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.List;
import java.util.Map;
@@ -74,6 +77,7 @@ public final class LinuxPty implements AutoCloseable {
private static final long SPAWN_ACTIONS_SIZE = 256;
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 GRANTPT = handle("grantpt", FD_INT_INT);
private static final MethodHandle UNLOCKPT = handle("unlockpt", FD_INT_INT);
@@ -97,6 +101,7 @@ public final class LinuxPty implements AutoCloseable {
private final Arena arena = Arena.ofShared();
private final MemorySegment readBuffer = arena.allocate(65536);
private final MemorySegment writeBuffer = arena.allocate(65536);
private final Object writeLock = new Object();
private final int masterFd;
private final int pid;
@@ -186,21 +191,43 @@ public final class LinuxPty implements AutoCloseable {
return;
}
synchronized (writeLock) {
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) {
long n = callLong(WRITE, masterFd, buf.asSlice(offset), data.length - offset);
if (n < 0) {
int offset = 0;
while (offset < data.length) {
int chunk = (int) Math.min(writeBuffer.byteSize(), data.length - offset);
MemorySegment.copy(data, offset, writeBuffer, ValueLayout.JAVA_BYTE, 0, chunk);
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");
}
offset += n;
written += 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. */
public void setWinSize(int columns, int rows) {
if (closed) {

View File

@@ -3,6 +3,7 @@ package com.gregor.jprototerm;
import javafx.animation.AnimationTimer;
import javafx.application.Application;
import javafx.application.Platform;
import javafx.geometry.Rectangle2D;
import javafx.scene.Scene;
import javafx.scene.control.ButtonType;
import javafx.scene.control.ComboBox;
@@ -10,15 +11,18 @@ import javafx.scene.control.Dialog;
import javafx.scene.control.Label;
import javafx.scene.control.Spinner;
import javafx.scene.control.SpinnerValueFactory;
import javafx.scene.input.Clipboard;
import javafx.scene.input.KeyEvent;
import javafx.scene.layout.GridPane;
import javafx.scene.layout.StackPane;
import javafx.scene.text.Font;
import javafx.stage.Screen;
import javafx.stage.Stage;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
public final class Main extends Application {
private Compositor compositor;
@@ -32,7 +36,7 @@ public final class Main extends Application {
metrics = new TerminalMetrics(config.fontFamily(), config.fontSize());
compositor = new Compositor(config, metrics);
StackPane root = new StackPane(compositor.canvas());
StackPane root = new StackPane(compositor.canvas(), compositor.imageOverlay());
compositor.canvas().widthProperty().bind(root.widthProperty());
compositor.canvas().heightProperty().bind(root.heightProperty());
@@ -52,10 +56,37 @@ public final class Main extends Application {
stage.setOnCloseRequest(event -> {
compositor.close();
});
// JavaFX centres a new stage on the primary screen; on X11 there's no "focused monitor"
// to honour, so place it on the screen under the mouse pointer instead.
centreOnActiveScreen(stage, config.windowWidth(), config.windowHeight());
stage.show();
// Ask the window manager to raise and focus the new window so the user can type right
// away; the canvas requestFocus() below only routes events within the scene.
stage.toFront();
stage.requestFocus();
compositor.canvas().requestFocus();
}
// Centre the stage within the screen the mouse pointer is on (the best proxy for the
// "active" monitor on X11, which exposes no focused-monitor concept to JavaFX).
private static void centreOnActiveScreen(Stage stage, double width, double height) {
Rectangle2D bounds = activeScreen().getVisualBounds();
stage.setX(bounds.getMinX() + ((bounds.getWidth() - width) / 2.0));
stage.setY(bounds.getMinY() + ((bounds.getHeight() - height) / 2.0));
}
private static Screen activeScreen() {
int[] at = X11Pointer.query();
if (at != null) {
// libX11 and JavaFX share a coordinate space on the X11 virtual screen.
List<Screen> screens = Screen.getScreensForRectangle(at[0], at[1], 1.0, 1.0);
if (!screens.isEmpty()) {
return screens.get(0);
}
}
return Screen.getPrimary();
}
private void handlePressed(KeyEvent event) {
if (config.keybindings().get("navigate_left").matches(event)) {
compositor.navigate(Direction.LEFT);
@@ -78,6 +109,9 @@ public final class Main extends Application {
} 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)) {
compositor.closeActivePane();
event.consume();
@@ -101,6 +135,9 @@ public final class Main extends Application {
} else if (config.keybindings().get("open_scrollback").matches(event)) {
openScrollbackInEditor();
event.consume();
} else if (config.keybindings().get("paste").matches(event)) {
pasteFromClipboard();
event.consume();
} else {
String encoded = KeyEncoder.encode(event);
if (encoded != null) {
@@ -122,6 +159,13 @@ public final class Main extends Application {
}
}
private void pasteFromClipboard() {
Clipboard clipboard = Clipboard.getSystemClipboard();
if (clipboard.hasString()) {
compositor.activePane().paste(clipboard.getString());
}
}
private void openFontSelector() {
Dialog<ButtonType> dialog = new Dialog<>();
dialog.setTitle("Font");
@@ -167,11 +211,16 @@ public final class Main extends Application {
private void openScrollbackInEditor() {
try {
// Capture the active pane's scrollback before opening the floating pane, since that
// makes the new pane active.
Path file = Files.createTempFile("jprototerm-scrollback-", ".txt");
Files.writeString(file, compositor.activePane().scrollbackText());
file.toFile().deleteOnExit();
compositor.activePane().send(scrollbackEditorCommand(file) + "\r");
TerminalPane pane = compositor.openFloatingPane();
if (pane != null) {
pane.send(scrollbackEditorCommand(file) + "\r");
}
} catch (IOException ex) {
System.err.println("Could not open scrollback in editor: " + ex.getMessage());
}

View File

@@ -20,7 +20,8 @@ public final class ShellSession implements AutoCloseable {
});
}
public static ShellSession start(String shell, Map<String, String> envOverride, TerminalPane pane, int columns, int rows) {
public static ShellSession start(String shell, Map<String, String> envOverride, TerminalPane pane,
int columns, int rows, String workingDirectory) {
try {
Map<String, String> environment = new HashMap<>(System.getenv());
environment.put("TERM", "xterm-kitty");
@@ -31,7 +32,7 @@ public final class ShellSession implements AutoCloseable {
LinuxPty pty = LinuxPty.spawn(
new String[] {shell, "-i"},
environment,
System.getProperty("user.home"));
workingDirectory != null ? workingDirectory : System.getProperty("user.home"));
ShellSession session = new ShellSession(pty);
session.resize(columns, rows);
return session;
@@ -69,6 +70,11 @@ public final class ShellSession implements AutoCloseable {
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) {
if (closed) {
return;

View File

@@ -6,6 +6,7 @@ import javafx.scene.shape.Shape;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.concurrent.atomic.AtomicLong;
import java.util.stream.Stream;
/**
@@ -22,6 +23,7 @@ final class Tab implements AutoCloseable {
private final List<TerminalPane> floating = new ArrayList<>();
private boolean floatingVisible;
private TerminalPane active;
private final String initialWorkingDirectory;
// The floating pane to re-focus when the group is shown again, and to prefer when promoting
// after the last tiled pane closes.
private TerminalPane lastFocusedFloating;
@@ -33,13 +35,22 @@ final class Tab implements AutoCloseable {
private double lastTopInset;
// 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.
private long contentVersion;
private final AtomicLong contentVersion = new AtomicLong();
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.metrics = metrics;
this.lastWidth = config.windowWidth();
this.lastHeight = config.windowHeight();
this.initialWorkingDirectory = initialWorkingDirectory;
TerminalPane first = openPane(false);
tiled.add(first);
active = first;
@@ -54,7 +65,7 @@ final class Tab implements AutoCloseable {
}
long contentVersion() {
return contentVersion;
return contentVersion.get();
}
/**
@@ -211,6 +222,22 @@ final class Tab implements AutoCloseable {
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() {
TerminalPane closing = active;
boolean wasFloating = floating.remove(closing);
@@ -256,11 +283,12 @@ final class Tab implements AutoCloseable {
}
}
private void createFloatingPane() {
TerminalPane createFloatingPane() {
TerminalPane pane = openPane(true);
floating.add(pane);
floatingVisible = true;
setActive(pane);
return pane;
}
private boolean navigateFloatingStack(Direction direction) {
@@ -291,7 +319,7 @@ final class Tab implements AutoCloseable {
}
private void markContentChanged() {
contentVersion++;
contentVersion.incrementAndGet();
}
private TerminalPane openPane(boolean asFloating) {
@@ -306,7 +334,11 @@ final class Tab implements AutoCloseable {
widthPx = lastWidth / (tiled.size() + 1);
heightPx = availHeight;
}
return TerminalPane.create(config, metrics, this::markContentChanged, widthPx, heightPx);
// Open the 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.
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) {

View File

@@ -16,6 +16,7 @@ import javafx.scene.canvas.GraphicsContext;
import javafx.scene.shape.Shape;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicLong;
/**
* One terminal: owns its ghostty {@link Terminal}, the {@link ShellSession}/pty driving it,
@@ -49,7 +50,7 @@ public final class TerminalPane implements AutoCloseable, RenderTarget {
private int rows;
private int pixelWidth;
private int pixelHeight;
private long contentVersion;
private final AtomicLong contentVersion = new AtomicLong();
private long snapshotVersion = -1;
private TerminalPane(Terminal terminal, TerminalMetrics metrics, boolean kittyEnabled,
@@ -68,9 +69,11 @@ 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
* 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
* {@code onContentChange} on every content change.
* {@code onContentChange} on every content change. The shell starts in {@code workingDirectory}
* (e.g. the active pane's cwd), or the user's home when {@code null}.
*/
public static TerminalPane create(AppConfig config, TerminalMetrics metrics, Runnable onContentChange, double widthPx, double heightPx) {
public static TerminalPane create(AppConfig config, TerminalMetrics metrics, Runnable onContentChange,
double widthPx, double heightPx, String workingDirectory) {
int columns = widthPx > 0 ? metrics.columnsFor(widthPx) : config.columns();
int rows = heightPx > 0 ? metrics.rowsFor(heightPx) : config.rows();
Terminal terminal = Ghostty.open(new TerminalOptions(columns, rows, config.maxScrollback()));
@@ -78,7 +81,7 @@ public final class TerminalPane implements AutoCloseable, RenderTarget {
TerminalPane pane = new TerminalPane(terminal, metrics, config.kittyGraphics(), onContentChange,
new GhosttyTerminalRenderer(metrics), columns, rows);
pane.refresh();
pane.attach(ShellSession.start(config.shell(), config.envOverride(), pane, columns, rows));
pane.attach(ShellSession.start(config.shell(), config.envOverride(), pane, columns, rows, workingDirectory));
return pane;
}
@@ -114,6 +117,19 @@ public final class TerminalPane implements AutoCloseable, RenderTarget {
}
}
/**
* Paste text to the shell: ghostty sanitises it (stripping anything that could smuggle in
* control sequences) and wraps it in the bracketed-paste markers, then it goes to the pty
* like typed input. We always request bracketed mode — every modern shell and editor enables
* DECSET 2004, and the jlibghostty API does not expose querying the terminal's live mode.
*/
public void paste(String text) {
if (text == null || text.isEmpty()) {
return;
}
send(Ghostty.encodePaste(text, true));
}
public boolean sendMouse(MouseInput input, MouseEncoderSize size, boolean anyButtonPressed) {
synchronized (terminal) {
mouseEncoder.syncFromTerminal(terminal);
@@ -169,16 +185,17 @@ public final class TerminalPane implements AutoCloseable, RenderTarget {
private RenderStateSnapshot takeSnapshot(boolean full) {
synchronized (terminal) {
long version = contentVersion.get();
if (full) {
renderState.update(terminal);
cachedSnapshot = renderState.snapshot();
renderState.resetDirty();
snapshotVersion = contentVersion;
} else if (snapshotVersion != contentVersion) {
snapshotVersion = version;
} else if (snapshotVersion != version) {
renderState.update(terminal);
cachedSnapshot = renderState.snapshotIncremental();
renderState.resetDirty();
snapshotVersion = contentVersion;
snapshotVersion = version;
}
return cachedSnapshot;
}
@@ -190,9 +207,15 @@ 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()}). */
public long contentVersion() {
return contentVersion;
return contentVersion.get();
}
@Override
@@ -276,18 +299,28 @@ public final class TerminalPane implements AutoCloseable, RenderTarget {
// Mark this pane's content dirty (the snapshot is computed lazily in the paint path,
// so a burst of writes collapses into one snapshot per frame) and tell the owning tab
// one of its panes changed.
contentVersion++;
contentVersion.incrementAndGet();
onContentChange.run();
}
/** Paint the whole pane; see {@link TerminalRenderer#paintFull}. */
public void paintFull(GraphicsContext gc, boolean active) {
public long paintFull(GraphicsContext gc, boolean active) {
renderer.paintFull(gc, this, active);
return snapshotVersion;
}
/** Repaint what changed; see {@link TerminalRenderer#paintIncremental}. */
public void paintIncremental(GraphicsContext gc, boolean active) {
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

View File

@@ -25,6 +25,14 @@ abstract class TerminalRenderer {
/** 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);

View File

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