10 Commits

Author SHA1 Message Date
Gregor Lohaus
cf218e2afd scrollback 2026-05-28 12:48:28 +02:00
Gregor Lohaus
f07e524fbb pretty good 2026-05-28 02:07:44 +02:00
Gregor Lohaus
a3f4878fc7 stacking 2026-05-28 00:33:38 +02:00
Gregor Lohaus
82cc7f4729 graal metadata bullshit 2026-05-27 16:27:02 +02:00
Gregor Lohaus
9f8767bc88 remove deprecated drivers, expand jni metadata for graal 2026-05-27 16:25:23 +02:00
Gregor Lohaus
1ae1548db0 opengl drivers, jni metadata for javafx glass 2026-05-27 16:23:25 +02:00
Gregor Lohaus
4e9f1487cb fix x11 lib namnes 2026-05-27 16:22:13 +02:00
Gregor Lohaus
b60dcd5918 include gtk deps 2026-05-27 16:20:31 +02:00
Gregor Lohaus
addeed6f30 default to graphics accelaration 2026-05-27 16:18:04 +02:00
Gregor Lohaus
a2de5118c1 avoid glx es2 setup 2026-05-27 16:15:13 +02:00
51 changed files with 1287 additions and 723 deletions

18
.classpath Normal file
View File

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

13
.gitignore vendored
View File

@@ -1 +1,14 @@
result
# Devenv
.devenv*
devenv.local.nix
devenv.local.yaml
# direnv
.direnv
# pre-commit
.pre-commit-config.yaml
build
build

Binary file not shown.

Binary file not shown.

View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

Binary file not shown.

View File

@@ -0,0 +1,2 @@
#Wed May 27 23:44:22 CEST 2026
gradle.version=9.4.1

Binary file not shown.

BIN
.gradle/file-system.probe Normal file

Binary file not shown.

View File

34
.project Normal file
View File

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

View File

@@ -0,0 +1,13 @@
arguments=--init-script /home/anon/.eclipse/1927926929_linux_gtk_x86_64/configuration/org.eclipse.osgi/58/0/.cp/gradle/init/init.gradle
auto.sync=false
build.scans.enabled=false
connection.gradle.distribution=GRADLE_DISTRIBUTION(VERSION(8.9))
connection.project.dir=
eclipse.preferences.version=1
gradle.user.home=
java.home=/nix/store/c3pl7bqrx3d2rc3dh98z6yaj0mv1p52g-openjdk-21.0.10+7/lib/openjdk
jvm.arguments=
offline.mode=false
override.workspace.settings=true
show.console.view=true
show.executions.view=true

View File

@@ -8,17 +8,17 @@ JavaFX canvas terminal prototype using `jlibghostty` for terminal emulation, Nix
nix build
```
The package build compiles with Nix-provided OpenJFX 25, `jlibghostty`, JToml, and GraalVM Native Image directly so it does not depend on Gradle plugin resolution inside the Nix sandbox.
The package build uses GluonFX through Gradle so JavaFX native-image metadata is generated by the toolchain that is designed for it. In a strict pure Nix sandbox, Gradle dependencies must be vendored first with `gradle2nix` or a checked-in Maven/Gradle cache.
For development:
```sh
nix develop
gradle -PjlibghosttyMavenRepo="$JLIBGHOSTTY_MAVEN_REPO" run
gradle -PjlibghosttyMavenRepo="$JLIBGHOSTTY_MAVEN_REPO" nativeCompile
gradle -PjlibghosttyMavenRepo="$JLIBGHOSTTY_MAVEN_REPO" -Pgluonfx.mainClassName=com.gregor.jprototerm.Main nativeExecutable
```
The Gradle project is kept for interactive development and IDE import.
The Gradle project is the source of truth for native JavaFX builds.
## Config
@@ -34,6 +34,8 @@ If `XDG_CONFIG_HOME` is unset, the fallback is:
$HOME/.config/jprototerm/config.toml
```
If no config file exists, jprototerm writes the default config on startup.
Example, also available in `config.example.toml`:
```toml
@@ -57,11 +59,19 @@ navigate_down = "ALT+J"
navigate_up = "ALT+K"
navigate_right = "ALT+L"
toggle_floating = "ALT+F"
new_floating = "ALT+SHIFT+F"
next_floating = "ALT+F12"
close_pane = "ALT+X"
open_font_selector = "ALT+T"
```
## Defaults
- `Alt+h/j/k/l`: navigate panes
- `Alt+f`: open or close a floating pane
- Font default: `Symbols Nerd Font Mono`
- `Alt+f`: show or hide all floating panes
- `Alt+Shift+f`: create a new floating pane
- `Alt+F12`: cycle floating panes
- `Alt+x`: close the active floating pane
- `Alt+t`: open the font selector
- Font default: `JetBrainsMono Nerd Font`
- Kitty graphics protocol parsing is enabled by default

31
build.gradle Normal file
View File

@@ -0,0 +1,31 @@
plugins {
id 'application'
id 'org.openjfx.javafxplugin' version '0.1.0'
}
repositories {
mavenCentral()
maven {
url = uri(System.getenv("JLIBGHOSTTY_MAVEN_REPO"))
}
}
dependencies {
implementation 'io.github.wasabithumb:jtoml:1.5.2'
implementation 'dev.jlibghostty:jlibghostty:0.1.0-SNAPSHOT'
implementation 'org.jetbrains.pty4j:pty4j:0.13.11'
}
javafx {
version = '22'
modules = [ 'javafx.controls', 'javafx.fxml' ]
}
application {
mainClass = 'com.gregor.jprototerm.Main'
}
run {
jvmArgs += ['--enable-native-access=ALL-UNNAMED']
}

View File

@@ -1,32 +0,0 @@
plugins {
application
id("org.openjfx.javafxplugin") version "0.1.0"
id("com.gluonhq.gluonfx-gradle-plugin") version "1.0.28"
}
group = "com.gregor"
version = "0.1.0"
dependencies {
implementation("dev.jlibghostty:jlibghostty:0.1.0-SNAPSHOT")
implementation("io.github.wasabithumb:jtoml:1.5.2")
}
java {
toolchain {
languageVersion.set(JavaLanguageVersion.of(25))
}
}
application {
mainClass.set("com.gregor.jprototerm.Main")
}
javafx {
version = "25"
modules = listOf("javafx.controls", "javafx.graphics")
}
gluonfx {
mainClassName = "com.gregor.jprototerm.Main"
}

View File

@@ -1,6 +1,7 @@
[terminal]
columns = 100
rows = 30
max_scrollback = 100000
shell = "/bin/bash"
font_family = "JetBrainsMono Nerd Font"
font_size = 15
@@ -18,3 +19,7 @@ navigate_down = "ALT+J"
navigate_up = "ALT+K"
navigate_right = "ALT+L"
toggle_floating = "ALT+F"
new_floating = "ALT+SHIFT+F"
next_floating = "ALT+F12"
close_pane = "ALT+X"
open_font_selector = "ALT+T"

65
devenv.lock Normal file
View File

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

66
devenv.nix Normal file
View File

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

18
devenv.yaml Normal file
View File

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

274
flake.lock generated
View File

