Compare commits
36 Commits
test-new-j
...
44edff25d9
| Author | SHA1 | Date | |
|---|---|---|---|
| 44edff25d9 | |||
| cc694a257a | |||
| 1895a48550 | |||
| 40230dd8f7 | |||
| a31cf06cbd | |||
| 6cf9afd664 | |||
| 75cbea61dd | |||
| dbb5dc350b | |||
| 1776aa251a | |||
| 0be3662a93 | |||
| 8f70c4bf45 | |||
| 6738051da1 | |||
| 65f69d5c75 | |||
| 85f2d86c09 | |||
| 5f0edcbe31 | |||
| ebf87c0bff | |||
| a51bee3b43 | |||
| aa5ca0451c | |||
| 8ac07218fe | |||
| 6bf69e8572 | |||
| 07585a314c | |||
| bdb33450f1 | |||
|
|
2c020bb6cb | ||
|
|
71a533ec34 | ||
|
|
54b08c7eca | ||
|
|
2fcdb286af | ||
|
|
e6848ec684 | ||
|
|
38822d66b8 | ||
|
|
586150de59 | ||
|
|
494d2c40cf | ||
|
|
a99cbdc61a | ||
|
|
86f7174eee | ||
|
|
137db24023 | ||
|
|
d8faf8d6df | ||
|
|
9903e9174f | ||
|
|
9b7247a4e0 |
18
.classpath
18
.classpath
@@ -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>
|
||||
@@ -1 +0,0 @@
|
||||
019e6999-b7c8-7591-a8aa-ea51b89a7f7e
|
||||
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
@@ -0,0 +1 @@
|
||||
*.mp4 filter=lfs diff=lfs merge=lfs -text
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -11,6 +11,10 @@ devenv.local.yaml
|
||||
# pre-commit
|
||||
.pre-commit-config.yaml
|
||||
build
|
||||
build
|
||||
.gradle
|
||||
bin
|
||||
.settings
|
||||
.project
|
||||
.worktrees
|
||||
.classpath
|
||||
.codexsession
|
||||
|
||||
34
.project
34
.project
@@ -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>
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
65
devenv.lock
65
devenv.lock
@@ -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
|
||||
}
|
||||
66
devenv.nix
66
devenv.nix
@@ -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
|
||||
'';
|
||||
}
|
||||
18
devenv.yaml
18
devenv.yaml
@@ -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
8
flake.lock
generated
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"))
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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";
|
||||
|
||||
32
src/main/java/com/gregor/jprototerm/KittyImageNode.java
Normal file
32
src/main/java/com/gregor/jprototerm/KittyImageNode.java
Normal 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);
|
||||
}
|
||||
}
|
||||
151
src/main/java/com/gregor/jprototerm/KittyImageOverlay.java
Normal file
151
src/main/java/com/gregor/jprototerm/KittyImageOverlay.java
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,19 +191,41 @@ 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;
|
||||
int offset = 0;
|
||||
while (offset < data.length) {
|
||||
long n = callLong(WRITE, masterFd, buf.asSlice(offset), data.length - offset);
|
||||
if (n < 0) {
|
||||
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. */
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
66
src/main/java/com/gregor/jprototerm/X11Pointer.java
Normal file
66
src/main/java/com/gregor/jprototerm/X11Pointer.java
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user