Compare commits
11 Commits
44edff25d9
...
scene-grap
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50641d0a6a | ||
|
|
51f64e7ca8 | ||
|
|
528afafcda | ||
|
|
093a09da39 | ||
|
|
59ab33bc01 | ||
|
|
d8447d9e29 | ||
|
|
dba6474491 | ||
|
|
743f312921 | ||
|
|
3054b3ec77 | ||
|
|
2bcaf951df | ||
|
|
beba14c3ea |
18
.classpath
Normal file
18
.classpath
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<classpath>
|
||||||
|
<classpathentry kind="src" output="bin/main" path="src/main/java">
|
||||||
|
<attributes>
|
||||||
|
<attribute name="gradle_scope" value="main"/>
|
||||||
|
<attribute name="gradle_used_by_scope" value="main,test"/>
|
||||||
|
</attributes>
|
||||||
|
</classpathentry>
|
||||||
|
<classpathentry kind="src" output="bin/main" path="src/main/resources">
|
||||||
|
<attributes>
|
||||||
|
<attribute name="gradle_scope" value="main"/>
|
||||||
|
<attribute name="gradle_used_by_scope" value="main,test"/>
|
||||||
|
</attributes>
|
||||||
|
</classpathentry>
|
||||||
|
<classpathentry kind="con" path="org.eclipse.jdt.launching.JRE_CONTAINER/org.eclipse.jdt.internal.debug.ui.launcher.StandardVMType/JavaSE-21/"/>
|
||||||
|
<classpathentry kind="con" path="org.eclipse.buildship.core.gradleclasspathcontainer"/>
|
||||||
|
<classpathentry kind="output" path="bin/default"/>
|
||||||
|
</classpath>
|
||||||
1
.codexsession
Normal file
1
.codexsession
Normal file
@@ -0,0 +1 @@
|
|||||||
|
019e6999-b7c8-7591-a8aa-ea51b89a7f7e
|
||||||
1
.gitattributes
vendored
1
.gitattributes
vendored
@@ -1 +0,0 @@
|
|||||||
*.mp4 filter=lfs diff=lfs merge=lfs -text
|
|
||||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -11,10 +11,6 @@ devenv.local.yaml
|
|||||||
# pre-commit
|
# pre-commit
|
||||||
.pre-commit-config.yaml
|
.pre-commit-config.yaml
|
||||||
build
|
build
|
||||||
|
build
|
||||||
.gradle
|
.gradle
|
||||||
bin
|
bin
|
||||||
.settings
|
|
||||||
.project
|
|
||||||
.worktrees
|
|
||||||
.classpath
|
|
||||||
.codexsession
|
|
||||||
|
|||||||
34
.project
Normal file
34
.project
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<projectDescription>
|
||||||
|
<name>jprototerm</name>
|
||||||
|
<comment>Project jprototerm created by Buildship.</comment>
|
||||||
|
<projects>
|
||||||
|
</projects>
|
||||||
|
<buildSpec>
|
||||||
|
<buildCommand>
|
||||||
|
<name>org.eclipse.jdt.core.javabuilder</name>
|
||||||
|
<arguments>
|
||||||
|
</arguments>
|
||||||
|
</buildCommand>
|
||||||
|
<buildCommand>
|
||||||
|
<name>org.eclipse.buildship.core.gradleprojectbuilder</name>
|
||||||
|
<arguments>
|
||||||
|
</arguments>
|
||||||
|
</buildCommand>
|
||||||
|
</buildSpec>
|
||||||
|
<natures>
|
||||||
|
<nature>org.eclipse.jdt.core.javanature</nature>
|
||||||
|
<nature>org.eclipse.buildship.core.gradleprojectnature</nature>
|
||||||
|
</natures>
|
||||||
|
<filteredResources>
|
||||||
|
<filter>
|
||||||
|
<id>1779917652126</id>
|
||||||
|
<name></name>
|
||||||
|
<type>30</type>
|
||||||
|
<matcher>
|
||||||
|
<id>org.eclipse.core.resources.regexFilterMatcher</id>
|
||||||
|
<arguments>node_modules|\.git|__CREATED_BY_JAVA_LANGUAGE_SERVER__</arguments>
|
||||||
|
</matcher>
|
||||||
|
</filter>
|
||||||
|
</filteredResources>
|
||||||
|
</projectDescription>
|
||||||
13
.settings/org.eclipse.buildship.core.prefs
Normal file
13
.settings/org.eclipse.buildship.core.prefs
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
arguments=--init-script /home/anon/Src/eclipse.jdt.ls/org.eclipse.jdt.ls.product/target/repository/configuration/org.eclipse.osgi/58/0/.cp/gradle/init/init.gradle
|
||||||
|
auto.sync=false
|
||||||
|
build.scans.enabled=false
|
||||||
|
connection.gradle.distribution=GRADLE_DISTRIBUTION(LOCAL_INSTALLATION(/home/anon/.sdkman/candidates/gradle/current))
|
||||||
|
connection.project.dir=
|
||||||
|
eclipse.preferences.version=1
|
||||||
|
gradle.user.home=
|
||||||
|
java.home=/nix/store/c3pl7bqrx3d2rc3dh98z6yaj0mv1p52g-openjdk-21.0.10+7/lib/openjdk
|
||||||
|
jvm.arguments=
|
||||||
|
offline.mode=false
|
||||||
|
override.workspace.settings=true
|
||||||
|
show.console.view=true
|
||||||
|
show.executions.view=true
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
|
||||||
|
|||||||
65
devenv.lock
Normal file
65
devenv.lock
Normal 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
66
devenv.nix
Normal 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
18
devenv.yaml
Normal 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
8
flake.lock
generated
@@ -70,11 +70,11 @@
|
|||||||
"nixpkgs": "nixpkgs"
|
"nixpkgs": "nixpkgs"
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1780272954,
|
"lastModified": 1780079529,
|
||||||
"narHash": "sha256-bVWY60iw8yPIu7I8FuRPf06T0H1TDvQDVUlzeHQs8UA=",
|
"narHash": "sha256-AxlGTL8c5xSLcQHvWlm994IdOqxsN8iKrA02Cpv7vso=",
|
||||||
"ref": "refs/heads/main",
|
"ref": "refs/heads/main",
|
||||||
"rev": "06a9d5d3ecf11c58f0e41214d1b59900e672dd3a",
|
"rev": "68121d50b52fb56038871c97c97e7a12ffe987c2",
|
||||||
"revCount": 24,
|
"revCount": 20,
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://gitea.gregorlohaus.com/gregor/jlibghostty.git"
|
"url": "https://gitea.gregorlohaus.com/gregor/jlibghostty.git"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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"))
|
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -26,7 +26,7 @@ final class KeyEncoder {
|
|||||||
return switch (code) {
|
return switch (code) {
|
||||||
case ENTER -> "\r";
|
case ENTER -> "\r";
|
||||||
case BACK_SPACE -> "\u007f";
|
case BACK_SPACE -> "\u007f";
|
||||||
case TAB -> event.isShiftDown() ? "\u001b[Z" : "\t";
|
case TAB -> "\t";
|
||||||
case ESCAPE -> "\u001b";
|
case ESCAPE -> "\u001b";
|
||||||
case UP -> "\u001b[A";
|
case UP -> "\u001b[A";
|
||||||
case DOWN -> "\u001b[B";
|
case DOWN -> "\u001b[B";
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
@@ -207,45 +180,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 +222,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 +258,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) {
|
||||||
|
|||||||
975
src/main/java/com/gregor/jprototerm/TerminalPaneNode.java
Normal file
975
src/main/java/com/gregor/jprototerm/TerminalPaneNode.java
Normal file
@@ -0,0 +1,975 @@
|
|||||||
|
package com.gregor.jprototerm;
|
||||||
|
|
||||||
|
import dev.jlibghostty.KittyImageCompression;
|
||||||
|
import dev.jlibghostty.KittyImageFormat;
|
||||||
|
import dev.jlibghostty.KittyImageSnapshot;
|
||||||
|
import dev.jlibghostty.KittyPlacement;
|
||||||
|
import dev.jlibghostty.KittyPlacementLayer;
|
||||||
|
import dev.jlibghostty.KittyPlaceholder;
|
||||||
|
import dev.jlibghostty.KittyRenderInfo;
|
||||||
|
import dev.jlibghostty.RenderCell;
|
||||||
|
import dev.jlibghostty.RenderColor;
|
||||||
|
import dev.jlibghostty.RenderCursorStyle;
|
||||||
|
import dev.jlibghostty.RenderRow;
|
||||||
|
import dev.jlibghostty.RenderStateSnapshot;
|
||||||
|
import javafx.geometry.Rectangle2D;
|
||||||
|
import javafx.scene.Node;
|
||||||
|
import javafx.scene.canvas.Canvas;
|
||||||
|
import javafx.scene.canvas.GraphicsContext;
|
||||||
|
import javafx.scene.image.Image;
|
||||||
|
import javafx.scene.image.ImageView;
|
||||||
|
import javafx.scene.image.PixelFormat;
|
||||||
|
import javafx.scene.image.WritableImage;
|
||||||
|
import javafx.scene.layout.Pane;
|
||||||
|
import javafx.scene.layout.Region;
|
||||||
|
import javafx.scene.paint.Color;
|
||||||
|
import javafx.scene.shape.Line;
|
||||||
|
import javafx.scene.shape.Rectangle;
|
||||||
|
import javafx.scene.text.FontSmoothingType;
|
||||||
|
|
||||||
|
import java.io.ByteArrayInputStream;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JavaFX node for one terminal pane. The pane is composed from JavaFX primitives: one node per
|
||||||
|
* terminal row, kitty graphics as ImageView nodes, plus background/cursor/border nodes.
|
||||||
|
*/
|
||||||
|
final class TerminalPaneNode extends Region {
|
||||||
|
private static final int DIRTY_PARTIAL = 1;
|
||||||
|
private static final int DIRTY_FULL = 2;
|
||||||
|
|
||||||
|
private static final Color DEFAULT_FOREGROUND = Color.rgb(225, 229, 235);
|
||||||
|
private static final Color SELECTED_BACKGROUND = Color.rgb(52, 92, 140);
|
||||||
|
private static final Color PANE_BACKGROUND = Color.rgb(9, 10, 12);
|
||||||
|
private static final Map<Integer, Color> COLOR_CACHE = new HashMap<>();
|
||||||
|
|
||||||
|
private final TerminalPane pane;
|
||||||
|
private final TerminalMetrics metrics;
|
||||||
|
private final Rectangle background = new Rectangle();
|
||||||
|
private final Pane belowImageLayer = new Pane();
|
||||||
|
private final Pane rowLayer = new Pane();
|
||||||
|
private final Pane cursorLayer = new Pane();
|
||||||
|
private final Pane aboveImageLayer = new Pane();
|
||||||
|
private final Rectangle topPadding = new Rectangle();
|
||||||
|
private final Rectangle bottomPadding = new Rectangle();
|
||||||
|
private final Rectangle border = new Rectangle();
|
||||||
|
private final Map<Integer, TerminalRowNode> rows = new HashMap<>();
|
||||||
|
private final Map<Integer, Long> rowFingerprints = new HashMap<>();
|
||||||
|
private final Map<KittyImageKey, Image> kittyImageCache = new HashMap<>();
|
||||||
|
private long drawnContentVersion = Long.MIN_VALUE;
|
||||||
|
private double drawnWidth = -1.0;
|
||||||
|
private double drawnHeight = -1.0;
|
||||||
|
|
||||||
|
TerminalPaneNode(TerminalPane pane, TerminalMetrics metrics) {
|
||||||
|
this.pane = pane;
|
||||||
|
this.metrics = metrics;
|
||||||
|
setPickOnBounds(true);
|
||||||
|
setClip(new Rectangle());
|
||||||
|
background.setFill(PANE_BACKGROUND);
|
||||||
|
border.setFill(Color.TRANSPARENT);
|
||||||
|
getChildren().setAll(background, belowImageLayer, rowLayer, cursorLayer, aboveImageLayer, border);
|
||||||
|
rowLayer.getChildren().setAll(topPadding, bottomPadding);
|
||||||
|
}
|
||||||
|
|
||||||
|
void discard() {
|
||||||
|
drawnContentVersion = Long.MIN_VALUE;
|
||||||
|
drawnWidth = -1.0;
|
||||||
|
drawnHeight = -1.0;
|
||||||
|
rows.clear();
|
||||||
|
rowFingerprints.clear();
|
||||||
|
rowLayer.getChildren().setAll(topPadding, bottomPadding);
|
||||||
|
belowImageLayer.getChildren().clear();
|
||||||
|
aboveImageLayer.getChildren().clear();
|
||||||
|
cursorLayer.getChildren().clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
void renderFull(boolean active) {
|
||||||
|
prepareGeometry();
|
||||||
|
RenderStateSnapshot snapshot = pane.snapshotFull();
|
||||||
|
long renderedVersion = pane.snapshotVersion();
|
||||||
|
boolean withKitty = pane.kittyEnabled() && hasKittyGraphics();
|
||||||
|
updateRowsFull(snapshot);
|
||||||
|
updateKittyGraphics(snapshot, withKitty);
|
||||||
|
updateCursor(snapshot);
|
||||||
|
updateBorder(active);
|
||||||
|
markDrawn(renderedVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
void renderIncremental(boolean active) {
|
||||||
|
boolean geometryChanged = prepareGeometry();
|
||||||
|
boolean withKitty = pane.kittyEnabled() && hasKittyGraphics();
|
||||||
|
if (drawnContentVersion == Long.MIN_VALUE || geometryChanged || withKitty) {
|
||||||
|
renderFull(active);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (drawnContentVersion == pane.contentVersion()) {
|
||||||
|
updateBorder(active);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
RenderStateSnapshot snapshot = pane.snapshot();
|
||||||
|
long renderedVersion = pane.snapshotVersion();
|
||||||
|
int dirty = snapshot == null ? DIRTY_FULL : snapshot.dirty();
|
||||||
|
if (dirty == DIRTY_FULL) {
|
||||||
|
updateChangedRows(snapshot, snapshot.renderRows());
|
||||||
|
} else if (dirty == DIRTY_PARTIAL) {
|
||||||
|
updateDirtyRows(snapshot);
|
||||||
|
}
|
||||||
|
updateKittyGraphics(snapshot, false);
|
||||||
|
updateCursor(snapshot);
|
||||||
|
updateBorder(active);
|
||||||
|
markDrawn(renderedVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean prepareGeometry() {
|
||||||
|
double width = Math.max(0.0, pane.width());
|
||||||
|
double height = Math.max(0.0, pane.height());
|
||||||
|
boolean changed = drawnWidth != width || drawnHeight != height;
|
||||||
|
resize(width, height);
|
||||||
|
background.setWidth(width);
|
||||||
|
background.setHeight(height);
|
||||||
|
resizeLayer(belowImageLayer, width, height);
|
||||||
|
resizeLayer(rowLayer, width, height);
|
||||||
|
resizeLayer(cursorLayer, width, height);
|
||||||
|
resizeLayer(aboveImageLayer, width, height);
|
||||||
|
border.setWidth(Math.max(0.0, width - 1.0));
|
||||||
|
border.setHeight(Math.max(0.0, height - 1.0));
|
||||||
|
border.relocate(0.5, 0.5);
|
||||||
|
Node clip = getClip();
|
||||||
|
if (clip instanceof Rectangle rectangle) {
|
||||||
|
rectangle.setWidth(width);
|
||||||
|
rectangle.setHeight(height);
|
||||||
|
}
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void resizeLayer(Pane layer, double width, double height) {
|
||||||
|
layer.resizeRelocate(0.0, 0.0, width, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updateRowsFull(RenderStateSnapshot snapshot) {
|
||||||
|
if (snapshot == null) {
|
||||||
|
rows.clear();
|
||||||
|
rowFingerprints.clear();
|
||||||
|
rowLayer.getChildren().setAll(topPadding, bottomPadding);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Node> ordered = new ArrayList<>(snapshot.renderRows().size() + 2);
|
||||||
|
ordered.add(topPadding);
|
||||||
|
ordered.add(bottomPadding);
|
||||||
|
Set<Integer> liveRows = new HashSet<>();
|
||||||
|
for (RenderRow row : snapshot.renderRows()) {
|
||||||
|
TerminalRowNode node = rowNode(row.row());
|
||||||
|
long fingerprint = rowFingerprint(row);
|
||||||
|
node.render(row);
|
||||||
|
rowFingerprints.put(row.row(), fingerprint);
|
||||||
|
liveRows.add(row.row());
|
||||||
|
ordered.add(node);
|
||||||
|
}
|
||||||
|
rows.keySet().retainAll(liveRows);
|
||||||
|
rowFingerprints.keySet().retainAll(liveRows);
|
||||||
|
rowLayer.getChildren().setAll(ordered);
|
||||||
|
updateVerticalPadding(snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updateDirtyRows(RenderStateSnapshot snapshot) {
|
||||||
|
List<RenderRow> dirtyRows = snapshot.renderRows().stream()
|
||||||
|
.filter(RenderRow::dirty)
|
||||||
|
.toList();
|
||||||
|
updateChangedRows(snapshot, dirtyRows);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updateChangedRows(RenderStateSnapshot snapshot, List<RenderRow> changedRows) {
|
||||||
|
if (snapshot == null || changedRows.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Set<Integer> movedRows = moveShiftedRows(snapshot, changedRows);
|
||||||
|
for (RenderRow row : snapshot.renderRows()) {
|
||||||
|
if (!changedRows.contains(row) || movedRows.contains(row.row())) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
TerminalRowNode node = rowNode(row.row());
|
||||||
|
long fingerprint = rowFingerprint(row);
|
||||||
|
node.renderChanged(row);
|
||||||
|
rowFingerprints.put(row.row(), fingerprint);
|
||||||
|
}
|
||||||
|
for (RenderRow row : changedRows) {
|
||||||
|
updateDirtyVerticalPadding(snapshot, row);
|
||||||
|
}
|
||||||
|
syncRowChildren();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Set<Integer> moveShiftedRows(RenderStateSnapshot snapshot, List<RenderRow> changedRows) {
|
||||||
|
if (rowFingerprints.isEmpty() || changedRows.size() < Math.max(4, snapshot.rows() / 3)) {
|
||||||
|
return Set.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
ShiftPlan plan = detectShift(snapshot, changedRows);
|
||||||
|
if (plan == null) {
|
||||||
|
return Set.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<Integer, TerminalRowNode> oldRows = new HashMap<>(rows);
|
||||||
|
Map<Integer, Long> oldFingerprints = new HashMap<>(rowFingerprints);
|
||||||
|
for (RowMove move : plan.moves()) {
|
||||||
|
rows.remove(move.sourceRow());
|
||||||
|
rowFingerprints.remove(move.sourceRow());
|
||||||
|
}
|
||||||
|
for (RowMove move : plan.moves()) {
|
||||||
|
TerminalRowNode node = oldRows.get(move.sourceRow());
|
||||||
|
if (node == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
node.moveToRow(move.targetRow());
|
||||||
|
rows.put(move.targetRow(), node);
|
||||||
|
rowFingerprints.put(move.targetRow(), oldFingerprints.get(move.sourceRow()));
|
||||||
|
}
|
||||||
|
return plan.targetRows();
|
||||||
|
}
|
||||||
|
|
||||||
|
private ShiftPlan detectShift(RenderStateSnapshot snapshot, List<RenderRow> changedRows) {
|
||||||
|
int bestDelta = 0;
|
||||||
|
int bestScore = 0;
|
||||||
|
int rowCount = snapshot.rows();
|
||||||
|
for (int delta = -rowCount + 1; delta < rowCount; delta++) {
|
||||||
|
if (delta == 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
int score = 0;
|
||||||
|
for (RenderRow row : changedRows) {
|
||||||
|
int sourceRow = row.row() + delta;
|
||||||
|
if (sourceRow < 0 || sourceRow >= rowCount || !rows.containsKey(sourceRow)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Long previous = rowFingerprints.get(sourceRow);
|
||||||
|
if (previous != null && previous == rowFingerprint(row)) {
|
||||||
|
score++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (score > bestScore) {
|
||||||
|
bestScore = score;
|
||||||
|
bestDelta = delta;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int threshold = Math.max(4, (changedRows.size() * 2 + 2) / 3);
|
||||||
|
if (bestScore < threshold) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<RowMove> moves = new ArrayList<>(bestScore);
|
||||||
|
Set<Integer> targetRows = new HashSet<>();
|
||||||
|
for (RenderRow row : changedRows) {
|
||||||
|
int sourceRow = row.row() + bestDelta;
|
||||||
|
if (sourceRow < 0 || sourceRow >= rowCount || !rows.containsKey(sourceRow)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Long previous = rowFingerprints.get(sourceRow);
|
||||||
|
if (previous != null && previous == rowFingerprint(row)) {
|
||||||
|
moves.add(new RowMove(sourceRow, row.row()));
|
||||||
|
targetRows.add(row.row());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new ShiftPlan(moves, targetRows);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void syncRowChildren() {
|
||||||
|
List<Node> ordered = new ArrayList<>(rows.size() + 2);
|
||||||
|
ordered.add(topPadding);
|
||||||
|
ordered.add(bottomPadding);
|
||||||
|
rows.entrySet().stream()
|
||||||
|
.sorted(Comparator.comparingInt(Map.Entry::getKey))
|
||||||
|
.map(Map.Entry::getValue)
|
||||||
|
.forEach(ordered::add);
|
||||||
|
rowLayer.getChildren().setAll(ordered);
|
||||||
|
}
|
||||||
|
|
||||||
|
private TerminalRowNode rowNode(int row) {
|
||||||
|
return rows.computeIfAbsent(row, ignored -> {
|
||||||
|
TerminalRowNode created = new TerminalRowNode(metrics);
|
||||||
|
if (!rowLayer.getChildren().contains(created)) {
|
||||||
|
rowLayer.getChildren().add(created);
|
||||||
|
}
|
||||||
|
return created;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updateVerticalPadding(RenderStateSnapshot snapshot) {
|
||||||
|
List<RenderRow> renderRows = snapshot.renderRows();
|
||||||
|
if (renderRows.isEmpty()) {
|
||||||
|
topPadding.setVisible(false);
|
||||||
|
bottomPadding.setVisible(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
double width = pane.width();
|
||||||
|
double top = TerminalMetrics.PADDING;
|
||||||
|
double contentBottom = top + snapshot.rows() * metrics.lineHeight();
|
||||||
|
topPadding.setVisible(true);
|
||||||
|
topPadding.setFill(rowEdgeBackground(renderRows.get(0), true));
|
||||||
|
topPadding.relocate(0.0, 0.0);
|
||||||
|
topPadding.setWidth(width);
|
||||||
|
topPadding.setHeight(top);
|
||||||
|
|
||||||
|
bottomPadding.setVisible(true);
|
||||||
|
bottomPadding.setFill(rowEdgeBackground(renderRows.get(renderRows.size() - 1), true));
|
||||||
|
bottomPadding.relocate(0.0, contentBottom);
|
||||||
|
bottomPadding.setWidth(width);
|
||||||
|
bottomPadding.setHeight(Math.max(0.0, pane.height() - contentBottom));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updateDirtyVerticalPadding(RenderStateSnapshot snapshot, RenderRow row) {
|
||||||
|
if (row.row() == 0) {
|
||||||
|
topPadding.setVisible(true);
|
||||||
|
topPadding.setFill(rowEdgeBackground(row, true));
|
||||||
|
topPadding.relocate(0.0, 0.0);
|
||||||
|
topPadding.setWidth(pane.width());
|
||||||
|
topPadding.setHeight(TerminalMetrics.PADDING);
|
||||||
|
}
|
||||||
|
if (row.row() == snapshot.rows() - 1) {
|
||||||
|
double contentBottom = TerminalMetrics.PADDING + snapshot.rows() * metrics.lineHeight();
|
||||||
|
bottomPadding.setVisible(true);
|
||||||
|
bottomPadding.setFill(rowEdgeBackground(row, true));
|
||||||
|
bottomPadding.relocate(0.0, contentBottom);
|
||||||
|
bottomPadding.setWidth(pane.width());
|
||||||
|
bottomPadding.setHeight(Math.max(0.0, pane.height() - contentBottom));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updateCursor(RenderStateSnapshot snapshot) {
|
||||||
|
cursorLayer.getChildren().clear();
|
||||||
|
if (snapshot == null || !snapshot.cursorVisible() || !snapshot.cursorViewportHasValue()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
double x = TerminalMetrics.PADDING + (snapshot.cursorViewportX() * metrics.cellWidth());
|
||||||
|
double y = TerminalMetrics.PADDING + (snapshot.cursorViewportY() * metrics.lineHeight());
|
||||||
|
double cellWidth = metrics.cellWidth();
|
||||||
|
double lineHeight = metrics.lineHeight();
|
||||||
|
RenderCursorStyle style = snapshot.cursorStyle();
|
||||||
|
if (style == RenderCursorStyle.BAR) {
|
||||||
|
Line line = new Line(x + 0.5, y + 2.0, x + 0.5, y + lineHeight - 2.0);
|
||||||
|
line.setStroke(DEFAULT_FOREGROUND);
|
||||||
|
line.setStrokeWidth(1.5);
|
||||||
|
cursorLayer.getChildren().add(line);
|
||||||
|
} else if (style == RenderCursorStyle.UNDERLINE) {
|
||||||
|
Line line = new Line(x + 1.0, y + lineHeight - 2.0, x + cellWidth - 1.0, y + lineHeight - 2.0);
|
||||||
|
line.setStroke(DEFAULT_FOREGROUND);
|
||||||
|
line.setStrokeWidth(1.5);
|
||||||
|
cursorLayer.getChildren().add(line);
|
||||||
|
} else if (style == RenderCursorStyle.BLOCK) {
|
||||||
|
Rectangle rectangle = new Rectangle(x + 0.5, y + 1.0, Math.max(1.0, cellWidth - 1.0), Math.max(1.0, lineHeight - 2.0));
|
||||||
|
rectangle.setFill(Color.rgb(225, 229, 235, 0.28));
|
||||||
|
cursorLayer.getChildren().add(rectangle);
|
||||||
|
} else {
|
||||||
|
Rectangle rectangle = new Rectangle(x + 0.5, y + 1.0, Math.max(1.0, cellWidth - 1.0), Math.max(1.0, lineHeight - 2.0));
|
||||||
|
rectangle.setFill(Color.TRANSPARENT);
|
||||||
|
rectangle.setStroke(DEFAULT_FOREGROUND);
|
||||||
|
rectangle.setStrokeWidth(1.5);
|
||||||
|
cursorLayer.getChildren().add(rectangle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updateBorder(boolean active) {
|
||||||
|
border.setStroke(active ? Color.rgb(87, 166, 255) : Color.rgb(52, 57, 65));
|
||||||
|
border.setStrokeWidth(active ? 2.0 : 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updateKittyGraphics(RenderStateSnapshot snapshot, boolean withKitty) {
|
||||||
|
belowImageLayer.getChildren().clear();
|
||||||
|
aboveImageLayer.getChildren().clear();
|
||||||
|
if (!withKitty || snapshot == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<KittyPlaceholderKey, KittyPlaceholderBounds> placeholderBounds = kittyPlaceholderBounds(snapshot);
|
||||||
|
addKittyGraphics(belowImageLayer, KittyPlacementLayer.BELOW_TEXT, placeholderBounds);
|
||||||
|
addKittyGraphics(aboveImageLayer, KittyPlacementLayer.ABOVE_TEXT, placeholderBounds);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void addKittyGraphics(Pane layer, KittyPlacementLayer placementLayer, Map<KittyPlaceholderKey, KittyPlaceholderBounds> placeholderBounds) {
|
||||||
|
pane.kittyGraphics().ifPresent(graphics -> {
|
||||||
|
for (KittyPlacement placement : graphics.placements(placementLayer)) {
|
||||||
|
Image image = imageFor(placement);
|
||||||
|
if (image == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
ImageView view = placement.virtual()
|
||||||
|
? virtualKittyView(placement, image, placeholderBounds)
|
||||||
|
: pinnedKittyView(placement, image);
|
||||||
|
if (view != null) {
|
||||||
|
layer.getChildren().add(view);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private ImageView pinnedKittyView(KittyPlacement placement, Image image) {
|
||||||
|
KittyRenderInfo renderInfo = placement.renderInfo().orElse(null);
|
||||||
|
if (renderInfo == null || !renderInfo.viewportVisible()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
double sourceX = renderInfo.sourceX();
|
||||||
|
double sourceY = renderInfo.sourceY();
|
||||||
|
double sourceWidth = renderInfo.sourceWidth();
|
||||||
|
double sourceHeight = renderInfo.sourceHeight();
|
||||||
|
if (sourceWidth <= 0.0 || sourceHeight <= 0.0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
double x = TerminalMetrics.PADDING + (renderInfo.viewportColumn() * metrics.cellWidth()) + placement.xOffset();
|
||||||
|
double y = TerminalMetrics.PADDING + (renderInfo.viewportRow() * metrics.lineHeight()) + placement.yOffset();
|
||||||
|
double width = renderInfo.pixelWidth() > 0 ? renderInfo.pixelWidth() : renderInfo.gridColumns() * metrics.cellWidth();
|
||||||
|
double height = renderInfo.pixelHeight() > 0 ? renderInfo.pixelHeight() : renderInfo.gridRows() * metrics.lineHeight();
|
||||||
|
return imageView(image, sourceX, sourceY, sourceWidth, sourceHeight, x, y, width, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ImageView virtualKittyView(KittyPlacement placement, Image image, Map<KittyPlaceholderKey, KittyPlaceholderBounds> placeholderBounds) {
|
||||||
|
KittyPlaceholderBounds bounds = placeholderBounds.get(new KittyPlaceholderKey(placement.imageId(), placement.placementId()));
|
||||||
|
if (bounds == null) {
|
||||||
|
bounds = placeholderBounds.get(new KittyPlaceholderKey(placement.imageId(), 0));
|
||||||
|
}
|
||||||
|
if (bounds == null && placement.placementId() == 0) {
|
||||||
|
bounds = placeholderBounds.entrySet().stream()
|
||||||
|
.filter(entry -> entry.getKey().imageId() == placement.imageId())
|
||||||
|
.map(Map.Entry::getValue)
|
||||||
|
.findFirst()
|
||||||
|
.orElse(null);
|
||||||
|
}
|
||||||
|
if (bounds == null || bounds.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
SourceRect source = sourceRect(placement, image);
|
||||||
|
if (source.width() <= 0.0 || source.height() <= 0.0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
long gridColumns = gridColumns(placement, bounds);
|
||||||
|
long gridRows = gridRows(placement, bounds);
|
||||||
|
double sourceCellWidth = source.width() / Math.max(1L, gridColumns);
|
||||||
|
double sourceCellHeight = source.height() / Math.max(1L, gridRows);
|
||||||
|
|
||||||
|
double sourceX = source.x() + (bounds.minSourceColumn * sourceCellWidth);
|
||||||
|
double sourceY = source.y() + (bounds.minSourceRow * sourceCellHeight);
|
||||||
|
double sourceWidth = bounds.sourceColumns() * sourceCellWidth;
|
||||||
|
double sourceHeight = bounds.sourceRows() * sourceCellHeight;
|
||||||
|
double x = TerminalMetrics.PADDING + (bounds.minColumn * metrics.cellWidth());
|
||||||
|
double y = TerminalMetrics.PADDING + (bounds.minRow * metrics.lineHeight());
|
||||||
|
double availableWidth = bounds.columns() * metrics.cellWidth();
|
||||||
|
double availableHeight = bounds.rows() * metrics.lineHeight();
|
||||||
|
if (sourceWidth <= 0.0 || sourceHeight <= 0.0 || availableWidth <= 0.0 || availableHeight <= 0.0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
double scale = Math.min(availableWidth / sourceWidth, availableHeight / sourceHeight);
|
||||||
|
return imageView(image, sourceX, sourceY, sourceWidth, sourceHeight, x, y, sourceWidth * scale, sourceHeight * scale);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ImageView imageView(Image image, double sourceX, double sourceY, double sourceWidth, double sourceHeight,
|
||||||
|
double x, double y, double width, double height) {
|
||||||
|
if (width <= 0.0 || height <= 0.0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
ImageView view = new ImageView(image);
|
||||||
|
view.setViewport(new Rectangle2D(sourceX, sourceY, sourceWidth, sourceHeight));
|
||||||
|
view.setFitWidth(width);
|
||||||
|
view.setFitHeight(height);
|
||||||
|
view.setPreserveRatio(false);
|
||||||
|
view.relocate(x, y);
|
||||||
|
return view;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean hasKittyGraphics() {
|
||||||
|
return pane.kittyGraphics()
|
||||||
|
.map(graphics -> !graphics.placements().isEmpty())
|
||||||
|
.orElse(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Image imageFor(KittyPlacement placement) {
|
||||||
|
return placement.image().map(snapshot -> {
|
||||||
|
byte[] data = snapshot.data();
|
||||||
|
KittyImageKey key = KittyImageKey.of(snapshot, data);
|
||||||
|
Image cached = kittyImageCache.get(key);
|
||||||
|
if (cached != null) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
kittyImageCache.keySet().removeIf(existing -> existing.id() == snapshot.id());
|
||||||
|
Image decoded = decodeImage(snapshot, data);
|
||||||
|
if (decoded != null) {
|
||||||
|
kittyImageCache.put(key, decoded);
|
||||||
|
}
|
||||||
|
return decoded;
|
||||||
|
}).orElse(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Image decodeImage(KittyImageSnapshot snapshot, byte[] data) {
|
||||||
|
if (snapshot.compression() != KittyImageCompression.NONE) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (snapshot.format() == KittyImageFormat.PNG) {
|
||||||
|
return new Image(new ByteArrayInputStream(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
int width = Math.toIntExact(snapshot.width());
|
||||||
|
int height = Math.toIntExact(snapshot.height());
|
||||||
|
WritableImage image = new WritableImage(width, height);
|
||||||
|
|
||||||
|
if (snapshot.format() == KittyImageFormat.RGBA) {
|
||||||
|
image.getPixelWriter().setPixels(0, 0, width, height, PixelFormat.getByteBgraInstance(), rgbaToBgra(data), 0, width * 4);
|
||||||
|
} else if (snapshot.format() == KittyImageFormat.RGB) {
|
||||||
|
image.getPixelWriter().setPixels(0, 0, width, height, PixelFormat.getByteRgbInstance(), data, 0, width * 3);
|
||||||
|
}
|
||||||
|
return image;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] rgbaToBgra(byte[] rgba) {
|
||||||
|
byte[] bgra = new byte[rgba.length];
|
||||||
|
for (int i = 0; i + 3 < rgba.length; i += 4) {
|
||||||
|
bgra[i] = rgba[i + 2];
|
||||||
|
bgra[i + 1] = rgba[i + 1];
|
||||||
|
bgra[i + 2] = rgba[i];
|
||||||
|
bgra[i + 3] = rgba[i + 3];
|
||||||
|
}
|
||||||
|
return bgra;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Map<KittyPlaceholderKey, KittyPlaceholderBounds> kittyPlaceholderBounds(RenderStateSnapshot snapshot) {
|
||||||
|
Map<KittyPlaceholderKey, KittyPlaceholderBounds> result = new HashMap<>();
|
||||||
|
for (RenderRow row : snapshot.renderRows()) {
|
||||||
|
for (RenderCell cell : row.cells()) {
|
||||||
|
cell.kittyPlaceholder().ifPresent(placeholder -> {
|
||||||
|
KittyPlaceholderKey key = new KittyPlaceholderKey(placeholder.imageId(), placeholder.placementId());
|
||||||
|
result.computeIfAbsent(key, ignored -> new KittyPlaceholderBounds()).include(row.row(), cell.column(), placeholder);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long gridColumns(KittyPlacement placement, KittyPlaceholderBounds bounds) {
|
||||||
|
if (placement.columns() > 0) {
|
||||||
|
return placement.columns();
|
||||||
|
}
|
||||||
|
return Math.max(bounds.maxSourceColumn + 1, bounds.sourceColumns());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long gridRows(KittyPlacement placement, KittyPlaceholderBounds bounds) {
|
||||||
|
if (placement.rows() > 0) {
|
||||||
|
return placement.rows();
|
||||||
|
}
|
||||||
|
return Math.max(bounds.maxSourceRow + 1, bounds.sourceRows());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SourceRect sourceRect(KittyPlacement placement, Image image) {
|
||||||
|
double sourceX = placement.sourceX();
|
||||||
|
double sourceY = placement.sourceY();
|
||||||
|
double sourceWidth = placement.sourceWidth() > 0 ? placement.sourceWidth() : image.getWidth() - sourceX;
|
||||||
|
double sourceHeight = placement.sourceHeight() > 0 ? placement.sourceHeight() : image.getHeight() - sourceY;
|
||||||
|
return new SourceRect(sourceX, sourceY, Math.min(sourceWidth, image.getWidth() - sourceX), Math.min(sourceHeight, image.getHeight() - sourceY));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void markDrawn(long renderedVersion) {
|
||||||
|
drawnContentVersion = renderedVersion;
|
||||||
|
drawnWidth = pane.width();
|
||||||
|
drawnHeight = pane.height();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Color cellBackgroundColor(RenderCell cell) {
|
||||||
|
if (cell.inverse()) {
|
||||||
|
var fg = cell.foreground();
|
||||||
|
return fg.isPresent() ? toFxColor(fg.get()) : DEFAULT_FOREGROUND;
|
||||||
|
}
|
||||||
|
var bg = cell.background();
|
||||||
|
return bg.isPresent() ? toFxColor(bg.get()) : PANE_BACKGROUND;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Color rowEdgeBackground(RenderRow row, boolean firstCell) {
|
||||||
|
List<RenderCell> cells = row.cells();
|
||||||
|
if (cells.isEmpty()) {
|
||||||
|
return PANE_BACKGROUND;
|
||||||
|
}
|
||||||
|
return cellBackgroundColor(firstCell ? cells.get(0) : cells.get(cells.size() - 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Color cellBackgroundOverride(RenderCell cell) {
|
||||||
|
if (cell.inverse()) {
|
||||||
|
var fg = cell.foreground();
|
||||||
|
return fg.isPresent() ? toFxColor(fg.get()) : DEFAULT_FOREGROUND;
|
||||||
|
}
|
||||||
|
var bg = cell.background();
|
||||||
|
return bg.isPresent() ? toFxColor(bg.get()) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Color cellForegroundColor(RenderCell cell) {
|
||||||
|
var fgOpt = cell.foreground();
|
||||||
|
var bgOpt = cell.background();
|
||||||
|
Color fg = fgOpt.isPresent() ? toFxColor(fgOpt.get()) : DEFAULT_FOREGROUND;
|
||||||
|
Color bg = bgOpt.isPresent() ? toFxColor(bgOpt.get()) : null;
|
||||||
|
|
||||||
|
if (cell.inverse()) {
|
||||||
|
return (bg != null) ? bg : PANE_BACKGROUND;
|
||||||
|
}
|
||||||
|
return fg;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Color toFxColor(RenderColor color) {
|
||||||
|
int key = (color.red() << 16) | (color.green() << 8) | color.blue();
|
||||||
|
Color cached = COLOR_CACHE.get(key);
|
||||||
|
if (cached != null) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
if (COLOR_CACHE.size() >= 4096) {
|
||||||
|
COLOR_CACHE.clear();
|
||||||
|
}
|
||||||
|
Color created = Color.rgb(color.red(), color.green(), color.blue());
|
||||||
|
COLOR_CACHE.put(key, created);
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long rowFingerprint(RenderRow row) {
|
||||||
|
long hash = 0xcbf29ce484222325L;
|
||||||
|
hash = mix(hash, row.cells().size());
|
||||||
|
for (RenderCell cell : row.cells()) {
|
||||||
|
hash = mix(hash, cellFingerprint(cell));
|
||||||
|
}
|
||||||
|
return hash;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long cellFingerprint(RenderCell cell) {
|
||||||
|
long hash = 0xcbf29ce484222325L;
|
||||||
|
hash = mix(hash, cell.column());
|
||||||
|
hash = mix(hash, cell.inverse() ? 1 : 0);
|
||||||
|
hash = mix(hash, cell.selected() ? 1 : 0);
|
||||||
|
hash = mix(hash, colorFingerprint(cell.foreground().orElse(null)));
|
||||||
|
hash = mix(hash, colorFingerprint(cell.background().orElse(null)));
|
||||||
|
for (int codepoint : cell.codepoints()) {
|
||||||
|
hash = mix(hash, codepoint);
|
||||||
|
}
|
||||||
|
if (cell.kittyPlaceholder().isPresent()) {
|
||||||
|
KittyPlaceholder placeholder = cell.kittyPlaceholder().get();
|
||||||
|
hash = mix(hash, placeholder.imageId());
|
||||||
|
hash = mix(hash, placeholder.placementId());
|
||||||
|
hash = mix(hash, placeholder.sourceRow());
|
||||||
|
hash = mix(hash, placeholder.sourceColumn());
|
||||||
|
}
|
||||||
|
return hash;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long colorFingerprint(RenderColor color) {
|
||||||
|
if (color == null) {
|
||||||
|
return -1L;
|
||||||
|
}
|
||||||
|
return ((long) color.red() << 16) | ((long) color.green() << 8) | color.blue();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long mix(long hash, long value) {
|
||||||
|
hash ^= value;
|
||||||
|
return hash * 0x100000001b3L;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class TerminalRowNode extends Region {
|
||||||
|
private final TerminalMetrics metrics;
|
||||||
|
private final Canvas canvas = new Canvas();
|
||||||
|
private long[] cellFingerprints = new long[0];
|
||||||
|
|
||||||
|
private TerminalRowNode(TerminalMetrics metrics) {
|
||||||
|
this.metrics = metrics;
|
||||||
|
getChildren().add(canvas);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void render(RenderRow row) {
|
||||||
|
prepareCanvas(row);
|
||||||
|
|
||||||
|
GraphicsContext gc = canvas.getGraphicsContext2D();
|
||||||
|
gc.clearRect(0.0, 0.0, canvas.getWidth(), canvas.getHeight());
|
||||||
|
gc.setFontSmoothingType(FontSmoothingType.LCD);
|
||||||
|
gc.setFont(metrics.font());
|
||||||
|
|
||||||
|
paintSidePadding(gc, row, canvas.getWidth(), canvas.getHeight());
|
||||||
|
drawRow(gc, row, rowTop(row), metrics.cellWidth(), metrics.lineHeight());
|
||||||
|
cellFingerprints = cellFingerprints(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void renderChanged(RenderRow row) {
|
||||||
|
double oldWidth = canvas.getWidth();
|
||||||
|
double oldHeight = canvas.getHeight();
|
||||||
|
prepareCanvas(row);
|
||||||
|
long[] nextFingerprints = cellFingerprints(row);
|
||||||
|
if (cellFingerprints.length != nextFingerprints.length
|
||||||
|
|| oldWidth != canvas.getWidth()
|
||||||
|
|| oldHeight != canvas.getHeight()) {
|
||||||
|
render(row);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
GraphicsContext gc = canvas.getGraphicsContext2D();
|
||||||
|
gc.setFontSmoothingType(FontSmoothingType.LCD);
|
||||||
|
gc.setFont(metrics.font());
|
||||||
|
|
||||||
|
int runStart = -1;
|
||||||
|
int runEnd = -1;
|
||||||
|
for (int column = 0; column < nextFingerprints.length; column++) {
|
||||||
|
if (cellFingerprints[column] == nextFingerprints[column]) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
int start = Math.max(0, column - 1);
|
||||||
|
int end = Math.min(nextFingerprints.length - 1, column + 1);
|
||||||
|
if (runStart < 0) {
|
||||||
|
runStart = start;
|
||||||
|
runEnd = end;
|
||||||
|
} else if (start <= runEnd + 1) {
|
||||||
|
runEnd = Math.max(runEnd, end);
|
||||||
|
} else {
|
||||||
|
repaintColumns(gc, row, runStart, runEnd);
|
||||||
|
runStart = start;
|
||||||
|
runEnd = end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (runStart >= 0) {
|
||||||
|
repaintColumns(gc, row, runStart, runEnd);
|
||||||
|
}
|
||||||
|
cellFingerprints = nextFingerprints;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void prepareCanvas(RenderRow row) {
|
||||||
|
double paneWidth = ((Region) getParent()).getWidth();
|
||||||
|
double rowTop = rowTop(row);
|
||||||
|
double rowBottom = rowBottom(row);
|
||||||
|
double rowHeight = Math.max(1.0, rowBottom - rowTop);
|
||||||
|
resizeRelocate(0.0, rowTop, paneWidth, rowHeight);
|
||||||
|
canvas.setWidth(Math.max(0.0, paneWidth));
|
||||||
|
canvas.setHeight(rowHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void moveToRow(int row) {
|
||||||
|
double paneWidth = ((Region) getParent()).getWidth();
|
||||||
|
double rowTop = rowTop(row);
|
||||||
|
double rowBottom = rowBottom(row);
|
||||||
|
double rowHeight = Math.max(1.0, rowBottom - rowTop);
|
||||||
|
resizeRelocate(0.0, rowTop, paneWidth, rowHeight);
|
||||||
|
canvas.setWidth(Math.max(0.0, paneWidth));
|
||||||
|
canvas.setHeight(rowHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
private double rowTop(RenderRow row) {
|
||||||
|
return rowTop(row.row());
|
||||||
|
}
|
||||||
|
|
||||||
|
private double rowTop(int row) {
|
||||||
|
return Math.floor(TerminalMetrics.PADDING + row * metrics.lineHeight());
|
||||||
|
}
|
||||||
|
|
||||||
|
private double rowBottom(RenderRow row) {
|
||||||
|
return rowBottom(row.row());
|
||||||
|
}
|
||||||
|
|
||||||
|
private double rowBottom(int row) {
|
||||||
|
return Math.ceil(TerminalMetrics.PADDING + (row + 1) * metrics.lineHeight());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void repaintColumns(GraphicsContext gc, RenderRow row, int startColumn, int endColumn) {
|
||||||
|
if (endColumn < startColumn) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
double cellWidth = metrics.cellWidth();
|
||||||
|
double lineHeight = metrics.lineHeight();
|
||||||
|
double rowTop = rowTop(row);
|
||||||
|
double contentTop = TerminalMetrics.PADDING + row.row() * lineHeight;
|
||||||
|
double localCellTop = contentTop - rowTop;
|
||||||
|
double baseline = TerminalMetrics.PADDING + metrics.baselineOffset() + row.row() * lineHeight - rowTop;
|
||||||
|
double x = TerminalMetrics.PADDING + startColumn * cellWidth;
|
||||||
|
double width = (endColumn - startColumn + 1) * cellWidth;
|
||||||
|
|
||||||
|
gc.clearRect(x, 0.0, width, canvas.getHeight());
|
||||||
|
if (startColumn == 0) {
|
||||||
|
gc.setFill(rowEdgeBackground(row, true));
|
||||||
|
gc.fillRect(0.0, 0.0, TerminalMetrics.PADDING, canvas.getHeight());
|
||||||
|
}
|
||||||
|
if (endColumn >= row.cells().size() - 1) {
|
||||||
|
double contentRight = TerminalMetrics.PADDING + row.cells().size() * cellWidth;
|
||||||
|
gc.setFill(rowEdgeBackground(row, false));
|
||||||
|
gc.fillRect(contentRight, 0.0, canvas.getWidth() - contentRight, canvas.getHeight());
|
||||||
|
}
|
||||||
|
|
||||||
|
drawRowBackgrounds(gc, row, localCellTop, cellWidth, lineHeight, startColumn, endColumn);
|
||||||
|
drawRowText(gc, row, baseline, cellWidth, startColumn, endColumn);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void paintSidePadding(GraphicsContext gc, RenderRow row, double paneWidth, double bandHeight) {
|
||||||
|
int columns = row.cells().size();
|
||||||
|
if (columns == 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
double contentLeft = TerminalMetrics.PADDING;
|
||||||
|
double contentRight = contentLeft + columns * metrics.cellWidth();
|
||||||
|
gc.setFill(rowEdgeBackground(row, true));
|
||||||
|
gc.fillRect(0.0, 0.0, contentLeft, bandHeight);
|
||||||
|
gc.setFill(rowEdgeBackground(row, false));
|
||||||
|
gc.fillRect(contentRight, 0.0, paneWidth - contentRight, bandHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void drawRow(GraphicsContext gc, RenderRow row, double rowTop, double cellWidth, double lineHeight) {
|
||||||
|
double contentTop = TerminalMetrics.PADDING + row.row() * lineHeight;
|
||||||
|
double localCellTop = contentTop - rowTop;
|
||||||
|
double baseline = TerminalMetrics.PADDING + metrics.baselineOffset() + row.row() * lineHeight - rowTop;
|
||||||
|
drawRowBackgrounds(gc, row, localCellTop, cellWidth, lineHeight, 0, row.cells().size() - 1);
|
||||||
|
drawRowText(gc, row, baseline, cellWidth, 0, row.cells().size() - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void drawRowBackgrounds(GraphicsContext gc, RenderRow row, double localCellTop,
|
||||||
|
double cellWidth, double lineHeight, int startColumn, int endColumn) {
|
||||||
|
Color runBackground = null;
|
||||||
|
int runStartColumn = 0;
|
||||||
|
int previousColumn = -1;
|
||||||
|
for (RenderCell cell : row.cells()) {
|
||||||
|
if (cell.column() < startColumn || cell.column() > endColumn) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (cell.kittyPlaceholder().isPresent()) {
|
||||||
|
flushBackgroundRun(gc, runBackground, localCellTop, cellWidth, lineHeight, runStartColumn, previousColumn);
|
||||||
|
runBackground = null;
|
||||||
|
previousColumn = -1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
Color background = cell.selected() ? SELECTED_BACKGROUND : cellBackgroundOverride(cell);
|
||||||
|
if (background == null) {
|
||||||
|
flushBackgroundRun(gc, runBackground, localCellTop, cellWidth, lineHeight, runStartColumn, previousColumn);
|
||||||
|
runBackground = null;
|
||||||
|
previousColumn = -1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (runBackground == null || background != runBackground || cell.column() != previousColumn + 1) {
|
||||||
|
flushBackgroundRun(gc, runBackground, localCellTop, cellWidth, lineHeight, runStartColumn, previousColumn);
|
||||||
|
runBackground = background;
|
||||||
|
runStartColumn = cell.column();
|
||||||
|
}
|
||||||
|
previousColumn = cell.column();
|
||||||
|
}
|
||||||
|
flushBackgroundRun(gc, runBackground, localCellTop, cellWidth, lineHeight, runStartColumn, previousColumn);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void flushBackgroundRun(GraphicsContext gc, Color background, double localCellTop,
|
||||||
|
double cellWidth, double lineHeight, int startColumn, int endColumn) {
|
||||||
|
if (background == null || endColumn < startColumn) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
gc.setFill(background);
|
||||||
|
gc.fillRect(
|
||||||
|
TerminalMetrics.PADDING + startColumn * cellWidth,
|
||||||
|
localCellTop,
|
||||||
|
(endColumn - startColumn + 1) * cellWidth,
|
||||||
|
lineHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void drawRowText(GraphicsContext gc, RenderRow row, double baseline,
|
||||||
|
double cellWidth, int startColumn, int endColumn) {
|
||||||
|
for (RenderCell cell : row.cells()) {
|
||||||
|
if (cell.column() < startColumn || cell.column() > endColumn) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (cell.kittyPlaceholder().isPresent() || cell.codepoints().length == 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
gc.setFill(cellForegroundColor(cell));
|
||||||
|
gc.fillText(cell.text(), TerminalMetrics.PADDING + cell.column() * cellWidth, baseline);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long[] cellFingerprints(RenderRow row) {
|
||||||
|
int columns = row.cells().size();
|
||||||
|
for (RenderCell cell : row.cells()) {
|
||||||
|
columns = Math.max(columns, cell.column() + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
long[] fingerprints = new long[columns];
|
||||||
|
for (RenderCell cell : row.cells()) {
|
||||||
|
fingerprints[cell.column()] = cellFingerprint(cell);
|
||||||
|
}
|
||||||
|
return fingerprints;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private record KittyImageKey(long id, long number, long width, long height, KittyImageFormat format, int dataLength) {
|
||||||
|
private static KittyImageKey of(KittyImageSnapshot snapshot, byte[] data) {
|
||||||
|
return new KittyImageKey(
|
||||||
|
snapshot.id(),
|
||||||
|
snapshot.number(),
|
||||||
|
snapshot.width(),
|
||||||
|
snapshot.height(),
|
||||||
|
snapshot.format(),
|
||||||
|
data.length
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private record KittyPlaceholderKey(long imageId, long placementId) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private record SourceRect(double x, double y, double width, double height) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private record RowMove(int sourceRow, int targetRow) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private record ShiftPlan(List<RowMove> moves, Set<Integer> targetRows) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class KittyPlaceholderBounds {
|
||||||
|
private int minRow = Integer.MAX_VALUE;
|
||||||
|
private int maxRow = Integer.MIN_VALUE;
|
||||||
|
private int minColumn = Integer.MAX_VALUE;
|
||||||
|
private int maxColumn = Integer.MIN_VALUE;
|
||||||
|
private long minSourceRow = Long.MAX_VALUE;
|
||||||
|
private long maxSourceRow = Long.MIN_VALUE;
|
||||||
|
private long minSourceColumn = Long.MAX_VALUE;
|
||||||
|
private long maxSourceColumn = Long.MIN_VALUE;
|
||||||
|
|
||||||
|
private void include(int row, int column, KittyPlaceholder placeholder) {
|
||||||
|
minRow = Math.min(minRow, row);
|
||||||
|
maxRow = Math.max(maxRow, row);
|
||||||
|
minColumn = Math.min(minColumn, column);
|
||||||
|
maxColumn = Math.max(maxColumn, column);
|
||||||
|
minSourceRow = Math.min(minSourceRow, placeholder.sourceRow());
|
||||||
|
maxSourceRow = Math.max(maxSourceRow, placeholder.sourceRow());
|
||||||
|
minSourceColumn = Math.min(minSourceColumn, placeholder.sourceColumn());
|
||||||
|
maxSourceColumn = Math.max(maxSourceColumn, placeholder.sourceColumn());
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isEmpty() {
|
||||||
|
return minRow == Integer.MAX_VALUE;
|
||||||
|
}
|
||||||
|
|
||||||
|
private int rows() {
|
||||||
|
return maxRow - minRow + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private int columns() {
|
||||||
|
return maxColumn - minColumn + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private long sourceRows() {
|
||||||
|
return maxSourceRow - minSourceRow + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private long sourceColumns() {
|
||||||
|
return maxSourceColumn - minSourceColumn + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user