@@ -1,274 +0,0 @@
{
"nodes": {
"flake-compat": {
"flake": false,
"locked": {
"lastModified": 1761588595,
"narHash": "sha256-XKUZz9zewJNUj46b4AJdiRZJAvSZ0Dqj2BNfXvFlJC4=",
"owner": "edolstra",
"repo": "flake-compat",
"rev": "f387cd2afec9419c8ee37694406ca490c3f34ee5",
"type": "github"
},
"original": {
"owner": "edolstra",
"repo": "flake-compat",
"type": "github"
}
},
"ghostty": {
"inputs": {
"flake-compat": "flake-compat",
"home-manager": "home-manager",
"nixpkgs": [
"jlibghostty",
"nixpkgs"
],
"systems": "systems",
"zig": "zig",
"zon2nix": "zon2nix"
},
"locked": {
"lastModified": 1779812402,
"narHash": "sha256-gozJEyJHbaAyrbzODKeWJhxpUrGK6m4DIPDogfjz2BU=",
"owner": "ghostty-org",
"repo": "ghostty",
"rev": "2e5ad917eb4e325a3dbb161c3f41208a8cd35e44",
"type": "github"
},
"original": {
"owner": "ghostty-org",
"repo": "ghostty",
"type": "github"
}
},
"home-manager": {
"inputs": {
"nixpkgs": [
"jlibghostty",
"ghostty",
"nixpkgs"
]
},
"locked": {
"lastModified": 1770586272,
"narHash": "sha256-Ucci8mu8QfxwzyfER2DQDbvW9t1BnTUJhBmY7ybralo=",
"owner": "nix-community",
"repo": "home-manager",
"rev": "b1f916ba052341edc1f80d4b2399f1092a4873ca",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "home-manager",
"type": "github"
}
},
"javafx-base": {
"flake": false,
"locked": {
"narHash": "sha256-96fttJUts/rFwKB7u5G8NWkK2NjJ3a6eIKbe1RTWkmM=",
"type": "file",
"url": "https://repo.maven.apache.org/maven2/org/openjfx/javafx-base/25/javafx-base-25-linux.jar"
},
"original": {
"type": "file",
"url": "https://repo.maven.apache.org/maven2/org/openjfx/javafx-base/25/javafx-base-25-linux.jar"
}
},
"javafx-controls": {
"flake": false,
"locked": {
"narHash": "sha256-2Cdc2/hPOjJmQidDjXu9vnlwAuawLn0cg/tLhzFfkUs=",
"type": "file",
"url": "https://repo.maven.apache.org/maven2/org/openjfx/javafx-controls/25/javafx-controls-25-linux.jar"
},
"original": {
"type": "file",
"url": "https://repo.maven.apache.org/maven2/org/openjfx/javafx-controls/25/javafx-controls-25-linux.jar"
}
},
"javafx-graphics": {
"flake": false,
"locked": {
"narHash": "sha256-w01IhRAQzcfTvwkqIQkjrI8ZPXT0VTEeijfzbqp3G0k=",
"type": "file",
"url": "https://repo.maven.apache.org/maven2/org/openjfx/javafx-graphics/25/javafx-graphics-25-linux.jar"
},
"original": {
"type": "file",
"url": "https://repo.maven.apache.org/maven2/org/openjfx/javafx-graphics/25/javafx-graphics-25-linux.jar"
}
},
"jlibghostty": {
"inputs": {
"ghostty": "ghostty",
"nixpkgs": "nixpkgs"
},
"locked": {
"lastModified": 1779889299,
"narHash": "sha256-B82MyhTvlfeszdcuM3F8YDSZYaxUom+m59oQKSoWjmQ=",
"ref": "refs/heads/main",
"rev": "eea43843002f8fae4fa4cb1c46b64339124bf6b2",
"revCount": 6,
"type": "git",
"url": "https://gitea.gregorlohaus.com/gregor/jlibghostty.git"
},
"original": {
"type": "git",
"url": "https://gitea.gregorlohaus.com/gregor/jlibghostty.git"
}
},
"jtoml-all": {
"flake": false,
"locked": {
"narHash": "sha256-KWrUaDVmnWzdkQxjgPFFNl8DOEvkCqWW3OmXU2sZHKw=",
"type": "file",
"url": "https://repo.maven.apache.org/maven2/io/github/wasabithumb/jtoml-all/1.5.2/jtoml-all-1.5.2.jar"
},
"original": {
"type": "file",
"url": "https://repo.maven.apache.org/maven2/io/github/wasabithumb/jtoml-all/1.5.2/jtoml-all-1.5.2.jar"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1779560665,
"narHash": "sha256-tpyBcxPpcQb8ukyNF7DoCwfSY3VPsxHoYwj00Cayv5o=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "64c08a7ca051951c8eae34e3e3cb1e202fe36786",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_2": {
"locked": {
"lastModified": 1779560665,
"narHash": "sha256-tpyBcxPpcQb8ukyNF7DoCwfSY3VPsxHoYwj00Cayv5o=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "64c08a7ca051951c8eae34e3e3cb1e202fe36786",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"javafx-base": "javafx-base",
"javafx-controls": "javafx-controls",
"javafx-graphics": "javafx-graphics",
"jlibghostty": "jlibghostty",
"jtoml-all": "jtoml-all",
"nixpkgs": "nixpkgs_2"
}
},
"systems": {
"flake": false,
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
},
"zig": {
"inputs": {
"flake-compat": [
"jlibghostty",
"ghostty",
"flake-compat"
],
"nixpkgs": [
"jlibghostty",
"ghostty",
"nixpkgs"
],
"systems": [
"jlibghostty",
"ghostty",
"systems"
]
},
"locked": {
"lastModified": 1776789209,
"narHash": "sha256-G6B7Q4TXn7MZ1mB+f9rymjsYF5PLWoSvmbxijb/99bw=",
"owner": "mitchellh",
"repo": "zig-overlay",
"rev": "14fe971844e841297ddd2ce9783d6892b467af39",
"type": "github"
},
"original": {
"owner": "mitchellh",
"repo": "zig-overlay",
"type": "github"
}
},
"zig_2": {
"inputs": {
"nixpkgs": [
"jlibghostty",
"ghostty",
"zon2nix",
"nixpkgs"
]
},
"locked": {
"lastModified": 1777234348,
"narHash": "sha256-fKw44a4qbUuI5eTG8k0gPbqMV5TOrjYF35PBzsYgd2U=",
"ref": "refs/heads/main",
"rev": "2c781c0609ecda600ab98f98cca417bbd981bd53",
"revCount": 1677,
"type": "git",
"url": "https://codeberg.org/jcollie/zig-overlay.git"
},
"original": {
"type": "git",
"url": "https://codeberg.org/jcollie/zig-overlay.git"
}
},
"zon2nix": {
"inputs": {
"nixpkgs": [
"jlibghostty",
"ghostty",
"nixpkgs"
],
"zig": "zig_2"
},
"locked": {
"lastModified": 1777314365,
"narHash": "sha256-eLxQaD0wc96Neqkln8wHS0rNq/chPODifFkhwrwilEU=",
"owner": "jcollie",
"repo": "zon2nix",
"rev": "a5a1d412ad1ab6305511997bbc92b3a9dd6cb784",
"type": "github"
},
"original": {
"owner": "jcollie",
"ref": "main",
"repo": "zon2nix",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

131
flake.nix
View File

@@ -1,131 +0,0 @@
{
description = "JavaFX terminal using jlibghostty and GraalVM Native Image";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
jlibghostty.url = "git+https://gitea.gregorlohaus.com/gregor/jlibghostty.git";
jtoml-all = {
url = "https://repo.maven.apache.org/maven2/io/github/wasabithumb/jtoml-all/1.5.2/jtoml-all-1.5.2.jar";
flake = false;
};
javafx-base = {
url = "https://repo.maven.apache.org/maven2/org/openjfx/javafx-base/25/javafx-base-25-linux.jar";
flake = false;
};
javafx-controls = {
url = "https://repo.maven.apache.org/maven2/org/openjfx/javafx-controls/25/javafx-controls-25-linux.jar";
flake = false;
};
javafx-graphics = {
url = "https://repo.maven.apache.org/maven2/org/openjfx/javafx-graphics/25/javafx-graphics-25-linux.jar";
flake = false;
};
};
outputs = {
self,
nixpkgs,
jlibghostty,
jtoml-all,
javafx-base,
javafx-controls,
javafx-graphics
}:
let
system = "x86_64-linux";
pkgs = import nixpkgs { inherit system; };
jlib = jlibghostty.packages.${system}.jlibghostty;
graalvm = pkgs.graalvmPackages.graalvm-ce;
gradle = if pkgs ? gradle_9 then pkgs.gradle_9 else pkgs.gradle;
openjfx = pkgs.javaPackages.openjfx25;
in {
packages.${system}.default = pkgs.stdenvNoCC.mkDerivation {
pname = "jprototerm";
version = "0.1.0";
src = ./.;
nativeBuildInputs = [
graalvm
pkgs.makeWrapper
];
buildPhase = ''
runHook preBuild
mkdir -p build/classes build/native-image build/lib build/javafx-modules
find src/main/java -name '*.java' | sort > build/sources.txt
cp ${jtoml-all} build/lib/jtoml-all.jar
cp ${javafx-base} build/javafx-modules/javafx-base.jar
cp ${javafx-controls} build/javafx-modules/javafx-controls.jar
cp ${javafx-graphics} build/javafx-modules/javafx-graphics.jar
javafx_module_path="build/javafx-modules"
jlib_classpath="$(
find ${jlib}/maven -type f -name '*.jar' \
! -name '*-sources.jar' \
! -name '*-javadoc.jar' \
| sort \
| paste -sd: -
)"
app_classpath="build/classes:build/lib/jtoml-all.jar:$jlib_classpath:build/javafx-modules/javafx-base.jar:build/javafx-modules/javafx-controls.jar:build/javafx-modules/javafx-graphics.jar"
javac \
--release 25 \
--module-path "$javafx_module_path" \
--add-modules javafx.controls,javafx.graphics \
-cp "build/lib/jtoml-all.jar:$jlib_classpath" \
-d build/classes \
@build/sources.txt
if [ -d src/main/resources ]; then
cp -R src/main/resources/. build/classes/
fi
native-image \
--no-fallback \
--enable-native-access=javafx.graphics \
--module-path "$javafx_module_path" \
--add-modules javafx.controls,javafx.graphics \
-cp "$app_classpath" \
-H:Class=com.gregor.jprototerm.Main \
-o build/native-image/jprototerm
runHook postBuild
'';
installPhase = ''
runHook preInstall
mkdir -p $out/bin
cp build/native-image/jprototerm $out/bin/jprototerm
wrapProgram $out/bin/jprototerm \
--set GDK_BACKEND x11 \
--set JAVA_TOOL_OPTIONS "-Dprism.order=sw" \
--prefix LD_LIBRARY_PATH : ${pkgs.lib.makeLibraryPath [ openjfx jlib ]}:${openjfx}/modules_libs/javafx.graphics \
--prefix PATH : ${pkgs.lib.makeBinPath [ pkgs.util-linux pkgs.bash ]}
runHook postInstall
'';
};
devShells.${system}.default = pkgs.mkShell {
packages = [
graalvm
gradle
pkgs.util-linux
];
shellHook = ''
export JLIBGHOSTTY_MAVEN_REPO=${jlib}/maven
echo "Use: gradle -PjlibghosttyMavenRepo=$JLIBGHOSTTY_MAVEN_REPO run"
'';
};
};
}

View File

@@ -1,22 +0,0 @@
pluginManagement {
repositories {
gradlePluginPortal()
mavenCentral()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
mavenCentral()
maven {
url = uri(
providers.gradleProperty("jlibghosttyMavenRepo")
.orElse("../jlibghostty/result/maven")
.get()
)
}
}
}
rootProject.name = "jprototerm"

View File

@@ -8,12 +8,17 @@ import io.github.wasabithumb.jtoml.value.primitive.TomlPrimitive;
import io.github.wasabithumb.jtoml.value.table.TomlTable;
import java.nio.file.Files;
import java.io.IOException;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
public record AppConfig(
int columns,
int rows,
long maxScrollback,
String shell,
String fontFamily,
double fontSize,
@@ -22,10 +27,23 @@ public record AppConfig(
boolean kittyGraphics,
Map<String, KeyBinding> keybindings
) {
private static final List<String> KEYBINDING_KEYS = List.of(
"navigate_left",
"navigate_down",
"navigate_up",
"navigate_right",
"toggle_floating",
"new_floating",
"next_floating",
"close_pane",
"open_font_selector"
);
public static AppConfig load() {
AppConfig defaults = defaults();
Path path = configPath();
if (!Files.isRegularFile(path)) {
writeDefaultConfig(path, defaults);
return defaults;
}
@@ -34,19 +52,14 @@ public record AppConfig(
return new AppConfig(
intValue(document, "terminal.columns", defaults.columns),
intValue(document, "terminal.rows", defaults.rows),
longValue(document, "terminal.max_scrollback", defaults.maxScrollback),
stringValue(document, "terminal.shell", defaults.shell),
stringValue(document, "terminal.font_family", defaults.fontFamily),
doubleValue(document, "terminal.font_size", defaults.fontSize),
doubleValue(document, "window.width", defaults.windowWidth),
doubleValue(document, "window.height", defaults.windowHeight),
booleanValue(document, "kitty_graphics.enabled", defaults.kittyGraphics),
Map.of(
"navigate_left", binding(document, "keybindings.navigate_left", defaults.keybindings.get("navigate_left")),
"navigate_down", binding(document, "keybindings.navigate_down", defaults.keybindings.get("navigate_down")),
"navigate_up", binding(document, "keybindings.navigate_up", defaults.keybindings.get("navigate_up")),
"navigate_right", binding(document, "keybindings.navigate_right", defaults.keybindings.get("navigate_right")),
"toggle_floating", binding(document, "keybindings.toggle_floating", defaults.keybindings.get("toggle_floating"))
)
keybindings(document, defaults)
);
} catch (TomlException ex) {
System.err.println("Could not parse " + path + ": " + ex.getMessage());
@@ -58,8 +71,9 @@ public record AppConfig(
return new AppConfig(
100,
30,
100_000,
defaultShell(),
"Symbols Nerd Font Mono",
"JetBrainsMono Nerd Font",
15.0,
1200.0,
760.0,
@@ -69,11 +83,34 @@ public record AppConfig(
"navigate_down", KeyBinding.parse("ALT+J"),
"navigate_up", KeyBinding.parse("ALT+K"),
"navigate_right", KeyBinding.parse("ALT+L"),
"toggle_floating", KeyBinding.parse("ALT+F")
"toggle_floating", KeyBinding.parse("ALT+F"),
"new_floating", KeyBinding.parse("ALT+SHIFT+F"),
"next_floating", KeyBinding.parse("ALT+F12"),
"close_pane", KeyBinding.parse("ALT+X"),
"open_font_selector", KeyBinding.parse("ALT+T")
)
);
}
public AppConfig withFont(String family, double size) {
return new AppConfig(
columns,
rows,
maxScrollback,
shell,
family,
size,
windowWidth,
windowHeight,
kittyGraphics,
keybindings
);
}
public void save() {
save(configPath(), this);
}
public static Path configPath() {
String configHome = System.getenv("XDG_CONFIG_HOME");
if (configHome != null && !configHome.isBlank()) {
@@ -83,8 +120,78 @@ public record AppConfig(
}
private static String defaultShell() {
String shell = System.getenv("SHELL");
return shell == null || shell.isBlank() ? "/bin/sh" : shell;
return "/bin/bash";
}
private static Map<String, KeyBinding> keybindings(TomlTable table, AppConfig defaults) {
Map<String, KeyBinding> parsed = new LinkedHashMap<>();
for (String key : KEYBINDING_KEYS) {
parsed.put(key, binding(table, "keybindings." + key, defaults.keybindings.get(key)));
}
return Map.copyOf(parsed);
}
private static void writeDefaultConfig(Path path, AppConfig defaults) {
save(path, defaults);
}
private static void save(Path path, AppConfig config) {
try {
Path parent = path.getParent();
if (parent != null) {
Files.createDirectories(parent);
}
Files.writeString(
path,
config.toToml(),
StandardOpenOption.CREATE,
StandardOpenOption.TRUNCATE_EXISTING,
StandardOpenOption.WRITE
);
} catch (IOException ex) {
System.err.println("Could not write " + path + ": " + ex.getMessage());
}
}
private String toToml() {
StringBuilder builder = new StringBuilder();
builder.append("[terminal]\n");
builder.append("columns = ").append(columns).append('\n');
builder.append("rows = ").append(rows).append('\n');
builder.append("max_scrollback = ").append(maxScrollback).append('\n');
builder.append("shell = ").append(quoted(shell)).append('\n');
builder.append("font_family = ").append(quoted(fontFamily)).append('\n');
builder.append("font_size = ").append(trimDouble(fontSize)).append("\n\n");
builder.append("[window]\n");
builder.append("width = ").append(trimDouble(windowWidth)).append('\n');
builder.append("height = ").append(trimDouble(windowHeight)).append("\n\n");
builder.append("[kitty_graphics]\n");
builder.append("enabled = ").append(kittyGraphics).append("\n\n");
builder.append("[keybindings]\n");
for (String key : KEYBINDING_KEYS) {
KeyBinding binding = keybindings.get(key);
if (binding != null) {
builder.append(key).append(" = ").append(quoted(binding.toString())).append('\n');
}
}
return builder.toString();
}
private static String quoted(String value) {
return "\"" + value
.replace("\\", "\\\\")
.replace("\"", "\\\"")
.replace("\n", "\\n")
.replace("\r", "\\r")
.replace("\t", "\\t")
+ "\"";
}
private static String trimDouble(double value) {
if (value == Math.rint(value)) {
return Long.toString((long) value);
}
return Double.toString(value);
}
private static KeyBinding binding(TomlTable table, String key, KeyBinding fallback) {
@@ -116,6 +223,18 @@ public record AppConfig(
}
}
private static long longValue(TomlTable table, String key, long fallback) {
TomlPrimitive primitive = primitive(table, key);
if (primitive == null) {
return fallback;
}
try {
return primitive.asInteger();
} catch (RuntimeException ex) {
return fallback;
}
}
private static double doubleValue(TomlTable table, String key, double fallback) {
TomlPrimitive primitive = primitive(table, key);
if (primitive == null) {

View File

@@ -18,7 +18,7 @@ public record KeyBinding(boolean alt, boolean control, boolean shift, KeyCode co
case "ALT", "META" -> alt = true;
case "CTRL", "CONTROL" -> control = true;
case "SHIFT" -> shift = true;
default -> code = KeyCode.getKeyCode(token);
default -> code = keyCode(token);
}
}
@@ -34,4 +34,35 @@ public record KeyBinding(boolean alt, boolean control, boolean shift, KeyCode co
&& event.isShiftDown() == shift
&& event.getCode() == code;
}
@Override
public String toString() {
StringBuilder builder = new StringBuilder();
if (control) {
builder.append("CTRL+");
}
if (alt) {
builder.append("ALT+");
}
if (shift) {
builder.append("SHIFT+");
}
builder.append(code.getName().toUpperCase(Locale.ROOT).replace(' ', '_'));
return builder.toString();
}
private static KeyCode keyCode(String token) {
KeyCode alias = switch (token) {
case "GRAVE", "BACKTICK", "BACK_QUOTE", "`" -> KeyCode.BACK_QUOTE;
default -> null;
};
if (alias != null) {
return alias;
}
try {
return KeyCode.valueOf(token);
} catch (IllegalArgumentException ex) {
return KeyCode.getKeyCode(token);
}
}
}

View File

@@ -8,6 +8,20 @@ final class KeyEncoder {
}
static String encode(KeyEvent event) {
if (event.isControlDown() && !event.isAltDown() && !event.isMetaDown()) {
String control = controlSequence(event);
if (control != null) {
return control;
}
}
if (event.isAltDown() && !event.isControlDown() && !event.isMetaDown()) {
String alt = altSequence(event);
if (alt != null) {
return alt;
}
}
KeyCode code = event.getCode();
return switch (code) {
case ENTER -> "\r";
@@ -23,7 +37,43 @@ final class KeyEncoder {
case DELETE -> "\u001b[3~";
case PAGE_UP -> "\u001b[5~";
case PAGE_DOWN -> "\u001b[6~";
case F1 -> "\u001bOP";
case F2 -> "\u001bOQ";
case F3 -> "\u001bOR";
case F4 -> "\u001bOS";
case F5 -> "\u001b[15~";
case F6 -> "\u001b[17~";
case F7 -> "\u001b[18~";
case F8 -> "\u001b[19~";
case F9 -> "\u001b[20~";
case F10 -> "\u001b[21~";
case F11 -> "\u001b[23~";
case F12 -> "\u001b[24~";
default -> null;
};
}
private static String controlSequence(KeyEvent event) {
KeyCode code = event.getCode();
if (code.isLetterKey()) {
return String.valueOf((char) (Character.toUpperCase(code.getName().charAt(0)) - '@'));
}
return switch (code) {
case SPACE -> "\u0000";
case OPEN_BRACKET -> "\u001b";
case BACK_SLASH -> "\u001c";
case CLOSE_BRACKET -> "\u001d";
case DIGIT6 -> "\u001e";
case MINUS -> "\u001f";
default -> null;
};
}
private static String altSequence(KeyEvent event) {
KeyCode code = event.getCode();
if (code.isLetterKey() || code.isDigitKey()) {
return "\u001b" + code.getName().toLowerCase();
}
return null;
}
}

View File

@@ -1,143 +0,0 @@
package com.gregor.jprototerm;
import javafx.scene.canvas.GraphicsContext;
import javafx.scene.image.Image;
import java.io.ByteArrayInputStream;
import java.util.ArrayList;
import java.util.Base64;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
public final class KittyGraphicsRegistry {
private final boolean enabled;
private final StringBuilder stream = new StringBuilder();
private final Map<Integer, StringBuilder> chunks = new HashMap<>();
private final List<Placement> placements = new ArrayList<>();
public KittyGraphicsRegistry(boolean enabled) {
this.enabled = enabled;
}
public synchronized void accept(String text) {
if (!enabled) {
return;
}
stream.append(text);
parseBufferedCommands();
}
public synchronized void draw(GraphicsContext gc, double originX, double originY, double cellWidth, double lineHeight) {
if (!enabled) {
return;
}
for (Placement placement : placements) {
double x = originX + placement.column * cellWidth;
double y = originY + placement.row * lineHeight;
double width = placement.columns <= 0 ? placement.image.getWidth() : placement.columns * cellWidth;
double height = placement.rows <= 0 ? placement.image.getHeight() : placement.rows * lineHeight;
gc.drawImage(placement.image, x, y, width, height);
}
}
public synchronized void clear() {
chunks.clear();
placements.clear();
stream.setLength(0);
}
private void parseBufferedCommands() {
int start;
while ((start = stream.indexOf("\u001b_G")) >= 0) {
int end = commandEnd(start + 3);
if (end < 0) {
if (start > 0) {
stream.delete(0, start);
}
return;
}
String command = stream.substring(start + 3, end);
handleCommand(command);
stream.delete(0, end + terminatorLength(end));
}
if (stream.length() > 16384) {
stream.delete(0, stream.length() - 4096);
}
}
private int commandEnd(int from) {
int bell = stream.indexOf("\u0007", from);
int st = stream.indexOf("\u001b\\", from);
if (bell < 0) {
return st;
}
if (st < 0) {
return bell;
}
return Math.min(bell, st);
}
private int terminatorLength(int end) {
return stream.charAt(end) == '\u0007' ? 1 : 2;
}
private void handleCommand(String command) {
int separator = command.indexOf(';');
if (separator < 0) {
return;
}
Map<String, String> control = parseControl(command.substring(0, separator));
String payload = command.substring(separator + 1).replace("\n", "").replace("\r", "");
int id = intControl(control, "i", 1);
boolean more = intControl(control, "m", 0) == 1;
chunks.computeIfAbsent(id, ignored -> new StringBuilder()).append(payload);
if (more) {
return;
}
String data = chunks.remove(id).toString();
try {
byte[] bytes = Base64.getDecoder().decode(data);
Image image = new Image(new ByteArrayInputStream(bytes));
if (!image.isError()) {
placements.add(new Placement(
image,
intControl(control, "x", 0),
intControl(control, "y", 0),
intControl(control, "c", 0),
intControl(control, "r", 0)
));
}
} catch (IllegalArgumentException ignored) {
chunks.remove(id);
}
}
private static Map<String, String> parseControl(String text) {
Map<String, String> result = new HashMap<>();
for (String part : text.split(",")) {
int equals = part.indexOf('=');
if (equals > 0) {
result.put(part.substring(0, equals), part.substring(equals + 1));
}
}
return result;
}
private static int intControl(Map<String, String> control, String key, int fallback) {
try {
return Integer.parseInt(control.getOrDefault(key, String.valueOf(fallback)));
} catch (NumberFormatException ex) {
return fallback;
}
}
private record Placement(Image image, int column, int row, int columns, int rows) {
}
}

View File

@@ -3,26 +3,36 @@ package com.gregor.jprototerm;
import javafx.animation.AnimationTimer;
import javafx.application.Application;
import javafx.scene.Scene;
import javafx.scene.control.ButtonType;
import javafx.scene.control.ComboBox;
import javafx.scene.control.Dialog;
import javafx.scene.control.Label;
import javafx.scene.control.Spinner;
import javafx.scene.control.SpinnerValueFactory;
import javafx.scene.input.KeyEvent;
import javafx.scene.layout.GridPane;
import javafx.scene.layout.StackPane;
import javafx.scene.text.Font;
import javafx.stage.Stage;
public final class Main extends Application {
private TerminalWorkspace workspace;
private TerminalCanvasView terminalView;
private AppConfig config;
@Override
public void start(Stage stage) {
AppConfig config = AppConfig.load();
config = AppConfig.load();
workspace = new TerminalWorkspace(config);
TerminalCanvasView terminalView = new TerminalCanvasView(workspace, config);
terminalView = new TerminalCanvasView(workspace, config);
StackPane root = new StackPane(terminalView.canvas());
terminalView.canvas().widthProperty().bind(root.widthProperty());
terminalView.canvas().heightProperty().bind(root.heightProperty());
Scene scene = new Scene(root, config.windowWidth(), config.windowHeight());
scene.addEventFilter(KeyEvent.KEY_PRESSED, event -> handlePressed(config, event));
scene.addEventFilter(KeyEvent.KEY_PRESSED, this::handlePressed);
scene.addEventFilter(KeyEvent.KEY_TYPED, event -> handleTyped(event));
new AnimationTimer() {
@@ -38,9 +48,10 @@ public final class Main extends Application {
workspace.close();
});
stage.show();
terminalView.canvas().requestFocus();
}
private void handlePressed(AppConfig config, KeyEvent event) {
private void handlePressed(KeyEvent event) {
if (config.keybindings().get("navigate_left").matches(event)) {
workspace.navigate(Direction.LEFT);
event.consume();
@@ -56,6 +67,18 @@ public final class Main extends Application {
} else if (config.keybindings().get("toggle_floating").matches(event)) {
workspace.toggleFloating();
event.consume();
} else if (config.keybindings().get("new_floating").matches(event)) {
workspace.createFloatingPane();
event.consume();
} else if (config.keybindings().get("next_floating").matches(event)) {
workspace.nextFloatingPane();
event.consume();
} else if (config.keybindings().get("close_pane").matches(event)) {
workspace.closeActivePane();
event.consume();
} else if (config.keybindings().get("open_font_selector").matches(event)) {
openFontSelector();
event.consume();
} else {
String encoded = KeyEncoder.encode(event);
if (encoded != null) {
@@ -77,9 +100,51 @@ public final class Main extends Application {
}
}
private void openFontSelector() {
Dialog<ButtonType> dialog = new Dialog<>();
dialog.setTitle("Font");
dialog.getDialogPane().getButtonTypes().addAll(ButtonType.OK, ButtonType.CANCEL);
ComboBox<String> family = new ComboBox<>();
family.getItems().setAll(Font.getFamilies());
family.setEditable(true);
family.setMaxWidth(Double.MAX_VALUE);
family.setValue(config.fontFamily());
Spinner<Double> size = new Spinner<>();
size.setEditable(true);
size.setValueFactory(new SpinnerValueFactory.DoubleSpinnerValueFactory(6.0, 48.0, config.fontSize(), 0.5));
GridPane content = new GridPane();
content.setHgap(10.0);
content.setVgap(10.0);
content.add(new Label("Family"), 0, 0);
content.add(family, 1, 0);
content.add(new Label("Size"), 0, 1);
content.add(size, 1, 1);
dialog.getDialogPane().setContent(content);
dialog.showAndWait()
.filter(button -> button == ButtonType.OK)
.ifPresent(ignored -> {
String selectedFamily = family.getEditor().getText();
if (selectedFamily == null || selectedFamily.isBlank()) {
selectedFamily = family.getValue();
}
if (selectedFamily == null || selectedFamily.isBlank()) {
return;
}
double selectedSize = size.getValue();
config = config.withFont(selectedFamily.trim(), selectedSize);
config.save();
terminalView.setFont(config.fontFamily(), config.fontSize());
terminalView.canvas().requestFocus();
});
}
public static void main(String[] args) {
System.setProperty("prism.order", "sw");
System.setProperty("prism.verbose", "true");
System.setProperty("prism.order", System.getProperty("prism.order", "es2,sw"));
launch(Main.class, args);
}
}

View File

@@ -1,20 +1,25 @@
package com.gregor.jprototerm;
import com.pty4j.PtyProcess;
import com.pty4j.PtyProcessBuilder;
import com.pty4j.WinSize;
import javafx.application.Platform;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public final class ShellSession implements AutoCloseable {
private final Process process;
private final PtyProcess process;
private final OutputStream stdin;
private final ExecutorService reader;
private volatile boolean closed;
private ShellSession(Process process, TerminalPane pane, KittyGraphicsRegistry graphicsRegistry) {
private ShellSession(PtyProcess process) {
this.process = process;
this.stdin = process.getOutputStream();
this.reader = Executors.newSingleThreadExecutor(runnable -> {
@@ -22,50 +27,65 @@ public final class ShellSession implements AutoCloseable {
thread.setDaemon(true);
return thread;
});
reader.submit(() -> readOutput(pane, graphicsRegistry));
}
public static ShellSession start(String shell, TerminalPane pane, KittyGraphicsRegistry graphicsRegistry) {
public static ShellSession start(String shell, TerminalPane pane, int columns, int rows) {
try {
ProcessBuilder processBuilder = new ProcessBuilder(
"script",
"-qfec",
shell + " -i",
"/dev/null"
).redirectErrorStream(true);
processBuilder.environment().put("TERM", "xterm-kitty");
processBuilder.environment().put("COLORTERM", "truecolor");
Process process = processBuilder.start();
return new ShellSession(process, pane, graphicsRegistry);
Map<String, String> environment = new HashMap<>(System.getenv());
environment.put("TERM", "xterm-kitty");
environment.put("COLORTERM", "truecolor");
PtyProcess process = new PtyProcessBuilder(new String[] {shell, "-i"})
.setEnvironment(environment)
.setInitialColumns(columns)
.setInitialRows(rows)
.setDirectory(System.getProperty("user.home"))
.start();
return new ShellSession(process);
} catch (IOException ex) {
pane.write("failed to start shell: " + ex.getMessage() + "\r\n");
throw new IllegalStateException("Could not start shell " + shell, ex);
}
}
public void startReading(TerminalPane pane) {
reader.submit(() -> readOutput(pane));
}
public void resize(int columns, int rows) {
if (closed) {
return;
}
process.setWinSize(new WinSize(columns, rows));
}
public void send(String text) {
send(text.getBytes(StandardCharsets.UTF_8));
}
public void send(byte[] bytes) {
if (closed) {
return;
}
try {
stdin.write(text.getBytes(StandardCharsets.UTF_8));
stdin.write(bytes);
stdin.flush();
} catch (IOException ex) {
close();
}
}
private void readOutput(TerminalPane pane, KittyGraphicsRegistry graphicsRegistry) {
private void readOutput(TerminalPane pane) {
byte[] buffer = new byte[8192];
try {
int read;
while ((read = process.getInputStream().read(buffer)) != -1) {
String text = new String(buffer, 0, read, StandardCharsets.UTF_8);
if (!closed) {
graphicsRegistry.accept(text);
byte[] bytes = new byte[read];
System.arraycopy(buffer, 0, bytes, 0, read);
Platform.runLater(() -> {
if (!closed) {
pane.write(text);
pane.write(bytes);
}
});
}

View File

@@ -1,26 +1,72 @@
package com.gregor.jprototerm;
import dev.jlibghostty.KittyImageCompression;
import dev.jlibghostty.KittyImageFormat;
import dev.jlibghostty.KittyImageSnapshot;
import dev.jlibghostty.KittyPlacement;
import dev.jlibghostty.KittyRenderInfo;
import dev.jlibghostty.KeyModifiers;
import dev.jlibghostty.MouseButton;
import dev.jlibghostty.MouseEncoderSize;
import dev.jlibghostty.MouseInput;
import dev.jlibghostty.RenderCell;
import dev.jlibghostty.RenderColor;
import dev.jlibghostty.RenderCursorStyle;
import dev.jlibghostty.RenderRow;
import dev.jlibghostty.RenderStateSnapshot;
import javafx.scene.canvas.Canvas;
import javafx.scene.canvas.GraphicsContext;
import javafx.scene.image.Image;
import javafx.scene.image.PixelFormat;
import javafx.scene.image.WritableImage;
import javafx.scene.input.InputEvent;
import javafx.scene.input.MouseEvent;
import javafx.scene.input.ScrollEvent;
import javafx.scene.input.ScrollEvent.VerticalTextScrollUnits;
import javafx.scene.paint.Color;
import javafx.scene.text.Font;
import javafx.scene.text.FontSmoothingType;
import javafx.scene.text.Text;
import java.io.ByteArrayInputStream;
import java.util.HashMap;
import java.util.Map;
public final class TerminalCanvasView {
private static final Color DEFAULT_FOREGROUND = Color.rgb(225, 229, 235);
private static final Color SELECTED_BACKGROUND = Color.rgb(52, 92, 140);
private final Canvas canvas = new Canvas();
private final TerminalWorkspace workspace;
private final AppConfig config;
private final Map<Long, Image> kittyImageCache = new HashMap<>();
private String fontFamily;
private double fontSize;
private boolean mouseButtonPressed;
private MouseButton pressedButton = MouseButton.UNKNOWN;
public TerminalCanvasView(TerminalWorkspace workspace, AppConfig config) {
this.workspace = workspace;
this.config = config;
this.fontFamily = config.fontFamily();
this.fontSize = config.fontSize();
canvas.setFocusTraversable(true);
canvas.setOnMousePressed(this::handleMousePressed);
canvas.setOnMouseReleased(this::handleMouseReleased);
canvas.setOnMouseDragged(this::handleMouseDragged);
canvas.setOnMouseMoved(this::handleMouseMoved);
canvas.setOnScroll(this::handleScroll);
}
public Canvas canvas() {
return canvas;
}
public void setFont(String family, double size) {
this.fontFamily = family;
this.fontSize = size;
}
public void render() {
double width = canvas.getWidth();
double height = canvas.getHeight();
@@ -29,7 +75,7 @@ public final class TerminalCanvasView {
GraphicsContext gc = canvas.getGraphicsContext2D();
gc.setFill(Color.rgb(16, 16, 18));
gc.fillRect(0, 0, width, height);
gc.setFontSmoothingType(FontSmoothingType.GRAY);
gc.setFontSmoothingType(FontSmoothingType.LCD);
for (TerminalPane pane : workspace.panes()) {
drawPane(gc, pane);
@@ -53,22 +99,359 @@ public final class TerminalCanvasView {
gc.setLineWidth(workspace.isActive(pane) ? 2.0 : 1.0);
gc.strokeRect(pane.x() + 0.5, pane.y() + 0.5, pane.width() - 1.0, pane.height() - 1.0);
Font font = Font.font(config.fontFamily(), config.fontSize());
Font font = Font.font(fontFamily, fontSize);
gc.setFont(font);
gc.setFill(Color.rgb(225, 229, 235));
double lineHeight = Math.ceil(config.fontSize() * 1.35);
FontMetrics metrics = measureFontMetrics(font);
int columns = Math.max(1, (int) ((pane.width() - 24.0) / metrics.cellWidth));
int rows = Math.max(1, (int) ((pane.height() - 24.0) / metrics.lineHeight));
pane.resize(columns, rows, (int) Math.round(metrics.cellWidth), (int) Math.round(metrics.lineHeight));
double left = pane.x() + 12.0;
double baseline = pane.y() + 18.0;
int maxLines = Math.max(1, (int) ((pane.height() - 24.0) / lineHeight));
double top = pane.y() + 12.0;
double baseline = top + metrics.baselineOffset;
String[] lines = pane.snapshotText().split("\\R", -1);
int start = Math.max(0, lines.length - maxLines);
for (int i = start; i < lines.length; i++) {
gc.fillText(lines[i], left, baseline + ((i - start) * lineHeight));
RenderStateSnapshot snapshot = pane.renderSnapshot();
if (snapshot != null) {
for (RenderRow row : snapshot.renderRows()) {
drawRow(gc, row, left, top, baseline, metrics.cellWidth, metrics.lineHeight);
}
}
pane.graphicsRegistry().draw(gc, pane.x() + 12.0, pane.y() + 12.0, config.fontSize() * 0.62, lineHeight);
if (snapshot != null) {
drawCursor(gc, snapshot, left, top, metrics.cellWidth, metrics.lineHeight);
}
if (config.kittyGraphics()) {
drawKittyGraphics(gc, pane, left, top, metrics.cellWidth, metrics.lineHeight);
}
gc.restore();
}
private static FontMetrics measureFontMetrics(Font font) {
Text text = new Text("┃MgÅjy");
text.setFont(font);
double lineHeight = Math.max(1.0, text.getLayoutBounds().getHeight());
double baselineOffset = -text.getLayoutBounds().getMinY();
String sample = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
Text cell = new Text(sample);
cell.setFont(font);
double cellWidth = Math.max(1.0, cell.getLayoutBounds().getWidth() / sample.length());
return new FontMetrics(cellWidth, lineHeight, baselineOffset);
}
private void handleMousePressed(MouseEvent event) {
canvas.requestFocus();
TerminalPane pane = paneAt(event.getX(), event.getY());
if (pane == null) {
return;
}
workspace.focus(pane);
pressedButton = mouseButton(event);
mouseButtonPressed = true;
sendMouse(pane, MouseInput.press(pressedButton, eventX(pane, event.getX()), eventY(pane, event.getY()), modifiers(event)), true, event);
}
private void handleMouseReleased(MouseEvent event) {
TerminalPane pane = paneAt(event.getX(), event.getY());
if (pane == null) {
pane = workspace.activePane();
}
MouseButton button = pressedButton == MouseButton.UNKNOWN ? mouseButton(event) : pressedButton;
sendMouse(pane, MouseInput.release(button, eventX(pane, event.getX()), eventY(pane, event.getY()), modifiers(event)), false, event);
mouseButtonPressed = false;
pressedButton = MouseButton.UNKNOWN;
}
private void handleMouseDragged(MouseEvent event) {
TerminalPane pane = paneAt(event.getX(), event.getY());
if (pane == null) {
pane = workspace.activePane();
}
MouseButton button = pressedButton == MouseButton.UNKNOWN ? mouseButton(event) : pressedButton;
sendMouse(pane, MouseInput.drag(button, eventX(pane, event.getX()), eventY(pane, event.getY()), modifiers(event)), true, event);
}
private void handleMouseMoved(MouseEvent event) {
TerminalPane pane = paneAt(event.getX(), event.getY());
if (pane == null) {
return;
}
sendMouse(pane, MouseInput.motion(eventX(pane, event.getX()), eventY(pane, event.getY()), modifiers(event)), mouseButtonPressed, event);
}
private void handleScroll(ScrollEvent event) {
TerminalPane pane = paneAt(event.getX(), event.getY());
if (pane == null) {
return;
}
canvas.requestFocus();
workspace.focus(pane);
int direction = scrollDirection(event);
if (direction == 0) {
return;
}
MouseButton wheelButton = direction > 0 ? MouseButton.FOUR : MouseButton.FIVE;
int rows = scrollRows(event);
boolean sent = false;
for (int i = 0; i < rows; i++) {
sent |= sendMouse(
pane,
MouseInput.press(wheelButton, eventX(pane, event.getX()), eventY(pane, event.getY()), modifiers(event)),
mouseButtonPressed,
event
);
}
if (!sent) {
pane.scrollViewport(direction > 0 ? -rows : rows);
event.consume();
}
}
private boolean sendMouse(TerminalPane pane, MouseInput input, boolean anyButtonPressed, InputEvent event) {
MouseTarget target = mouseTarget(pane);
if (target == null) {
return false;
}
boolean sent = pane.sendMouse(input, target.size(), anyButtonPressed);
if (sent) {
event.consume();
}
return sent;
}
private TerminalPane paneAt(double x, double y) {
java.util.List<TerminalPane> panes = workspace.panes();
for (int i = panes.size() - 1; i >= 0; i--) {
TerminalPane pane = panes.get(i);
if (x >= pane.x() && x < pane.x() + pane.width() && y >= pane.y() && y < pane.y() + pane.height()) {
return pane;
}
}
return null;
}
private MouseTarget mouseTarget(TerminalPane pane) {
if (pane.width() <= 24.0 || pane.height() <= 24.0) {
return null;
}
FontMetrics metrics = measureFontMetrics(Font.font(fontFamily, fontSize));
int columns = Math.max(1, (int) ((pane.width() - 24.0) / metrics.cellWidth));
int rows = Math.max(1, (int) ((pane.height() - 24.0) / metrics.lineHeight));
long cellWidth = Math.max(1L, Math.round(metrics.cellWidth));
long cellHeight = Math.max(1L, Math.round(metrics.lineHeight));
long screenWidth = Math.max(1L, Math.round(columns * metrics.cellWidth));
long screenHeight = Math.max(1L, Math.round(rows * metrics.lineHeight));
return new MouseTarget(MouseEncoderSize.of(screenWidth, screenHeight, cellWidth, cellHeight), screenWidth, screenHeight);
}
private double eventX(TerminalPane pane, double canvasX) {
MouseTarget target = mouseTarget(pane);
if (target == null) {
return 0.0;
}
return clamp(canvasX - pane.x() - 12.0, 0.0, target.screenWidth() - 1.0);
}
private double eventY(TerminalPane pane, double canvasY) {
MouseTarget target = mouseTarget(pane);
if (target == null) {
return 0.0;
}
return clamp(canvasY - pane.y() - 12.0, 0.0, target.screenHeight() - 1.0);
}
private static double clamp(double value, double min, double max) {
return Math.max(min, Math.min(max, value));
}
private static KeyModifiers modifiers(MouseEvent event) {
return KeyModifiers.of(event.isShiftDown(), event.isControlDown(), event.isAltDown(), event.isMetaDown());
}
private static KeyModifiers modifiers(ScrollEvent event) {
return KeyModifiers.of(event.isShiftDown(), event.isControlDown(), event.isAltDown(), event.isMetaDown());
}
private static int scrollRows(ScrollEvent event) {
double rows;
if (event.getTextDeltaYUnits() == VerticalTextScrollUnits.LINES && event.getTextDeltaY() != 0.0) {
rows = Math.abs(event.getTextDeltaY());
} else if (event.getTextDeltaYUnits() == VerticalTextScrollUnits.PAGES && event.getTextDeltaY() != 0.0) {
rows = Math.abs(event.getTextDeltaY()) * 24.0;
} else if (event.getMultiplierY() > 0.0) {
rows = Math.abs(event.getDeltaY()) / event.getMultiplierY();
} else {
rows = Math.abs(event.getDeltaY()) / 40.0;
}
return Math.max(1, Math.min(64, (int) Math.ceil(rows)));
}
private static int scrollDirection(ScrollEvent event) {
if (event.getDeltaY() != 0.0) {
return event.getDeltaY() > 0.0 ? 1 : -1;
}
if (event.getTextDeltaYUnits() != VerticalTextScrollUnits.NONE && event.getTextDeltaY() != 0.0) {
return event.getTextDeltaY() > 0.0 ? 1 : -1;
}
return 0;
}
private static MouseButton mouseButton(MouseEvent event) {
return switch (event.getButton()) {
case PRIMARY -> MouseButton.LEFT;
case SECONDARY -> MouseButton.RIGHT;
case MIDDLE -> MouseButton.MIDDLE;
default -> MouseButton.UNKNOWN;
};
}
private static void drawRow(
GraphicsContext gc,
RenderRow row,
double left,
double top,
double baseline,
double cellWidth,
double lineHeight
) {
for (RenderCell cell : row.cells()) {
double x = left + (cell.column() * cellWidth);
double cellTop = top + (row.row() * lineHeight);
cell.background().ifPresent(background -> {
gc.setFill(toFxColor(background));
fillCellRect(gc, x, cellTop, cellWidth, lineHeight);
});
if (cell.selected()) {
gc.setFill(SELECTED_BACKGROUND);
fillCellRect(gc, x, cellTop, cellWidth, lineHeight);
}
if (cell.codepoints().length == 0) {
continue;
}
double y = baseline + (row.row() * lineHeight);
Color foreground = cell.foreground().map(TerminalCanvasView::toFxColor).orElse(DEFAULT_FOREGROUND);
gc.setFill(foreground);
gc.fillText(cell.text(), x, y);
}
}
private static void fillCellRect(GraphicsContext gc, double x, double y, double width, double height) {
double x1 = Math.floor(x);
double y1 = Math.floor(y);
double x2 = Math.ceil(x + width);
double y2 = Math.ceil(y + height);
gc.fillRect(x1, y1, Math.max(1.0, x2 - x1), Math.max(1.0, y2 - y1));
}
private static Color toFxColor(RenderColor color) {
return Color.rgb(color.red(), color.green(), color.blue());
}
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(Color.rgb(225, 229, 235));
gc.setFill(Color.rgb(225, 229, 235, 0.28));
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));
}
}
private void drawKittyGraphics(GraphicsContext gc, TerminalPane pane, double originX, double originY, double cellWidth, double lineHeight) {
pane.kittyGraphics().ifPresent(graphics -> {
for (KittyPlacement placement : graphics.placements()) {
Image image = imageFor(placement);
if (image == null) {
continue;
}
KittyRenderInfo renderInfo = placement.renderInfo().orElse(null);
double x = originX;
double y = originY;
double width = image.getWidth();
double height = image.getHeight();
if (renderInfo != null) {
x += renderInfo.viewportColumn() * cellWidth;
y += renderInfo.viewportRow() * lineHeight;
width = renderInfo.gridColumns() > 0 ? renderInfo.gridColumns() * cellWidth : renderInfo.pixelWidth();
height = renderInfo.gridRows() > 0 ? renderInfo.gridRows() * lineHeight : renderInfo.pixelHeight();
} else {
width = placement.columns() > 0 ? placement.columns() * cellWidth : width;
height = placement.rows() > 0 ? placement.rows() * lineHeight : height;
}
gc.drawImage(image, x + placement.xOffset(), y + placement.yOffset(), width, height);
}
});
}
private Image imageFor(KittyPlacement placement) {
return placement.image()
.map(snapshot -> kittyImageCache.computeIfAbsent(snapshot.id(), ignored -> decodeImage(snapshot)))
.orElse(null);
}
private Image decodeImage(KittyImageSnapshot snapshot) {
if (snapshot.compression() != KittyImageCompression.NONE) {
return null;
}
if (snapshot.format() == KittyImageFormat.PNG) {
return new Image(new ByteArrayInputStream(snapshot.data()));
}
int width = Math.toIntExact(snapshot.width());
int height = Math.toIntExact(snapshot.height());
WritableImage image = new WritableImage(width, height);
byte[] data = snapshot.data();
if (snapshot.format() == KittyImageFormat.RGBA) {
image.getPixelWriter().setPixels(0, 0, width, height, PixelFormat.getByteBgraInstance(), rgbaToBgra(data), 0, width * 4);
} else if (snapshot.format() == KittyImageFormat.RGB) {
image.getPixelWriter().setPixels(0, 0, width, height, PixelFormat.getByteRgbInstance(), data, 0, width * 3);
}
return image;
}
private static byte[] rgbaToBgra(byte[] rgba) {
byte[] bgra = new byte[rgba.length];
for (int i = 0; i + 3 < rgba.length; i += 4) {
bgra[i] = rgba[i + 2];
bgra[i + 1] = rgba[i + 1];
bgra[i + 2] = rgba[i];
bgra[i + 3] = rgba[i + 3];
}
return bgra;
}
private record FontMetrics(double cellWidth, double lineHeight, double baselineOffset) {
}
private record MouseTarget(MouseEncoderSize size, long screenWidth, long screenHeight) {
}
}

View File

@@ -1,30 +1,46 @@
package com.gregor.jprototerm;
import dev.jlibghostty.Ghostty;
import dev.jlibghostty.KittyGraphics;
import dev.jlibghostty.MouseAction;
import dev.jlibghostty.MouseEncoder;
import dev.jlibghostty.MouseEncoderSize;
import dev.jlibghostty.MouseInput;
import dev.jlibghostty.RenderStateSnapshot;
import dev.jlibghostty.ScrollViewport;
import dev.jlibghostty.Terminal;
import dev.jlibghostty.TerminalOptions;
import dev.jlibghostty.DeviceAttributes;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicReference;
public final class TerminalPane implements AutoCloseable {
private final Terminal terminal;
private final KittyGraphicsRegistry graphicsRegistry;
private final AtomicReference<String> snapshotText = new AtomicReference<>("");
private final MouseEncoder mouseEncoder = new MouseEncoder();
private final AtomicReference<RenderStateSnapshot> renderSnapshot = new AtomicReference<>();
private ShellSession session;
private boolean floating;
private boolean visible = true;
private double x;
private double y;
private double width;
private double height;
private int columns;
private int rows;
private int pixelWidth;
private int pixelHeight;
private TerminalPane(Terminal terminal, KittyGraphicsRegistry graphicsRegistry) {
private TerminalPane(Terminal terminal, int columns, int rows) {
this.terminal = terminal;
this.graphicsRegistry = graphicsRegistry;
this.columns = columns;
this.rows = rows;
}
public static TerminalPane create(int columns, int rows, boolean kittyGraphics) {
Terminal terminal = Ghostty.open(TerminalOptions.of(columns, rows));
TerminalPane pane = new TerminalPane(terminal, new KittyGraphicsRegistry(kittyGraphics));
public static TerminalPane create(int columns, int rows, long maxScrollback) {
Terminal terminal = Ghostty.open(new TerminalOptions(columns, rows, maxScrollback));
terminal.setDeviceAttributesProvider(DeviceAttributes::xtermCompatible);
TerminalPane pane = new TerminalPane(terminal, columns, rows);
pane.refresh();
return pane;
}
@@ -36,22 +52,72 @@ public final class TerminalPane implements AutoCloseable {
}
}
public void write(byte[] bytes) {
synchronized (terminal) {
terminal.write(bytes);
refresh();
}
}
public void attach(ShellSession session) {
this.session = session;
terminal.setPtyWriter(bytes -> {
ShellSession current = this.session;
if (current != null) {
current.send(bytes);
}
});
session.startReading(this);
}
public void send(String text) {
scrollViewportToBottom();
if (session != null) {
session.send(text);
}
}
public String snapshotText() {
return snapshotText.get();
public boolean sendMouse(MouseInput input, MouseEncoderSize size, boolean anyButtonPressed) {
synchronized (terminal) {
mouseEncoder.syncFromTerminal(terminal);
mouseEncoder.setSize(size);
mouseEncoder.setAnyButtonPressed(anyButtonPressed);
mouseEncoder.setTrackLastCell(input.action() == MouseAction.MOTION && input.button().isEmpty());
byte[] encoded = mouseEncoder.encode(input);
if (encoded.length == 0) {
return false;
}
if (session != null) {
session.send(encoded);
}
return true;
}
}
public KittyGraphicsRegistry graphicsRegistry() {
return graphicsRegistry;
public void scrollViewport(long rows) {
synchronized (terminal) {
terminal.scrollViewport(ScrollViewport.delta(rows));
refresh();
}
}
public void scrollViewportToBottom() {
synchronized (terminal) {
terminal.scrollViewport(ScrollViewport.bottom());
refresh();
}
}
public RenderStateSnapshot renderSnapshot() {
return renderSnapshot.get();
}
public Optional<KittyGraphics> kittyGraphics() {
synchronized (terminal) {
return terminal.kittyGraphics();
}
}
public boolean floating() {
@@ -62,6 +128,14 @@ public final class TerminalPane implements AutoCloseable {
this.floating = floating;
}
public boolean visible() {
return visible;
}
public void setVisible(boolean visible) {
this.visible = visible;
}
public double x() {
return x;
}
@@ -85,8 +159,29 @@ public final class TerminalPane implements AutoCloseable {
this.height = height;
}
public void resize(int columns, int rows, int pixelWidth, int pixelHeight) {
if (columns <= 0 || rows <= 0 || pixelWidth <= 0 || pixelHeight <= 0) {
return;
}
if (this.columns == columns && this.rows == rows && this.pixelWidth == pixelWidth && this.pixelHeight == pixelHeight) {
return;
}
synchronized (terminal) {
terminal.resize(columns, rows, pixelWidth, pixelHeight);
if (session != null) {
session.resize(columns, rows);
}
this.columns = columns;
this.rows = rows;
this.pixelWidth = pixelWidth;
this.pixelHeight = pixelHeight;
refresh();
}
}
private void refresh() {
snapshotText.set(String.valueOf(terminal.snapshot()));
renderSnapshot.set(terminal.renderSnapshot());
}
@Override
@@ -95,6 +190,7 @@ public final class TerminalPane implements AutoCloseable {
session.close();
session = null;
}
mouseEncoder.close();
terminal.close();
}
}

View File

@@ -8,6 +8,7 @@ public final class TerminalWorkspace implements AutoCloseable {
private final AppConfig config;
private final List<TerminalPane> panes = new ArrayList<>();
private int activeIndex;
private int hiddenFloatingFocusIndex = -1;
public TerminalWorkspace(AppConfig config) {
this.config = config;
@@ -19,28 +20,55 @@ public final class TerminalWorkspace implements AutoCloseable {
}
public List<TerminalPane> panes() {
return List.copyOf(panes);
List<TerminalPane> visible = panes.stream().filter(TerminalPane::visible).toList();
TerminalPane active = activePane();
if (!active.visible() || !active.floating()) {
return visible;
}
List<TerminalPane> ordered = new ArrayList<>(visible.size());
visible.stream()
.filter(pane -> pane != active)
.forEach(ordered::add);
ordered.add(active);
return List.copyOf(ordered);
}
public boolean isActive(TerminalPane pane) {
return activePane() == pane;
}
public void focus(TerminalPane pane) {
int index = panes.indexOf(pane);
if (index >= 0 && pane.visible()) {
activeIndex = index;
}
}
public void layout(double width, double height) {
List<TerminalPane> tiled = panes.stream().filter(pane -> !pane.floating()).toList();
List<TerminalPane> tiled = panes.stream()
.filter(TerminalPane::visible)
.filter(pane -> !pane.floating())
.toList();
int tileCount = Math.max(1, tiled.size());
double tileWidth = width / tileCount;
for (int i = 0; i < tiled.size(); i++) {
tiled.get(i).bounds(i * tileWidth, 0, tileWidth, height);
}
for (TerminalPane pane : panes) {
if (pane.floating()) {
List<TerminalPane> floating = panes.stream()
.filter(TerminalPane::visible)
.filter(TerminalPane::floating)
.toList();
for (int i = 0; i < floating.size(); i++) {
TerminalPane pane = floating.get(i);
if (pane.visible() && pane.floating()) {
double floatingWidth = Math.max(420, width * 0.58);
double floatingHeight = Math.max(260, height * 0.58);
double offset = i * 28.0;
pane.bounds(
(width - floatingWidth) / 2.0,
(height - floatingHeight) / 2.0,
Math.min(width - floatingWidth - 12.0, ((width - floatingWidth) / 2.0) + offset),
Math.min(height - floatingHeight - 12.0, ((height - floatingHeight) / 2.0) + offset),
floatingWidth,
floatingHeight
);
@@ -50,7 +78,12 @@ public final class TerminalWorkspace implements AutoCloseable {
public void navigate(Direction direction) {
TerminalPane current = activePane();
if (current.floating() && navigateFloatingStack(direction)) {
return;
}
panes.stream()
.filter(TerminalPane::visible)
.filter(pane -> pane != current)
.filter(pane -> directionFilter(direction, current, pane))
.min(Comparator.comparingDouble(pane -> distance(current, pane)))
@@ -58,23 +91,160 @@ public final class TerminalWorkspace implements AutoCloseable {
}
public void toggleFloating() {
TerminalPane active = activePane();
if (active.floating()) {
panes.remove(activeIndex);
active.close();
activeIndex = Math.max(0, activeIndex - 1);
List<TerminalPane> floating = panes.stream()
.filter(TerminalPane::floating)
.toList();
if (floating.isEmpty()) {
createFloatingPane();
return;
}
boolean anyVisible = floating.stream().anyMatch(TerminalPane::visible);
if (anyVisible) {
TerminalPane active = activePane();
hiddenFloatingFocusIndex = active.floating() ? activeIndex : firstVisibleFloatingIndex();
floating.forEach(pane -> pane.setVisible(false));
activeIndex = firstVisibleNonFloatingIndex();
} else {
floating.forEach(pane -> pane.setVisible(true));
activeIndex = visibleIndexOrFallback(hiddenFloatingFocusIndex, panes.indexOf(floating.get(floating.size() - 1)));
hiddenFloatingFocusIndex = -1;
}
}
public void createFloatingPane() {
TerminalPane pane = openPane(true);
panes.add(pane);
activeIndex = panes.size() - 1;
}
public void nextFloatingPane() {
TerminalPane next = nextFloatingAfter(activeIndex);
next.setVisible(true);
activeIndex = panes.indexOf(next);
}
public void closeActivePane() {
TerminalPane active = activePane();
if (!active.floating() || panes.stream().filter(pane -> !pane.floating()).count() == 0) {
return;
}
int removed = activeIndex;
int previous = previousVisibleIndex(removed);
panes.remove(removed);
active.close();
activeIndex = adjustIndexAfterRemoval(previous, removed);
hiddenFloatingFocusIndex = adjustHiddenFocusAfterRemoval(hiddenFloatingFocusIndex, removed);
}
private TerminalPane nextFloatingAfter(int index) {
for (int i = index + 1; i < panes.size(); i++) {
TerminalPane pane = panes.get(i);
if (pane.floating()) {
return pane;
}
}
for (int i = 0; i <= index && i < panes.size(); i++) {
TerminalPane pane = panes.get(i);
if (pane.floating()) {
return pane;
}
}
return createAndReturnFloatingPane();
}
private TerminalPane createAndReturnFloatingPane() {
TerminalPane pane = openPane(true);
panes.add(pane);
return pane;
}
private boolean navigateFloatingStack(Direction direction) {
List<TerminalPane> floating = panes.stream()
.filter(TerminalPane::visible)
.filter(TerminalPane::floating)
.toList();
if (floating.size() < 2) {
return false;
}
int current = floating.indexOf(activePane());
if (current < 0) {
return false;
}
int next = switch (direction) {
case LEFT, UP -> current - 1;
case DOWN, RIGHT -> current + 1;
};
if (next < 0 || next >= floating.size()) {
return false;
}
activeIndex = panes.indexOf(floating.get(next));
return true;
}
private int firstVisibleFloatingIndex() {
for (int i = 0; i < panes.size(); i++) {
TerminalPane pane = panes.get(i);
if (pane.visible() && pane.floating()) {
return i;
}
}
return -1;
}
private int firstVisibleNonFloatingIndex() {
for (int i = 0; i < panes.size(); i++) {
TerminalPane pane = panes.get(i);
if (pane.visible() && !pane.floating()) {
return i;
}
}
return 0;
}
private int previousVisibleIndex(int index) {
for (int i = index - 1; i >= 0; i--) {
if (panes.get(i).visible()) {
return i;
}
}
for (int i = index + 1; i < panes.size(); i++) {
if (panes.get(i).visible()) {
return i;
}
}
return firstVisibleNonFloatingIndex();
}
private int visibleIndexOrFallback(int index, int fallback) {
if (index >= 0 && index < panes.size() && panes.get(index).visible()) {
return index;
}
return fallback;
}
private static int adjustIndexAfterRemoval(int index, int removedIndex) {
if (index < 0) {
return 0;
}
return index > removedIndex ? index - 1 : index;
}
private static int adjustHiddenFocusAfterRemoval(int index, int removedIndex) {
if (index < 0 || index == removedIndex) {
return -1;
}
return index > removedIndex ? index - 1 : index;
}
private TerminalPane openPane(boolean floating) {
TerminalPane pane = TerminalPane.create(config.columns(), config.rows(), config.kittyGraphics());
TerminalPane pane = TerminalPane.create(config.columns(), config.rows(), config.maxScrollback());
pane.setFloating(floating);
pane.attach(ShellSession.start(config.shell(), pane, pane.graphicsRegistry()));
pane.attach(ShellSession.start(config.shell(), pane, config.columns(), config.rows()));
return pane;
}

View File

@@ -1,37 +0,0 @@
[
{
"name": "com.gregor.jprototerm.Main",
"allDeclaredConstructors": true,
"allPublicConstructors": true
},
{
"name": "com.sun.javafx.tk.quantum.QuantumToolkit",
"allDeclaredConstructors": true,
"allPublicConstructors": true
},
{
"name": "com.sun.glass.ui.gtk.GtkPlatformFactory",
"allDeclaredConstructors": true,
"allPublicConstructors": true
},
{
"name": "com.sun.glass.ui.gtk.GtkApplication",
"allDeclaredConstructors": true,
"allPublicConstructors": true
},
{
"name": "com.sun.prism.es2.ES2Pipeline",
"allDeclaredConstructors": true,
"allPublicConstructors": true
},
{
"name": "com.sun.prism.es2.X11GLFactory",
"allDeclaredConstructors": true,
"allPublicConstructors": true
},
{
"name": "com.sun.prism.sw.SWPipeline",
"allDeclaredConstructors": true,
"allPublicConstructors": true
}
]

View File

@@ -1,6 +0,0 @@
{
"resources": [
{ "pattern": ".*\\.css$" },
{ "pattern": ".*\\.toml$" }
]
}