Compare commits
24 Commits
codex-perf
...
c2ccd056af
| Author | SHA1 | Date | |
|---|---|---|---|
| c2ccd056af | |||
| 11734d89f7 | |||
| 4ed2b82f2f | |||
| 64d86fe487 | |||
| 556ec9787b | |||
| b6a65c9f4a | |||
| 44edff25d9 | |||
| cc694a257a | |||
| 1895a48550 | |||
| 40230dd8f7 | |||
| a31cf06cbd | |||
| 6cf9afd664 | |||
| 75cbea61dd | |||
| dbb5dc350b | |||
| 1776aa251a | |||
| 0be3662a93 | |||
| 8f70c4bf45 | |||
| 6738051da1 | |||
| 65f69d5c75 | |||
| 85f2d86c09 | |||
| 5f0edcbe31 | |||
| ebf87c0bff | |||
| a51bee3b43 | |||
| aa5ca0451c |
18
.classpath
18
.classpath
@@ -1,18 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<classpath>
|
|
||||||
<classpathentry kind="src" output="bin/main" path="src/main/java">
|
|
||||||
<attributes>
|
|
||||||
<attribute name="gradle_scope" value="main"/>
|
|
||||||
<attribute name="gradle_used_by_scope" value="main,test"/>
|
|
||||||
</attributes>
|
|
||||||
</classpathentry>
|
|
||||||
<classpathentry kind="src" output="bin/main" path="src/main/resources">
|
|
||||||
<attributes>
|
|
||||||
<attribute name="gradle_scope" value="main"/>
|
|
||||||
<attribute name="gradle_used_by_scope" value="main,test"/>
|
|
||||||
</attributes>
|
|
||||||
</classpathentry>
|
|
||||||
<classpathentry kind="con" path="org.eclipse.jdt.launching.JRE_CONTAINER/org.eclipse.jdt.internal.debug.ui.launcher.StandardVMType/JavaSE-21/"/>
|
|
||||||
<classpathentry kind="con" path="org.eclipse.buildship.core.gradleclasspathcontainer"/>
|
|
||||||
<classpathentry kind="output" path="bin/default"/>
|
|
||||||
</classpath>
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
019e6999-b7c8-7591-a8aa-ea51b89a7f7e
|
|
||||||
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
*.mp4 filter=lfs diff=lfs merge=lfs -text
|
||||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -11,6 +11,10 @@ devenv.local.yaml
|
|||||||
# pre-commit
|
# pre-commit
|
||||||
.pre-commit-config.yaml
|
.pre-commit-config.yaml
|
||||||
build
|
build
|
||||||
build
|
|
||||||
.gradle
|
.gradle
|
||||||
bin
|
bin
|
||||||
|
.settings
|
||||||
|
.project
|
||||||
|
.worktrees
|
||||||
|
.classpath
|
||||||
|
.codexsession
|
||||||
|
|||||||
34
.project
34
.project
@@ -1,34 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<projectDescription>
|
|
||||||
<name>jprototerm</name>
|
|
||||||
<comment>Project jprototerm created by Buildship.</comment>
|
|
||||||
<projects>
|
|
||||||
</projects>
|
|
||||||
<buildSpec>
|
|
||||||
<buildCommand>
|
|
||||||
<name>org.eclipse.jdt.core.javabuilder</name>
|
|
||||||
<arguments>
|
|
||||||
</arguments>
|
|
||||||
</buildCommand>
|
|
||||||
<buildCommand>
|
|
||||||
<name>org.eclipse.buildship.core.gradleprojectbuilder</name>
|
|
||||||
<arguments>
|
|
||||||
</arguments>
|
|
||||||
</buildCommand>
|
|
||||||
</buildSpec>
|
|
||||||
<natures>
|
|
||||||
<nature>org.eclipse.jdt.core.javanature</nature>
|
|
||||||
<nature>org.eclipse.buildship.core.gradleprojectnature</nature>
|
|
||||||
</natures>
|
|
||||||
<filteredResources>
|
|
||||||
<filter>
|
|
||||||
<id>1779917652126</id>
|
|
||||||
<name></name>
|
|
||||||
<type>30</type>
|
|
||||||
<matcher>
|
|
||||||
<id>org.eclipse.core.resources.regexFilterMatcher</id>
|
|
||||||
<arguments>node_modules|\.git|__CREATED_BY_JAVA_LANGUAGE_SERVER__</arguments>
|
|
||||||
</matcher>
|
|
||||||
</filter>
|
|
||||||
</filteredResources>
|
|
||||||
</projectDescription>
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
arguments=--init-script /home/anon/Src/eclipse.jdt.ls/org.eclipse.jdt.ls.product/target/repository/configuration/org.eclipse.osgi/58/0/.cp/gradle/init/init.gradle
|
|
||||||
auto.sync=false
|
|
||||||
build.scans.enabled=false
|
|
||||||
connection.gradle.distribution=GRADLE_DISTRIBUTION(LOCAL_INSTALLATION(/home/anon/.sdkman/candidates/gradle/current))
|
|
||||||
connection.project.dir=
|
|
||||||
eclipse.preferences.version=1
|
|
||||||
gradle.user.home=
|
|
||||||
java.home=/home/anon/.local/lib/graalvm-jdk-21.0.9+7.1
|
|
||||||
jvm.arguments=
|
|
||||||
offline.mode=false
|
|
||||||
override.workspace.settings=true
|
|
||||||
show.console.view=true
|
|
||||||
show.executions.view=true
|
|
||||||
@@ -12,6 +12,8 @@ floating panes and tabs.
|
|||||||
> setups, or Mesa/AMD/Intel GPUs. I'm happy to accept pull requests that broaden host
|
> setups, or Mesa/AMD/Intel GPUs. I'm happy to accept pull requests that broaden host
|
||||||
> support.
|
> support.
|
||||||
|
|
||||||
|
<video src="https://gitea.gregorlohaus.com/gregor/jprototerm/media/branch/main/demo.mp4" controls></video>
|
||||||
|
|
||||||
## Build
|
## Build
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|||||||
@@ -30,3 +30,4 @@ 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
65
devenv.lock
@@ -1,65 +0,0 @@
|
|||||||
{
|
|
||||||
"nodes": {
|
|
||||||
"devenv": {
|
|
||||||
"locked": {
|
|
||||||
"dir": "src/modules",
|
|
||||||
"lastModified": 1779749056,
|
|
||||||
"narHash": "sha256-AtocdrunzuxTvSDn+82RntEhrs6TicM6Z4/zNQS9KKg=",
|
|
||||||
"owner": "cachix",
|
|
||||||
"repo": "devenv",
|
|
||||||
"rev": "099ac65fcef79e88127bdc06adbd1ea94255274a",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"dir": "src/modules",
|
|
||||||
"owner": "cachix",
|
|
||||||
"repo": "devenv",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"nixpkgs": {
|
|
||||||
"inputs": {
|
|
||||||
"nixpkgs-src": "nixpkgs-src"
|
|
||||||
},
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1778507786,
|
|
||||||
"narHash": "sha256-HzSQCKMsMr8r55LwM1JuzIOB+8bzk0FEv6sItKvsfoY=",
|
|
||||||
"owner": "cachix",
|
|
||||||
"repo": "devenv-nixpkgs",
|
|
||||||
"rev": "8f24a228a782e24576b155d1e39f0d914b380691",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"owner": "cachix",
|
|
||||||
"ref": "rolling",
|
|
||||||
"repo": "devenv-nixpkgs",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"nixpkgs-src": {
|
|
||||||
"flake": false,
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1778274207,
|
|
||||||
"narHash": "sha256-I4puXmX1iovcCHZlRmztO3vW0mAbbRvq4F8wgIMQ1MM=",
|
|
||||||
"owner": "NixOS",
|
|
||||||
"repo": "nixpkgs",
|
|
||||||
"rev": "b3da656039dc7a6240f27b2ef8cc6a3ef3bccae7",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"owner": "NixOS",
|
|
||||||
"ref": "nixpkgs-unstable",
|
|
||||||
"repo": "nixpkgs",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"root": {
|
|
||||||
"inputs": {
|
|
||||||
"devenv": "devenv",
|
|
||||||
"nixpkgs": "nixpkgs"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"root": "root",
|
|
||||||
"version": 7
|
|
||||||
}
|
|
||||||
66
devenv.nix
66
devenv.nix
@@ -1,66 +0,0 @@
|
|||||||
{ pkgs, lib, config, inputs, ... }:
|
|
||||||
|
|
||||||
let
|
|
||||||
system = pkgs.stdenv.hostPlatform.system;
|
|
||||||
|
|
||||||
jlibghostty = builtins.getFlake
|
|
||||||
"git+https://gitea.gregorlohaus.com/gregor/jlibghostty.git";
|
|
||||||
|
|
||||||
jlib = jlibghostty.packages.${system}.jlibghostty;
|
|
||||||
hostNvidiaLibs = ".devenv/host-nvidia-libs";
|
|
||||||
in
|
|
||||||
{
|
|
||||||
packages = [
|
|
||||||
pkgs.git
|
|
||||||
pkgs.gradle_9
|
|
||||||
pkgs.jdk25
|
|
||||||
pkgs.jdt-language-server
|
|
||||||
pkgs.openjfx
|
|
||||||
|
|
||||||
pkgs.glib
|
|
||||||
pkgs.xorg.libXxf86vm
|
|
||||||
pkgs.xorg.libXrender
|
|
||||||
pkgs.xorg.libXtst
|
|
||||||
pkgs.xorg.libXi
|
|
||||||
pkgs.xorg.libXrandr
|
|
||||||
|
|
||||||
pkgs.libGL
|
|
||||||
pkgs.gtk3
|
|
||||||
pkgs.alsa-lib
|
|
||||||
pkgs.mesa-demos
|
|
||||||
];
|
|
||||||
|
|
||||||
env.LD_LIBRARY_PATH = "${hostNvidiaLibs}:" + lib.makeLibraryPath [
|
|
||||||
pkgs.openjfx
|
|
||||||
|
|
||||||
pkgs.glib
|
|
||||||
pkgs.xorg.libXxf86vm
|
|
||||||
pkgs.xorg.libXrender
|
|
||||||
pkgs.xorg.libXtst
|
|
||||||
pkgs.xorg.libXi
|
|
||||||
pkgs.xorg.libXrandr
|
|
||||||
|
|
||||||
pkgs.libGL
|
|
||||||
pkgs.gtk3
|
|
||||||
pkgs.alsa-lib
|
|
||||||
] + ":/usr/lib/x86_64-linux-gnu/nvidia/current";
|
|
||||||
env.__GLX_VENDOR_LIBRARY_NAME = "nvidia";
|
|
||||||
env.__EGL_VENDOR_LIBRARY_FILENAMES = "/usr/share/glvnd/egl_vendor.d/10_nvidia.json";
|
|
||||||
env.JLIBGHOSTTY_MAVEN_REPO = "${jlib}/maven";
|
|
||||||
|
|
||||||
enterShell = ''
|
|
||||||
mkdir -p ${hostNvidiaLibs}
|
|
||||||
for lib in \
|
|
||||||
/usr/lib/x86_64-linux-gnu/libnvidia*.so* \
|
|
||||||
/usr/lib/x86_64-linux-gnu/libGLX_nvidia.so* \
|
|
||||||
/usr/lib/x86_64-linux-gnu/libEGL_nvidia.so* \
|
|
||||||
/usr/lib/x86_64-linux-gnu/nvidia/current/libnvidia*.so* \
|
|
||||||
/usr/lib/x86_64-linux-gnu/nvidia/current/libGLX_nvidia.so* \
|
|
||||||
/usr/lib/x86_64-linux-gnu/nvidia/current/libEGL_nvidia.so*
|
|
||||||
do
|
|
||||||
if [ -e "$lib" ]; then
|
|
||||||
ln -sfn "$lib" ${hostNvidiaLibs}/"$(basename "$lib")"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
'';
|
|
||||||
}
|
|
||||||
18
devenv.yaml
18
devenv.yaml
@@ -1,18 +0,0 @@
|
|||||||
# yaml-language-server: $schema=https://devenv.sh/devenv.schema.json
|
|
||||||
inputs:
|
|
||||||
nixpkgs:
|
|
||||||
url: github:cachix/devenv-nixpkgs/rolling
|
|
||||||
|
|
||||||
# If you're using non-OSS software, you can set allowUnfree to true.
|
|
||||||
# allowUnfree: true
|
|
||||||
|
|
||||||
# If you're not willing to allow unsupported packages:
|
|
||||||
# allowUnsupportedSystem: false
|
|
||||||
|
|
||||||
# If you're willing to use a package that's vulnerable
|
|
||||||
# permittedInsecurePackages:
|
|
||||||
# - "openssl-1.1.1w"
|
|
||||||
|
|
||||||
# If you have more than one devenv you can merge them
|
|
||||||
#imports:
|
|
||||||
# - ./backend
|
|
||||||
8
flake.lock
generated
8
flake.lock
generated
@@ -70,11 +70,11 @@
|
|||||||
"nixpkgs": "nixpkgs"
|
"nixpkgs": "nixpkgs"
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1780258814,
|
"lastModified": 1780363584,
|
||||||
"narHash": "sha256-8rxL7xaZ/loYg3zdt0w5+hfNyHFVknDZN360NzrtCsQ=",
|
"narHash": "sha256-BN6kwSBnlavuySut6vvLsfwkfKBjrPvFgJeeMMTXNtg=",
|
||||||
"ref": "refs/heads/main",
|
"ref": "refs/heads/main",
|
||||||
"rev": "6a3d5aa0b0b1f738c958e2a2f0249574c07d9c4d",
|
"rev": "1cd908e5d0070fdc1c86fc6b300cf1d6dbb5d184",
|
||||||
"revCount": 23,
|
"revCount": 25,
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://gitea.gregorlohaus.com/gregor/jlibghostty.git"
|
"url": "https://gitea.gregorlohaus.com/gregor/jlibghostty.git"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import io.github.wasabithumb.jtoml.document.TomlDocument;
|
|||||||
import io.github.wasabithumb.jtoml.except.TomlException;
|
import io.github.wasabithumb.jtoml.except.TomlException;
|
||||||
import io.github.wasabithumb.jtoml.key.TomlKey;
|
import io.github.wasabithumb.jtoml.key.TomlKey;
|
||||||
import io.github.wasabithumb.jtoml.value.TomlValue;
|
import io.github.wasabithumb.jtoml.value.TomlValue;
|
||||||
|
import io.github.wasabithumb.jtoml.value.array.TomlArray;
|
||||||
import io.github.wasabithumb.jtoml.value.primitive.TomlPrimitive;
|
import io.github.wasabithumb.jtoml.value.primitive.TomlPrimitive;
|
||||||
import io.github.wasabithumb.jtoml.value.table.TomlTable;
|
import io.github.wasabithumb.jtoml.value.table.TomlTable;
|
||||||
|
|
||||||
@@ -12,6 +13,7 @@ import java.nio.file.Files;
|
|||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.nio.file.StandardOpenOption;
|
import java.nio.file.StandardOpenOption;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@@ -20,7 +22,7 @@ public record AppConfig(
|
|||||||
int columns,
|
int columns,
|
||||||
int rows,
|
int rows,
|
||||||
long maxScrollback,
|
long maxScrollback,
|
||||||
String shell,
|
List<String> shell,
|
||||||
String fontFamily,
|
String fontFamily,
|
||||||
double fontSize,
|
double fontSize,
|
||||||
double windowWidth,
|
double windowWidth,
|
||||||
@@ -38,12 +40,14 @@ public record AppConfig(
|
|||||||
"toggle_floating",
|
"toggle_floating",
|
||||||
"new_pane",
|
"new_pane",
|
||||||
"next_floating",
|
"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() {
|
||||||
@@ -60,7 +64,7 @@ public record AppConfig(
|
|||||||
intValue(document, "terminal.columns", defaults.columns),
|
intValue(document, "terminal.columns", defaults.columns),
|
||||||
intValue(document, "terminal.rows", defaults.rows),
|
intValue(document, "terminal.rows", defaults.rows),
|
||||||
longValue(document, "terminal.max_scrollback", defaults.maxScrollback),
|
longValue(document, "terminal.max_scrollback", defaults.maxScrollback),
|
||||||
stringValue(document, "terminal.shell", defaults.shell),
|
stringListValue(document, "terminal.shell", defaults.shell),
|
||||||
stringValue(document, "terminal.font_family", defaults.fontFamily),
|
stringValue(document, "terminal.font_family", defaults.fontFamily),
|
||||||
doubleValue(document, "terminal.font_size", defaults.fontSize),
|
doubleValue(document, "terminal.font_size", defaults.fontSize),
|
||||||
doubleValue(document, "window.width", defaults.windowWidth),
|
doubleValue(document, "window.width", defaults.windowWidth),
|
||||||
@@ -97,12 +101,14 @@ public record AppConfig(
|
|||||||
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("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"))
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -136,8 +142,10 @@ public record AppConfig(
|
|||||||
return Path.of(System.getProperty("user.home"), ".config", "jprototerm", "config.toml");
|
return Path.of(System.getProperty("user.home"), ".config", "jprototerm", "config.toml");
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String defaultShell() {
|
private static List<String> defaultShell() {
|
||||||
return "/bin/bash";
|
// The executable plus its arguments, spawned verbatim. -i makes bash interactive; a
|
||||||
|
// different shell can use whatever flags it needs (or none) by editing this list.
|
||||||
|
return List.of("/bin/bash", "-i");
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String defaultScrollbackEditorCommand() {
|
private static String defaultScrollbackEditorCommand() {
|
||||||
@@ -184,7 +192,7 @@ public record AppConfig(
|
|||||||
builder.append("columns = ").append(columns).append('\n');
|
builder.append("columns = ").append(columns).append('\n');
|
||||||
builder.append("rows = ").append(rows).append('\n');
|
builder.append("rows = ").append(rows).append('\n');
|
||||||
builder.append("max_scrollback = ").append(maxScrollback).append('\n');
|
builder.append("max_scrollback = ").append(maxScrollback).append('\n');
|
||||||
builder.append("shell = ").append(quoted(shell)).append('\n');
|
builder.append("shell = ").append(quotedList(shell)).append('\n');
|
||||||
builder.append("font_family = ").append(quoted(fontFamily)).append('\n');
|
builder.append("font_family = ").append(quoted(fontFamily)).append('\n');
|
||||||
builder.append("font_size = ").append(trimDouble(fontSize)).append("\n\n");
|
builder.append("font_size = ").append(trimDouble(fontSize)).append("\n\n");
|
||||||
builder.append("[window]\n");
|
builder.append("[window]\n");
|
||||||
@@ -209,6 +217,17 @@ public record AppConfig(
|
|||||||
return builder.toString();
|
return builder.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static String quotedList(List<String> values) {
|
||||||
|
StringBuilder builder = new StringBuilder("[");
|
||||||
|
for (int i = 0; i < values.size(); i++) {
|
||||||
|
if (i > 0) {
|
||||||
|
builder.append(", ");
|
||||||
|
}
|
||||||
|
builder.append(quoted(values.get(i)));
|
||||||
|
}
|
||||||
|
return builder.append("]").toString();
|
||||||
|
}
|
||||||
|
|
||||||
private static String quoted(String value) {
|
private static String quoted(String value) {
|
||||||
return "\"" + value
|
return "\"" + value
|
||||||
.replace("\\", "\\\\")
|
.replace("\\", "\\\\")
|
||||||
@@ -268,6 +287,26 @@ public record AppConfig(
|
|||||||
return primitive == null ? fallback : primitive.asString();
|
return primitive == null ? fallback : primitive.asString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Reads a TOML array of strings (e.g. {@code shell = ["/bin/bash", "-i"]}), or the fallback. */
|
||||||
|
private static List<String> stringListValue(TomlTable table, String key, List<String> fallback) {
|
||||||
|
TomlValue value = table.get(key);
|
||||||
|
if (value == null || !value.isArray()) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
List<String> result = new ArrayList<>();
|
||||||
|
for (TomlValue element : value.asArray()) {
|
||||||
|
if (element.isPrimitive()) {
|
||||||
|
try {
|
||||||
|
result.add(element.asPrimitive().asString());
|
||||||
|
} catch (RuntimeException ignored) {
|
||||||
|
// Skip non-string entries; a shell command line is a list of strings.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// An empty or all-invalid array would mean "no program to run"; keep the default instead.
|
||||||
|
return result.isEmpty() ? fallback : List.copyOf(result);
|
||||||
|
}
|
||||||
|
|
||||||
private static int intValue(TomlTable table, String key, int fallback) {
|
private static int intValue(TomlTable table, String key, int fallback) {
|
||||||
TomlPrimitive primitive = primitive(table, key);
|
TomlPrimitive primitive = primitive(table, key);
|
||||||
if (primitive == null) {
|
if (primitive == null) {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ 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.VPos;
|
||||||
|
import javafx.scene.Node;
|
||||||
import javafx.scene.canvas.Canvas;
|
import javafx.scene.canvas.Canvas;
|
||||||
import javafx.scene.canvas.GraphicsContext;
|
import javafx.scene.canvas.GraphicsContext;
|
||||||
import javafx.scene.input.InputEvent;
|
import javafx.scene.input.InputEvent;
|
||||||
@@ -38,6 +39,8 @@ public final class Compositor {
|
|||||||
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 Canvas canvas = new Canvas();
|
||||||
|
// Kitty images are drawn as retained nodes layered over the canvas, not composited onto it.
|
||||||
|
private final KittyImageOverlay imageOverlay = new KittyImageOverlay();
|
||||||
private final AppConfig config;
|
private final AppConfig config;
|
||||||
private final TerminalMetrics metrics;
|
private final TerminalMetrics metrics;
|
||||||
private final List<Tab> tabs = new ArrayList<>();
|
private final List<Tab> tabs = new ArrayList<>();
|
||||||
@@ -58,11 +61,13 @@ public final class Compositor {
|
|||||||
private long lastContentVersion = Long.MIN_VALUE;
|
private long lastContentVersion = Long.MIN_VALUE;
|
||||||
private boolean mouseButtonPressed;
|
private boolean mouseButtonPressed;
|
||||||
private MouseButton pressedButton = MouseButton.UNKNOWN;
|
private MouseButton pressedButton = MouseButton.UNKNOWN;
|
||||||
|
// Run when the last pane closes (so the window can quit). No-op until Main sets it.
|
||||||
|
private Runnable onEmpty = () -> {};
|
||||||
|
|
||||||
public Compositor(AppConfig config, TerminalMetrics metrics) {
|
public Compositor(AppConfig config, TerminalMetrics metrics) {
|
||||||
this.config = config;
|
this.config = config;
|
||||||
this.metrics = metrics;
|
this.metrics = metrics;
|
||||||
tabs.add(new Tab(config, metrics));
|
tabs.add(new Tab(config, metrics, this::closePane));
|
||||||
canvas.setFocusTraversable(true);
|
canvas.setFocusTraversable(true);
|
||||||
canvas.setOnMousePressed(this::handleMousePressed);
|
canvas.setOnMousePressed(this::handleMousePressed);
|
||||||
canvas.setOnMouseReleased(this::handleMouseReleased);
|
canvas.setOnMouseReleased(this::handleMouseReleased);
|
||||||
@@ -75,6 +80,16 @@ public final class Compositor {
|
|||||||
return canvas;
|
return canvas;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Sets the callback run when the last pane closes (e.g. to quit the application). */
|
||||||
|
public void setOnEmpty(Runnable onEmpty) {
|
||||||
|
this.onEmpty = onEmpty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The kitty-image overlay, to be stacked directly above {@link #canvas()} in the window. */
|
||||||
|
public Node imageOverlay() {
|
||||||
|
return imageOverlay.node();
|
||||||
|
}
|
||||||
|
|
||||||
public void setFont(String family, double size) {
|
public void setFont(String family, double size) {
|
||||||
metrics.setFont(family, size);
|
metrics.setFont(family, size);
|
||||||
paneContentVersion.clear();
|
paneContentVersion.clear();
|
||||||
@@ -113,6 +128,29 @@ public final class Compositor {
|
|||||||
layoutVersion++;
|
layoutVersion++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Opens a new floating pane, makes it active, and returns it (null when no tab exists). */
|
||||||
|
public TerminalPane openFloatingPane() {
|
||||||
|
if (isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
TerminalPane pane = currentTab().createFloatingPane();
|
||||||
|
layoutVersion++;
|
||||||
|
return pane;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens a floating pane running {@code command} directly (auto-closing when it exits), makes it
|
||||||
|
* active, and returns it (null when no tab exists).
|
||||||
|
*/
|
||||||
|
public TerminalPane openFloatingPane(String command) {
|
||||||
|
if (isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
TerminalPane pane = currentTab().createFloatingPane(command);
|
||||||
|
layoutVersion++;
|
||||||
|
return pane;
|
||||||
|
}
|
||||||
|
|
||||||
public void nextFloatingPane() {
|
public void nextFloatingPane() {
|
||||||
if (isEmpty()) {
|
if (isEmpty()) {
|
||||||
return;
|
return;
|
||||||
@@ -121,24 +159,58 @@ public final class Compositor {
|
|||||||
layoutVersion++;
|
layoutVersion++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void promoteActiveFloating() {
|
||||||
|
if (isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
currentTab().promoteActiveFloating();
|
||||||
|
layoutVersion++;
|
||||||
|
}
|
||||||
|
|
||||||
public void closeActivePane() {
|
public void closeActivePane() {
|
||||||
if (isEmpty()) {
|
if (isEmpty()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
currentTab().closeActivePane();
|
TerminalPane active = currentTab().activePane();
|
||||||
if (currentTab().isEmpty()) {
|
if (active != null) {
|
||||||
// Closing a tab's last pane closes the tab. When no tabs remain the surface is
|
closePane(active);
|
||||||
// empty and Main quits.
|
}
|
||||||
tabs.remove(currentTabIndex);
|
}
|
||||||
if (currentTabIndex >= tabs.size()) {
|
|
||||||
currentTabIndex = Math.max(0, tabs.size() - 1);
|
/**
|
||||||
|
* Closes a specific pane, wherever it lives. Driven both by the key-bound close (via
|
||||||
|
* {@link #closeActivePane()}) and by a pane whose process exited on its own. Drops the owning
|
||||||
|
* tab if it becomes empty, and fires {@link #setOnEmpty} when the last pane is gone. Must run on
|
||||||
|
* the FX thread.
|
||||||
|
*/
|
||||||
|
public void closePane(TerminalPane pane) {
|
||||||
|
for (int i = 0; i < tabs.size(); i++) {
|
||||||
|
Tab tab = tabs.get(i);
|
||||||
|
if (tab.closePane(pane)) {
|
||||||
|
if (tab.isEmpty()) {
|
||||||
|
// Closing a tab's last pane closes the tab. Keep currentTabIndex pointing at the
|
||||||
|
// same tab (or clamp it when the current/last tab went away).
|
||||||
|
tabs.remove(i);
|
||||||
|
if (i < currentTabIndex) {
|
||||||
|
currentTabIndex--;
|
||||||
|
} else if (currentTabIndex >= tabs.size()) {
|
||||||
|
currentTabIndex = Math.max(0, tabs.size() - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
layoutVersion++;
|
||||||
|
if (isEmpty()) {
|
||||||
|
onEmpty.run();
|
||||||
|
}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
layoutVersion++;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void newTab() {
|
public void newTab() {
|
||||||
tabs.add(new Tab(config, metrics));
|
// Open the new tab in the currently active pane's working directory, so it lands where the
|
||||||
|
// user currently is rather than always in home.
|
||||||
|
String workingDirectory = isEmpty() ? null : currentTab().activePane().currentWorkingDirectory();
|
||||||
|
tabs.add(new Tab(config, metrics, workingDirectory, this::closePane));
|
||||||
currentTabIndex = tabs.size() - 1;
|
currentTabIndex = tabs.size() - 1;
|
||||||
layoutVersion++;
|
layoutVersion++;
|
||||||
}
|
}
|
||||||
@@ -246,6 +318,7 @@ public final class Compositor {
|
|||||||
for (TerminalPane pane : panes) {
|
for (TerminalPane pane : panes) {
|
||||||
paneContentVersion.put(pane, pane.paintFull(gc, isActive(pane)));
|
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
|
// Repaint just the panes whose content changed, directly on the retained canvas. Each pane
|
||||||
@@ -262,6 +335,7 @@ public final class Compositor {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
paneContentVersion.put(pane, pane.paintIncremental(gc, isActive(pane)));
|
paneContentVersion.put(pane, pane.paintIncremental(gc, isActive(pane)));
|
||||||
|
imageOverlay.updatePane(pane);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
package com.gregor.jprototerm;
|
package com.gregor.jprototerm;
|
||||||
|
|
||||||
|
import dev.jlibghostty.KittyImage;
|
||||||
import dev.jlibghostty.KittyImageCompression;
|
import dev.jlibghostty.KittyImageCompression;
|
||||||
import dev.jlibghostty.KittyImageFormat;
|
import dev.jlibghostty.KittyImageFormat;
|
||||||
import dev.jlibghostty.KittyImageSnapshot;
|
|
||||||
import dev.jlibghostty.KittyPlacement;
|
import dev.jlibghostty.KittyPlacement;
|
||||||
import dev.jlibghostty.KittyPlacementLayer;
|
import dev.jlibghostty.KittyPlacementLayer;
|
||||||
import dev.jlibghostty.KittyPlaceholder;
|
import dev.jlibghostty.KittyPlaceholder;
|
||||||
@@ -27,15 +27,18 @@ import javafx.scene.text.Text;
|
|||||||
|
|
||||||
import java.io.ByteArrayInputStream;
|
import java.io.ByteArrayInputStream;
|
||||||
import java.nio.IntBuffer;
|
import java.nio.IntBuffer;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The real terminal renderer: paints a pane's background, cell rows, cursor, border, padding
|
* The real terminal renderer: rasterizes a pane's background, cell rows, cursor, border and
|
||||||
* and (when enabled) kitty graphics. One instance per pane, since it caches that pane's
|
* padding into the {@link SoftwareBackbuffer} and blits it to the canvas. Kitty images are not
|
||||||
* decoded kitty images.
|
* drawn here — each paint collects their placements (see {@link #kittyImages()}) for the
|
||||||
|
* compositor to render as overlay nodes above the canvas. One instance per pane, since it caches
|
||||||
|
* that pane's decoded kitty images.
|
||||||
*/
|
*/
|
||||||
final class GhosttyTerminalRenderer extends TerminalRenderer {
|
final class GhosttyTerminalRenderer extends TerminalRenderer {
|
||||||
// GhosttyRenderStateDirty values (stable C ABI; see ghostty/vt/render.h).
|
// GhosttyRenderStateDirty values (stable C ABI; see ghostty/vt/render.h).
|
||||||
@@ -60,6 +63,9 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
|
|||||||
// Decoded kitty images for this renderer's pane (kitty graphics state is per-terminal).
|
// Decoded kitty images for this renderer's pane (kitty graphics state is per-terminal).
|
||||||
private final Map<KittyImageKey, Image> kittyImageCache = new HashMap<>();
|
private final Map<KittyImageKey, Image> kittyImageCache = new HashMap<>();
|
||||||
private final SoftwareBackbuffer software = new SoftwareBackbuffer();
|
private final SoftwareBackbuffer software = new SoftwareBackbuffer();
|
||||||
|
// Image placements produced by the last paint; the compositor reads these and renders them
|
||||||
|
// as overlay nodes rather than compositing them onto the canvas. Empty for non-kitty panes.
|
||||||
|
private List<KittyImageNode> kittyImageNodes = List.of();
|
||||||
|
|
||||||
GhosttyTerminalRenderer(TerminalMetrics metrics) {
|
GhosttyTerminalRenderer(TerminalMetrics metrics) {
|
||||||
this.metrics = metrics;
|
this.metrics = metrics;
|
||||||
@@ -73,15 +79,10 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
|
|||||||
double height = target.height();
|
double height = target.height();
|
||||||
gc.save();
|
gc.save();
|
||||||
clip(gc, px, py, width, height, target.clip());
|
clip(gc, px, py, width, height, target.clip());
|
||||||
boolean withKitty = target.kittyEnabled() && hasKittyGraphics(target);
|
|
||||||
RenderStateSnapshot snapshot = target.snapshotFull();
|
RenderStateSnapshot snapshot = target.snapshotFull();
|
||||||
if (withKitty) {
|
software.paintFull(gc, snapshot, px, py, width, height, active);
|
||||||
drawContent(gc, target, snapshot, px, py, width, height, active, true);
|
|
||||||
software.invalidate();
|
|
||||||
} else {
|
|
||||||
software.paintFull(gc, snapshot, px, py, width, height, active);
|
|
||||||
}
|
|
||||||
gc.restore();
|
gc.restore();
|
||||||
|
collectKittyImages(target, snapshot, px, py);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -93,171 +94,30 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
|
|||||||
gc.save();
|
gc.save();
|
||||||
clip(gc, px, py, width, height, target.clip());
|
clip(gc, px, py, width, height, target.clip());
|
||||||
if (target.kittyEnabled() && hasKittyGraphics(target)) {
|
if (target.kittyEnabled() && hasKittyGraphics(target)) {
|
||||||
// Kitty placements can move without a per-row dirty flag, so always redraw whole.
|
// Images render as overlay nodes, not on the canvas, but their positions track the
|
||||||
drawContent(gc, target, target.snapshotFull(), px, py, width, height, active, true);
|
// grid (scroll/cursor), so we need a full snapshot to locate placeholder cells. The
|
||||||
software.invalidate();
|
// software path itself still repaints only the text rows whose hash changed.
|
||||||
} else {
|
RenderStateSnapshot snapshot = target.snapshotFull();
|
||||||
RenderStateSnapshot snapshot = target.snapshot();
|
software.paintFullOrShifted(gc, snapshot, px, py, width, height, active);
|
||||||
int dirty = snapshot == null ? DIRTY_FULL : snapshot.dirty();
|
gc.restore();
|
||||||
if (dirty == DIRTY_FULL) {
|
collectKittyImages(target, snapshot, px, py);
|
||||||
software.paintFullOrShifted(gc, target.snapshotFull(), px, py, width, height, active);
|
|
||||||
} else if (dirty == DIRTY_PARTIAL) {
|
|
||||||
software.paintDirty(gc, target, snapshot, px, py, width, height, active);
|
|
||||||
}
|
|
||||||
// dirty == FALSE: nothing visible changed.
|
|
||||||
}
|
|
||||||
gc.restore();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Full content render: background, border, all rows, cursor, and (when enabled) kitty
|
|
||||||
// graphics. Used by the kitty direct path and by full redraws.
|
|
||||||
private void drawContent(
|
|
||||||
GraphicsContext gc,
|
|
||||||
RenderTarget target,
|
|
||||||
RenderStateSnapshot snapshot,
|
|
||||||
double x,
|
|
||||||
double y,
|
|
||||||
double width,
|
|
||||||
double height,
|
|
||||||
boolean active,
|
|
||||||
boolean withKitty
|
|
||||||
) {
|
|
||||||
double cellWidth = metrics.cellWidth();
|
|
||||||
double lineHeight = metrics.lineHeight();
|
|
||||||
gc.setFontSmoothingType(FontSmoothingType.LCD);
|
|
||||||
gc.setFill(PANE_BACKGROUND);
|
|
||||||
gc.fillRect(x, y, width, height);
|
|
||||||
gc.setFont(metrics.font());
|
|
||||||
|
|
||||||
double left = x + TerminalMetrics.PADDING;
|
|
||||||
double top = y + TerminalMetrics.PADDING;
|
|
||||||
double baseline = top + metrics.baselineOffset();
|
|
||||||
|
|
||||||
Map<KittyPlaceholderKey, KittyPlaceholderBounds> placeholderBounds = withKitty
|
|
||||||
? kittyPlaceholderBounds(snapshot)
|
|
||||||
: Map.of();
|
|
||||||
|
|
||||||
if (withKitty) {
|
|
||||||
drawKittyGraphics(gc, target, KittyPlacementLayer.BELOW_TEXT, placeholderBounds, left, top, cellWidth, lineHeight);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (snapshot != null) {
|
|
||||||
double contentBottom = top + snapshot.rows() * lineHeight;
|
|
||||||
fillVerticalPadding(gc, snapshot, x, y, width, height, top, contentBottom);
|
|
||||||
for (RenderRow row : snapshot.renderRows()) {
|
|
||||||
double y0 = Math.floor(top + (row.row() * lineHeight));
|
|
||||||
double y1 = Math.ceil(top + ((row.row() + 1) * lineHeight));
|
|
||||||
paintSidePadding(gc, row, x, width, left, cellWidth, y0, y1 - y0);
|
|
||||||
drawRow(gc, row, left, top, baseline, cellWidth, lineHeight);
|
|
||||||
}
|
|
||||||
drawCursor(gc, snapshot, left, top, cellWidth, lineHeight);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (withKitty) {
|
|
||||||
drawKittyGraphics(gc, target, KittyPlacementLayer.ABOVE_TEXT, placeholderBounds, left, top, cellWidth, lineHeight);
|
|
||||||
}
|
|
||||||
|
|
||||||
drawBorder(gc, x, y, width, height, active);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Incremental render: repaint only the rows ghostty flagged dirty, then restore the
|
|
||||||
// cursor and border. The local band tracks the repainted span only so the border redraw
|
|
||||||
// can be limited to it.
|
|
||||||
private void drawDirtyRows(
|
|
||||||
GraphicsContext gc,
|
|
||||||
RenderStateSnapshot snapshot,
|
|
||||||
double px,
|
|
||||||
double py,
|
|
||||||
double pw,
|
|
||||||
double ph,
|
|
||||||
boolean active
|
|
||||||
) {
|
|
||||||
double cellWidth = metrics.cellWidth();
|
|
||||||
double lineHeight = metrics.lineHeight();
|
|
||||||
gc.setFontSmoothingType(FontSmoothingType.LCD);
|
|
||||||
gc.setFont(metrics.font());
|
|
||||||
double left = px + TerminalMetrics.PADDING;
|
|
||||||
double top = py + TerminalMetrics.PADDING;
|
|
||||||
double baseline = top + metrics.baselineOffset();
|
|
||||||
|
|
||||||
double contentBottom = top + snapshot.rows() * lineHeight;
|
|
||||||
int lastRow = snapshot.rows() - 1;
|
|
||||||
List<RenderRow> rows = snapshot.renderRows();
|
|
||||||
boolean allRowsDirty = allRowsDirty(snapshot, rows);
|
|
||||||
if (allRowsDirty) {
|
|
||||||
gc.setFill(PANE_BACKGROUND);
|
|
||||||
gc.fillRect(px, py, pw, ph);
|
|
||||||
}
|
|
||||||
|
|
||||||
boolean cursorRowDirty = false;
|
|
||||||
double bandMin = Double.POSITIVE_INFINITY;
|
|
||||||
double bandMax = Double.NEGATIVE_INFINITY;
|
|
||||||
for (RenderRow row : rows) {
|
|
||||||
if (!row.dirty()) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// Snap the row band to integer pixels and paint opaque: a fractional-height fill
|
|
||||||
// would leave sub-pixel seams between rows.
|
|
||||||
double y0 = Math.floor(top + (row.row() * lineHeight));
|
|
||||||
double y1 = Math.ceil(top + ((row.row() + 1) * lineHeight));
|
|
||||||
if (!allRowsDirty) {
|
|
||||||
gc.setFill(PANE_BACKGROUND);
|
|
||||||
gc.fillRect(px, y0, pw, y1 - y0);
|
|
||||||
}
|
|
||||||
paintSidePadding(gc, row, px, pw, left, cellWidth, y0, y1 - y0);
|
|
||||||
drawRow(gc, row, left, top, baseline, cellWidth, lineHeight);
|
|
||||||
bandMin = Math.min(bandMin, y0);
|
|
||||||
bandMax = Math.max(bandMax, y1);
|
|
||||||
// Edge rows also own the top/bottom padding strip; repaint it and extend the
|
|
||||||
// band so panes stacked above get restored over it too.
|
|
||||||
if (row.row() == 0) {
|
|
||||||
gc.setFill(rowEdgeBackground(row, true));
|
|
||||||
gc.fillRect(px, py, pw, top - py);
|
|
||||||
bandMin = Math.min(bandMin, py);
|
|
||||||
}
|
|
||||||
if (row.row() == lastRow) {
|
|
||||||
gc.setFill(rowEdgeBackground(row, true));
|
|
||||||
gc.fillRect(px, contentBottom, pw, py + ph - contentBottom);
|
|
||||||
bandMax = Math.max(bandMax, py + ph);
|
|
||||||
}
|
|
||||||
if (snapshot.cursorViewportHasValue() && row.row() == snapshot.cursorViewportY()) {
|
|
||||||
cursorRowDirty = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (bandMin > bandMax) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
RenderStateSnapshot snapshot = target.snapshot();
|
||||||
// The cursor overlays its cell; redraw it only when its row was repainted, so we
|
int dirty = snapshot == null ? DIRTY_FULL : snapshot.dirty();
|
||||||
// neither leave a stale cursor nor stack the translucent overlay on itself.
|
if (dirty == DIRTY_FULL) {
|
||||||
if (cursorRowDirty) {
|
software.paintFullOrShifted(gc, target.snapshotFull(), px, py, width, height, active);
|
||||||
drawCursor(gc, snapshot, left, top, cellWidth, lineHeight);
|
} else if (dirty == DIRTY_PARTIAL) {
|
||||||
|
software.paintDirty(gc, target, snapshot, px, py, width, height, active);
|
||||||
}
|
}
|
||||||
// Repainting rows clears the side borders within the band; restore just those
|
// dirty == FALSE: nothing visible changed.
|
||||||
// segments, clipped to the band so we don't redraw the whole outline.
|
|
||||||
gc.save();
|
|
||||||
clipRect(gc, px, bandMin, pw, bandMax - bandMin);
|
|
||||||
drawBorder(gc, px, py, pw, ph, active);
|
|
||||||
gc.restore();
|
gc.restore();
|
||||||
|
kittyImageNodes = List.of();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static boolean allRowsDirty(RenderStateSnapshot snapshot, List<RenderRow> rows) {
|
@Override
|
||||||
if (rows.size() != snapshot.rows()) {
|
List<KittyImageNode> kittyImages() {
|
||||||
return false;
|
return kittyImageNodes;
|
||||||
}
|
|
||||||
for (int i = 0; i < rows.size(); i++) {
|
|
||||||
RenderRow row = rows.get(i);
|
|
||||||
if (!row.dirty() || row.row() != i) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void drawBorder(GraphicsContext gc, double x, double y, double width, double height, boolean active) {
|
|
||||||
gc.setStroke(active ? ACTIVE_BORDER : INACTIVE_BORDER);
|
|
||||||
gc.setLineWidth(active ? 2.0 : 1.0);
|
|
||||||
gc.strokeRect(x + 0.5, y + 0.5, width - 1.0, height - 1.0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Effective background colour of a cell as it is drawn (reverse video swaps fg/bg, an
|
// Effective background colour of a cell as it is drawn (reverse video swaps fg/bg, an
|
||||||
@@ -279,124 +139,6 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
|
|||||||
return cellBackgroundColor(firstCell ? cells.get(0) : cells.get(cells.size() - 1));
|
return cellBackgroundColor(firstCell ? cells.get(0) : cells.get(cells.size() - 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extend the row's edge-cell backgrounds into the left/right padding (the margin and the
|
|
||||||
// right-edge rounding sliver), so the unused space matches the rendered content.
|
|
||||||
private void paintSidePadding(GraphicsContext gc, RenderRow row, double paneX, double paneWidth,
|
|
||||||
double contentLeft, double cellWidth, double yTop, double bandHeight) {
|
|
||||||
int columns = row.cells().size();
|
|
||||||
if (columns == 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
double contentRight = contentLeft + (columns * cellWidth);
|
|
||||||
gc.setFill(rowEdgeBackground(row, true));
|
|
||||||
gc.fillRect(paneX, yTop, contentLeft - paneX, bandHeight);
|
|
||||||
gc.setFill(rowEdgeBackground(row, false));
|
|
||||||
gc.fillRect(contentRight, yTop, paneX + paneWidth - contentRight, bandHeight);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fill the top/bottom padding strips with the top/bottom row's edge colour.
|
|
||||||
private void fillVerticalPadding(GraphicsContext gc, RenderStateSnapshot snapshot,
|
|
||||||
double paneX, double paneY, double paneWidth, double paneHeight, double contentTop, double contentBottom) {
|
|
||||||
List<RenderRow> rows = snapshot.renderRows();
|
|
||||||
if (rows.isEmpty()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
gc.setFill(rowEdgeBackground(rows.get(0), true));
|
|
||||||
gc.fillRect(paneX, paneY, paneWidth, contentTop - paneY);
|
|
||||||
gc.setFill(rowEdgeBackground(rows.get(rows.size() - 1), true));
|
|
||||||
gc.fillRect(paneX, contentBottom, paneWidth, paneY + paneHeight - contentBottom);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void drawRow(
|
|
||||||
GraphicsContext gc,
|
|
||||||
RenderRow row,
|
|
||||||
double left,
|
|
||||||
double top,
|
|
||||||
double baseline,
|
|
||||||
double cellWidth,
|
|
||||||
double lineHeight
|
|
||||||
) {
|
|
||||||
drawRowBackgrounds(gc, row, left, top, cellWidth, lineHeight);
|
|
||||||
drawRowText(gc, row, left, baseline, cellWidth, lineHeight);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void drawRowBackgrounds(
|
|
||||||
GraphicsContext gc,
|
|
||||||
RenderRow row,
|
|
||||||
double left,
|
|
||||||
double top,
|
|
||||||
double cellWidth,
|
|
||||||
double lineHeight
|
|
||||||
) {
|
|
||||||
Color runBackground = null;
|
|
||||||
int runStartColumn = 0;
|
|
||||||
int previousColumn = -1;
|
|
||||||
for (RenderCell cell : row.cells()) {
|
|
||||||
if (cell.kittyPlaceholder().isPresent()) {
|
|
||||||
flushBackgroundRun(gc, runBackground, left, top, cellWidth, lineHeight, row.row(), runStartColumn, previousColumn);
|
|
||||||
runBackground = null;
|
|
||||||
previousColumn = -1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
Color bg = cell.selected() ? SELECTED_BACKGROUND : cellBackgroundOverride(cell);
|
|
||||||
if (bg == null) {
|
|
||||||
flushBackgroundRun(gc, runBackground, left, top, cellWidth, lineHeight, row.row(), runStartColumn, previousColumn);
|
|
||||||
runBackground = null;
|
|
||||||
previousColumn = -1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (runBackground == null || bg != runBackground || cell.column() != previousColumn + 1) {
|
|
||||||
flushBackgroundRun(gc, runBackground, left, top, cellWidth, lineHeight, row.row(), runStartColumn, previousColumn);
|
|
||||||
runBackground = bg;
|
|
||||||
runStartColumn = cell.column();
|
|
||||||
}
|
|
||||||
previousColumn = cell.column();
|
|
||||||
}
|
|
||||||
flushBackgroundRun(gc, runBackground, left, top, cellWidth, lineHeight, row.row(), runStartColumn, previousColumn);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void flushBackgroundRun(
|
|
||||||
GraphicsContext gc,
|
|
||||||
Color background,
|
|
||||||
double left,
|
|
||||||
double top,
|
|
||||||
double cellWidth,
|
|
||||||
double lineHeight,
|
|
||||||
int row,
|
|
||||||
int startColumn,
|
|
||||||
int endColumn
|
|
||||||
) {
|
|
||||||
if (background == null || endColumn < startColumn) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
gc.setFill(background);
|
|
||||||
gc.fillRect(
|
|
||||||
left + (startColumn * cellWidth),
|
|
||||||
top + (row * lineHeight),
|
|
||||||
(endColumn - startColumn + 1) * cellWidth,
|
|
||||||
lineHeight);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void drawRowText(
|
|
||||||
GraphicsContext gc,
|
|
||||||
RenderRow row,
|
|
||||||
double left,
|
|
||||||
double baseline,
|
|
||||||
double cellWidth,
|
|
||||||
double lineHeight
|
|
||||||
) {
|
|
||||||
for (RenderCell cell : row.cells()) {
|
|
||||||
if (cell.kittyPlaceholder().isPresent() || cell.codepoints().length == 0) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
gc.setFill(cellForegroundColor(cell));
|
|
||||||
gc.fillText(cell.text(), left + (cell.column() * cellWidth), baseline + (row.row() * lineHeight));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Background override for a cell: null means the pane default background already covers it.
|
// Background override for a cell: null means the pane default background already covers it.
|
||||||
private static Color cellBackgroundOverride(RenderCell cell) {
|
private static Color cellBackgroundOverride(RenderCell cell) {
|
||||||
if (cell.inverse()) {
|
if (cell.inverse()) {
|
||||||
@@ -434,46 +176,42 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
|
|||||||
return created;
|
return created;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void drawCursor(GraphicsContext gc, RenderStateSnapshot snapshot, double left, double top, double cellWidth, double lineHeight) {
|
|
||||||
if (!snapshot.cursorVisible() || !snapshot.cursorViewportHasValue()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
double x = left + (snapshot.cursorViewportX() * cellWidth);
|
|
||||||
double y = top + (snapshot.cursorViewportY() * lineHeight);
|
|
||||||
gc.setStroke(DEFAULT_FOREGROUND);
|
|
||||||
gc.setFill(CURSOR_FILL);
|
|
||||||
gc.setLineWidth(1.5);
|
|
||||||
|
|
||||||
RenderCursorStyle style = snapshot.cursorStyle();
|
|
||||||
if (style == RenderCursorStyle.BAR) {
|
|
||||||
gc.strokeLine(x + 0.5, y + 2.0, x + 0.5, y + lineHeight - 2.0);
|
|
||||||
} else if (style == RenderCursorStyle.UNDERLINE) {
|
|
||||||
gc.strokeLine(x + 1.0, y + lineHeight - 2.0, x + cellWidth - 1.0, y + lineHeight - 2.0);
|
|
||||||
} else if (style == RenderCursorStyle.BLOCK) {
|
|
||||||
gc.fillRect(x + 0.5, y + 1.0, Math.max(1.0, cellWidth - 1.0), Math.max(1.0, lineHeight - 2.0));
|
|
||||||
} else {
|
|
||||||
gc.strokeRect(x + 0.5, y + 1.0, Math.max(1.0, cellWidth - 1.0), Math.max(1.0, lineHeight - 2.0));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Kitty graphics --------------------------------------------------------------
|
// ---- Kitty graphics --------------------------------------------------------------
|
||||||
|
|
||||||
private static boolean hasKittyGraphics(RenderTarget target) {
|
private static boolean hasKittyGraphics(RenderTarget target) {
|
||||||
return target.kittyGraphics()
|
return target.kittyGraphics()
|
||||||
.map(graphics -> !graphics.placements().isEmpty())
|
.map(graphics -> !graphics.isEmpty())
|
||||||
.orElse(false);
|
.orElse(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void drawKittyGraphics(
|
// Build the image-node descriptors for the current frame. Reads the decoded-image cache and
|
||||||
GraphicsContext gc,
|
// each placement's geometry, but draws nothing — the compositor turns these into overlay
|
||||||
|
// nodes clipped to the pane.
|
||||||
|
private void collectKittyImages(RenderTarget target, RenderStateSnapshot snapshot, double px, double py) {
|
||||||
|
if (!target.kittyEnabled() || !hasKittyGraphics(target)) {
|
||||||
|
kittyImageNodes = List.of();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
double cellWidth = metrics.cellWidth();
|
||||||
|
double lineHeight = metrics.lineHeight();
|
||||||
|
double originX = px + TerminalMetrics.PADDING;
|
||||||
|
double originY = py + TerminalMetrics.PADDING;
|
||||||
|
Map<KittyPlaceholderKey, KittyPlaceholderBounds> placeholderBounds = kittyPlaceholderBounds(snapshot);
|
||||||
|
List<KittyImageNode> nodes = new ArrayList<>();
|
||||||
|
collectLayer(target, KittyPlacementLayer.BELOW_TEXT, placeholderBounds, originX, originY, cellWidth, lineHeight, nodes);
|
||||||
|
collectLayer(target, KittyPlacementLayer.ABOVE_TEXT, placeholderBounds, originX, originY, cellWidth, lineHeight, nodes);
|
||||||
|
kittyImageNodes = nodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void collectLayer(
|
||||||
RenderTarget target,
|
RenderTarget target,
|
||||||
KittyPlacementLayer layer,
|
KittyPlacementLayer layer,
|
||||||
Map<KittyPlaceholderKey, KittyPlaceholderBounds> placeholderBounds,
|
Map<KittyPlaceholderKey, KittyPlaceholderBounds> placeholderBounds,
|
||||||
double originX,
|
double originX,
|
||||||
double originY,
|
double originY,
|
||||||
double cellWidth,
|
double cellWidth,
|
||||||
double lineHeight
|
double lineHeight,
|
||||||
|
List<KittyImageNode> out
|
||||||
) {
|
) {
|
||||||
target.kittyGraphics().ifPresent(graphics -> {
|
target.kittyGraphics().ifPresent(graphics -> {
|
||||||
for (KittyPlacement placement : graphics.placements(layer)) {
|
for (KittyPlacement placement : graphics.placements(layer)) {
|
||||||
@@ -482,17 +220,17 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (placement.virtual()) {
|
KittyImageNode node = placement.virtual()
|
||||||
drawVirtualKittyPlacement(gc, placement, image, placeholderBounds, originX, originY, cellWidth, lineHeight);
|
? virtualNode(placement, image, placeholderBounds, originX, originY, cellWidth, lineHeight)
|
||||||
} else {
|
: pinnedNode(placement, image, originX, originY, cellWidth, lineHeight);
|
||||||
drawPinnedKittyPlacement(gc, placement, image, originX, originY, cellWidth, lineHeight);
|
if (node != null) {
|
||||||
|
out.add(node);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void drawPinnedKittyPlacement(
|
private static KittyImageNode pinnedNode(
|
||||||
GraphicsContext gc,
|
|
||||||
KittyPlacement placement,
|
KittyPlacement placement,
|
||||||
Image image,
|
Image image,
|
||||||
double originX,
|
double originX,
|
||||||
@@ -502,15 +240,13 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
|
|||||||
) {
|
) {
|
||||||
KittyRenderInfo renderInfo = placement.renderInfo().orElse(null);
|
KittyRenderInfo renderInfo = placement.renderInfo().orElse(null);
|
||||||
if (renderInfo == null || !renderInfo.viewportVisible()) {
|
if (renderInfo == null || !renderInfo.viewportVisible()) {
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
double sourceX = renderInfo.sourceX();
|
|
||||||
double sourceY = renderInfo.sourceY();
|
|
||||||
double sourceWidth = renderInfo.sourceWidth();
|
double sourceWidth = renderInfo.sourceWidth();
|
||||||
double sourceHeight = renderInfo.sourceHeight();
|
double sourceHeight = renderInfo.sourceHeight();
|
||||||
if (sourceWidth <= 0.0 || sourceHeight <= 0.0) {
|
if (sourceWidth <= 0.0 || sourceHeight <= 0.0) {
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
double x = originX + (renderInfo.viewportColumn() * cellWidth) + placement.xOffset();
|
double x = originX + (renderInfo.viewportColumn() * cellWidth) + placement.xOffset();
|
||||||
@@ -518,14 +254,14 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
|
|||||||
double width = renderInfo.pixelWidth() > 0 ? renderInfo.pixelWidth() : renderInfo.gridColumns() * cellWidth;
|
double width = renderInfo.pixelWidth() > 0 ? renderInfo.pixelWidth() : renderInfo.gridColumns() * cellWidth;
|
||||||
double height = renderInfo.pixelHeight() > 0 ? renderInfo.pixelHeight() : renderInfo.gridRows() * lineHeight;
|
double height = renderInfo.pixelHeight() > 0 ? renderInfo.pixelHeight() : renderInfo.gridRows() * lineHeight;
|
||||||
if (width <= 0.0 || height <= 0.0) {
|
if (width <= 0.0 || height <= 0.0) {
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
gc.drawImage(image, sourceX, sourceY, sourceWidth, sourceHeight, x, y, width, height);
|
return new KittyImageNode(placement.imageId(), placement.placementId(), image,
|
||||||
|
renderInfo.sourceX(), renderInfo.sourceY(), sourceWidth, sourceHeight, x, y, width, height);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void drawVirtualKittyPlacement(
|
private static KittyImageNode virtualNode(
|
||||||
GraphicsContext gc,
|
|
||||||
KittyPlacement placement,
|
KittyPlacement placement,
|
||||||
Image image,
|
Image image,
|
||||||
Map<KittyPlaceholderKey, KittyPlaceholderBounds> placeholderBounds,
|
Map<KittyPlaceholderKey, KittyPlaceholderBounds> placeholderBounds,
|
||||||
@@ -546,12 +282,12 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
|
|||||||
.orElse(null);
|
.orElse(null);
|
||||||
}
|
}
|
||||||
if (bounds == null || bounds.isEmpty()) {
|
if (bounds == null || bounds.isEmpty()) {
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
SourceRect source = sourceRect(placement, image);
|
SourceRect source = sourceRect(placement, image);
|
||||||
if (source.width() <= 0.0 || source.height() <= 0.0) {
|
if (source.width() <= 0.0 || source.height() <= 0.0) {
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
long gridColumns = gridColumns(placement, bounds);
|
long gridColumns = gridColumns(placement, bounds);
|
||||||
@@ -569,13 +305,14 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
|
|||||||
double availableHeight = bounds.rows() * lineHeight;
|
double availableHeight = bounds.rows() * lineHeight;
|
||||||
|
|
||||||
if (sourceWidth <= 0.0 || sourceHeight <= 0.0 || availableWidth <= 0.0 || availableHeight <= 0.0) {
|
if (sourceWidth <= 0.0 || sourceHeight <= 0.0 || availableWidth <= 0.0 || availableHeight <= 0.0) {
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
double scale = Math.min(availableWidth / sourceWidth, availableHeight / sourceHeight);
|
double scale = Math.min(availableWidth / sourceWidth, availableHeight / sourceHeight);
|
||||||
double width = sourceWidth * scale;
|
double width = sourceWidth * scale;
|
||||||
double height = sourceHeight * scale;
|
double height = sourceHeight * scale;
|
||||||
gc.drawImage(image, sourceX, sourceY, sourceWidth, sourceHeight, x, y, width, height);
|
return new KittyImageNode(placement.imageId(), placement.placementId(), image,
|
||||||
|
sourceX, sourceY, sourceWidth, sourceHeight, x, y, width, height);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static long gridColumns(KittyPlacement placement, KittyPlaceholderBounds bounds) {
|
private static long gridColumns(KittyPlacement placement, KittyPlaceholderBounds bounds) {
|
||||||
@@ -601,16 +338,17 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private Image imageFor(KittyPlacement placement) {
|
private Image imageFor(KittyPlacement placement) {
|
||||||
return placement.image().map(snapshot -> {
|
return placement.image().map(image -> {
|
||||||
byte[] data = snapshot.data();
|
// Build the cache key from cheap metadata only — the pixel buffer is never copied out
|
||||||
KittyImageKey key = KittyImageKey.of(snapshot, data);
|
// of native memory on a cache hit (the common per-frame case).
|
||||||
|
KittyImageKey key = KittyImageKey.of(image);
|
||||||
Image cached = kittyImageCache.get(key);
|
Image cached = kittyImageCache.get(key);
|
||||||
if (cached != null) {
|
if (cached != null) {
|
||||||
return cached;
|
return cached;
|
||||||
}
|
}
|
||||||
|
|
||||||
kittyImageCache.keySet().removeIf(existing -> existing.id() == snapshot.id());
|
kittyImageCache.keySet().removeIf(existing -> existing.id() == image.id());
|
||||||
Image decoded = decodeImage(snapshot, data);
|
Image decoded = decodeImage(image);
|
||||||
if (decoded != null) {
|
if (decoded != null) {
|
||||||
kittyImageCache.put(key, decoded);
|
kittyImageCache.put(key, decoded);
|
||||||
}
|
}
|
||||||
@@ -618,22 +356,25 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
|
|||||||
}).orElse(null);
|
}).orElse(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
private Image decodeImage(KittyImageSnapshot snapshot, byte[] data) {
|
private Image decodeImage(KittyImage source) {
|
||||||
if (snapshot.compression() != KittyImageCompression.NONE) {
|
if (source.compression() != KittyImageCompression.NONE) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (snapshot.format() == KittyImageFormat.PNG) {
|
// Only now — on a cache miss — do we pull the raw bytes across the native boundary.
|
||||||
|
byte[] data = source.data();
|
||||||
|
KittyImageFormat format = source.format();
|
||||||
|
if (format == KittyImageFormat.PNG) {
|
||||||
return new Image(new ByteArrayInputStream(data));
|
return new Image(new ByteArrayInputStream(data));
|
||||||
}
|
}
|
||||||
|
|
||||||
int width = Math.toIntExact(snapshot.width());
|
int width = Math.toIntExact(source.width());
|
||||||
int height = Math.toIntExact(snapshot.height());
|
int height = Math.toIntExact(source.height());
|
||||||
WritableImage image = new WritableImage(width, height);
|
WritableImage image = new WritableImage(width, height);
|
||||||
|
|
||||||
if (snapshot.format() == KittyImageFormat.RGBA) {
|
if (format == KittyImageFormat.RGBA) {
|
||||||
image.getPixelWriter().setPixels(0, 0, width, height, PixelFormat.getByteBgraInstance(), rgbaToBgra(data), 0, width * 4);
|
image.getPixelWriter().setPixels(0, 0, width, height, PixelFormat.getByteBgraInstance(), rgbaToBgra(data), 0, width * 4);
|
||||||
} else if (snapshot.format() == KittyImageFormat.RGB) {
|
} else if (format == KittyImageFormat.RGB) {
|
||||||
image.getPixelWriter().setPixels(0, 0, width, height, PixelFormat.getByteRgbInstance(), data, 0, width * 3);
|
image.getPixelWriter().setPixels(0, 0, width, height, PixelFormat.getByteRgbInstance(), data, 0, width * 3);
|
||||||
}
|
}
|
||||||
return image;
|
return image;
|
||||||
@@ -1321,15 +1062,15 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
|
|||||||
// identity + dimensions + payload length are enough to key the decoded-image cache, and
|
// identity + dimensions + payload length are enough to key the decoded-image cache, and
|
||||||
// we avoid fingerprinting the whole payload — which previously ran once per frame per
|
// we avoid fingerprinting the whole payload — which previously ran once per frame per
|
||||||
// placement (O(image size)) just to look the image up.
|
// placement (O(image size)) just to look the image up.
|
||||||
private record KittyImageKey(long id, long number, long width, long height, KittyImageFormat format, int dataLength) {
|
private record KittyImageKey(long id, long number, long width, long height, KittyImageFormat format, long dataLength) {
|
||||||
private static KittyImageKey of(KittyImageSnapshot snapshot, byte[] data) {
|
private static KittyImageKey of(KittyImage image) {
|
||||||
return new KittyImageKey(
|
return new KittyImageKey(
|
||||||
snapshot.id(),
|
image.id(),
|
||||||
snapshot.number(),
|
image.number(),
|
||||||
snapshot.width(),
|
image.width(),
|
||||||
snapshot.height(),
|
image.height(),
|
||||||
snapshot.format(),
|
image.format(),
|
||||||
data.length
|
image.dataLength()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
32
src/main/java/com/gregor/jprototerm/KittyImageNode.java
Normal file
32
src/main/java/com/gregor/jprototerm/KittyImageNode.java
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
package com.gregor.jprototerm;
|
||||||
|
|
||||||
|
import javafx.scene.image.Image;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single kitty image to display, produced by the renderer and consumed by {@link
|
||||||
|
* KittyImageOverlay}. Images are not painted onto the canvas; each becomes a retained
|
||||||
|
* {@code ImageView} node positioned over the pane. The {@code source*} fields are the region of
|
||||||
|
* {@link #image()} to show (in image pixels); the {@code x/y/width/height} are where to put it,
|
||||||
|
* in scene coordinates (the same space the pane's clip {@code Shape} lives in).
|
||||||
|
*
|
||||||
|
* <p>{@code imageId}+{@code placementId} identify the placement so the overlay can reuse the
|
||||||
|
* same node across frames instead of recreating it.
|
||||||
|
*/
|
||||||
|
record KittyImageNode(
|
||||||
|
long imageId,
|
||||||
|
long placementId,
|
||||||
|
Image image,
|
||||||
|
double sourceX,
|
||||||
|
double sourceY,
|
||||||
|
double sourceWidth,
|
||||||
|
double sourceHeight,
|
||||||
|
double x,
|
||||||
|
double y,
|
||||||
|
double width,
|
||||||
|
double height
|
||||||
|
) {
|
||||||
|
/** Stable per-pane key for node reuse. Packs the two u32 ids without collision. */
|
||||||
|
long key() {
|
||||||
|
return (imageId << 32) | (placementId & 0xffffffffL);
|
||||||
|
}
|
||||||
|
}
|
||||||
151
src/main/java/com/gregor/jprototerm/KittyImageOverlay.java
Normal file
151
src/main/java/com/gregor/jprototerm/KittyImageOverlay.java
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
package com.gregor.jprototerm;
|
||||||
|
|
||||||
|
import javafx.geometry.Rectangle2D;
|
||||||
|
import javafx.scene.Group;
|
||||||
|
import javafx.scene.Node;
|
||||||
|
import javafx.scene.image.ImageView;
|
||||||
|
import javafx.scene.layout.Pane;
|
||||||
|
import javafx.scene.shape.Rectangle;
|
||||||
|
import javafx.scene.shape.Shape;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.Iterator;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders kitty graphics images as retained scene-graph nodes layered over the {@link Compositor}
|
||||||
|
* canvas, instead of compositing them onto the canvas. Each pane gets a {@link Group} clipped to
|
||||||
|
* that pane's region (the same clip {@code Shape} the canvas renderer uses), and each visible
|
||||||
|
* image placement is an {@link ImageView} inside it, reused across frames so an unchanged image
|
||||||
|
* costs nothing to redraw.
|
||||||
|
*
|
||||||
|
* <p>The overlay {@link #node()} is mouse-transparent and sits above the canvas in the window's
|
||||||
|
* {@code StackPane}; its children use scene coordinates, which line up with the canvas because
|
||||||
|
* both fill the same root.
|
||||||
|
*/
|
||||||
|
final class KittyImageOverlay {
|
||||||
|
private final Pane root = new Pane();
|
||||||
|
private final Map<TerminalPane, PaneOverlay> overlays = new HashMap<>();
|
||||||
|
|
||||||
|
KittyImageOverlay() {
|
||||||
|
// Input belongs to the canvas underneath; the overlay only shows pixels.
|
||||||
|
root.setMouseTransparent(true);
|
||||||
|
root.setManaged(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
Node node() {
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full reconcile to {@code panes} (bottom-to-top): drop overlays for panes that went away,
|
||||||
|
* refresh each surviving/added pane's images and clip, and order the per-pane groups to match
|
||||||
|
* the pane z-order. Called on layout frames, after the panes have painted.
|
||||||
|
*/
|
||||||
|
void sync(List<TerminalPane> panes) {
|
||||||
|
Iterator<Map.Entry<TerminalPane, PaneOverlay>> it = overlays.entrySet().iterator();
|
||||||
|
while (it.hasNext()) {
|
||||||
|
Map.Entry<TerminalPane, PaneOverlay> entry = it.next();
|
||||||
|
if (!panes.contains(entry.getKey())) {
|
||||||
|
root.getChildren().remove(entry.getValue().group);
|
||||||
|
it.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (TerminalPane pane : panes) {
|
||||||
|
updatePane(pane);
|
||||||
|
}
|
||||||
|
// Only panes that actually have images get a group; order those to match pane z-order.
|
||||||
|
List<Node> ordered = new ArrayList<>(panes.size());
|
||||||
|
for (TerminalPane pane : panes) {
|
||||||
|
PaneOverlay overlay = overlays.get(pane);
|
||||||
|
if (overlay != null) {
|
||||||
|
ordered.add(overlay.group);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!root.getChildren().equals(ordered)) {
|
||||||
|
root.getChildren().setAll(ordered);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh one pane's images and clip (called on content frames for each repainted pane).
|
||||||
|
* Creates the pane's group if this is the first time it has shown an image.
|
||||||
|
*/
|
||||||
|
void updatePane(TerminalPane pane) {
|
||||||
|
List<KittyImageNode> images = pane.kittyImages();
|
||||||
|
PaneOverlay overlay = overlays.get(pane);
|
||||||
|
if (overlay == null) {
|
||||||
|
if (images.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
overlay = new PaneOverlay();
|
||||||
|
overlays.put(pane, overlay);
|
||||||
|
root.getChildren().add(overlay.group);
|
||||||
|
}
|
||||||
|
overlay.group.setClip(clipFor(pane));
|
||||||
|
reconcile(overlay, images);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void reconcile(PaneOverlay overlay, List<KittyImageNode> images) {
|
||||||
|
Set<Long> seen = new HashSet<>();
|
||||||
|
for (KittyImageNode node : images) {
|
||||||
|
long key = node.key();
|
||||||
|
seen.add(key);
|
||||||
|
ImageView view = overlay.views.get(key);
|
||||||
|
if (view == null) {
|
||||||
|
view = new ImageView();
|
||||||
|
view.setManaged(false);
|
||||||
|
view.setSmooth(true);
|
||||||
|
view.setPreserveRatio(false);
|
||||||
|
overlay.views.put(key, view);
|
||||||
|
overlay.group.getChildren().add(view);
|
||||||
|
}
|
||||||
|
apply(view, node);
|
||||||
|
}
|
||||||
|
if (overlay.views.size() == seen.size()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Iterator<Map.Entry<Long, ImageView>> it = overlay.views.entrySet().iterator();
|
||||||
|
while (it.hasNext()) {
|
||||||
|
Map.Entry<Long, ImageView> entry = it.next();
|
||||||
|
if (!seen.contains(entry.getKey())) {
|
||||||
|
overlay.group.getChildren().remove(entry.getValue());
|
||||||
|
it.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void apply(ImageView view, KittyImageNode node) {
|
||||||
|
if (view.getImage() != node.image()) {
|
||||||
|
view.setImage(node.image());
|
||||||
|
}
|
||||||
|
view.setViewport(new Rectangle2D(node.sourceX(), node.sourceY(), node.sourceWidth(), node.sourceHeight()));
|
||||||
|
view.setFitWidth(node.width());
|
||||||
|
view.setFitHeight(node.height());
|
||||||
|
view.setLayoutX(node.x());
|
||||||
|
view.setLayoutY(node.y());
|
||||||
|
}
|
||||||
|
|
||||||
|
// The pane's occlusion clip when one is set (rect minus covering panes), else the pane's
|
||||||
|
// plain bounds so an image can't spill outside its pane. Matches Tab's pixel snapping.
|
||||||
|
private static Shape clipFor(TerminalPane pane) {
|
||||||
|
Shape clip = pane.clip();
|
||||||
|
if (clip != null) {
|
||||||
|
return clip;
|
||||||
|
}
|
||||||
|
return new Rectangle(Math.round(pane.x()), Math.round(pane.y()), pane.width(), pane.height());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class PaneOverlay {
|
||||||
|
private final Group group = new Group();
|
||||||
|
private final Map<Long, ImageView> views = new HashMap<>();
|
||||||
|
|
||||||
|
private PaneOverlay() {
|
||||||
|
group.setManaged(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,9 @@ import java.lang.foreign.MemorySegment;
|
|||||||
import java.lang.foreign.SymbolLookup;
|
import java.lang.foreign.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;
|
||||||
@@ -74,6 +77,7 @@ 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);
|
||||||
@@ -205,6 +209,25 @@ public final class LinuxPty implements AutoCloseable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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. */
|
||||||
public void setWinSize(int columns, int rows) {
|
public void setWinSize(int columns, int rows) {
|
||||||
if (closed) {
|
if (closed) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ 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;
|
||||||
@@ -10,15 +11,18 @@ 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.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;
|
||||||
@@ -31,8 +35,14 @@ public final class Main extends Application {
|
|||||||
|
|
||||||
metrics = new TerminalMetrics(config.fontFamily(), config.fontSize());
|
metrics = new TerminalMetrics(config.fontFamily(), config.fontSize());
|
||||||
compositor = new Compositor(config, metrics);
|
compositor = new Compositor(config, metrics);
|
||||||
|
// When the last pane closes — whether via the close-pane key or because a pane's process
|
||||||
|
// exited on its own — tear down and quit.
|
||||||
|
compositor.setOnEmpty(() -> {
|
||||||
|
compositor.close();
|
||||||
|
Platform.exit();
|
||||||
|
});
|
||||||
|
|
||||||
StackPane root = new StackPane(compositor.canvas());
|
StackPane root = new StackPane(compositor.canvas(), compositor.imageOverlay());
|
||||||
compositor.canvas().widthProperty().bind(root.widthProperty());
|
compositor.canvas().widthProperty().bind(root.widthProperty());
|
||||||
compositor.canvas().heightProperty().bind(root.heightProperty());
|
compositor.canvas().heightProperty().bind(root.heightProperty());
|
||||||
|
|
||||||
@@ -52,10 +62,37 @@ 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
|
||||||
|
// away; the canvas requestFocus() below only routes events within the scene.
|
||||||
|
stage.toFront();
|
||||||
|
stage.requestFocus();
|
||||||
compositor.canvas().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) {
|
||||||
if (config.keybindings().get("navigate_left").matches(event)) {
|
if (config.keybindings().get("navigate_left").matches(event)) {
|
||||||
compositor.navigate(Direction.LEFT);
|
compositor.navigate(Direction.LEFT);
|
||||||
@@ -78,14 +115,13 @@ public final class Main extends Application {
|
|||||||
} else if (config.keybindings().get("next_floating").matches(event)) {
|
} else if (config.keybindings().get("next_floating").matches(event)) {
|
||||||
compositor.nextFloatingPane();
|
compositor.nextFloatingPane();
|
||||||
event.consume();
|
event.consume();
|
||||||
|
} else if (config.keybindings().get("promote_floating").matches(event)) {
|
||||||
|
compositor.promoteActiveFloating();
|
||||||
|
event.consume();
|
||||||
} else if (config.keybindings().get("close_pane").matches(event)) {
|
} else if (config.keybindings().get("close_pane").matches(event)) {
|
||||||
|
// Closing the last pane quits the app, via the compositor's onEmpty hook.
|
||||||
compositor.closeActivePane();
|
compositor.closeActivePane();
|
||||||
event.consume();
|
event.consume();
|
||||||
if (compositor.isEmpty()) {
|
|
||||||
// Closing the last pane quits the app.
|
|
||||||
compositor.close();
|
|
||||||
Platform.exit();
|
|
||||||
}
|
|
||||||
} else if (config.keybindings().get("new_tab").matches(event)) {
|
} else if (config.keybindings().get("new_tab").matches(event)) {
|
||||||
compositor.newTab();
|
compositor.newTab();
|
||||||
event.consume();
|
event.consume();
|
||||||
@@ -101,6 +137,9 @@ 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) {
|
||||||
@@ -122,6 +161,13 @@ public final class Main extends Application {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void pasteFromClipboard() {
|
||||||
|
Clipboard clipboard = Clipboard.getSystemClipboard();
|
||||||
|
if (clipboard.hasString()) {
|
||||||
|
compositor.activePane().paste(clipboard.getString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void openFontSelector() {
|
private void openFontSelector() {
|
||||||
Dialog<ButtonType> dialog = new Dialog<>();
|
Dialog<ButtonType> dialog = new Dialog<>();
|
||||||
dialog.setTitle("Font");
|
dialog.setTitle("Font");
|
||||||
@@ -167,11 +213,16 @@ public final class Main extends Application {
|
|||||||
|
|
||||||
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();
|
||||||
|
|
||||||
compositor.activePane().send(scrollbackEditorCommand(file) + "\r");
|
// Run the editor as the floating pane's process (via /bin/sh -c) rather than typing the
|
||||||
|
// command into an interactive shell. The command runs deterministically from the start
|
||||||
|
// — no shell startup/rc race — and the pane auto-closes when the editor exits.
|
||||||
|
compositor.openFloatingPane(scrollbackEditorCommand(file));
|
||||||
} catch (IOException ex) {
|
} catch (IOException ex) {
|
||||||
System.err.println("Could not open scrollback in editor: " + ex.getMessage());
|
System.err.println("Could not open scrollback in editor: " + ex.getMessage());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.gregor.jprototerm;
|
|||||||
|
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.concurrent.ExecutorService;
|
import java.util.concurrent.ExecutorService;
|
||||||
import java.util.concurrent.Executors;
|
import java.util.concurrent.Executors;
|
||||||
@@ -20,27 +21,56 @@ public final class ShellSession implements AutoCloseable {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public static ShellSession start(String shell, Map<String, String> envOverride, TerminalPane pane, int columns, int rows) {
|
/**
|
||||||
|
* Starts the configured shell. {@code shellCommand} is the executable plus its arguments (e.g.
|
||||||
|
* {@code ["/bin/bash", "-i"]}), spawned verbatim — any interactive flag is the user's choice in
|
||||||
|
* config, not assumed here.
|
||||||
|
*/
|
||||||
|
public static ShellSession start(List<String> shellCommand, Map<String, String> envOverride, TerminalPane pane,
|
||||||
|
int columns, int rows, String workingDirectory) {
|
||||||
try {
|
try {
|
||||||
Map<String, String> environment = new HashMap<>(System.getenv());
|
return spawn(shellCommand.toArray(new String[0]), envOverride, columns, rows, workingDirectory);
|
||||||
environment.put("TERM", "xterm-kitty");
|
|
||||||
environment.put("COLORTERM", "truecolor");
|
|
||||||
sanitizeWrapperEnvironment(environment);
|
|
||||||
environment.putAll(envOverride);
|
|
||||||
|
|
||||||
LinuxPty pty = LinuxPty.spawn(
|
|
||||||
new String[] {shell, "-i"},
|
|
||||||
environment,
|
|
||||||
System.getProperty("user.home"));
|
|
||||||
ShellSession session = new ShellSession(pty);
|
|
||||||
session.resize(columns, rows);
|
|
||||||
return session;
|
|
||||||
} catch (RuntimeException ex) {
|
} catch (RuntimeException ex) {
|
||||||
pane.write("failed to start shell: " + ex.getMessage() + "\r\n");
|
pane.write("failed to start shell: " + ex.getMessage() + "\r\n");
|
||||||
throw new IllegalStateException("Could not start shell " + shell, ex);
|
throw new IllegalStateException("Could not start shell " + String.join(" ", shellCommand), ex);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts a session whose first and only process is {@code /bin/sh -c command}, so the program
|
||||||
|
* runs deterministically from the start rather than being typed into an interactive shell —
|
||||||
|
* there is no startup/rc race to lose or mangle the input. When the process exits the pty
|
||||||
|
* closes and the pane auto-closes. {@code /bin/sh -c} is used (not the user's configured shell)
|
||||||
|
* because it is the portable way to run a command line and does not depend on shell-specific
|
||||||
|
* flags. {@code command} must not be null.
|
||||||
|
*/
|
||||||
|
public static ShellSession startCommand(Map<String, String> envOverride, TerminalPane pane,
|
||||||
|
int columns, int rows, String workingDirectory, String command) {
|
||||||
|
try {
|
||||||
|
return spawn(new String[] {"/bin/sh", "-c", command}, envOverride, columns, rows, workingDirectory);
|
||||||
|
} catch (RuntimeException ex) {
|
||||||
|
pane.write("failed to run command: " + ex.getMessage() + "\r\n");
|
||||||
|
throw new IllegalStateException("Could not run command: " + command, ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ShellSession spawn(String[] argv, Map<String, String> envOverride,
|
||||||
|
int columns, int rows, String workingDirectory) {
|
||||||
|
Map<String, String> environment = new HashMap<>(System.getenv());
|
||||||
|
environment.put("TERM", "xterm-kitty");
|
||||||
|
environment.put("COLORTERM", "truecolor");
|
||||||
|
sanitizeWrapperEnvironment(environment);
|
||||||
|
environment.putAll(envOverride);
|
||||||
|
|
||||||
|
LinuxPty pty = LinuxPty.spawn(
|
||||||
|
argv,
|
||||||
|
environment,
|
||||||
|
workingDirectory != null ? workingDirectory : System.getProperty("user.home"));
|
||||||
|
ShellSession session = new ShellSession(pty);
|
||||||
|
session.resize(columns, rows);
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Strips the variables injected by the Nix launcher wrapper from the shell's
|
* Strips the variables injected by the Nix launcher wrapper from the shell's
|
||||||
* environment so they do not leak into terminal subprocesses.
|
* environment so they do not leak into terminal subprocesses.
|
||||||
@@ -69,6 +99,11 @@ 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;
|
||||||
@@ -113,6 +148,11 @@ public final class ShellSession implements AutoCloseable {
|
|||||||
pane.write("\r\nshell output stopped: " + ex.getMessage() + "\r\n");
|
pane.write("\r\nshell output stopped: " + ex.getMessage() + "\r\n");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// The stream ended without us closing the session, so the process exited on its own (the
|
||||||
|
// user typed `exit`, or a one-shot command pane finished). Let the pane tear itself down.
|
||||||
|
if (!closed) {
|
||||||
|
pane.handleSessionExit();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ 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.concurrent.atomic.AtomicLong;
|
||||||
|
import java.util.function.Consumer;
|
||||||
import java.util.stream.Stream;
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -19,13 +20,19 @@ import java.util.stream.Stream;
|
|||||||
final class Tab implements AutoCloseable {
|
final class Tab implements AutoCloseable {
|
||||||
private final AppConfig config;
|
private final AppConfig config;
|
||||||
private final TerminalMetrics metrics;
|
private final TerminalMetrics metrics;
|
||||||
|
// Notified (on the FX thread) when one of this tab's panes' process exits on its own, so the
|
||||||
|
// compositor can close that pane and reap the tab/app if it was the last one.
|
||||||
|
private final Consumer<TerminalPane> onPaneExit;
|
||||||
private final List<TerminalPane> tiled = new ArrayList<>();
|
private final List<TerminalPane> tiled = new ArrayList<>();
|
||||||
private final List<TerminalPane> floating = new ArrayList<>();
|
private final List<TerminalPane> floating = new ArrayList<>();
|
||||||
private boolean floatingVisible;
|
private boolean floatingVisible;
|
||||||
private TerminalPane active;
|
private TerminalPane active;
|
||||||
|
private final String initialWorkingDirectory;
|
||||||
// The floating pane to re-focus when the group is shown again, and to prefer when promoting
|
// The floating pane to re-focus when the group is shown again, and to prefer when promoting
|
||||||
// after the last tiled pane closes.
|
// after the last tiled pane closes.
|
||||||
private TerminalPane lastFocusedFloating;
|
private TerminalPane lastFocusedFloating;
|
||||||
|
// The tiled pane to re-focus when the floating group is hidden.
|
||||||
|
private TerminalPane lastFocusedTiled;
|
||||||
// Last laid-out size, so a newly opened pane can be created at roughly its eventual rect
|
// Last laid-out size, so a newly opened pane can be created at roughly its eventual rect
|
||||||
// (and thus grid). Seeded from the configured window size for the first pane, which is
|
// (and thus grid). Seeded from the configured window size for the first pane, which is
|
||||||
// opened before any layout pass runs.
|
// opened before any layout pass runs.
|
||||||
@@ -36,11 +43,22 @@ final class Tab implements AutoCloseable {
|
|||||||
// 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 final AtomicLong contentVersion = new AtomicLong();
|
||||||
|
|
||||||
Tab(AppConfig config, TerminalMetrics metrics) {
|
Tab(AppConfig config, TerminalMetrics metrics, Consumer<TerminalPane> onPaneExit) {
|
||||||
|
this(config, metrics, null, onPaneExit);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a tab whose first pane starts in {@code initialWorkingDirectory} (e.g. the cwd of the
|
||||||
|
* pane that was active when this tab was opened), or the user's home when {@code null}.
|
||||||
|
*/
|
||||||
|
Tab(AppConfig config, TerminalMetrics metrics, String initialWorkingDirectory,
|
||||||
|
Consumer<TerminalPane> onPaneExit) {
|
||||||
this.config = config;
|
this.config = config;
|
||||||
this.metrics = metrics;
|
this.metrics = metrics;
|
||||||
|
this.onPaneExit = onPaneExit;
|
||||||
this.lastWidth = config.windowWidth();
|
this.lastWidth = config.windowWidth();
|
||||||
this.lastHeight = config.windowHeight();
|
this.lastHeight = config.windowHeight();
|
||||||
|
this.initialWorkingDirectory = initialWorkingDirectory;
|
||||||
TerminalPane first = openPane(false);
|
TerminalPane first = openPane(false);
|
||||||
tiled.add(first);
|
tiled.add(first);
|
||||||
active = first;
|
active = first;
|
||||||
@@ -183,7 +201,7 @@ final class Tab implements AutoCloseable {
|
|||||||
if (floatingVisible) {
|
if (floatingVisible) {
|
||||||
floatingVisible = false;
|
floatingVisible = false;
|
||||||
if (floating.contains(active)) {
|
if (floating.contains(active)) {
|
||||||
setActive(tiled.get(0));
|
setActive(tiled.contains(lastFocusedTiled) ? lastFocusedTiled : tiled.get(0));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
floatingVisible = true;
|
floatingVisible = true;
|
||||||
@@ -212,20 +230,52 @@ final class Tab implements AutoCloseable {
|
|||||||
setActive(floating.get((current + 1 + floating.size()) % floating.size()));
|
setActive(floating.get((current + 1 + floating.size()) % floating.size()));
|
||||||
}
|
}
|
||||||
|
|
||||||
void closeActivePane() {
|
/** Promotes the active floating pane to a tiled pane, joining the tiled row. No-op otherwise. */
|
||||||
TerminalPane closing = active;
|
void promoteActiveFloating() {
|
||||||
boolean wasFloating = floating.remove(closing);
|
TerminalPane promote = active;
|
||||||
if (!wasFloating) {
|
if (!floating.remove(promote)) {
|
||||||
tiled.remove(closing);
|
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() {
|
||||||
|
if (active != null) {
|
||||||
|
closePane(active);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Closes {@code closing} (the active pane on a key-bound close, or any pane whose process just
|
||||||
|
* exited) and re-selects the active pane only when the one that closed was active. Returns
|
||||||
|
* false when the pane is not in this tab. Leaves the tab empty ({@code active == null}) when its
|
||||||
|
* last pane closes, so the compositor can drop it.
|
||||||
|
*/
|
||||||
|
boolean closePane(TerminalPane closing) {
|
||||||
|
boolean wasFloating = floating.remove(closing);
|
||||||
|
boolean wasTiled = !wasFloating && tiled.remove(closing);
|
||||||
|
if (!wasFloating && !wasTiled) {
|
||||||
|
return false; // not one of this tab's panes (already gone)
|
||||||
|
}
|
||||||
|
boolean wasActive = closing == active;
|
||||||
if (closing == lastFocusedFloating) {
|
if (closing == lastFocusedFloating) {
|
||||||
lastFocusedFloating = null;
|
lastFocusedFloating = null;
|
||||||
}
|
}
|
||||||
|
if (closing == lastFocusedTiled) {
|
||||||
|
lastFocusedTiled = null;
|
||||||
|
}
|
||||||
closing.close();
|
closing.close();
|
||||||
|
|
||||||
if (tiled.isEmpty() && floating.isEmpty()) {
|
if (tiled.isEmpty() && floating.isEmpty()) {
|
||||||
active = null; // tab is now empty; the compositor drops it
|
active = null; // tab is now empty; the compositor drops it
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Always keep a tiled base: if the last tiled pane just closed, promote a floating one
|
// Always keep a tiled base: if the last tiled pane just closed, promote a floating one
|
||||||
@@ -237,31 +287,54 @@ final class Tab implements AutoCloseable {
|
|||||||
floating.remove(promote);
|
floating.remove(promote);
|
||||||
tiled.add(promote);
|
tiled.add(promote);
|
||||||
if (promote == lastFocusedFloating) {
|
if (promote == lastFocusedFloating) {
|
||||||
lastFocusedFloating = null;
|
lastFocusedFloating = floating.isEmpty() ? null : floating.get(nextFocussed);
|
||||||
if (!floating.isEmpty()) {
|
|
||||||
lastFocusedFloating = floating.isEmpty() ? null : floating.get(nextFocussed);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (floating.isEmpty()) {
|
if (floating.isEmpty()) {
|
||||||
floatingVisible = false;
|
floatingVisible = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
setActive(wasFloating && floatingVisible ? floating.get(floating.size() - 1) : tiled.get(0));
|
// Only the active pane closing forces a re-selection; closing a background pane (e.g. one
|
||||||
|
// whose process exited while another is focused) leaves focus where it is.
|
||||||
|
if (wasActive) {
|
||||||
|
setActive(wasFloating && floatingVisible
|
||||||
|
? floating.get(floating.size() - 1)
|
||||||
|
: tiled.contains(lastFocusedTiled) ? lastFocusedTiled : tiled.get(0));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void setActive(TerminalPane pane) {
|
private void setActive(TerminalPane pane) {
|
||||||
active = pane;
|
active = pane;
|
||||||
if (floating.contains(pane)) {
|
if (floating.contains(pane)) {
|
||||||
lastFocusedFloating = pane;
|
lastFocusedFloating = pane;
|
||||||
|
} else if (tiled.contains(pane)) {
|
||||||
|
lastFocusedTiled = pane;
|
||||||
|
// A tiled pane gaining focus hides the floating group: leaving it shown while a tiled
|
||||||
|
// pane is active strands focus behind the overlay and disables navigation.
|
||||||
|
floatingVisible = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void createFloatingPane() {
|
TerminalPane createFloatingPane() {
|
||||||
TerminalPane pane = openPane(true);
|
return addFloating(openPane(true));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens a floating pane whose process runs {@code command} directly (auto-closing when it
|
||||||
|
* exits), rather than an interactive shell. Used for one-shot panes like the scrollback editor.
|
||||||
|
*/
|
||||||
|
TerminalPane createFloatingPane(String command) {
|
||||||
|
double[] size = paneSize(true);
|
||||||
|
return addFloating(register(TerminalPane.createWithCommand(
|
||||||
|
config, metrics, this::markContentChanged, size[0], size[1], paneWorkingDirectory(), command)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private TerminalPane addFloating(TerminalPane pane) {
|
||||||
floating.add(pane);
|
floating.add(pane);
|
||||||
floatingVisible = true;
|
floatingVisible = true;
|
||||||
setActive(pane);
|
setActive(pane);
|
||||||
|
return pane;
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean navigateFloatingStack(Direction direction) {
|
private boolean navigateFloatingStack(Direction direction) {
|
||||||
@@ -296,18 +369,31 @@ final class Tab implements AutoCloseable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private TerminalPane openPane(boolean asFloating) {
|
private TerminalPane openPane(boolean asFloating) {
|
||||||
|
double[] size = paneSize(asFloating);
|
||||||
|
return register(TerminalPane.create(
|
||||||
|
config, metrics, this::markContentChanged, size[0], size[1], paneWorkingDirectory()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private double[] paneSize(boolean asFloating) {
|
||||||
double availHeight = lastHeight - lastTopInset;
|
double availHeight = lastHeight - lastTopInset;
|
||||||
double widthPx;
|
|
||||||
double heightPx;
|
|
||||||
if (asFloating) {
|
if (asFloating) {
|
||||||
widthPx = Math.max(420, lastWidth * 0.58);
|
return new double[] {Math.max(420, lastWidth * 0.58), Math.max(260, availHeight * 0.58)};
|
||||||
heightPx = Math.max(260, availHeight * 0.58);
|
|
||||||
} else {
|
|
||||||
// A new tiled pane joins the row, so each gets 1/(n+1) of the width.
|
|
||||||
widthPx = lastWidth / (tiled.size() + 1);
|
|
||||||
heightPx = availHeight;
|
|
||||||
}
|
}
|
||||||
return TerminalPane.create(config, metrics, this::markContentChanged, widthPx, heightPx);
|
// A new tiled pane joins the row, so each gets 1/(n+1) of the width.
|
||||||
|
return new double[] {lastWidth / (tiled.size() + 1), availHeight};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open a new pane in the active pane's working directory, so a split/new pane lands where the
|
||||||
|
// user currently is. With no active pane yet (the tab's first pane), fall back to the directory
|
||||||
|
// this tab was opened in. null (cwd unknown) falls back to home downstream.
|
||||||
|
private String paneWorkingDirectory() {
|
||||||
|
return active != null ? active.currentWorkingDirectory() : initialWorkingDirectory;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wire the pane's self-exit (process ended) back to the compositor so it gets reaped.
|
||||||
|
private TerminalPane register(TerminalPane pane) {
|
||||||
|
pane.setOnExit(() -> onPaneExit.accept(pane));
|
||||||
|
return pane;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static boolean directionFilter(Direction direction, TerminalPane current, TerminalPane candidate) {
|
private static boolean directionFilter(Direction direction, TerminalPane current, TerminalPane candidate) {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import dev.jlibghostty.RenderStateSnapshot;
|
|||||||
import dev.jlibghostty.ScrollViewport;
|
import dev.jlibghostty.ScrollViewport;
|
||||||
import dev.jlibghostty.Terminal;
|
import dev.jlibghostty.Terminal;
|
||||||
import dev.jlibghostty.TerminalOptions;
|
import dev.jlibghostty.TerminalOptions;
|
||||||
|
import javafx.application.Platform;
|
||||||
import javafx.scene.canvas.GraphicsContext;
|
import javafx.scene.canvas.GraphicsContext;
|
||||||
import javafx.scene.shape.Shape;
|
import javafx.scene.shape.Shape;
|
||||||
|
|
||||||
@@ -39,6 +40,10 @@ public final class TerminalPane implements AutoCloseable, RenderTarget {
|
|||||||
private final RenderState renderState = new RenderState();
|
private final RenderState renderState = new RenderState();
|
||||||
private RenderStateSnapshot cachedSnapshot;
|
private RenderStateSnapshot cachedSnapshot;
|
||||||
private ShellSession session;
|
private ShellSession session;
|
||||||
|
// Run once (on the FX thread) when this pane's process exits on its own, so the owning tab can
|
||||||
|
// remove it. Set by the Tab that creates the pane; null until then.
|
||||||
|
private Runnable onExit;
|
||||||
|
private boolean exited;
|
||||||
// Clip region for rendering (rect minus the panes covering this one), set at layout time;
|
// 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().
|
// null means clip to the plain bounds. See RenderTarget#clip().
|
||||||
private Shape clip;
|
private Shape clip;
|
||||||
@@ -69,9 +74,32 @@ 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.
|
* {@code onContentChange} on every content change. The shell starts in {@code workingDirectory}
|
||||||
|
* (e.g. the active pane's cwd), or the user's home when {@code null}.
|
||||||
*/
|
*/
|
||||||
public static TerminalPane create(AppConfig config, TerminalMetrics metrics, Runnable onContentChange, double widthPx, double heightPx) {
|
public static TerminalPane create(AppConfig config, TerminalMetrics metrics, Runnable onContentChange,
|
||||||
|
double widthPx, double heightPx, String workingDirectory) {
|
||||||
|
TerminalPane pane = newPane(config, metrics, onContentChange, widthPx, heightPx);
|
||||||
|
pane.attach(ShellSession.start(config.shell(), config.envOverride(), pane, pane.columns, pane.rows,
|
||||||
|
workingDirectory));
|
||||||
|
return pane;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens a pane whose process runs {@code command} directly (via {@code /bin/sh -c}) instead of
|
||||||
|
* an interactive shell. The pane auto-closes when the command exits. See
|
||||||
|
* {@link ShellSession#startCommand}.
|
||||||
|
*/
|
||||||
|
public static TerminalPane createWithCommand(AppConfig config, TerminalMetrics metrics, Runnable onContentChange,
|
||||||
|
double widthPx, double heightPx, String workingDirectory, String command) {
|
||||||
|
TerminalPane pane = newPane(config, metrics, onContentChange, widthPx, heightPx);
|
||||||
|
pane.attach(ShellSession.startCommand(config.envOverride(), pane, pane.columns, pane.rows,
|
||||||
|
workingDirectory, command));
|
||||||
|
return pane;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TerminalPane newPane(AppConfig config, TerminalMetrics metrics, Runnable onContentChange,
|
||||||
|
double widthPx, double heightPx) {
|
||||||
int columns = widthPx > 0 ? metrics.columnsFor(widthPx) : config.columns();
|
int columns = widthPx > 0 ? metrics.columnsFor(widthPx) : config.columns();
|
||||||
int rows = heightPx > 0 ? metrics.rowsFor(heightPx) : config.rows();
|
int rows = heightPx > 0 ? metrics.rowsFor(heightPx) : config.rows();
|
||||||
Terminal terminal = Ghostty.open(new TerminalOptions(columns, rows, config.maxScrollback()));
|
Terminal terminal = Ghostty.open(new TerminalOptions(columns, rows, config.maxScrollback()));
|
||||||
@@ -79,10 +107,31 @@ public final class TerminalPane implements AutoCloseable, RenderTarget {
|
|||||||
TerminalPane pane = new TerminalPane(terminal, metrics, config.kittyGraphics(), onContentChange,
|
TerminalPane pane = new TerminalPane(terminal, metrics, config.kittyGraphics(), onContentChange,
|
||||||
new GhosttyTerminalRenderer(metrics), columns, rows);
|
new GhosttyTerminalRenderer(metrics), columns, rows);
|
||||||
pane.refresh();
|
pane.refresh();
|
||||||
pane.attach(ShellSession.start(config.shell(), config.envOverride(), pane, columns, rows));
|
|
||||||
return pane;
|
return pane;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Sets the callback run when this pane's process exits on its own (see {@link #handleSessionExit}). */
|
||||||
|
public void setOnExit(Runnable onExit) {
|
||||||
|
this.onExit = onExit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called from the shell reader thread when the pty stream ends without us closing it (the
|
||||||
|
* process exited). Hops to the FX thread and fires {@link #onExit} once, so tab/compositor
|
||||||
|
* mutation happens on the thread that owns the layout.
|
||||||
|
*/
|
||||||
|
void handleSessionExit() {
|
||||||
|
Platform.runLater(() -> {
|
||||||
|
if (exited) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
exited = true;
|
||||||
|
if (onExit != null) {
|
||||||
|
onExit.run();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private void attach(ShellSession session) {
|
private void attach(ShellSession session) {
|
||||||
this.session = session;
|
this.session = session;
|
||||||
terminal.setPtyWriter(bytes -> {
|
terminal.setPtyWriter(bytes -> {
|
||||||
@@ -115,6 +164,19 @@ public final class TerminalPane implements AutoCloseable, RenderTarget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Paste text to the shell: ghostty sanitises it (stripping anything that could smuggle in
|
||||||
|
* control sequences) and wraps it in the bracketed-paste markers, then it goes to the pty
|
||||||
|
* like typed input. We always request bracketed mode — every modern shell and editor enables
|
||||||
|
* DECSET 2004, and the jlibghostty API does not expose querying the terminal's live mode.
|
||||||
|
*/
|
||||||
|
public void paste(String text) {
|
||||||
|
if (text == null || text.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
send(Ghostty.encodePaste(text, true));
|
||||||
|
}
|
||||||
|
|
||||||
public boolean sendMouse(MouseInput input, MouseEncoderSize size, boolean anyButtonPressed) {
|
public boolean sendMouse(MouseInput input, MouseEncoderSize size, boolean anyButtonPressed) {
|
||||||
synchronized (terminal) {
|
synchronized (terminal) {
|
||||||
mouseEncoder.syncFromTerminal(terminal);
|
mouseEncoder.syncFromTerminal(terminal);
|
||||||
@@ -192,6 +254,12 @@ 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();
|
||||||
@@ -294,6 +362,14 @@ public final class TerminalPane implements AutoCloseable, RenderTarget {
|
|||||||
return snapshotVersion;
|
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) {
|
||||||
|
|||||||
@@ -25,6 +25,14 @@ abstract class TerminalRenderer {
|
|||||||
/** Repaint only what changed since the last frame, clipped to the target's clip region. */
|
/** Repaint only what changed since the last frame, clipped to the target's clip region. */
|
||||||
abstract void paintIncremental(GraphicsContext gc, RenderTarget target, boolean active);
|
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) {
|
protected static void clipRect(GraphicsContext gc, double x, double y, double width, double height) {
|
||||||
gc.beginPath();
|
gc.beginPath();
|
||||||
gc.rect(x, y, width, height);
|
gc.rect(x, y, width, height);
|
||||||
|
|||||||
66
src/main/java/com/gregor/jprototerm/X11Pointer.java
Normal file
66
src/main/java/com/gregor/jprototerm/X11Pointer.java
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
package com.gregor.jprototerm;
|
||||||
|
|
||||||
|
import java.lang.foreign.Arena;
|
||||||
|
import java.lang.foreign.FunctionDescriptor;
|
||||||
|
import java.lang.foreign.Linker;
|
||||||
|
import java.lang.foreign.MemorySegment;
|
||||||
|
import java.lang.foreign.SymbolLookup;
|
||||||
|
import java.lang.invoke.MethodHandle;
|
||||||
|
|
||||||
|
import static java.lang.foreign.ValueLayout.ADDRESS;
|
||||||
|
import static java.lang.foreign.ValueLayout.JAVA_INT;
|
||||||
|
import static java.lang.foreign.ValueLayout.JAVA_LONG;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads the X11 pointer location directly via libX11 ({@code XQueryPointer}). Unlike AWT's
|
||||||
|
* {@code MouseInfo}, this never calls {@code XSetErrorHandler}, so it doesn't trip GDK's
|
||||||
|
* "XSetErrorHandler called with a GDK error trap pushed" warning when JavaFX's GTK backend is
|
||||||
|
* already up. Returns {@code null} when not on X11 or libX11 can't be loaded.
|
||||||
|
*/
|
||||||
|
final class X11Pointer {
|
||||||
|
private X11Pointer() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** {@code {x, y}} of the pointer in X root-window (virtual screen) space, or {@code null}. */
|
||||||
|
static int[] query() {
|
||||||
|
try (Arena arena = Arena.ofConfined()) {
|
||||||
|
Linker linker = Linker.nativeLinker();
|
||||||
|
SymbolLookup x11 = SymbolLookup.libraryLookup("libX11.so.6", arena);
|
||||||
|
MethodHandle openDisplay = linker.downcallHandle(x11.find("XOpenDisplay").orElseThrow(),
|
||||||
|
FunctionDescriptor.of(ADDRESS, ADDRESS));
|
||||||
|
MethodHandle defaultRootWindow = linker.downcallHandle(x11.find("XDefaultRootWindow").orElseThrow(),
|
||||||
|
FunctionDescriptor.of(JAVA_LONG, ADDRESS));
|
||||||
|
MethodHandle queryPointer = linker.downcallHandle(x11.find("XQueryPointer").orElseThrow(),
|
||||||
|
FunctionDescriptor.of(JAVA_INT, ADDRESS, JAVA_LONG,
|
||||||
|
ADDRESS, ADDRESS, ADDRESS, ADDRESS, ADDRESS, ADDRESS, ADDRESS));
|
||||||
|
MethodHandle closeDisplay = linker.downcallHandle(x11.find("XCloseDisplay").orElseThrow(),
|
||||||
|
FunctionDescriptor.of(JAVA_INT, ADDRESS));
|
||||||
|
|
||||||
|
MemorySegment display = (MemorySegment) openDisplay.invoke(MemorySegment.NULL);
|
||||||
|
if (display.address() == 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
long root = (long) defaultRootWindow.invoke(display);
|
||||||
|
MemorySegment rootReturn = arena.allocate(JAVA_LONG);
|
||||||
|
MemorySegment childReturn = arena.allocate(JAVA_LONG);
|
||||||
|
MemorySegment rootX = arena.allocate(JAVA_INT);
|
||||||
|
MemorySegment rootY = arena.allocate(JAVA_INT);
|
||||||
|
MemorySegment winX = arena.allocate(JAVA_INT);
|
||||||
|
MemorySegment winY = arena.allocate(JAVA_INT);
|
||||||
|
MemorySegment mask = arena.allocate(JAVA_INT);
|
||||||
|
int onSameScreen = (int) queryPointer.invoke(display, root,
|
||||||
|
rootReturn, childReturn, rootX, rootY, winX, winY, mask);
|
||||||
|
if (onSameScreen == 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return new int[] { rootX.get(JAVA_INT, 0), rootY.get(JAVA_INT, 0) };
|
||||||
|
} finally {
|
||||||
|
closeDisplay.invoke(display);
|
||||||
|
}
|
||||||
|
} catch (Throwable ignored) {
|
||||||
|
// Not X11, libX11 missing, or the call failed — caller falls back to the primary screen.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user