Compare commits
58 Commits
80cd318c1c
...
refactor
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3017b99f87 | ||
|
|
0958c93b4f | ||
|
|
9c98d87783 | ||
|
|
76c731578f | ||
|
|
95619f5b4c | ||
|
|
174cfc00d3 | ||
|
|
29e84c9830 | ||
|
|
a7baa08e68 | ||
|
|
76f539d34a | ||
|
|
ba884cd0a2 | ||
|
|
7dbbf89b27 | ||
|
|
e2850f067e | ||
|
|
022cf22463 | ||
|
|
250b182060 | ||
|
|
ebba6cc44f | ||
|
|
4c3449129c | ||
|
|
40d6287867 | ||
|
|
ff21bf3544 | ||
|
|
c03d9245d0 | ||
|
|
0915c649bd | ||
|
|
4a06a89400 | ||
|
|
4de2d31e91 | ||
|
|
b98a18b49f | ||
|
|
08ad025f76 | ||
|
|
c9fb8b5f0a | ||
|
|
3b26a8d12c | ||
|
|
f545375957 | ||
|
|
224e8d0273 | ||
|
|
422b672dec | ||
|
|
2c7f71064c | ||
|
|
829eb66bbc | ||
|
|
8d36fbae35 | ||
|
|
f9454b28db | ||
|
|
f75b8c29e0 | ||
|
|
2816d99ce4 | ||
|
|
a1e0c2b2d4 | ||
|
|
3612de46bd | ||
|
|
d588eb75a6 | ||
|
|
7b8d30a058 | ||
|
|
96a752566b | ||
|
|
6ebf710031 | ||
|
|
3f102a9ede | ||
|
|
57f97e4119 | ||
|
|
163c7b7279 | ||
|
|
8669de2d32 | ||
|
|
a1717438e4 | ||
|
|
d14fa5c1cb | ||
|
|
6e1aff6200 | ||
|
|
6e4ddbf9df | ||
|
|
ee127fd006 | ||
|
|
34ae351431 | ||
|
|
191ec6d0e0 | ||
|
|
19f20a4039 | ||
|
|
0698016a65 | ||
|
|
0a84ec720a | ||
|
|
e3405fee48 | ||
|
|
380996fe50 | ||
|
|
c7f734bf64 |
1
.codexsession
Normal file
1
.codexsession
Normal file
@@ -0,0 +1 @@
|
|||||||
|
019e6999-b7c8-7591-a8aa-ea51b89a7f7e
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -12,3 +12,5 @@ devenv.local.yaml
|
|||||||
.pre-commit-config.yaml
|
.pre-commit-config.yaml
|
||||||
build
|
build
|
||||||
build
|
build
|
||||||
|
.gradle
|
||||||
|
bin
|
||||||
|
|||||||
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.
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.
@@ -1,2 +0,0 @@
|
|||||||
#Wed May 27 23:44:22 CEST 2026
|
|
||||||
gradle.version=9.4.1
|
|
||||||
Binary file not shown.
Binary file not shown.
@@ -1,7 +1,7 @@
|
|||||||
arguments=--init-script /home/anon/.eclipse/1927926929_linux_gtk_x86_64/configuration/org.eclipse.osgi/58/0/.cp/gradle/init/init.gradle
|
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
|
auto.sync=false
|
||||||
build.scans.enabled=false
|
build.scans.enabled=false
|
||||||
connection.gradle.distribution=GRADLE_DISTRIBUTION(VERSION(8.9))
|
connection.gradle.distribution=GRADLE_DISTRIBUTION(LOCAL_INSTALLATION(/home/anon/.sdkman/candidates/gradle/current))
|
||||||
connection.project.dir=
|
connection.project.dir=
|
||||||
eclipse.preferences.version=1
|
eclipse.preferences.version=1
|
||||||
gradle.user.home=
|
gradle.user.home=
|
||||||
|
|||||||
70
README.md
70
README.md
@@ -1,24 +1,64 @@
|
|||||||
# jprototerm
|
# jprototerm
|
||||||
|
|
||||||
JavaFX canvas terminal prototype using `jlibghostty` for terminal emulation, Nix for the build environment, and GluonFX/GraalVM Native Image for the Linux binary.
|
JavaFX canvas terminal prototype using `jlibghostty` for terminal emulation and Nix for
|
||||||
|
the build environment. It builds a plain JavaFX application (JDK 25, JavaFX 25 via Gradle)
|
||||||
|
packaged as a Nix derivation — no GraalVM/GluonFX native image. It supports tiled and
|
||||||
|
floating panes and tabs.
|
||||||
|
|
||||||
|
> [!CAUTION]
|
||||||
|
> `nix profile add` has only been tested on **Debian with the proprietary NVIDIA driver**.
|
||||||
|
> The runtime GL shim hardcodes Debian's `/lib/x86_64-linux-gnu` driver paths and selects
|
||||||
|
> the NVIDIA GLX/EGL vendor, so it likely won't work yet on other distros, Wayland-only
|
||||||
|
> setups, or Mesa/AMD/Intel GPUs. I'm happy to accept pull requests that broaden host
|
||||||
|
> support.
|
||||||
|
|
||||||
## Build
|
## Build
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
nix build
|
nix build
|
||||||
|
./result/bin/jprototerm
|
||||||
```
|
```
|
||||||
|
|
||||||
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.
|
Install it into a profile (see the caution above on host support):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
nix profile add .
|
||||||
|
jprototerm
|
||||||
|
```
|
||||||
|
|
||||||
|
Or install straight from the remote — note the `git+https://` scheme (a bare `https://`
|
||||||
|
URL is treated as a tarball, not a git repo):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
nix profile add git+https://gitea.gregorlohaus.com/gregor/jprototerm
|
||||||
|
```
|
||||||
|
|
||||||
|
Add `?ref=<branch-or-tag>` to pin a revision. The target machine needs Nix with the
|
||||||
|
`nix-command` and `flakes` features enabled and network access — the build fetches the
|
||||||
|
`jlibghostty`/`ghostty` flake inputs plus the JDK and Gradle from the binary caches.
|
||||||
|
|
||||||
|
The flake bundles everything the app needs — the JDK 25 runtime, the Maven JavaFX modules
|
||||||
|
and their native libraries, and the gtk/glib/freetype/X11 libraries they load — **except**
|
||||||
|
the system OpenGL/graphics drivers. `libGL` is supplied by the host at runtime through a GL
|
||||||
|
shim in the launcher wrapper, so the same closure runs against NixOS, Mesa, or vendor
|
||||||
|
(e.g. NVIDIA) GPU drivers.
|
||||||
|
|
||||||
|
Gradle dependencies are vendored in `deps.json` for the pure Nix sandbox. Regenerate it
|
||||||
|
after changing dependencies in `build.gradle` (the update script writes `deps.json` in the
|
||||||
|
current directory):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
$(nix build .#gradleDepsUpdateScript --no-link --print-out-paths)
|
||||||
|
```
|
||||||
|
|
||||||
For development:
|
For development:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
nix develop
|
nix develop
|
||||||
gradle -PjlibghosttyMavenRepo="$JLIBGHOSTTY_MAVEN_REPO" run
|
gradle run
|
||||||
gradle -PjlibghosttyMavenRepo="$JLIBGHOSTTY_MAVEN_REPO" -Pgluonfx.mainClassName=com.gregor.jprototerm.Main nativeExecutable
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The Gradle project is the source of truth for native JavaFX builds.
|
The Gradle project is the source of truth for the JavaFX build.
|
||||||
|
|
||||||
## Config
|
## Config
|
||||||
|
|
||||||
@@ -56,15 +96,21 @@ enabled = true
|
|||||||
[scrollback]
|
[scrollback]
|
||||||
editor_command = "vi {file}"
|
editor_command = "vi {file}"
|
||||||
|
|
||||||
|
[env.override]
|
||||||
|
ZELLIJ_SESSION_NAME = ""
|
||||||
|
|
||||||
[keybindings]
|
[keybindings]
|
||||||
navigate_left = "ALT+H"
|
navigate_left = "ALT+H"
|
||||||
navigate_down = "ALT+J"
|
navigate_down = "ALT+J"
|
||||||
navigate_up = "ALT+K"
|
navigate_up = "ALT+K"
|
||||||
navigate_right = "ALT+L"
|
navigate_right = "ALT+L"
|
||||||
toggle_floating = "ALT+F"
|
toggle_floating = "ALT+F"
|
||||||
new_floating = "ALT+SHIFT+F"
|
new_pane = "ALT+N"
|
||||||
next_floating = "ALT+F12"
|
next_floating = "ALT+F12"
|
||||||
close_pane = "ALT+X"
|
close_pane = "ALT+X"
|
||||||
|
new_tab = "ALT+A"
|
||||||
|
previous_tab = "ALT+SHIFT+H"
|
||||||
|
next_tab = "ALT+SHIFT+L"
|
||||||
open_font_selector = "ALT+T"
|
open_font_selector = "ALT+T"
|
||||||
open_scrollback = "ALT+S"
|
open_scrollback = "ALT+S"
|
||||||
```
|
```
|
||||||
@@ -72,11 +118,19 @@ open_scrollback = "ALT+S"
|
|||||||
## Defaults
|
## Defaults
|
||||||
|
|
||||||
- `Alt+h/j/k/l`: navigate panes
|
- `Alt+h/j/k/l`: navigate panes
|
||||||
|
- `Alt+n`: new pane — a floating pane when floating panes are shown, otherwise a new tiled
|
||||||
|
pane (tiled panes are split equally across the width)
|
||||||
- `Alt+f`: show or hide all floating panes
|
- `Alt+f`: show or hide all floating panes
|
||||||
- `Alt+Shift+f`: create a new floating pane
|
|
||||||
- `Alt+F12`: cycle floating panes
|
- `Alt+F12`: cycle floating panes
|
||||||
- `Alt+x`: close the active floating pane
|
- `Alt+x`: close the active pane; closing a tab's last pane closes the tab, and closing the
|
||||||
|
last pane of the last tab quits
|
||||||
|
- `Alt+a`: new tab
|
||||||
|
- `Alt+Shift+h` / `Alt+Shift+l`: previous / next tab
|
||||||
- `Alt+t`: open the font selector
|
- `Alt+t`: open the font selector
|
||||||
- `Alt+s`: open the active pane scrollback in `$EDITOR`
|
- `Alt+s`: open the active pane scrollback in `$EDITOR`
|
||||||
- Font default: `JetBrainsMono Nerd Font`
|
- Font default: `JetBrainsMono Nerd Font`
|
||||||
- Kitty graphics protocol parsing is enabled by default
|
- Kitty graphics protocol parsing is enabled by default
|
||||||
|
|
||||||
|
Each tab has its own stack of tiled and floating panes; only the active tab is rendered. A
|
||||||
|
thin tab bar appears at the top when more than one tab is open. Closing the last tiled pane
|
||||||
|
while floating panes exist promotes the most recently active floating pane to a tiled pane.
|
||||||
|
|||||||
2
TODOS.md
Normal file
2
TODOS.md
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
jlibghostty - why downcall metadata not propagated ?
|
||||||
|
jlibghostty - how need to change flake so consuming flakes dont have to depend on same ghostty flake ?
|
||||||
@@ -14,11 +14,10 @@ repositories {
|
|||||||
dependencies {
|
dependencies {
|
||||||
implementation 'io.github.wasabithumb:jtoml:1.5.2'
|
implementation 'io.github.wasabithumb:jtoml:1.5.2'
|
||||||
implementation 'dev.jlibghostty:jlibghostty:0.1.0-SNAPSHOT'
|
implementation 'dev.jlibghostty:jlibghostty:0.1.0-SNAPSHOT'
|
||||||
implementation 'org.jetbrains.pty4j:pty4j:0.13.11'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
javafx {
|
javafx {
|
||||||
version = '22'
|
version = '25'
|
||||||
modules = [ 'javafx.controls', 'javafx.fxml' ]
|
modules = [ 'javafx.controls', 'javafx.fxml' ]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ enabled = true
|
|||||||
[scrollback]
|
[scrollback]
|
||||||
editor_command = "vi {file}"
|
editor_command = "vi {file}"
|
||||||
|
|
||||||
|
[env.override]
|
||||||
|
ZELLIJ_SESSION_NAME = ""
|
||||||
|
|
||||||
[keybindings]
|
[keybindings]
|
||||||
navigate_left = "ALT+H"
|
navigate_left = "ALT+H"
|
||||||
navigate_down = "ALT+J"
|
navigate_down = "ALT+J"
|
||||||
|
|||||||
76
deps.json
Normal file
76
deps.json
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
{
|
||||||
|
"!comment": "This is a nixpkgs Gradle dependency lockfile. For more details, refer to the Gradle section in the nixpkgs manual.",
|
||||||
|
"!version": 1,
|
||||||
|
"https://plugins.gradle.org/m2": {
|
||||||
|
"com/google/code/findbugs#jsr305/3.0.2": {
|
||||||
|
"jar": "sha256-dmrSoHg/JoeWLIrXTO7MOKKLn3Ki0IXuQ4t4E+ko0Mc=",
|
||||||
|
"pom": "sha256-GYidvfGyVLJgGl7mRbgUepdGRIgil2hMeYr+XWPXjf4="
|
||||||
|
},
|
||||||
|
"com/google/gradle#osdetector-gradle-plugin/1.7.3": {
|
||||||
|
"jar": "sha256-a0aS+ROiGx+2Axae54uo8+SrKvnXYq+cqIt5EmwcCtE=",
|
||||||
|
"pom": "sha256-hGDJUBJ8o1mHZhYeOLT/jWO01p+4MQoW4As1E1ABDBE="
|
||||||
|
},
|
||||||
|
"kr/motd/maven#os-maven-plugin/1.7.1": {
|
||||||
|
"jar": "sha256-9Hru+Ggh5SsrGHWJeL0EXwPXIikuMudHCCEixiKJUuA=",
|
||||||
|
"pom": "sha256-S3WABEIrljPdMY8p54Tx0YC9ilkgzVCvGTCGH21qVHY="
|
||||||
|
},
|
||||||
|
"org/openjfx#javafx-plugin/0.1.0": {
|
||||||
|
"jar": "sha256-Xq7sB5m0QGRrDKTP2iGaMttr4rpXktAyoNpKOlw4j6s=",
|
||||||
|
"module": "sha256-rf+3RA0kntF8BJOD1nBp+UU7F3gncMAFtoKkNBbYNmE=",
|
||||||
|
"pom": "sha256-NMjfVSfrWjXl8AmjzeH3oInEzkoOclgC8uy+UDu9PLY="
|
||||||
|
},
|
||||||
|
"org/openjfx/javafxplugin#org.openjfx.javafxplugin.gradle.plugin/0.1.0": {
|
||||||
|
"pom": "sha256-1tASf/Q2PQAXPDV6mByec+/wPDCl0Ohq2CtgVPrvqEE="
|
||||||
|
},
|
||||||
|
"org/sonatype/oss#oss-parent/7": {
|
||||||
|
"pom": "sha256-tR+IZ8kranIkmVV/w6H96ne9+e9XRyL+kM5DailVlFQ="
|
||||||
|
},
|
||||||
|
"org/sonatype/oss#oss-parent/9": {
|
||||||
|
"pom": "sha256-+0AmX5glSCEv+C42LllzKyGH7G8NgBgohcFO8fmCgno="
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"https://repo.maven.apache.org/maven2": {
|
||||||
|
"io/github/wasabithumb#jtoml-api/1.5.2": {
|
||||||
|
"jar": "sha256-3MvElpV+cmdqWO+SAHVKCWDYs+KMUXwIYYk7ax85yWo=",
|
||||||
|
"module": "sha256-RUZYGHsxZfuGBi5TogMHhWcbRcCUBTUAPN8FZpPszlE=",
|
||||||
|
"pom": "sha256-VOaIPO8w4z9saTR8smoLTSoih2PUFOBhBio9eqpoqo8="
|
||||||
|
},
|
||||||
|
"io/github/wasabithumb#jtoml-internals/1.5.2": {
|
||||||
|
"jar": "sha256-k4z/Uxzugk2hXUIdeeNLTz//NeynHzPfHCDXzDHP1Ys=",
|
||||||
|
"module": "sha256-OsnYjM8Tylw/MNdw0/HRUWvXgrQHdnT09O5vYlaZENU=",
|
||||||
|
"pom": "sha256-qXtmwHMJBNIWgrewEoUq1FBZjs2eRuRO1wMLndgsndg="
|
||||||
|
},
|
||||||
|
"io/github/wasabithumb#jtoml/1.5.2": {
|
||||||
|
"jar": "sha256-zDtf6VVGSrvC8StneL9fKq9LyaJSfiDb86w52s+bRYs=",
|
||||||
|
"module": "sha256-TRoE8nqf0ULuQ4J1/u2+voUNf421lTOJ1SajE07F8/Y=",
|
||||||
|
"pom": "sha256-sG4IDPD+ItRgyQcfDiLqdd+wCd40JHcSLocA+jWX1p0="
|
||||||
|
},
|
||||||
|
"org/openjfx#javafx-base/25": {
|
||||||
|
"pom": "sha256-XFYpcqK673qkB7J9Wc4XOl6lCht7dRgEO3/I92/v5Tc="
|
||||||
|
},
|
||||||
|
"org/openjfx#javafx-base/25/linux": {
|
||||||
|
"jar": "sha256-MkJZRruLjbBxfPovsuAOIc1InzW5ZitvrKGLYVpKlmk="
|
||||||
|
},
|
||||||
|
"org/openjfx#javafx-controls/25": {
|
||||||
|
"pom": "sha256-74cad6gX7nuDrKWKKe6yv5h2AvRseKHRXEYAgzpq1uM="
|
||||||
|
},
|
||||||
|
"org/openjfx#javafx-controls/25/linux": {
|
||||||
|
"jar": "sha256-NzVeTZHGfoj9mBX2AeasW1Xd3p9em5P8j0qgRXfmkdM="
|
||||||
|
},
|
||||||
|
"org/openjfx#javafx-fxml/25": {
|
||||||
|
"pom": "sha256-RopsFNQeVHnwNK4v4FPwyJEpfqJoo8dtf/047zyrsio="
|
||||||
|
},
|
||||||
|
"org/openjfx#javafx-fxml/25/linux": {
|
||||||
|
"jar": "sha256-OUrjL2TBIsFPvRDvSb3efbsFVpt6uOf58XDIGSS5Wis="
|
||||||
|
},
|
||||||
|
"org/openjfx#javafx-graphics/25": {
|
||||||
|
"pom": "sha256-zB2jY7Id7uvymRWBk9qmIB+USw+Setv13DhL62qDOfQ="
|
||||||
|
},
|
||||||
|
"org/openjfx#javafx-graphics/25/linux": {
|
||||||
|
"jar": "sha256-PlGLwX7lWFWaKsWKX3/UUmFRCNnVCI9lsTvuk5nDcis="
|
||||||
|
},
|
||||||
|
"org/openjfx#javafx/25": {
|
||||||
|
"pom": "sha256-55IzCPyt1/LGiwcgQfR9jnNVIj2EZVnutceA3EuivxM="
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
226
flake.lock
generated
Normal file
226
flake.lock
generated
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"jlibghostty": {
|
||||||
|
"inputs": {
|
||||||
|
"ghostty": "ghostty",
|
||||||
|
"nixpkgs": "nixpkgs"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1780079529,
|
||||||
|
"narHash": "sha256-AxlGTL8c5xSLcQHvWlm994IdOqxsN8iKrA02Cpv7vso=",
|
||||||
|
"ref": "refs/heads/main",
|
||||||
|
"rev": "68121d50b52fb56038871c97c97e7a12ffe987c2",
|
||||||
|
"revCount": 20,
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://gitea.gregorlohaus.com/gregor/jlibghostty.git"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://gitea.gregorlohaus.com/gregor/jlibghostty.git"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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": {
|
||||||
|
"ghostty": [
|
||||||
|
"jlibghostty",
|
||||||
|
"ghostty"
|
||||||
|
],
|
||||||
|
"jlibghostty": "jlibghostty",
|
||||||
|
"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
|
||||||
|
}
|
||||||
150
flake.nix
Normal file
150
flake.nix
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
{
|
||||||
|
description = "jprototerm";
|
||||||
|
|
||||||
|
inputs = {
|
||||||
|
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||||
|
jlibghostty.url = "git+https://gitea.gregorlohaus.com/gregor/jlibghostty.git";
|
||||||
|
ghostty.follows = "jlibghostty/ghostty";
|
||||||
|
};
|
||||||
|
|
||||||
|
outputs = { self, nixpkgs, jlibghostty, ghostty }:
|
||||||
|
let
|
||||||
|
supportedSystems = [ "x86_64-linux" ];
|
||||||
|
forAllSystems = nixpkgs.lib.genAttrs supportedSystems;
|
||||||
|
|
||||||
|
# Everything the JavaFX natives (and jlibghostty) dlopen at runtime, EXCEPT the
|
||||||
|
# system OpenGL/graphics drivers. libGL is intentionally left out: it is supplied
|
||||||
|
# by the host at runtime via the GL shim in the wrapper below, so the same closure
|
||||||
|
# works on NixOS and on a plain Debian box with vendor GPU drivers installed.
|
||||||
|
runtimeLibsFor = pkgs: ghosttyVt: [
|
||||||
|
pkgs.glib
|
||||||
|
pkgs.gtk3
|
||||||
|
pkgs.pango
|
||||||
|
pkgs.cairo
|
||||||
|
pkgs.gdk-pixbuf
|
||||||
|
pkgs.harfbuzz
|
||||||
|
pkgs.freetype
|
||||||
|
pkgs.fontconfig.lib
|
||||||
|
pkgs.libx11
|
||||||
|
pkgs.libxext
|
||||||
|
pkgs.libxrender
|
||||||
|
pkgs.libxtst
|
||||||
|
pkgs.libxi
|
||||||
|
pkgs.libxcursor
|
||||||
|
pkgs.libxrandr
|
||||||
|
pkgs.libxinerama
|
||||||
|
pkgs.libxcb
|
||||||
|
pkgs.libxxf86vm
|
||||||
|
pkgs.zlib
|
||||||
|
ghosttyVt
|
||||||
|
];
|
||||||
|
in {
|
||||||
|
packages = forAllSystems (system:
|
||||||
|
let
|
||||||
|
pkgs = import nixpkgs { inherit system; };
|
||||||
|
|
||||||
|
jlib = jlibghostty.packages.${system}.jlibghostty;
|
||||||
|
ghosttyVt = ghostty.packages.${system}.libghostty-vt;
|
||||||
|
|
||||||
|
runtimeLibs = runtimeLibsFor pkgs ghosttyVt;
|
||||||
|
|
||||||
|
jprototerm = pkgs.stdenv.mkDerivation (finalAttrs: {
|
||||||
|
pname = "jprototerm";
|
||||||
|
version = "0.1.0";
|
||||||
|
src = ./.;
|
||||||
|
|
||||||
|
nativeBuildInputs = [
|
||||||
|
pkgs.jdk25
|
||||||
|
pkgs.gradle_9
|
||||||
|
pkgs.makeWrapper
|
||||||
|
];
|
||||||
|
|
||||||
|
buildInputs = runtimeLibs;
|
||||||
|
|
||||||
|
mitmCache = pkgs.gradle_9.fetchDeps {
|
||||||
|
pkg = finalAttrs.finalPackage;
|
||||||
|
data = ./deps.json;
|
||||||
|
useBwrap = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
# Builds build/install/jprototerm/{bin,lib} with every runtime jar, including
|
||||||
|
# the maven javafx-*-linux jars that carry the platform natives.
|
||||||
|
gradleBuildTask = "installDist";
|
||||||
|
gradleFlags = [
|
||||||
|
"--no-build-cache"
|
||||||
|
"--stacktrace"
|
||||||
|
"-Dorg.gradle.java.home=${pkgs.jdk25}"
|
||||||
|
];
|
||||||
|
|
||||||
|
JAVA_HOME = "${pkgs.jdk25}";
|
||||||
|
JLIBGHOSTTY_MAVEN_REPO = "${jlib}/maven";
|
||||||
|
|
||||||
|
preBuild = ''
|
||||||
|
export HOME="$TMPDIR/home"
|
||||||
|
export GRADLE_OPTS="-Duser.home=$HOME ''${GRADLE_OPTS:-}"
|
||||||
|
'';
|
||||||
|
|
||||||
|
preGradleUpdate = ''
|
||||||
|
export HOME="$TMPDIR/home"
|
||||||
|
'';
|
||||||
|
|
||||||
|
installPhase = ''
|
||||||
|
runHook preInstall
|
||||||
|
|
||||||
|
mkdir -p "$out/share/jprototerm"
|
||||||
|
cp -a build/install/jprototerm/lib "$out/share/jprototerm/lib"
|
||||||
|
|
||||||
|
# JavaFX is a set of proper modular jars: put them on the module path and
|
||||||
|
# keep the application + plain dependency jars on the classpath, so the two
|
||||||
|
# worlds do not collide.
|
||||||
|
mkdir -p "$out/share/jprototerm/javafx"
|
||||||
|
mv "$out/share/jprototerm/lib"/javafx-*.jar "$out/share/jprototerm/javafx/"
|
||||||
|
|
||||||
|
# Build an explicit colon-separated classpath. A "lib/*" glob would be
|
||||||
|
# expanded by the wrapper's shell before java sees it, breaking -cp.
|
||||||
|
classpath=""
|
||||||
|
for jar in "$out"/share/jprototerm/lib/*.jar; do
|
||||||
|
classpath="$classpath''${classpath:+:}$jar"
|
||||||
|
done
|
||||||
|
|
||||||
|
makeWrapper "${pkgs.jdk25}/bin/java" "$out/bin/jprototerm" \
|
||||||
|
--run 'export JPROTOTERM_HOST_LD_LIBRARY_PATH="''${LD_LIBRARY_PATH:-}"' \
|
||||||
|
--add-flags "--enable-native-access=ALL-UNNAMED,javafx.graphics" \
|
||||||
|
--add-flags "--module-path $out/share/jprototerm/javafx" \
|
||||||
|
--add-flags "--add-modules javafx.controls,javafx.fxml" \
|
||||||
|
--add-flags "-cp $classpath" \
|
||||||
|
--add-flags "com.gregor.jprototerm.Main" \
|
||||||
|
--prefix LD_LIBRARY_PATH : "${pkgs.lib.makeLibraryPath runtimeLibs}" \
|
||||||
|
--run 'glShimDir="''${XDG_RUNTIME_DIR:-/tmp}/jprototerm-gl"; mkdir -p "$glShimDir"; for lib in /lib/x86_64-linux-gnu/libGL.so.1 /lib/x86_64-linux-gnu/libGLX.so.0 /lib/x86_64-linux-gnu/libGLdispatch.so.0 /usr/lib/x86_64-linux-gnu/libGLX_nvidia.so* /usr/lib/x86_64-linux-gnu/libEGL_nvidia.so* /usr/lib/x86_64-linux-gnu/libnvidia*.so* /usr/lib/x86_64-linux-gnu/nvidia/current/lib*.so*; do [ -e "$lib" ] && ln -sfn "$lib" "$glShimDir/$(basename "$lib")"; done; export LD_LIBRARY_PATH="$glShimDir''${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"; export __GLX_VENDOR_LIBRARY_NAME="''${__GLX_VENDOR_LIBRARY_NAME:-nvidia}"; if [ -e /usr/share/glvnd/egl_vendor.d/10_nvidia.json ]; then export __EGL_VENDOR_LIBRARY_FILENAMES="''${__EGL_VENDOR_LIBRARY_FILENAMES:-/usr/share/glvnd/egl_vendor.d/10_nvidia.json}"; fi' \
|
||||||
|
--set JLIBGHOSTTY_LIBRARY "${ghosttyVt}/lib/libghostty-vt.so" \
|
||||||
|
--set GDK_BACKEND x11
|
||||||
|
|
||||||
|
runHook postInstall
|
||||||
|
'';
|
||||||
|
});
|
||||||
|
in {
|
||||||
|
default = jprototerm;
|
||||||
|
gradleDepsUpdateScript = jprototerm.mitmCache.updateScript;
|
||||||
|
});
|
||||||
|
|
||||||
|
devShells = forAllSystems (system:
|
||||||
|
let
|
||||||
|
pkgs = import nixpkgs { inherit system; };
|
||||||
|
jlib = jlibghostty.packages.${system}.jlibghostty;
|
||||||
|
ghosttyVt = ghostty.packages.${system}.libghostty-vt;
|
||||||
|
runtimeLibs = runtimeLibsFor pkgs ghosttyVt;
|
||||||
|
in {
|
||||||
|
default = pkgs.mkShell {
|
||||||
|
packages = [
|
||||||
|
pkgs.gradle_9
|
||||||
|
pkgs.jdk25
|
||||||
|
pkgs.jdt-language-server
|
||||||
|
] ++ runtimeLibs;
|
||||||
|
|
||||||
|
JLIBGHOSTTY_MAVEN_REPO = "${jlib}/maven";
|
||||||
|
JLIBGHOSTTY_LIBRARY = "${ghosttyVt}/lib/libghostty-vt.so";
|
||||||
|
LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath runtimeLibs;
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
8
settings.gradle
Normal file
8
settings.gradle
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
pluginManagement {
|
||||||
|
repositories {
|
||||||
|
gradlePluginPortal()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rootProject.name = 'jprototerm'
|
||||||
@@ -3,6 +3,7 @@ package com.gregor.jprototerm;
|
|||||||
import io.github.wasabithumb.jtoml.JToml;
|
import io.github.wasabithumb.jtoml.JToml;
|
||||||
import io.github.wasabithumb.jtoml.document.TomlDocument;
|
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.value.TomlValue;
|
import io.github.wasabithumb.jtoml.value.TomlValue;
|
||||||
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;
|
||||||
@@ -26,6 +27,7 @@ public record AppConfig(
|
|||||||
double windowHeight,
|
double windowHeight,
|
||||||
boolean kittyGraphics,
|
boolean kittyGraphics,
|
||||||
String scrollbackEditorCommand,
|
String scrollbackEditorCommand,
|
||||||
|
Map<String, String> envOverride,
|
||||||
Map<String, KeyBinding> keybindings
|
Map<String, KeyBinding> keybindings
|
||||||
) {
|
) {
|
||||||
private static final List<String> KEYBINDING_KEYS = List.of(
|
private static final List<String> KEYBINDING_KEYS = List.of(
|
||||||
@@ -34,9 +36,12 @@ public record AppConfig(
|
|||||||
"navigate_up",
|
"navigate_up",
|
||||||
"navigate_right",
|
"navigate_right",
|
||||||
"toggle_floating",
|
"toggle_floating",
|
||||||
"new_floating",
|
"new_pane",
|
||||||
"next_floating",
|
"next_floating",
|
||||||
"close_pane",
|
"close_pane",
|
||||||
|
"new_tab",
|
||||||
|
"previous_tab",
|
||||||
|
"next_tab",
|
||||||
"open_font_selector",
|
"open_font_selector",
|
||||||
"open_scrollback"
|
"open_scrollback"
|
||||||
);
|
);
|
||||||
@@ -62,6 +67,7 @@ public record AppConfig(
|
|||||||
doubleValue(document, "window.height", defaults.windowHeight),
|
doubleValue(document, "window.height", defaults.windowHeight),
|
||||||
booleanValue(document, "kitty_graphics.enabled", defaults.kittyGraphics),
|
booleanValue(document, "kitty_graphics.enabled", defaults.kittyGraphics),
|
||||||
stringValue(document, "scrollback.editor_command", defaults.scrollbackEditorCommand),
|
stringValue(document, "scrollback.editor_command", defaults.scrollbackEditorCommand),
|
||||||
|
envOverride(document, defaults.envOverride),
|
||||||
keybindings(document, defaults)
|
keybindings(document, defaults)
|
||||||
);
|
);
|
||||||
} catch (TomlException ex) {
|
} catch (TomlException ex) {
|
||||||
@@ -82,17 +88,21 @@ public record AppConfig(
|
|||||||
760.0,
|
760.0,
|
||||||
true,
|
true,
|
||||||
defaultScrollbackEditorCommand(),
|
defaultScrollbackEditorCommand(),
|
||||||
Map.of(
|
Map.of(),
|
||||||
"navigate_left", KeyBinding.parse("ALT+H"),
|
Map.ofEntries(
|
||||||
"navigate_down", KeyBinding.parse("ALT+J"),
|
Map.entry("navigate_left", KeyBinding.parse("ALT+H")),
|
||||||
"navigate_up", KeyBinding.parse("ALT+K"),
|
Map.entry("navigate_down", KeyBinding.parse("ALT+J")),
|
||||||
"navigate_right", KeyBinding.parse("ALT+L"),
|
Map.entry("navigate_up", KeyBinding.parse("ALT+K")),
|
||||||
"toggle_floating", KeyBinding.parse("ALT+F"),
|
Map.entry("navigate_right", KeyBinding.parse("ALT+L")),
|
||||||
"new_floating", KeyBinding.parse("ALT+SHIFT+F"),
|
Map.entry("toggle_floating", KeyBinding.parse("ALT+F")),
|
||||||
"next_floating", KeyBinding.parse("ALT+F12"),
|
Map.entry("new_pane", KeyBinding.parse("ALT+N")),
|
||||||
"close_pane", KeyBinding.parse("ALT+X"),
|
Map.entry("next_floating", KeyBinding.parse("ALT+F12")),
|
||||||
"open_font_selector", KeyBinding.parse("ALT+T"),
|
Map.entry("close_pane", KeyBinding.parse("ALT+X")),
|
||||||
"open_scrollback", KeyBinding.parse("ALT+S")
|
Map.entry("new_tab", KeyBinding.parse("ALT+A")),
|
||||||
|
Map.entry("previous_tab", KeyBinding.parse("ALT+SHIFT+H")),
|
||||||
|
Map.entry("next_tab", KeyBinding.parse("ALT+SHIFT+L")),
|
||||||
|
Map.entry("open_font_selector", KeyBinding.parse("ALT+T")),
|
||||||
|
Map.entry("open_scrollback", KeyBinding.parse("ALT+S"))
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -109,6 +119,7 @@ public record AppConfig(
|
|||||||
windowHeight,
|
windowHeight,
|
||||||
kittyGraphics,
|
kittyGraphics,
|
||||||
scrollbackEditorCommand,
|
scrollbackEditorCommand,
|
||||||
|
envOverride,
|
||||||
keybindings
|
keybindings
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -183,6 +194,11 @@ public record AppConfig(
|
|||||||
builder.append("enabled = ").append(kittyGraphics).append("\n\n");
|
builder.append("enabled = ").append(kittyGraphics).append("\n\n");
|
||||||
builder.append("[scrollback]\n");
|
builder.append("[scrollback]\n");
|
||||||
builder.append("editor_command = ").append(quoted(scrollbackEditorCommand)).append("\n\n");
|
builder.append("editor_command = ").append(quoted(scrollbackEditorCommand)).append("\n\n");
|
||||||
|
builder.append("[env.override]\n");
|
||||||
|
for (Map.Entry<String, String> entry : envOverride.entrySet()) {
|
||||||
|
builder.append(entry.getKey()).append(" = ").append(quoted(entry.getValue())).append('\n');
|
||||||
|
}
|
||||||
|
builder.append('\n');
|
||||||
builder.append("[keybindings]\n");
|
builder.append("[keybindings]\n");
|
||||||
for (String key : KEYBINDING_KEYS) {
|
for (String key : KEYBINDING_KEYS) {
|
||||||
KeyBinding binding = keybindings.get(key);
|
KeyBinding binding = keybindings.get(key);
|
||||||
@@ -222,6 +238,31 @@ public record AppConfig(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static Map<String, String> envOverride(TomlTable table, Map<String, String> fallback) {
|
||||||
|
TomlValue value = table.get("env.override");
|
||||||
|
if (value == null || !value.isTable()) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, String> result = new LinkedHashMap<>();
|
||||||
|
TomlTable overrides = value.asTable();
|
||||||
|
for (TomlKey key : overrides.keys(false)) {
|
||||||
|
if (key.size() != 1) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
TomlValue override = overrides.get(key);
|
||||||
|
if (override != null && override.isPrimitive()) {
|
||||||
|
try {
|
||||||
|
result.put(key.get(0), override.asPrimitive().asString());
|
||||||
|
} catch (RuntimeException ignored) {
|
||||||
|
// Ignore non-string values; environment values are strings.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Map.copyOf(result);
|
||||||
|
}
|
||||||
|
|
||||||
private static String stringValue(TomlTable table, String key, String fallback) {
|
private static String stringValue(TomlTable table, String key, String fallback) {
|
||||||
TomlPrimitive primitive = primitive(table, key);
|
TomlPrimitive primitive = primitive(table, key);
|
||||||
return primitive == null ? fallback : primitive.asString();
|
return primitive == null ? fallback : primitive.asString();
|
||||||
|
|||||||
496
src/main/java/com/gregor/jprototerm/Compositor.java
Normal file
496
src/main/java/com/gregor/jprototerm/Compositor.java
Normal file
@@ -0,0 +1,496 @@
|
|||||||
|
package com.gregor.jprototerm;
|
||||||
|
|
||||||
|
import dev.jlibghostty.KeyModifiers;
|
||||||
|
import dev.jlibghostty.MouseButton;
|
||||||
|
import dev.jlibghostty.MouseEncoderSize;
|
||||||
|
import dev.jlibghostty.MouseInput;
|
||||||
|
import javafx.geometry.VPos;
|
||||||
|
import javafx.scene.canvas.Canvas;
|
||||||
|
import javafx.scene.canvas.GraphicsContext;
|
||||||
|
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.TextAlignment;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Owns the window's tabs and drives rendering and input. It composites only the current tab:
|
||||||
|
* each frame it lays that tab out, paints the panes bottom-to-top (so the active floating pane
|
||||||
|
* lands on top) and lets each pane paint its own content, clipped to the region the layout gave
|
||||||
|
* it. The cross-pane concerns live here — the dirty-frame bookkeeping, the tab strip, routing
|
||||||
|
* mouse/scroll to the pane under the pointer, and the tab/pane lifecycle that {@link Main}'s key
|
||||||
|
* bindings invoke.
|
||||||
|
*/
|
||||||
|
public final class Compositor {
|
||||||
|
// Canvas background shown wherever no pane covers (gaps). Painted on a full recomposite.
|
||||||
|
private static final Color GAP_BACKGROUND = Color.rgb(16, 16, 18);
|
||||||
|
private static final Color TAB_TEXT = Color.rgb(225, 229, 235);
|
||||||
|
// Thin tab strip shown at the top when more than one tab is open.
|
||||||
|
private static final double TAB_BAR_HEIGHT = 22.0;
|
||||||
|
|
||||||
|
private final Canvas canvas = new Canvas();
|
||||||
|
private final AppConfig config;
|
||||||
|
private final TerminalMetrics metrics;
|
||||||
|
private final List<Tab> tabs = new ArrayList<>();
|
||||||
|
private int currentTabIndex;
|
||||||
|
// Bumped on any structural change (tab switch, pane add/close/focus/move) so render()
|
||||||
|
// knows to recomposite. Terminal *content* changes are tracked separately through each
|
||||||
|
// tab's content version.
|
||||||
|
private long layoutVersion;
|
||||||
|
// Last content version drawn to the canvas per pane, so a content frame repaints only
|
||||||
|
// the panes that actually changed.
|
||||||
|
private final Map<TerminalPane, Long> paneContentVersion = new HashMap<>();
|
||||||
|
// Cheap per-frame dirty signal: skip the whole render when none of these changed.
|
||||||
|
private double lastWidth = -1.0;
|
||||||
|
private double lastHeight = -1.0;
|
||||||
|
private String lastFontFamily;
|
||||||
|
private double lastFontSize = -1.0;
|
||||||
|
private long lastLayoutVersion = Long.MIN_VALUE;
|
||||||
|
private long lastContentVersion = Long.MIN_VALUE;
|
||||||
|
private boolean mouseButtonPressed;
|
||||||
|
private MouseButton pressedButton = MouseButton.UNKNOWN;
|
||||||
|
|
||||||
|
public Compositor(AppConfig config, TerminalMetrics metrics) {
|
||||||
|
this.config = config;
|
||||||
|
this.metrics = metrics;
|
||||||
|
tabs.add(new Tab(config, metrics));
|
||||||
|
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) {
|
||||||
|
metrics.setFont(family, size);
|
||||||
|
paneContentVersion.clear();
|
||||||
|
lastWidth = -1.0; // force a redraw on the next frame
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Tabs and panes -------------------------------------------------------------
|
||||||
|
|
||||||
|
public boolean isEmpty() {
|
||||||
|
return tabs.isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
public TerminalPane activePane() {
|
||||||
|
return currentTab().activePane();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void navigate(Direction direction) {
|
||||||
|
if (!isEmpty() && currentTab().navigate(direction)) {
|
||||||
|
layoutVersion++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void toggleFloating() {
|
||||||
|
if (isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
currentTab().toggleFloating();
|
||||||
|
layoutVersion++;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void createPane() {
|
||||||
|
if (isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
currentTab().createPane();
|
||||||
|
layoutVersion++;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void nextFloatingPane() {
|
||||||
|
if (isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
currentTab().nextFloatingPane();
|
||||||
|
layoutVersion++;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void closeActivePane() {
|
||||||
|
if (isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
currentTab().closeActivePane();
|
||||||
|
if (currentTab().isEmpty()) {
|
||||||
|
// Closing a tab's last pane closes the tab. When no tabs remain the surface is
|
||||||
|
// empty and Main quits.
|
||||||
|
tabs.remove(currentTabIndex);
|
||||||
|
if (currentTabIndex >= tabs.size()) {
|
||||||
|
currentTabIndex = Math.max(0, tabs.size() - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
layoutVersion++;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void newTab() {
|
||||||
|
tabs.add(new Tab(config, metrics));
|
||||||
|
currentTabIndex = tabs.size() - 1;
|
||||||
|
layoutVersion++;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void nextTab() {
|
||||||
|
if (tabs.size() > 1) {
|
||||||
|
currentTabIndex = (currentTabIndex + 1) % tabs.size();
|
||||||
|
layoutVersion++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void previousTab() {
|
||||||
|
if (tabs.size() > 1) {
|
||||||
|
currentTabIndex = (currentTabIndex - 1 + tabs.size()) % tabs.size();
|
||||||
|
layoutVersion++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void close() {
|
||||||
|
for (Tab tab : tabs) {
|
||||||
|
tab.close();
|
||||||
|
}
|
||||||
|
tabs.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Tab currentTab() {
|
||||||
|
return tabs.get(currentTabIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<TerminalPane> currentPanes() {
|
||||||
|
return tabs.isEmpty() ? List.of() : currentTab().panes();
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isActive(TerminalPane pane) {
|
||||||
|
return !tabs.isEmpty() && currentTab().isActive(pane);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void focus(TerminalPane pane) {
|
||||||
|
if (!tabs.isEmpty() && currentTab().focus(pane)) {
|
||||||
|
layoutVersion++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Rendering ------------------------------------------------------------------
|
||||||
|
|
||||||
|
public void render() {
|
||||||
|
switch (nextFrameType()) {
|
||||||
|
case IDLE -> { }
|
||||||
|
case LAYOUT -> renderLayoutFrame();
|
||||||
|
case CONTENT -> renderContentFrame();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Classify this frame and commit the change trackers. A layout change (size, font,
|
||||||
|
// tab/pane set, z-order, active pane) needs a full recomposite; otherwise a change to the
|
||||||
|
// current tab's content version repaints only the panes that changed; otherwise nothing
|
||||||
|
// changed and the frame is idle.
|
||||||
|
private FrameType nextFrameType() {
|
||||||
|
double width = canvas.getWidth();
|
||||||
|
double height = canvas.getHeight();
|
||||||
|
long contentVersion = tabs.isEmpty() ? 0 : currentTab().contentVersion();
|
||||||
|
|
||||||
|
boolean layoutChanged = width != lastWidth || height != lastHeight
|
||||||
|
|| metrics.fontSize() != lastFontSize || !Objects.equals(metrics.fontFamily(), lastFontFamily)
|
||||||
|
|| layoutVersion != lastLayoutVersion;
|
||||||
|
boolean contentChanged = contentVersion != lastContentVersion;
|
||||||
|
|
||||||
|
lastWidth = width;
|
||||||
|
lastHeight = height;
|
||||||
|
lastFontFamily = metrics.fontFamily();
|
||||||
|
lastFontSize = metrics.fontSize();
|
||||||
|
lastLayoutVersion = layoutVersion;
|
||||||
|
lastContentVersion = contentVersion;
|
||||||
|
|
||||||
|
if (layoutChanged) {
|
||||||
|
return FrameType.LAYOUT;
|
||||||
|
}
|
||||||
|
if (contentChanged) {
|
||||||
|
return FrameType.CONTENT;
|
||||||
|
}
|
||||||
|
return FrameType.IDLE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full recomposite onto the retained canvas: lay the tab out, clear to the gap colour,
|
||||||
|
// draw the tab strip, then paint every pane bottom-to-top (panes() puts the active
|
||||||
|
// floating pane last == on top).
|
||||||
|
private void renderLayoutFrame() {
|
||||||
|
double topInset = tabs.size() > 1 ? TAB_BAR_HEIGHT : 0.0;
|
||||||
|
if (!tabs.isEmpty()) {
|
||||||
|
currentTab().layout(canvas.getWidth(), canvas.getHeight(), topInset);
|
||||||
|
}
|
||||||
|
List<TerminalPane> panes = currentPanes();
|
||||||
|
// Sync each pane's ghostty grid to its (possibly new) bounds; a no-op when unchanged.
|
||||||
|
for (TerminalPane pane : panes) {
|
||||||
|
pane.fitToBounds();
|
||||||
|
}
|
||||||
|
|
||||||
|
GraphicsContext gc = beginFrame();
|
||||||
|
paneContentVersion.keySet().retainAll(panes);
|
||||||
|
gc.setFill(GAP_BACKGROUND);
|
||||||
|
gc.fillRect(0, 0, canvas.getWidth(), canvas.getHeight());
|
||||||
|
if (topInset > 0.0) {
|
||||||
|
drawTabBar(gc, canvas.getWidth(), topInset);
|
||||||
|
}
|
||||||
|
for (TerminalPane pane : panes) {
|
||||||
|
pane.paintFull(gc, isActive(pane));
|
||||||
|
paneContentVersion.put(pane, pane.contentVersion());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Repaint just the panes whose content changed, directly on the retained canvas. Each pane
|
||||||
|
// clips itself to its rect minus the panes above it, so a lower pane's repaint can't bleed
|
||||||
|
// over one stacked on top — no restore pass needed. Bounds and grids can't have changed
|
||||||
|
// without a layout frame, so a content frame reuses the existing layout untouched.
|
||||||
|
private void renderContentFrame() {
|
||||||
|
List<TerminalPane> panes = currentPanes();
|
||||||
|
GraphicsContext gc = beginFrame();
|
||||||
|
|
||||||
|
for (TerminalPane pane : panes) {
|
||||||
|
Long drawn = paneContentVersion.get(pane);
|
||||||
|
if (drawn != null && drawn == pane.contentVersion()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
pane.paintIncremental(gc, isActive(pane));
|
||||||
|
paneContentVersion.put(pane, pane.contentVersion());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private GraphicsContext beginFrame() {
|
||||||
|
GraphicsContext gc = canvas.getGraphicsContext2D();
|
||||||
|
gc.setFontSmoothingType(FontSmoothingType.LCD); // the per-cell renderer relies on LCD
|
||||||
|
return gc;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Thin tab strip: one equal-width segment per tab, the current one highlighted, with a
|
||||||
|
// small 1-based number centred in each segment.
|
||||||
|
private void drawTabBar(GraphicsContext gc, double width, double barHeight) {
|
||||||
|
int count = tabs.size();
|
||||||
|
Font barFont = Font.font(metrics.fontFamily(), Math.max(9.0, Math.min(13.0, barHeight * 0.62)));
|
||||||
|
gc.setFont(barFont);
|
||||||
|
gc.setFontSmoothingType(FontSmoothingType.GRAY);
|
||||||
|
gc.setTextAlign(TextAlignment.CENTER);
|
||||||
|
gc.setTextBaseline(VPos.CENTER);
|
||||||
|
|
||||||
|
double gap = 1.0;
|
||||||
|
double segmentWidth = width / count;
|
||||||
|
for (int i = 0; i < count; i++) {
|
||||||
|
double x = i * segmentWidth;
|
||||||
|
boolean current = i == currentTabIndex;
|
||||||
|
gc.setFill(current ? Color.rgb(45, 55, 72) : Color.rgb(22, 24, 28));
|
||||||
|
gc.fillRect(x, 0.0, segmentWidth - gap, barHeight);
|
||||||
|
gc.setFill(current ? TAB_TEXT : Color.rgb(128, 136, 148));
|
||||||
|
gc.fillText(Integer.toString(i + 1), x + (segmentWidth - gap) / 2.0, barHeight / 2.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore the defaults the cell renderer relies on (left-aligned, baseline, LCD).
|
||||||
|
gc.setTextAlign(TextAlignment.LEFT);
|
||||||
|
gc.setTextBaseline(VPos.BASELINE);
|
||||||
|
gc.setFontSmoothingType(FontSmoothingType.LCD);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Input ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
private void handleMousePressed(MouseEvent event) {
|
||||||
|
canvas.requestFocus();
|
||||||
|
TerminalPane pane = paneAt(event.getX(), event.getY());
|
||||||
|
if (pane == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
focus(pane);
|
||||||
|
pressedButton = mouseButton(event);
|
||||||
|
mouseButtonPressed = true;
|
||||||
|
MouseTarget target = mouseTarget(pane);
|
||||||
|
if (target == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
send(pane, target, MouseInput.press(pressedButton, localX(event.getX(), pane, target), localY(event.getY(), pane, target), modifiers(event)), true, event);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleMouseReleased(MouseEvent event) {
|
||||||
|
TerminalPane pane = paneAt(event.getX(), event.getY());
|
||||||
|
if (pane == null) {
|
||||||
|
pane = activePane();
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseButton button = pressedButton == MouseButton.UNKNOWN ? mouseButton(event) : pressedButton;
|
||||||
|
MouseTarget target = mouseTarget(pane);
|
||||||
|
if (target != null) {
|
||||||
|
send(pane, target, MouseInput.release(button, localX(event.getX(), pane, target), localY(event.getY(), pane, target), 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 = activePane();
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseButton button = pressedButton == MouseButton.UNKNOWN ? mouseButton(event) : pressedButton;
|
||||||
|
MouseTarget target = mouseTarget(pane);
|
||||||
|
if (target == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
send(pane, target, MouseInput.drag(button, localX(event.getX(), pane, target), localY(event.getY(), pane, target), modifiers(event)), true, event);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleMouseMoved(MouseEvent event) {
|
||||||
|
TerminalPane pane = paneAt(event.getX(), event.getY());
|
||||||
|
if (pane == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseTarget target = mouseTarget(pane);
|
||||||
|
if (target == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
send(pane, target, MouseInput.motion(localX(event.getX(), pane, target), localY(event.getY(), pane, target), modifiers(event)), mouseButtonPressed, event);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleScroll(ScrollEvent event) {
|
||||||
|
TerminalPane pane = paneAt(event.getX(), event.getY());
|
||||||
|
if (pane == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.requestFocus();
|
||||||
|
focus(pane);
|
||||||
|
int direction = scrollDirection(event);
|
||||||
|
if (direction == 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseButton wheelButton = direction > 0 ? MouseButton.FOUR : MouseButton.FIVE;
|
||||||
|
int rows = scrollRows(event);
|
||||||
|
MouseTarget target = mouseTarget(pane);
|
||||||
|
boolean sent = false;
|
||||||
|
if (target != null) {
|
||||||
|
// The wheel sends one button press per scrolled row; resolve the position once.
|
||||||
|
double ex = localX(event.getX(), pane, target);
|
||||||
|
double ey = localY(event.getY(), pane, target);
|
||||||
|
KeyModifiers modifiers = modifiers(event);
|
||||||
|
for (int i = 0; i < rows; i++) {
|
||||||
|
sent |= send(pane, target, MouseInput.press(wheelButton, ex, ey, modifiers), mouseButtonPressed, event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!sent) {
|
||||||
|
// Not consumed by the app (e.g. mouse reporting off): scroll the local viewport.
|
||||||
|
pane.scrollViewport(direction > 0 ? -rows : rows);
|
||||||
|
event.consume();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forward an already-positioned mouse event to the pane, consuming it if the pane (i.e.
|
||||||
|
// the app running in it) acted on it. Returns whether it was sent.
|
||||||
|
private boolean send(TerminalPane pane, MouseTarget target, MouseInput input, boolean anyButtonPressed, InputEvent event) {
|
||||||
|
boolean sent = pane.sendMouse(input, target.size(), anyButtonPressed);
|
||||||
|
if (sent) {
|
||||||
|
event.consume();
|
||||||
|
}
|
||||||
|
return sent;
|
||||||
|
}
|
||||||
|
|
||||||
|
private TerminalPane paneAt(double x, double y) {
|
||||||
|
List<TerminalPane> panes = currentPanes();
|
||||||
|
for (int i = panes.size() - 1; i >= 0; i--) {
|
||||||
|
TerminalPane pane = panes.get(i);
|
||||||
|
if (x >= pane.x() && x < pane.x() + pane.width() && y >= pane.y() && y < pane.y() + pane.height()) {
|
||||||
|
return pane;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private MouseTarget mouseTarget(TerminalPane pane) {
|
||||||
|
if (pane.width() <= 2 * TerminalMetrics.PADDING || pane.height() <= 2 * TerminalMetrics.PADDING) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
int columns = metrics.columnsFor(pane.width());
|
||||||
|
int rows = metrics.rowsFor(pane.height());
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve a canvas-space pointer position to a pane-local pixel coordinate, clamped to
|
||||||
|
// the pane's reported screen size (what ghostty's mouse encoder expects).
|
||||||
|
private static double localX(double canvasX, TerminalPane pane, MouseTarget target) {
|
||||||
|
return clamp(canvasX - pane.x() - TerminalMetrics.PADDING, 0.0, target.screenWidth() - 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double localY(double canvasY, TerminalPane pane, MouseTarget target) {
|
||||||
|
return clamp(canvasY - pane.y() - TerminalMetrics.PADDING, 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;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// What one render() pass should do, decided from the change trackers in nextFrame().
|
||||||
|
private enum FrameType {
|
||||||
|
IDLE, // nothing changed since the last frame
|
||||||
|
LAYOUT, // geometry/font/tab/pane set changed: clear and repaint everything
|
||||||
|
CONTENT // only terminal content changed: repaint the panes that changed
|
||||||
|
}
|
||||||
|
|
||||||
|
private record MouseTarget(MouseEncoderSize size, long screenWidth, long screenHeight) {
|
||||||
|
}
|
||||||
|
}
|
||||||
631
src/main/java/com/gregor/jprototerm/GhosttyTerminalRenderer.java
Normal file
631
src/main/java/com/gregor/jprototerm/GhosttyTerminalRenderer.java
Normal file
@@ -0,0 +1,631 @@
|
|||||||
|
package com.gregor.jprototerm;
|
||||||
|
|
||||||
|
import dev.jlibghostty.KittyImageCompression;
|
||||||
|
import dev.jlibghostty.KittyImageFormat;
|
||||||
|
import dev.jlibghostty.KittyImageSnapshot;
|
||||||
|
import dev.jlibghostty.KittyPlacement;
|
||||||
|
import dev.jlibghostty.KittyPlacementLayer;
|
||||||
|
import dev.jlibghostty.KittyPlaceholder;
|
||||||
|
import dev.jlibghostty.KittyRenderInfo;
|
||||||
|
import dev.jlibghostty.RenderCell;
|
||||||
|
import dev.jlibghostty.RenderColor;
|
||||||
|
import dev.jlibghostty.RenderCursorStyle;
|
||||||
|
import dev.jlibghostty.RenderRow;
|
||||||
|
import dev.jlibghostty.RenderStateSnapshot;
|
||||||
|
import javafx.scene.canvas.GraphicsContext;
|
||||||
|
import javafx.scene.image.Image;
|
||||||
|
import javafx.scene.image.PixelFormat;
|
||||||
|
import javafx.scene.image.WritableImage;
|
||||||
|
import javafx.scene.paint.Color;
|
||||||
|
import javafx.scene.text.FontSmoothingType;
|
||||||
|
|
||||||
|
import java.io.ByteArrayInputStream;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The real terminal renderer: paints a pane's background, cell rows, cursor, border, padding
|
||||||
|
* and (when enabled) kitty graphics. One instance per pane, since it caches that pane's
|
||||||
|
* decoded kitty images.
|
||||||
|
*/
|
||||||
|
final class GhosttyTerminalRenderer extends TerminalRenderer {
|
||||||
|
// GhosttyRenderStateDirty values (stable C ABI; see ghostty/vt/render.h).
|
||||||
|
private static final int DIRTY_PARTIAL = 1;
|
||||||
|
private static final int DIRTY_FULL = 2;
|
||||||
|
|
||||||
|
private static final Color DEFAULT_FOREGROUND = Color.rgb(225, 229, 235);
|
||||||
|
private static final Color SELECTED_BACKGROUND = Color.rgb(52, 92, 140);
|
||||||
|
// The default cell background (used for cells with no explicit bg, and as the foreground
|
||||||
|
// for reverse-video cells whose background is the terminal default).
|
||||||
|
private static final Color PANE_BACKGROUND = Color.rgb(9, 10, 12);
|
||||||
|
|
||||||
|
// A full-screen redraw asks for one Color per cell; most cells share a handful of colors,
|
||||||
|
// so cache them by packed RGB instead of allocating a Color each time. Bounded so a
|
||||||
|
// truecolor gradient can't grow it without limit.
|
||||||
|
private static final Map<Integer, Color> COLOR_CACHE = new HashMap<>();
|
||||||
|
|
||||||
|
private final TerminalMetrics metrics;
|
||||||
|
// Decoded kitty images for this renderer's pane (kitty graphics state is per-terminal).
|
||||||
|
private final Map<KittyImageKey, Image> kittyImageCache = new HashMap<>();
|
||||||
|
|
||||||
|
GhosttyTerminalRenderer(TerminalMetrics metrics) {
|
||||||
|
this.metrics = metrics;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
void paintFull(GraphicsContext gc, RenderTarget target, boolean active) {
|
||||||
|
double px = Math.round(target.x());
|
||||||
|
double py = Math.round(target.y());
|
||||||
|
double width = target.width();
|
||||||
|
double height = target.height();
|
||||||
|
gc.save();
|
||||||
|
clip(gc, px, py, width, height, target.clip());
|
||||||
|
drawContent(gc, target, target.snapshotFull(), px, py, width, height, active,
|
||||||
|
target.kittyEnabled() && hasKittyGraphics(target));
|
||||||
|
gc.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
void paintIncremental(GraphicsContext gc, RenderTarget target, boolean active) {
|
||||||
|
double px = Math.round(target.x());
|
||||||
|
double py = Math.round(target.y());
|
||||||
|
double width = target.width();
|
||||||
|
double height = target.height();
|
||||||
|
gc.save();
|
||||||
|
clip(gc, px, py, width, height, target.clip());
|
||||||
|
if (target.kittyEnabled() && hasKittyGraphics(target)) {
|
||||||
|
// Kitty placements can move without a per-row dirty flag, so always redraw whole.
|
||||||
|
drawContent(gc, target, target.snapshotFull(), px, py, width, height, active, true);
|
||||||
|
} else {
|
||||||
|
RenderStateSnapshot snapshot = target.snapshot();
|
||||||
|
int dirty = snapshot == null ? DIRTY_FULL : snapshot.dirty();
|
||||||
|
if (dirty == DIRTY_FULL) {
|
||||||
|
drawContent(gc, target, snapshot, px, py, width, height, active, false);
|
||||||
|
} else if (dirty == DIRTY_PARTIAL) {
|
||||||
|
drawDirtyRows(gc, 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;
|
||||||
|
boolean cursorRowDirty = false;
|
||||||
|
double bandMin = Double.POSITIVE_INFINITY;
|
||||||
|
double bandMax = Double.NEGATIVE_INFINITY;
|
||||||
|
for (RenderRow row : snapshot.renderRows()) {
|
||||||
|
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));
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The cursor overlays its cell; redraw it only when its row was repainted, so we
|
||||||
|
// neither leave a stale cursor nor stack the translucent overlay on itself.
|
||||||
|
if (cursorRowDirty) {
|
||||||
|
drawCursor(gc, snapshot, left, top, cellWidth, lineHeight);
|
||||||
|
}
|
||||||
|
// Repainting rows clears the side borders within the band; restore just those
|
||||||
|
// 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void drawBorder(GraphicsContext gc, double x, double y, double width, double height, boolean active) {
|
||||||
|
gc.setStroke(active ? Color.rgb(87, 166, 255) : Color.rgb(52, 57, 65));
|
||||||
|
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
|
||||||
|
// unset colour falls back to the defaults).
|
||||||
|
private static Color cellBackgroundColor(RenderCell cell) {
|
||||||
|
if (cell.inverse()) {
|
||||||
|
var fg = cell.foreground();
|
||||||
|
return fg.isPresent() ? toFxColor(fg.get()) : DEFAULT_FOREGROUND;
|
||||||
|
}
|
||||||
|
var bg = cell.background();
|
||||||
|
return bg.isPresent() ? toFxColor(bg.get()) : PANE_BACKGROUND;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Color rowEdgeBackground(RenderRow row, boolean firstCell) {
|
||||||
|
List<RenderCell> cells = row.cells();
|
||||||
|
if (cells.isEmpty()) {
|
||||||
|
return PANE_BACKGROUND;
|
||||||
|
}
|
||||||
|
return cellBackgroundColor(firstCell ? cells.get(0) : cells.get(cells.size() - 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 static void drawRow(
|
||||||
|
GraphicsContext gc,
|
||||||
|
RenderRow row,
|
||||||
|
double left,
|
||||||
|
double top,
|
||||||
|
double baseline,
|
||||||
|
double cellWidth,
|
||||||
|
double lineHeight
|
||||||
|
) {
|
||||||
|
for (RenderCell cell : row.cells()) {
|
||||||
|
if (cell.kittyPlaceholder().isPresent()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
double x = left + (cell.column() * cellWidth);
|
||||||
|
double cellTop = top + (row.row() * lineHeight);
|
||||||
|
|
||||||
|
// Resolve fg/bg (null bg = terminal default, painted by the pane background).
|
||||||
|
// Avoid Optional.map's allocation on this hot path.
|
||||||
|
var fgOpt = cell.foreground();
|
||||||
|
var bgOpt = cell.background();
|
||||||
|
Color fg = fgOpt.isPresent() ? toFxColor(fgOpt.get()) : DEFAULT_FOREGROUND;
|
||||||
|
Color bg = bgOpt.isPresent() ? toFxColor(bgOpt.get()) : null;
|
||||||
|
|
||||||
|
// Reverse video: ghostty does not bake inverse into the resolved colours, so we
|
||||||
|
// swap them here, falling back to the terminal defaults for whichever is unset.
|
||||||
|
if (cell.inverse()) {
|
||||||
|
Color swappedBg = fg;
|
||||||
|
fg = (bg != null) ? bg : PANE_BACKGROUND;
|
||||||
|
bg = swappedBg;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bg != null) {
|
||||||
|
gc.setFill(bg);
|
||||||
|
gc.fillRect(x, cellTop, cellWidth, lineHeight);
|
||||||
|
}
|
||||||
|
if (cell.selected()) {
|
||||||
|
gc.setFill(SELECTED_BACKGROUND);
|
||||||
|
gc.fillRect(x, cellTop, cellWidth, lineHeight);
|
||||||
|
}
|
||||||
|
if (cell.codepoints().length == 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
double y = baseline + (row.row() * lineHeight);
|
||||||
|
gc.setFill(fg);
|
||||||
|
gc.fillText(cell.text(), x, y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Color toFxColor(RenderColor color) {
|
||||||
|
int key = (color.red() << 16) | (color.green() << 8) | color.blue();
|
||||||
|
Color cached = COLOR_CACHE.get(key);
|
||||||
|
if (cached != null) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
if (COLOR_CACHE.size() >= 4096) {
|
||||||
|
COLOR_CACHE.clear();
|
||||||
|
}
|
||||||
|
Color created = Color.rgb(color.red(), color.green(), color.blue());
|
||||||
|
COLOR_CACHE.put(key, created);
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Kitty graphics --------------------------------------------------------------
|
||||||
|
|
||||||
|
private static boolean hasKittyGraphics(RenderTarget target) {
|
||||||
|
return target.kittyGraphics()
|
||||||
|
.map(graphics -> !graphics.placements().isEmpty())
|
||||||
|
.orElse(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void drawKittyGraphics(
|
||||||
|
GraphicsContext gc,
|
||||||
|
RenderTarget target,
|
||||||
|
KittyPlacementLayer layer,
|
||||||
|
Map<KittyPlaceholderKey, KittyPlaceholderBounds> placeholderBounds,
|
||||||
|
double originX,
|
||||||
|
double originY,
|
||||||
|
double cellWidth,
|
||||||
|
double lineHeight
|
||||||
|
) {
|
||||||
|
target.kittyGraphics().ifPresent(graphics -> {
|
||||||
|
for (KittyPlacement placement : graphics.placements(layer)) {
|
||||||
|
Image image = imageFor(placement);
|
||||||
|
if (image == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (placement.virtual()) {
|
||||||
|
drawVirtualKittyPlacement(gc, placement, image, placeholderBounds, originX, originY, cellWidth, lineHeight);
|
||||||
|
} else {
|
||||||
|
drawPinnedKittyPlacement(gc, placement, image, originX, originY, cellWidth, lineHeight);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void drawPinnedKittyPlacement(
|
||||||
|
GraphicsContext gc,
|
||||||
|
KittyPlacement placement,
|
||||||
|
Image image,
|
||||||
|
double originX,
|
||||||
|
double originY,
|
||||||
|
double cellWidth,
|
||||||
|
double lineHeight
|
||||||
|
) {
|
||||||
|
KittyRenderInfo renderInfo = placement.renderInfo().orElse(null);
|
||||||
|
if (renderInfo == null || !renderInfo.viewportVisible()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
double sourceX = renderInfo.sourceX();
|
||||||
|
double sourceY = renderInfo.sourceY();
|
||||||
|
double sourceWidth = renderInfo.sourceWidth();
|
||||||
|
double sourceHeight = renderInfo.sourceHeight();
|
||||||
|
if (sourceWidth <= 0.0 || sourceHeight <= 0.0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
double x = originX + (renderInfo.viewportColumn() * cellWidth) + placement.xOffset();
|
||||||
|
double y = originY + (renderInfo.viewportRow() * lineHeight) + placement.yOffset();
|
||||||
|
double width = renderInfo.pixelWidth() > 0 ? renderInfo.pixelWidth() : renderInfo.gridColumns() * cellWidth;
|
||||||
|
double height = renderInfo.pixelHeight() > 0 ? renderInfo.pixelHeight() : renderInfo.gridRows() * lineHeight;
|
||||||
|
if (width <= 0.0 || height <= 0.0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
gc.drawImage(image, sourceX, sourceY, sourceWidth, sourceHeight, x, y, width, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void drawVirtualKittyPlacement(
|
||||||
|
GraphicsContext gc,
|
||||||
|
KittyPlacement placement,
|
||||||
|
Image image,
|
||||||
|
Map<KittyPlaceholderKey, KittyPlaceholderBounds> placeholderBounds,
|
||||||
|
double originX,
|
||||||
|
double originY,
|
||||||
|
double cellWidth,
|
||||||
|
double lineHeight
|
||||||
|
) {
|
||||||
|
KittyPlaceholderBounds bounds = placeholderBounds.get(new KittyPlaceholderKey(placement.imageId(), placement.placementId()));
|
||||||
|
if (bounds == null) {
|
||||||
|
bounds = placeholderBounds.get(new KittyPlaceholderKey(placement.imageId(), 0));
|
||||||
|
}
|
||||||
|
if (bounds == null && placement.placementId() == 0) {
|
||||||
|
bounds = placeholderBounds.entrySet().stream()
|
||||||
|
.filter(entry -> entry.getKey().imageId() == placement.imageId())
|
||||||
|
.map(Map.Entry::getValue)
|
||||||
|
.findFirst()
|
||||||
|
.orElse(null);
|
||||||
|
}
|
||||||
|
if (bounds == null || bounds.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
SourceRect source = sourceRect(placement, image);
|
||||||
|
if (source.width() <= 0.0 || source.height() <= 0.0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
long gridColumns = gridColumns(placement, bounds);
|
||||||
|
long gridRows = gridRows(placement, bounds);
|
||||||
|
double sourceCellWidth = source.width() / Math.max(1L, gridColumns);
|
||||||
|
double sourceCellHeight = source.height() / Math.max(1L, gridRows);
|
||||||
|
|
||||||
|
double sourceX = source.x() + (bounds.minSourceColumn * sourceCellWidth);
|
||||||
|
double sourceY = source.y() + (bounds.minSourceRow * sourceCellHeight);
|
||||||
|
double sourceWidth = bounds.sourceColumns() * sourceCellWidth;
|
||||||
|
double sourceHeight = bounds.sourceRows() * sourceCellHeight;
|
||||||
|
double x = originX + (bounds.minColumn * cellWidth);
|
||||||
|
double y = originY + (bounds.minRow * lineHeight);
|
||||||
|
double availableWidth = bounds.columns() * cellWidth;
|
||||||
|
double availableHeight = bounds.rows() * lineHeight;
|
||||||
|
|
||||||
|
if (sourceWidth <= 0.0 || sourceHeight <= 0.0 || availableWidth <= 0.0 || availableHeight <= 0.0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
double scale = Math.min(availableWidth / sourceWidth, availableHeight / sourceHeight);
|
||||||
|
double width = sourceWidth * scale;
|
||||||
|
double height = sourceHeight * scale;
|
||||||
|
gc.drawImage(image, sourceX, sourceY, sourceWidth, sourceHeight, x, y, width, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long gridColumns(KittyPlacement placement, KittyPlaceholderBounds bounds) {
|
||||||
|
if (placement.columns() > 0) {
|
||||||
|
return placement.columns();
|
||||||
|
}
|
||||||
|
return Math.max(bounds.maxSourceColumn + 1, bounds.sourceColumns());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long gridRows(KittyPlacement placement, KittyPlaceholderBounds bounds) {
|
||||||
|
if (placement.rows() > 0) {
|
||||||
|
return placement.rows();
|
||||||
|
}
|
||||||
|
return Math.max(bounds.maxSourceRow + 1, bounds.sourceRows());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SourceRect sourceRect(KittyPlacement placement, Image image) {
|
||||||
|
double sourceX = placement.sourceX();
|
||||||
|
double sourceY = placement.sourceY();
|
||||||
|
double sourceWidth = placement.sourceWidth() > 0 ? placement.sourceWidth() : image.getWidth() - sourceX;
|
||||||
|
double sourceHeight = placement.sourceHeight() > 0 ? placement.sourceHeight() : image.getHeight() - sourceY;
|
||||||
|
return new SourceRect(sourceX, sourceY, Math.min(sourceWidth, image.getWidth() - sourceX), Math.min(sourceHeight, image.getHeight() - sourceY));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Image imageFor(KittyPlacement placement) {
|
||||||
|
return placement.image().map(snapshot -> {
|
||||||
|
byte[] data = snapshot.data();
|
||||||
|
KittyImageKey key = KittyImageKey.of(snapshot, data);
|
||||||
|
Image cached = kittyImageCache.get(key);
|
||||||
|
if (cached != null) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
kittyImageCache.keySet().removeIf(existing -> existing.id() == snapshot.id());
|
||||||
|
Image decoded = decodeImage(snapshot, data);
|
||||||
|
if (decoded != null) {
|
||||||
|
kittyImageCache.put(key, decoded);
|
||||||
|
}
|
||||||
|
return decoded;
|
||||||
|
}).orElse(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Image decodeImage(KittyImageSnapshot snapshot, byte[] data) {
|
||||||
|
if (snapshot.compression() != KittyImageCompression.NONE) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (snapshot.format() == KittyImageFormat.PNG) {
|
||||||
|
return new Image(new ByteArrayInputStream(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
int width = Math.toIntExact(snapshot.width());
|
||||||
|
int height = Math.toIntExact(snapshot.height());
|
||||||
|
WritableImage image = new WritableImage(width, height);
|
||||||
|
|
||||||
|
if (snapshot.format() == KittyImageFormat.RGBA) {
|
||||||
|
image.getPixelWriter().setPixels(0, 0, width, height, PixelFormat.getByteBgraInstance(), rgbaToBgra(data), 0, width * 4);
|
||||||
|
} else if (snapshot.format() == KittyImageFormat.RGB) {
|
||||||
|
image.getPixelWriter().setPixels(0, 0, width, height, PixelFormat.getByteRgbInstance(), data, 0, width * 3);
|
||||||
|
}
|
||||||
|
return image;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] rgbaToBgra(byte[] rgba) {
|
||||||
|
byte[] bgra = new byte[rgba.length];
|
||||||
|
for (int i = 0; i + 3 < rgba.length; i += 4) {
|
||||||
|
bgra[i] = rgba[i + 2];
|
||||||
|
bgra[i + 1] = rgba[i + 1];
|
||||||
|
bgra[i + 2] = rgba[i];
|
||||||
|
bgra[i + 3] = rgba[i + 3];
|
||||||
|
}
|
||||||
|
return bgra;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Map<KittyPlaceholderKey, KittyPlaceholderBounds> kittyPlaceholderBounds(RenderStateSnapshot snapshot) {
|
||||||
|
if (snapshot == null) {
|
||||||
|
return Map.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<KittyPlaceholderKey, KittyPlaceholderBounds> result = new HashMap<>();
|
||||||
|
for (RenderRow row : snapshot.renderRows()) {
|
||||||
|
for (RenderCell cell : row.cells()) {
|
||||||
|
cell.kittyPlaceholder().ifPresent(placeholder -> {
|
||||||
|
KittyPlaceholderKey key = new KittyPlaceholderKey(placeholder.imageId(), placeholder.placementId());
|
||||||
|
result.computeIfAbsent(key, ignored -> new KittyPlaceholderBounds()).include(row.row(), cell.column(), placeholder);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A kitty image is immutable for a given (id, number); re-transmitting under the same id
|
||||||
|
// changes the number (and the snapshot below evicts stale entries by id anyway). So the
|
||||||
|
// 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
|
||||||
|
// 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 static KittyImageKey of(KittyImageSnapshot snapshot, byte[] data) {
|
||||||
|
return new KittyImageKey(
|
||||||
|
snapshot.id(),
|
||||||
|
snapshot.number(),
|
||||||
|
snapshot.width(),
|
||||||
|
snapshot.height(),
|
||||||
|
snapshot.format(),
|
||||||
|
data.length
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private record KittyPlaceholderKey(long imageId, long placementId) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private record SourceRect(double x, double y, double width, double height) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class KittyPlaceholderBounds {
|
||||||
|
private int minRow = Integer.MAX_VALUE;
|
||||||
|
private int maxRow = Integer.MIN_VALUE;
|
||||||
|
private int minColumn = Integer.MAX_VALUE;
|
||||||
|
private int maxColumn = Integer.MIN_VALUE;
|
||||||
|
private long minSourceRow = Long.MAX_VALUE;
|
||||||
|
private long maxSourceRow = Long.MIN_VALUE;
|
||||||
|
private long minSourceColumn = Long.MAX_VALUE;
|
||||||
|
private long maxSourceColumn = Long.MIN_VALUE;
|
||||||
|
|
||||||
|
private void include(int row, int column, KittyPlaceholder placeholder) {
|
||||||
|
minRow = Math.min(minRow, row);
|
||||||
|
maxRow = Math.max(maxRow, row);
|
||||||
|
minColumn = Math.min(minColumn, column);
|
||||||
|
maxColumn = Math.max(maxColumn, column);
|
||||||
|
minSourceRow = Math.min(minSourceRow, placeholder.sourceRow());
|
||||||
|
maxSourceRow = Math.max(maxSourceRow, placeholder.sourceRow());
|
||||||
|
minSourceColumn = Math.min(minSourceColumn, placeholder.sourceColumn());
|
||||||
|
maxSourceColumn = Math.max(maxSourceColumn, placeholder.sourceColumn());
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isEmpty() {
|
||||||
|
return minRow == Integer.MAX_VALUE;
|
||||||
|
}
|
||||||
|
|
||||||
|
private int rows() {
|
||||||
|
return maxRow - minRow + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private int columns() {
|
||||||
|
return maxColumn - minColumn + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private long sourceRows() {
|
||||||
|
return maxSourceRow - minSourceRow + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private long sourceColumns() {
|
||||||
|
return maxSourceColumn - minSourceColumn + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
401
src/main/java/com/gregor/jprototerm/LinuxPty.java
Normal file
401
src/main/java/com/gregor/jprototerm/LinuxPty.java
Normal file
@@ -0,0 +1,401 @@
|
|||||||
|
package com.gregor.jprototerm;
|
||||||
|
|
||||||
|
import java.lang.foreign.AddressLayout;
|
||||||
|
import java.lang.foreign.Arena;
|
||||||
|
import java.lang.foreign.FunctionDescriptor;
|
||||||
|
import java.lang.foreign.Linker;
|
||||||
|
import java.lang.foreign.MemoryLayout;
|
||||||
|
import java.lang.foreign.MemorySegment;
|
||||||
|
import java.lang.foreign.SymbolLookup;
|
||||||
|
import java.lang.foreign.ValueLayout;
|
||||||
|
import java.lang.invoke.MethodHandle;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A Linux PTY backed by libc via the Foreign Function & Memory API.
|
||||||
|
*
|
||||||
|
* <p>This replaces pty4j (which loads a JNA JNI shim). It uses
|
||||||
|
* {@code posix_openpt}/{@code posix_spawnp} rather than {@code fork}/{@code forkpty}:
|
||||||
|
* doing work between {@code fork} and {@code exec} inside a multithreaded JVM is unsafe
|
||||||
|
* (only async-signal-safe calls are permitted), whereas {@code posix_spawn} performs the
|
||||||
|
* dangerous part in libc with no Java on the stack.
|
||||||
|
*
|
||||||
|
* <p>The child gets a fresh session via {@code POSIX_SPAWN_SETSID}; it then opens the slave
|
||||||
|
* PTY itself (as fd 0, without {@code O_NOCTTY}) so the slave becomes its controlling
|
||||||
|
* terminal. glibc applies attribute flags (the setsid) before file actions, so the open
|
||||||
|
* happens in the new session.
|
||||||
|
*/
|
||||||
|
public final class LinuxPty implements AutoCloseable {
|
||||||
|
static final Linker LINKER = Linker.nativeLinker();
|
||||||
|
private static final SymbolLookup LIBC = LINKER.defaultLookup();
|
||||||
|
|
||||||
|
static final AddressLayout C_POINTER = (AddressLayout) LINKER.canonicalLayouts().get("void*");
|
||||||
|
static final ValueLayout.OfShort C_SHORT = (ValueLayout.OfShort) LINKER.canonicalLayouts().get("short");
|
||||||
|
static final ValueLayout.OfInt C_INT = (ValueLayout.OfInt) LINKER.canonicalLayouts().get("int");
|
||||||
|
static final ValueLayout.OfLong C_LONG = (ValueLayout.OfLong) LINKER.canonicalLayouts().get("long");
|
||||||
|
static final ValueLayout.OfLong C_SIZE_T = (ValueLayout.OfLong) LINKER.canonicalLayouts().get("size_t");
|
||||||
|
|
||||||
|
// Function descriptors.
|
||||||
|
static final FunctionDescriptor FD_INT_INT = FunctionDescriptor.of(C_INT, C_INT);
|
||||||
|
static final FunctionDescriptor FD_PTSNAME_R = FunctionDescriptor.of(C_INT, C_INT, C_POINTER, C_SIZE_T);
|
||||||
|
static final FunctionDescriptor FD_RW = FunctionDescriptor.of(C_LONG, C_INT, C_POINTER, C_SIZE_T);
|
||||||
|
static final FunctionDescriptor FD_IOCTL = FunctionDescriptor.of(C_INT, C_INT, C_LONG, C_POINTER);
|
||||||
|
static final FunctionDescriptor FD_KILL = FunctionDescriptor.of(C_INT, C_INT, C_INT);
|
||||||
|
static final FunctionDescriptor FD_WAITPID = FunctionDescriptor.of(C_INT, C_INT, C_POINTER, C_INT);
|
||||||
|
static final FunctionDescriptor FD_SPAWN = FunctionDescriptor.of(
|
||||||
|
C_INT, C_POINTER, C_POINTER, C_POINTER, C_POINTER, C_POINTER, C_POINTER);
|
||||||
|
static final FunctionDescriptor FD_FA_INIT = FunctionDescriptor.of(C_INT, C_POINTER);
|
||||||
|
static final FunctionDescriptor FD_FA_ADDCLOSE = FunctionDescriptor.of(C_INT, C_POINTER, C_INT);
|
||||||
|
static final FunctionDescriptor FD_FA_ADDDUP2 = FunctionDescriptor.of(C_INT, C_POINTER, C_INT, C_INT);
|
||||||
|
static final FunctionDescriptor FD_FA_ADDOPEN =
|
||||||
|
FunctionDescriptor.of(C_INT, C_POINTER, C_INT, C_POINTER, C_INT, C_INT);
|
||||||
|
static final FunctionDescriptor FD_FA_ADDCHDIR = FunctionDescriptor.of(C_INT, C_POINTER, C_POINTER);
|
||||||
|
static final FunctionDescriptor FD_ATTR_SETFLAGS = FunctionDescriptor.of(C_INT, C_POINTER, C_SHORT);
|
||||||
|
|
||||||
|
// Linux constants (x86-64 / arm64).
|
||||||
|
private static final int O_RDWR = 0x0002;
|
||||||
|
private static final int O_NOCTTY = 0x0100;
|
||||||
|
private static final long TIOCSWINSZ = 0x5414L;
|
||||||
|
private static final short POSIX_SPAWN_SETSID = 0x80;
|
||||||
|
private static final int SIGHUP = 1;
|
||||||
|
private static final int SIGKILL = 9;
|
||||||
|
private static final int WNOHANG = 1;
|
||||||
|
|
||||||
|
// struct winsize { unsigned short ws_row, ws_col, ws_xpixel, ws_ypixel; }
|
||||||
|
private static final MemoryLayout WINSIZE = MemoryLayout.structLayout(
|
||||||
|
C_SHORT.withName("ws_row"),
|
||||||
|
C_SHORT.withName("ws_col"),
|
||||||
|
C_SHORT.withName("ws_xpixel"),
|
||||||
|
C_SHORT.withName("ws_ypixel"));
|
||||||
|
|
||||||
|
// posix_spawn_file_actions_t / posix_spawnattr_t are opaque; over-allocate generously.
|
||||||
|
private static final long SPAWN_ACTIONS_SIZE = 256;
|
||||||
|
private static final long SPAWN_ATTR_SIZE = 512;
|
||||||
|
|
||||||
|
private static final MethodHandle POSIX_OPENPT = handle("posix_openpt", FD_INT_INT);
|
||||||
|
private static final MethodHandle GRANTPT = handle("grantpt", FD_INT_INT);
|
||||||
|
private static final MethodHandle UNLOCKPT = handle("unlockpt", FD_INT_INT);
|
||||||
|
private static final MethodHandle PTSNAME_R = handle("ptsname_r", FD_PTSNAME_R);
|
||||||
|
private static final MethodHandle CLOSE = handle("close", FD_INT_INT);
|
||||||
|
private static final MethodHandle READ = handle("read", FD_RW);
|
||||||
|
private static final MethodHandle WRITE = handle("write", FD_RW);
|
||||||
|
private static final MethodHandle IOCTL = handle("ioctl", FD_IOCTL, Linker.Option.firstVariadicArg(2));
|
||||||
|
private static final MethodHandle KILL = handle("kill", FD_KILL);
|
||||||
|
private static final MethodHandle WAITPID = handle("waitpid", FD_WAITPID);
|
||||||
|
private static final MethodHandle POSIX_SPAWNP = handle("posix_spawnp", FD_SPAWN);
|
||||||
|
private static final MethodHandle FA_INIT = handle("posix_spawn_file_actions_init", FD_FA_INIT);
|
||||||
|
private static final MethodHandle FA_DESTROY = handle("posix_spawn_file_actions_destroy", FD_FA_INIT);
|
||||||
|
private static final MethodHandle FA_ADDCLOSE = handle("posix_spawn_file_actions_addclose", FD_FA_ADDCLOSE);
|
||||||
|
private static final MethodHandle FA_ADDDUP2 = handle("posix_spawn_file_actions_adddup2", FD_FA_ADDDUP2);
|
||||||
|
private static final MethodHandle FA_ADDOPEN = handle("posix_spawn_file_actions_addopen", FD_FA_ADDOPEN);
|
||||||
|
private static final MethodHandle FA_ADDCHDIR = handle("posix_spawn_file_actions_addchdir_np", FD_FA_ADDCHDIR);
|
||||||
|
private static final MethodHandle ATTR_INIT = handle("posix_spawnattr_init", FD_FA_INIT);
|
||||||
|
private static final MethodHandle ATTR_DESTROY = handle("posix_spawnattr_destroy", FD_FA_INIT);
|
||||||
|
private static final MethodHandle ATTR_SETFLAGS = handle("posix_spawnattr_setflags", FD_ATTR_SETFLAGS);
|
||||||
|
|
||||||
|
private final Arena arena = Arena.ofShared();
|
||||||
|
private final MemorySegment readBuffer = arena.allocate(65536);
|
||||||
|
private final Object writeLock = new Object();
|
||||||
|
private final int masterFd;
|
||||||
|
private final int pid;
|
||||||
|
private volatile boolean closed;
|
||||||
|
|
||||||
|
private LinuxPty(int masterFd, int pid) {
|
||||||
|
this.masterFd = masterFd;
|
||||||
|
this.pid = pid;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens a PTY and spawns {@code argv} attached to its slave end.
|
||||||
|
*
|
||||||
|
* @param argv command and arguments (e.g. {@code {"/bin/zsh", "-i"}})
|
||||||
|
* @param environment environment for the child, as KEY=VALUE pairs
|
||||||
|
* @param workingDirectory directory the child starts in, or {@code null} to inherit
|
||||||
|
*/
|
||||||
|
public static LinuxPty spawn(String[] argv, Map<String, String> environment, String workingDirectory) {
|
||||||
|
Arena setup = Arena.ofConfined();
|
||||||
|
try {
|
||||||
|
int master = check(callInt(POSIX_OPENPT, O_RDWR | O_NOCTTY), "posix_openpt");
|
||||||
|
try {
|
||||||
|
check(callInt(GRANTPT, master), "grantpt");
|
||||||
|
check(callInt(UNLOCKPT, master), "unlockpt");
|
||||||
|
|
||||||
|
MemorySegment nameBuf = setup.allocate(256);
|
||||||
|
check(callPtsnameR(master, nameBuf), "ptsname_r");
|
||||||
|
String slavePath = nameBuf.getString(0);
|
||||||
|
|
||||||
|
MemorySegment actions = setup.allocate(SPAWN_ACTIONS_SIZE);
|
||||||
|
MemorySegment attr = setup.allocate(SPAWN_ATTR_SIZE);
|
||||||
|
check(callInt(FA_INIT, actions), "posix_spawn_file_actions_init");
|
||||||
|
check(callInt(ATTR_INIT, attr), "posix_spawnattr_init");
|
||||||
|
try {
|
||||||
|
check(callInt(ATTR_SETFLAGS, attr, POSIX_SPAWN_SETSID), "posix_spawnattr_setflags");
|
||||||
|
|
||||||
|
if (workingDirectory != null) {
|
||||||
|
MemorySegment dir = setup.allocateFrom(workingDirectory);
|
||||||
|
check(callAddChdir(actions, dir), "posix_spawn_file_actions_addchdir_np");
|
||||||
|
}
|
||||||
|
// Open the slave as fd 0 in the new session -> controlling terminal, then fan out.
|
||||||
|
MemorySegment slave = setup.allocateFrom(slavePath);
|
||||||
|
check(callAddOpen(actions, 0, slave, O_RDWR, 0), "posix_spawn_file_actions_addopen");
|
||||||
|
check(callAddDup2(actions, 0, 1), "posix_spawn_file_actions_adddup2");
|
||||||
|
check(callAddDup2(actions, 0, 2), "posix_spawn_file_actions_adddup2");
|
||||||
|
check(callAddClose(actions, master), "posix_spawn_file_actions_addclose");
|
||||||
|
|
||||||
|
MemorySegment argvSeg = cStringArray(setup, List.of(argv));
|
||||||
|
MemorySegment envpSeg = cStringArray(setup, toEnvList(environment));
|
||||||
|
MemorySegment path = setup.allocateFrom(argv[0]);
|
||||||
|
MemorySegment pidOut = setup.allocate(C_INT);
|
||||||
|
|
||||||
|
int rc = callSpawn(pidOut, path, actions, attr, argvSeg, envpSeg);
|
||||||
|
if (rc != 0) {
|
||||||
|
throw new IllegalStateException("posix_spawnp failed for " + argv[0] + " (rc=" + rc + ")");
|
||||||
|
}
|
||||||
|
return new LinuxPty(master, pidOut.get(C_INT, 0));
|
||||||
|
} finally {
|
||||||
|
callInt(ATTR_DESTROY, attr);
|
||||||
|
callInt(FA_DESTROY, actions);
|
||||||
|
}
|
||||||
|
} catch (RuntimeException ex) {
|
||||||
|
callInt(CLOSE, master);
|
||||||
|
throw ex;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setup.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reads available output into {@code dst}; returns bytes read, or -1 at EOF. */
|
||||||
|
public int read(byte[] dst) {
|
||||||
|
if (closed) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
long n = callLong(READ, masterFd, readBuffer, Math.min(dst.length, readBuffer.byteSize()));
|
||||||
|
if (n <= 0) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
MemorySegment.copy(readBuffer, ValueLayout.JAVA_BYTE, 0, dst, 0, (int) n);
|
||||||
|
return (int) n;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Writes all of {@code data} to the master end. */
|
||||||
|
public void write(byte[] data) {
|
||||||
|
if (closed || data.length == 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
synchronized (writeLock) {
|
||||||
|
try (Arena a = Arena.ofConfined()) {
|
||||||
|
MemorySegment buf = a.allocate(data.length);
|
||||||
|
MemorySegment.copy(data, 0, buf, ValueLayout.JAVA_BYTE, 0, data.length);
|
||||||
|
long offset = 0;
|
||||||
|
while (offset < data.length) {
|
||||||
|
long n = callLong(WRITE, masterFd, buf.asSlice(offset), data.length - offset);
|
||||||
|
if (n < 0) {
|
||||||
|
throw new IllegalStateException("write to pty failed");
|
||||||
|
}
|
||||||
|
offset += n;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resizes the terminal window. */
|
||||||
|
public void setWinSize(int columns, int rows) {
|
||||||
|
if (closed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try (Arena a = Arena.ofConfined()) {
|
||||||
|
MemorySegment ws = a.allocate(WINSIZE);
|
||||||
|
ws.set(C_SHORT, 0, (short) rows);
|
||||||
|
ws.set(C_SHORT, 2, (short) columns);
|
||||||
|
ws.set(C_SHORT, 4, (short) 0);
|
||||||
|
ws.set(C_SHORT, 6, (short) 0);
|
||||||
|
callIoctl(masterFd, TIOCSWINSZ, ws);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void close() {
|
||||||
|
if (closed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
closed = true;
|
||||||
|
callKill(pid, SIGHUP);
|
||||||
|
callInt(CLOSE, masterFd);
|
||||||
|
reap();
|
||||||
|
arena.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void reap() {
|
||||||
|
try (Arena a = Arena.ofConfined()) {
|
||||||
|
MemorySegment status = a.allocate(C_INT);
|
||||||
|
// Closing the master sends EOF/SIGHUP; an interactive shell exits promptly.
|
||||||
|
for (int attempt = 0; attempt < 50; attempt++) {
|
||||||
|
int r = callWaitpid(pid, status, WNOHANG);
|
||||||
|
if (r != 0) {
|
||||||
|
return; // reaped, or no such child
|
||||||
|
}
|
||||||
|
if (attempt == 25) {
|
||||||
|
callKill(pid, SIGKILL);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Thread.sleep(2);
|
||||||
|
} catch (InterruptedException ie) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- typed invokeExact wrappers ---------------------------------------------------------
|
||||||
|
|
||||||
|
private static int callInt(MethodHandle handle, int arg) {
|
||||||
|
try {
|
||||||
|
return (int) handle.invokeExact(arg);
|
||||||
|
} catch (Throwable t) {
|
||||||
|
throw sneaky(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int callInt(MethodHandle handle, MemorySegment arg) {
|
||||||
|
try {
|
||||||
|
return (int) handle.invokeExact(arg);
|
||||||
|
} catch (Throwable t) {
|
||||||
|
throw sneaky(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int callInt(MethodHandle handle, MemorySegment a, short b) {
|
||||||
|
try {
|
||||||
|
return (int) handle.invokeExact(a, b);
|
||||||
|
} catch (Throwable t) {
|
||||||
|
throw sneaky(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long callLong(MethodHandle handle, int fd, MemorySegment buf, long len) {
|
||||||
|
try {
|
||||||
|
return (long) handle.invokeExact(fd, buf, len);
|
||||||
|
} catch (Throwable t) {
|
||||||
|
throw sneaky(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int callPtsnameR(int fd, MemorySegment buf) {
|
||||||
|
try {
|
||||||
|
return (int) PTSNAME_R.invokeExact(fd, buf, buf.byteSize());
|
||||||
|
} catch (Throwable t) {
|
||||||
|
throw sneaky(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int callAddChdir(MemorySegment actions, MemorySegment path) {
|
||||||
|
try {
|
||||||
|
return (int) FA_ADDCHDIR.invokeExact(actions, path);
|
||||||
|
} catch (Throwable t) {
|
||||||
|
throw sneaky(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int callAddOpen(MemorySegment actions, int fd, MemorySegment path, int oflag, int mode) {
|
||||||
|
try {
|
||||||
|
return (int) FA_ADDOPEN.invokeExact(actions, fd, path, oflag, mode);
|
||||||
|
} catch (Throwable t) {
|
||||||
|
throw sneaky(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int callAddDup2(MemorySegment actions, int fd, int newFd) {
|
||||||
|
try {
|
||||||
|
return (int) FA_ADDDUP2.invokeExact(actions, fd, newFd);
|
||||||
|
} catch (Throwable t) {
|
||||||
|
throw sneaky(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int callAddClose(MemorySegment actions, int fd) {
|
||||||
|
try {
|
||||||
|
return (int) FA_ADDCLOSE.invokeExact(actions, fd);
|
||||||
|
} catch (Throwable t) {
|
||||||
|
throw sneaky(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int callSpawn(MemorySegment pid, MemorySegment path, MemorySegment actions,
|
||||||
|
MemorySegment attr, MemorySegment argv, MemorySegment envp) {
|
||||||
|
try {
|
||||||
|
return (int) POSIX_SPAWNP.invokeExact(pid, path, actions, attr, argv, envp);
|
||||||
|
} catch (Throwable t) {
|
||||||
|
throw sneaky(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void callIoctl(int fd, long request, MemorySegment arg) {
|
||||||
|
try {
|
||||||
|
int unused = (int) IOCTL.invokeExact(fd, request, arg);
|
||||||
|
} catch (Throwable t) {
|
||||||
|
throw sneaky(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void callKill(int pid, int signal) {
|
||||||
|
try {
|
||||||
|
int unused = (int) KILL.invokeExact(pid, signal);
|
||||||
|
} catch (Throwable t) {
|
||||||
|
throw sneaky(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int callWaitpid(int pid, MemorySegment status, int options) {
|
||||||
|
try {
|
||||||
|
return (int) WAITPID.invokeExact(pid, status, options);
|
||||||
|
} catch (Throwable t) {
|
||||||
|
throw sneaky(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- helpers ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private static MethodHandle handle(String symbol, FunctionDescriptor descriptor, Linker.Option... options) {
|
||||||
|
MemorySegment address = LIBC.find(symbol)
|
||||||
|
.orElseThrow(() -> new IllegalStateException("libc symbol not found: " + symbol));
|
||||||
|
return LINKER.downcallHandle(address, descriptor, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MemorySegment cStringArray(Arena arena, List<String> values) {
|
||||||
|
MemorySegment array = arena.allocate(C_POINTER, values.size() + 1L);
|
||||||
|
for (int i = 0; i < values.size(); i++) {
|
||||||
|
array.setAtIndex(C_POINTER, i, arena.allocateFrom(values.get(i)));
|
||||||
|
}
|
||||||
|
array.setAtIndex(C_POINTER, values.size(), MemorySegment.NULL);
|
||||||
|
return array;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<String> toEnvList(Map<String, String> environment) {
|
||||||
|
List<String> out = new ArrayList<>(environment.size());
|
||||||
|
for (Map.Entry<String, String> entry : environment.entrySet()) {
|
||||||
|
out.add(entry.getKey() + "=" + entry.getValue());
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int check(int rc, String what) {
|
||||||
|
if (rc < 0) {
|
||||||
|
throw new IllegalStateException(what + " failed (rc=" + rc + ")");
|
||||||
|
}
|
||||||
|
return rc;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static RuntimeException sneaky(Throwable t) {
|
||||||
|
if (t instanceof RuntimeException re) {
|
||||||
|
return re;
|
||||||
|
}
|
||||||
|
if (t instanceof Error e) {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
return new IllegalStateException(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,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.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;
|
||||||
@@ -20,20 +21,20 @@ import java.nio.file.Files;
|
|||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
|
|
||||||
public final class Main extends Application {
|
public final class Main extends Application {
|
||||||
private TerminalWorkspace workspace;
|
private Compositor compositor;
|
||||||
private TerminalCanvasView terminalView;
|
private TerminalMetrics metrics;
|
||||||
private AppConfig config;
|
private AppConfig config;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void start(Stage stage) {
|
public void start(Stage stage) {
|
||||||
config = AppConfig.load();
|
config = AppConfig.load();
|
||||||
|
|
||||||
workspace = new TerminalWorkspace(config);
|
metrics = new TerminalMetrics(config.fontFamily(), config.fontSize());
|
||||||
terminalView = new TerminalCanvasView(workspace, config);
|
compositor = new Compositor(config, metrics);
|
||||||
|
|
||||||
StackPane root = new StackPane(terminalView.canvas());
|
StackPane root = new StackPane(compositor.canvas());
|
||||||
terminalView.canvas().widthProperty().bind(root.widthProperty());
|
compositor.canvas().widthProperty().bind(root.widthProperty());
|
||||||
terminalView.canvas().heightProperty().bind(root.heightProperty());
|
compositor.canvas().heightProperty().bind(root.heightProperty());
|
||||||
|
|
||||||
Scene scene = new Scene(root, config.windowWidth(), config.windowHeight());
|
Scene scene = new Scene(root, config.windowWidth(), config.windowHeight());
|
||||||
scene.addEventFilter(KeyEvent.KEY_PRESSED, this::handlePressed);
|
scene.addEventFilter(KeyEvent.KEY_PRESSED, this::handlePressed);
|
||||||
@@ -42,43 +43,57 @@ public final class Main extends Application {
|
|||||||
new AnimationTimer() {
|
new AnimationTimer() {
|
||||||
@Override
|
@Override
|
||||||
public void handle(long now) {
|
public void handle(long now) {
|
||||||
terminalView.render();
|
compositor.render();
|
||||||
}
|
}
|
||||||
}.start();
|
}.start();
|
||||||
|
|
||||||
stage.setTitle("jprototerm");
|
stage.setTitle("jprototerm");
|
||||||
stage.setScene(scene);
|
stage.setScene(scene);
|
||||||
stage.setOnCloseRequest(event -> {
|
stage.setOnCloseRequest(event -> {
|
||||||
workspace.close();
|
compositor.close();
|
||||||
});
|
});
|
||||||
stage.show();
|
stage.show();
|
||||||
terminalView.canvas().requestFocus();
|
compositor.canvas().requestFocus();
|
||||||
}
|
}
|
||||||
|
|
||||||
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)) {
|
||||||
workspace.navigate(Direction.LEFT);
|
compositor.navigate(Direction.LEFT);
|
||||||
event.consume();
|
event.consume();
|
||||||
} else if (config.keybindings().get("navigate_down").matches(event)) {
|
} else if (config.keybindings().get("navigate_down").matches(event)) {
|
||||||
workspace.navigate(Direction.DOWN);
|
compositor.navigate(Direction.DOWN);
|
||||||
event.consume();
|
event.consume();
|
||||||
} else if (config.keybindings().get("navigate_up").matches(event)) {
|
} else if (config.keybindings().get("navigate_up").matches(event)) {
|
||||||
workspace.navigate(Direction.UP);
|
compositor.navigate(Direction.UP);
|
||||||
event.consume();
|
event.consume();
|
||||||
} else if (config.keybindings().get("navigate_right").matches(event)) {
|
} else if (config.keybindings().get("navigate_right").matches(event)) {
|
||||||
workspace.navigate(Direction.RIGHT);
|
compositor.navigate(Direction.RIGHT);
|
||||||
event.consume();
|
event.consume();
|
||||||
} else if (config.keybindings().get("toggle_floating").matches(event)) {
|
} else if (config.keybindings().get("toggle_floating").matches(event)) {
|
||||||
workspace.toggleFloating();
|
compositor.toggleFloating();
|
||||||
event.consume();
|
event.consume();
|
||||||
} else if (config.keybindings().get("new_floating").matches(event)) {
|
} else if (config.keybindings().get("new_pane").matches(event)) {
|
||||||
workspace.createFloatingPane();
|
compositor.createPane();
|
||||||
event.consume();
|
event.consume();
|
||||||
} else if (config.keybindings().get("next_floating").matches(event)) {
|
} else if (config.keybindings().get("next_floating").matches(event)) {
|
||||||
workspace.nextFloatingPane();
|
compositor.nextFloatingPane();
|
||||||
event.consume();
|
event.consume();
|
||||||
} else if (config.keybindings().get("close_pane").matches(event)) {
|
} else if (config.keybindings().get("close_pane").matches(event)) {
|
||||||
workspace.closeActivePane();
|
compositor.closeActivePane();
|
||||||
|
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)) {
|
||||||
|
compositor.newTab();
|
||||||
|
event.consume();
|
||||||
|
} else if (config.keybindings().get("previous_tab").matches(event)) {
|
||||||
|
compositor.previousTab();
|
||||||
|
event.consume();
|
||||||
|
} else if (config.keybindings().get("next_tab").matches(event)) {
|
||||||
|
compositor.nextTab();
|
||||||
event.consume();
|
event.consume();
|
||||||
} else if (config.keybindings().get("open_font_selector").matches(event)) {
|
} else if (config.keybindings().get("open_font_selector").matches(event)) {
|
||||||
openFontSelector();
|
openFontSelector();
|
||||||
@@ -89,7 +104,7 @@ public final class Main extends Application {
|
|||||||
} else {
|
} else {
|
||||||
String encoded = KeyEncoder.encode(event);
|
String encoded = KeyEncoder.encode(event);
|
||||||
if (encoded != null) {
|
if (encoded != null) {
|
||||||
workspace.activePane().send(encoded);
|
compositor.activePane().send(encoded);
|
||||||
event.consume();
|
event.consume();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -102,7 +117,7 @@ public final class Main extends Application {
|
|||||||
|
|
||||||
String text = event.getCharacter();
|
String text = event.getCharacter();
|
||||||
if (text != null && !text.isEmpty() && text.charAt(0) >= 0x20 && text.charAt(0) != 0x7f) {
|
if (text != null && !text.isEmpty() && text.charAt(0) >= 0x20 && text.charAt(0) != 0x7f) {
|
||||||
workspace.activePane().send(text);
|
compositor.activePane().send(text);
|
||||||
event.consume();
|
event.consume();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -145,18 +160,18 @@ public final class Main extends Application {
|
|||||||
double selectedSize = size.getValue();
|
double selectedSize = size.getValue();
|
||||||
config = config.withFont(selectedFamily.trim(), selectedSize);
|
config = config.withFont(selectedFamily.trim(), selectedSize);
|
||||||
config.save();
|
config.save();
|
||||||
terminalView.setFont(config.fontFamily(), config.fontSize());
|
compositor.setFont(config.fontFamily(), config.fontSize());
|
||||||
terminalView.canvas().requestFocus();
|
compositor.canvas().requestFocus();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private void openScrollbackInEditor() {
|
private void openScrollbackInEditor() {
|
||||||
try {
|
try {
|
||||||
Path file = Files.createTempFile("jprototerm-scrollback-", ".txt");
|
Path file = Files.createTempFile("jprototerm-scrollback-", ".txt");
|
||||||
Files.writeString(file, workspace.activePane().scrollbackText());
|
Files.writeString(file, compositor.activePane().scrollbackText());
|
||||||
file.toFile().deleteOnExit();
|
file.toFile().deleteOnExit();
|
||||||
|
|
||||||
workspace.activePane().send(scrollbackEditorCommand(file) + "\r");
|
compositor.activePane().send(scrollbackEditorCommand(file) + "\r");
|
||||||
} catch (IOException ex) {
|
} catch (IOException ex) {
|
||||||
System.err.println("Could not open scrollback in editor: " + ex.getMessage());
|
System.err.println("Could not open scrollback in editor: " + ex.getMessage());
|
||||||
}
|
}
|
||||||
|
|||||||
45
src/main/java/com/gregor/jprototerm/RenderTarget.java
Normal file
45
src/main/java/com/gregor/jprototerm/RenderTarget.java
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
package com.gregor.jprototerm;
|
||||||
|
|
||||||
|
import dev.jlibghostty.KittyGraphics;
|
||||||
|
import dev.jlibghostty.RenderStateSnapshot;
|
||||||
|
import javafx.scene.shape.Shape;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The read-only view of a pane that a {@link TerminalRenderer} draws: its on-screen rect, its
|
||||||
|
* current render snapshot, and its kitty-graphics state. Decoupling the renderer from
|
||||||
|
* {@link TerminalPane} through this interface lets the renderer be swapped (e.g. a debug
|
||||||
|
* renderer that just outlines bounds and clip bands) and unit-tested against a synthetic
|
||||||
|
* target without a real terminal.
|
||||||
|
*/
|
||||||
|
interface RenderTarget {
|
||||||
|
double x();
|
||||||
|
|
||||||
|
double y();
|
||||||
|
|
||||||
|
double width();
|
||||||
|
|
||||||
|
double height();
|
||||||
|
|
||||||
|
/** Whether kitty graphics should be drawn for this target at all. */
|
||||||
|
boolean kittyEnabled();
|
||||||
|
|
||||||
|
Optional<KittyGraphics> kittyGraphics();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Incremental snapshot: only rows that changed since the last frame are populated. May be
|
||||||
|
* {@code null} before the first snapshot exists.
|
||||||
|
*/
|
||||||
|
RenderStateSnapshot snapshot();
|
||||||
|
|
||||||
|
/** Full snapshot with every row populated, regardless of dirty state. */
|
||||||
|
RenderStateSnapshot snapshotFull();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The region this target may draw into, or {@code null} to clip to its plain rect. Set at
|
||||||
|
* layout time (a tiled pane gets its rect minus the floating panes that cover it), so the
|
||||||
|
* renderer can clip its own output and never paint over a pane on top.
|
||||||
|
*/
|
||||||
|
Shape clip();
|
||||||
|
}
|
||||||
@@ -1,12 +1,5 @@
|
|||||||
package com.gregor.jprototerm;
|
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.nio.charset.StandardCharsets;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@@ -14,14 +7,12 @@ import java.util.concurrent.ExecutorService;
|
|||||||
import java.util.concurrent.Executors;
|
import java.util.concurrent.Executors;
|
||||||
|
|
||||||
public final class ShellSession implements AutoCloseable {
|
public final class ShellSession implements AutoCloseable {
|
||||||
private final PtyProcess process;
|
private final LinuxPty pty;
|
||||||
private final OutputStream stdin;
|
|
||||||
private final ExecutorService reader;
|
private final ExecutorService reader;
|
||||||
private volatile boolean closed;
|
private volatile boolean closed;
|
||||||
|
|
||||||
private ShellSession(PtyProcess process) {
|
private ShellSession(LinuxPty pty) {
|
||||||
this.process = process;
|
this.pty = pty;
|
||||||
this.stdin = process.getOutputStream();
|
|
||||||
this.reader = Executors.newSingleThreadExecutor(runnable -> {
|
this.reader = Executors.newSingleThreadExecutor(runnable -> {
|
||||||
Thread thread = new Thread(runnable, "shell-output-reader");
|
Thread thread = new Thread(runnable, "shell-output-reader");
|
||||||
thread.setDaemon(true);
|
thread.setDaemon(true);
|
||||||
@@ -29,25 +20,51 @@ public final class ShellSession implements AutoCloseable {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public static ShellSession start(String shell, TerminalPane pane, int columns, int rows) {
|
public static ShellSession start(String shell, Map<String, String> envOverride, TerminalPane pane, int columns, int rows) {
|
||||||
try {
|
try {
|
||||||
Map<String, String> environment = new HashMap<>(System.getenv());
|
Map<String, String> environment = new HashMap<>(System.getenv());
|
||||||
environment.put("TERM", "xterm-kitty");
|
environment.put("TERM", "xterm-kitty");
|
||||||
environment.put("COLORTERM", "truecolor");
|
environment.put("COLORTERM", "truecolor");
|
||||||
|
sanitizeWrapperEnvironment(environment);
|
||||||
|
environment.putAll(envOverride);
|
||||||
|
|
||||||
PtyProcess process = new PtyProcessBuilder(new String[] {shell, "-i"})
|
LinuxPty pty = LinuxPty.spawn(
|
||||||
.setEnvironment(environment)
|
new String[] {shell, "-i"},
|
||||||
.setInitialColumns(columns)
|
environment,
|
||||||
.setInitialRows(rows)
|
System.getProperty("user.home"));
|
||||||
.setDirectory(System.getProperty("user.home"))
|
ShellSession session = new ShellSession(pty);
|
||||||
.start();
|
session.resize(columns, rows);
|
||||||
return new ShellSession(process);
|
return session;
|
||||||
} catch (IOException 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 " + shell, ex);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strips the variables injected by the Nix launcher wrapper from the shell's
|
||||||
|
* environment so they do not leak into terminal subprocesses.
|
||||||
|
*
|
||||||
|
* <p>jprototerm is launched from a Nix wrapper that prepends Nix store paths to
|
||||||
|
* {@code LD_LIBRARY_PATH} (and adds a GL shim) so the bundled JavaFX/ghostty natives
|
||||||
|
* resolve. If the shell inherited that path, host programs run inside the terminal
|
||||||
|
* (e.g. {@code flatpak}, {@code pdftoppm}) would load the Nix copies of libraries such
|
||||||
|
* as freetype/fontconfig/glib, which in turn drag in the Nix glibc through their
|
||||||
|
* RUNPATHs and clash with the host {@code libc.so.6}. We restore the user's original
|
||||||
|
* {@code LD_LIBRARY_PATH}, captured by the wrapper before it prepended anything.
|
||||||
|
*/
|
||||||
|
private static void sanitizeWrapperEnvironment(Map<String, String> environment) {
|
||||||
|
String hostLibraryPath = environment.remove("JPROTOTERM_HOST_LD_LIBRARY_PATH");
|
||||||
|
if (hostLibraryPath == null || hostLibraryPath.isEmpty()) {
|
||||||
|
environment.remove("LD_LIBRARY_PATH");
|
||||||
|
} else {
|
||||||
|
environment.put("LD_LIBRARY_PATH", hostLibraryPath);
|
||||||
|
}
|
||||||
|
// These are jprototerm's own runtime settings, not the user's shell environment.
|
||||||
|
environment.remove("GDK_BACKEND");
|
||||||
|
environment.remove("JLIBGHOSTTY_LIBRARY");
|
||||||
|
}
|
||||||
|
|
||||||
public void startReading(TerminalPane pane) {
|
public void startReading(TerminalPane pane) {
|
||||||
reader.submit(() -> readOutput(pane));
|
reader.submit(() -> readOutput(pane));
|
||||||
}
|
}
|
||||||
@@ -56,7 +73,7 @@ public final class ShellSession implements AutoCloseable {
|
|||||||
if (closed) {
|
if (closed) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
process.setWinSize(new WinSize(columns, rows));
|
pty.setWinSize(columns, rows);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void send(String text) {
|
public void send(String text) {
|
||||||
@@ -68,31 +85,32 @@ public final class ShellSession implements AutoCloseable {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
stdin.write(bytes);
|
pty.write(bytes);
|
||||||
stdin.flush();
|
} catch (RuntimeException ex) {
|
||||||
} catch (IOException ex) {
|
|
||||||
close();
|
close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void readOutput(TerminalPane pane) {
|
private void readOutput(TerminalPane pane) {
|
||||||
byte[] buffer = new byte[8192];
|
byte[] buffer = new byte[65536];
|
||||||
try {
|
try {
|
||||||
int read;
|
int read;
|
||||||
while ((read = process.getInputStream().read(buffer)) != -1) {
|
while ((read = pty.read(buffer)) != -1) {
|
||||||
if (!closed) {
|
if (closed) {
|
||||||
byte[] bytes = new byte[read];
|
break;
|
||||||
System.arraycopy(buffer, 0, bytes, 0, read);
|
|
||||||
Platform.runLater(() -> {
|
|
||||||
if (!closed) {
|
|
||||||
pane.write(bytes);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
byte[] bytes = new byte[read];
|
||||||
|
System.arraycopy(buffer, 0, bytes, 0, read);
|
||||||
|
// Feed the terminal model straight from the reader thread. terminal access is
|
||||||
|
// guarded by the per-terminal lock, and the render loop picks the change up on
|
||||||
|
// the next pulse. Avoiding a Platform.runLater hop per chunk removes a frame of
|
||||||
|
// latency and stops write tasks from contending with rendering on the FX thread
|
||||||
|
// when a TUI repaints heavily (the input-lag culprit).
|
||||||
|
pane.write(bytes);
|
||||||
}
|
}
|
||||||
} catch (IOException ex) {
|
} catch (RuntimeException ex) {
|
||||||
if (!closed) {
|
if (!closed) {
|
||||||
Platform.runLater(() -> pane.write("\r\nshell output stopped: " + ex.getMessage() + "\r\n"));
|
pane.write("\r\nshell output stopped: " + ex.getMessage() + "\r\n");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -101,6 +119,6 @@ public final class ShellSession implements AutoCloseable {
|
|||||||
public void close() {
|
public void close() {
|
||||||
closed = true;
|
closed = true;
|
||||||
reader.shutdownNow();
|
reader.shutdownNow();
|
||||||
process.destroy();
|
pty.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
339
src/main/java/com/gregor/jprototerm/Tab.java
Normal file
339
src/main/java/com/gregor/jprototerm/Tab.java
Normal file
@@ -0,0 +1,339 @@
|
|||||||
|
package com.gregor.jprototerm;
|
||||||
|
|
||||||
|
import javafx.scene.shape.Rectangle;
|
||||||
|
import javafx.scene.shape.Shape;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One tab: a row of tiled panes with a group of floating panes shown over them. Floating panes
|
||||||
|
* are shown/hidden as a group ({@code floatingVisible}), and there is always at least one tiled
|
||||||
|
* pane — a floating pane is promoted if the last tiled one closes — so the layout always has a
|
||||||
|
* base. The {@link Compositor} owns the tabs and renders only the current one; mutating methods
|
||||||
|
* return whether they actually changed anything so it can bump its layout version.
|
||||||
|
*/
|
||||||
|
final class Tab implements AutoCloseable {
|
||||||
|
private final AppConfig config;
|
||||||
|
private final TerminalMetrics metrics;
|
||||||
|
private final List<TerminalPane> tiled = new ArrayList<>();
|
||||||
|
private final List<TerminalPane> floating = new ArrayList<>();
|
||||||
|
private boolean floatingVisible;
|
||||||
|
private TerminalPane active;
|
||||||
|
// The floating pane to re-focus when the group is shown again, and to prefer when promoting
|
||||||
|
// after the last tiled pane closes.
|
||||||
|
private TerminalPane lastFocusedFloating;
|
||||||
|
// 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
|
||||||
|
// opened before any layout pass runs.
|
||||||
|
private double lastWidth;
|
||||||
|
private double lastHeight;
|
||||||
|
private double lastTopInset;
|
||||||
|
// Bumped whenever one of this tab's panes changes content; the compositor reads the current
|
||||||
|
// tab's value each frame as an O(1) "anything to repaint?" check.
|
||||||
|
private long contentVersion;
|
||||||
|
|
||||||
|
Tab(AppConfig config, TerminalMetrics metrics) {
|
||||||
|
this.config = config;
|
||||||
|
this.metrics = metrics;
|
||||||
|
this.lastWidth = config.windowWidth();
|
||||||
|
this.lastHeight = config.windowHeight();
|
||||||
|
TerminalPane first = openPane(false);
|
||||||
|
tiled.add(first);
|
||||||
|
active = first;
|
||||||
|
}
|
||||||
|
|
||||||
|
TerminalPane activePane() {
|
||||||
|
return active;
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean isEmpty() {
|
||||||
|
return tiled.isEmpty() && floating.isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
long contentVersion() {
|
||||||
|
return contentVersion;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Panes to composite, bottom-to-top: tiled first, then (when shown) the floating group with
|
||||||
|
* the active floating pane on top.
|
||||||
|
*/
|
||||||
|
List<TerminalPane> panes() {
|
||||||
|
if (!floatingVisible || floating.isEmpty()) {
|
||||||
|
return List.copyOf(tiled);
|
||||||
|
}
|
||||||
|
List<TerminalPane> ordered = new ArrayList<>(tiled.size() + floating.size());
|
||||||
|
ordered.addAll(tiled);
|
||||||
|
for (TerminalPane pane : floating) {
|
||||||
|
if (pane != active) {
|
||||||
|
ordered.add(pane);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (floating.contains(active)) {
|
||||||
|
ordered.add(active); // active floating pane on top
|
||||||
|
}
|
||||||
|
return List.copyOf(ordered);
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean isActive(TerminalPane pane) {
|
||||||
|
return pane != null && pane == active;
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean focus(TerminalPane pane) {
|
||||||
|
if (pane == active || !isFocusable(pane)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
setActive(pane);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void layout(double width, double height, double topInset) {
|
||||||
|
this.lastWidth = width;
|
||||||
|
this.lastHeight = height;
|
||||||
|
this.lastTopInset = topInset;
|
||||||
|
double availHeight = height - topInset;
|
||||||
|
|
||||||
|
double tileWidth = width / Math.max(1, tiled.size());
|
||||||
|
for (int i = 0; i < tiled.size(); i++) {
|
||||||
|
tiled.get(i).bounds(i * tileWidth, topInset, tileWidth, availHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
double floatingWidth = Math.max(420, width * 0.58);
|
||||||
|
double floatingHeight = Math.max(260, availHeight * 0.58);
|
||||||
|
for (int i = 0; i < floating.size(); i++) {
|
||||||
|
double offset = i * 28.0;
|
||||||
|
floating.get(i).bounds(
|
||||||
|
Math.min(width - floatingWidth - 12.0, ((width - floatingWidth) / 2.0) + offset),
|
||||||
|
Math.min(height - floatingHeight - 12.0, topInset + ((availHeight - floatingHeight) / 2.0) + offset),
|
||||||
|
floatingWidth,
|
||||||
|
floatingHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
assignClips();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Give each pane its clip region for the next paints, so repainting a pane on a content
|
||||||
|
// frame can never bleed over one stacked on top of it. Each pane is clipped to its rect
|
||||||
|
// minus the union of the panes above it: floating panes are clipped by the floating panes
|
||||||
|
// higher in the stack, and tiled panes by the whole floating group. When nothing floats,
|
||||||
|
// every pane clips to its plain bounds.
|
||||||
|
private void assignClips() {
|
||||||
|
if (!floatingVisible || floating.isEmpty()) {
|
||||||
|
tiled.forEach(pane -> pane.setClip(null));
|
||||||
|
floating.forEach(pane -> pane.setClip(null));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Floating panes bottom-to-top, matching panes(): insertion order, active pane on top.
|
||||||
|
List<TerminalPane> order = new ArrayList<>(floating.size());
|
||||||
|
for (TerminalPane pane : floating) {
|
||||||
|
if (pane != active) {
|
||||||
|
order.add(pane);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (floating.contains(active)) {
|
||||||
|
order.add(active);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Walk top-to-bottom, accumulating the union of the panes above each one.
|
||||||
|
Shape above = null;
|
||||||
|
for (int i = order.size() - 1; i >= 0; i--) {
|
||||||
|
Rectangle rect = rectOf(order.get(i));
|
||||||
|
order.get(i).setClip(above == null ? null : Shape.subtract(rect, above));
|
||||||
|
above = (above == null) ? rect : Shape.union(above, rect);
|
||||||
|
}
|
||||||
|
|
||||||
|
// `above` is now the union of every floating pane; tiled panes sit under all of them.
|
||||||
|
for (TerminalPane pane : tiled) {
|
||||||
|
pane.setClip(Shape.subtract(rectOf(pane), above));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match the renderer's pixel snapping (round the origin, keep width/height) so the clip
|
||||||
|
// lines up exactly with where the floating panes are drawn.
|
||||||
|
private static Rectangle rectOf(TerminalPane pane) {
|
||||||
|
return new Rectangle(Math.round(pane.x()), Math.round(pane.y()), pane.width(), pane.height());
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean navigate(Direction direction) {
|
||||||
|
if (floating.contains(active) && navigateFloatingStack(direction)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
TerminalPane target = focusable()
|
||||||
|
.filter(pane -> pane != active)
|
||||||
|
.filter(pane -> directionFilter(direction, active, pane))
|
||||||
|
.min(Comparator.comparingDouble(pane -> distance(active, pane)))
|
||||||
|
.orElse(null);
|
||||||
|
if (target != null) {
|
||||||
|
setActive(target);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void toggleFloating() {
|
||||||
|
if (floating.isEmpty()) {
|
||||||
|
createFloatingPane();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (floatingVisible) {
|
||||||
|
floatingVisible = false;
|
||||||
|
if (floating.contains(active)) {
|
||||||
|
setActive(tiled.get(0));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
floatingVisible = true;
|
||||||
|
setActive(floating.contains(lastFocusedFloating) ? lastFocusedFloating : floating.get(floating.size() - 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Adds a floating pane while the floating group is shown, otherwise a tiled pane. */
|
||||||
|
void createPane() {
|
||||||
|
if (floatingVisible) {
|
||||||
|
createFloatingPane();
|
||||||
|
} else {
|
||||||
|
TerminalPane pane = openPane(false);
|
||||||
|
tiled.add(pane);
|
||||||
|
setActive(pane);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void nextFloatingPane() {
|
||||||
|
if (floating.isEmpty()) {
|
||||||
|
createFloatingPane();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
floatingVisible = true;
|
||||||
|
int current = floating.indexOf(active); // -1 when the active pane is tiled
|
||||||
|
setActive(floating.get((current + 1 + floating.size()) % floating.size()));
|
||||||
|
}
|
||||||
|
|
||||||
|
void closeActivePane() {
|
||||||
|
TerminalPane closing = active;
|
||||||
|
boolean wasFloating = floating.remove(closing);
|
||||||
|
if (!wasFloating) {
|
||||||
|
tiled.remove(closing);
|
||||||
|
}
|
||||||
|
if (closing == lastFocusedFloating) {
|
||||||
|
lastFocusedFloating = null;
|
||||||
|
}
|
||||||
|
closing.close();
|
||||||
|
|
||||||
|
if (tiled.isEmpty() && floating.isEmpty()) {
|
||||||
|
active = null; // tab is now empty; the compositor drops it
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always keep a tiled base: if the last tiled pane just closed, promote a floating one
|
||||||
|
// (preferring the last focused).
|
||||||
|
if (tiled.isEmpty()) {
|
||||||
|
TerminalPane promote = floating.contains(lastFocusedFloating) ? lastFocusedFloating : floating.get(0);
|
||||||
|
var promoteIndex = floating.indexOf(promote);
|
||||||
|
var nextFocussed = promoteIndex == 0 ? 0 : promoteIndex - 1;
|
||||||
|
floating.remove(promote);
|
||||||
|
tiled.add(promote);
|
||||||
|
if (promote == lastFocusedFloating) {
|
||||||
|
lastFocusedFloating = null;
|
||||||
|
if (!floating.isEmpty()) {
|
||||||
|
lastFocusedFloating = floating.isEmpty() ? null : floating.get(nextFocussed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (floating.isEmpty()) {
|
||||||
|
floatingVisible = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
setActive(wasFloating && floatingVisible ? floating.get(floating.size() - 1) : tiled.get(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void setActive(TerminalPane pane) {
|
||||||
|
active = pane;
|
||||||
|
if (floating.contains(pane)) {
|
||||||
|
lastFocusedFloating = pane;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void createFloatingPane() {
|
||||||
|
TerminalPane pane = openPane(true);
|
||||||
|
floating.add(pane);
|
||||||
|
floatingVisible = true;
|
||||||
|
setActive(pane);
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean navigateFloatingStack(Direction direction) {
|
||||||
|
if (floating.size() < 2) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
int current = floating.indexOf(active);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
setActive(floating.get(next));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isFocusable(TerminalPane pane) {
|
||||||
|
return tiled.contains(pane) || (floatingVisible && floating.contains(pane));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Stream<TerminalPane> focusable() {
|
||||||
|
return floatingVisible ? Stream.concat(tiled.stream(), floating.stream()) : tiled.stream();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void markContentChanged() {
|
||||||
|
contentVersion++;
|
||||||
|
}
|
||||||
|
|
||||||
|
private TerminalPane openPane(boolean asFloating) {
|
||||||
|
double availHeight = lastHeight - lastTopInset;
|
||||||
|
double widthPx;
|
||||||
|
double heightPx;
|
||||||
|
if (asFloating) {
|
||||||
|
widthPx = Math.max(420, lastWidth * 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean directionFilter(Direction direction, TerminalPane current, TerminalPane candidate) {
|
||||||
|
double currentCenterX = current.x() + current.width() / 2.0;
|
||||||
|
double currentCenterY = current.y() + current.height() / 2.0;
|
||||||
|
double candidateCenterX = candidate.x() + candidate.width() / 2.0;
|
||||||
|
double candidateCenterY = candidate.y() + candidate.height() / 2.0;
|
||||||
|
|
||||||
|
return switch (direction) {
|
||||||
|
case LEFT -> candidateCenterX < currentCenterX;
|
||||||
|
case DOWN -> candidateCenterY > currentCenterY;
|
||||||
|
case UP -> candidateCenterY < currentCenterY;
|
||||||
|
case RIGHT -> candidateCenterX > currentCenterX;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double distance(TerminalPane current, TerminalPane candidate) {
|
||||||
|
double dx = (current.x() + current.width() / 2.0) - (candidate.x() + candidate.width() / 2.0);
|
||||||
|
double dy = (current.y() + current.height() / 2.0) - (candidate.y() + candidate.height() / 2.0);
|
||||||
|
return Math.sqrt(dx * dx + dy * dy);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void close() {
|
||||||
|
tiled.forEach(TerminalPane::close);
|
||||||
|
floating.forEach(TerminalPane::close);
|
||||||
|
tiled.clear();
|
||||||
|
floating.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,788 +0,0 @@
|
|||||||
package com.gregor.jprototerm;
|
|
||||||
|
|
||||||
import dev.jlibghostty.KittyImageCompression;
|
|
||||||
import dev.jlibghostty.KittyImageFormat;
|
|
||||||
import dev.jlibghostty.KittyImageSnapshot;
|
|
||||||
import dev.jlibghostty.KittyPlacement;
|
|
||||||
import dev.jlibghostty.KittyPlacementLayer;
|
|
||||||
import dev.jlibghostty.KittyPlaceholder;
|
|
||||||
import dev.jlibghostty.KittyRenderInfo;
|
|
||||||
import dev.jlibghostty.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.List;
|
|
||||||
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<KittyImageKey, Image> kittyImageCache = new HashMap<>();
|
|
||||||
private final Map<TerminalPane, PaneRenderCache> paneRenderCache = new HashMap<>();
|
|
||||||
private String fontFamily;
|
|
||||||
private double fontSize;
|
|
||||||
private Font cachedFont;
|
|
||||||
private FontMetrics cachedMetrics;
|
|
||||||
private String cachedFontFamily;
|
|
||||||
private double cachedFontSize;
|
|
||||||
private String lastRenderKey;
|
|
||||||
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;
|
|
||||||
cachedFont = null;
|
|
||||||
cachedMetrics = null;
|
|
||||||
paneRenderCache.clear();
|
|
||||||
lastRenderKey = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void render() {
|
|
||||||
double width = canvas.getWidth();
|
|
||||||
double height = canvas.getHeight();
|
|
||||||
workspace.layout(width, height);
|
|
||||||
Font font = currentFont();
|
|
||||||
FontMetrics metrics = currentFontMetrics();
|
|
||||||
List<TerminalPane> panes = workspace.panes();
|
|
||||||
|
|
||||||
String renderKey = renderKey(width, height, metrics, panes);
|
|
||||||
if (renderKey.equals(lastRenderKey)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
lastRenderKey = renderKey;
|
|
||||||
|
|
||||||
GraphicsContext gc = canvas.getGraphicsContext2D();
|
|
||||||
gc.setFill(Color.rgb(16, 16, 18));
|
|
||||||
gc.fillRect(0, 0, width, height);
|
|
||||||
gc.setFontSmoothingType(FontSmoothingType.LCD);
|
|
||||||
|
|
||||||
paneRenderCache.keySet().removeIf(pane -> !panes.contains(pane));
|
|
||||||
for (TerminalPane pane : panes) {
|
|
||||||
drawPane(gc, pane, font, metrics);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void drawPane(GraphicsContext gc, TerminalPane pane, Font font, FontMetrics metrics) {
|
|
||||||
if (config.kittyGraphics() && paneHasKittyGraphics(pane)) {
|
|
||||||
paneRenderCache.remove(pane);
|
|
||||||
gc.save();
|
|
||||||
gc.beginPath();
|
|
||||||
gc.rect(pane.x(), pane.y(), pane.width(), pane.height());
|
|
||||||
gc.clip();
|
|
||||||
drawPaneContent(gc, pane, font, metrics, pane.x(), pane.y(), pane.width(), pane.height(), false);
|
|
||||||
gc.restore();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
PaneRenderCache cache = paneRenderCache.computeIfAbsent(pane, ignored -> new PaneRenderCache());
|
|
||||||
String cacheKey = paneCacheKey(pane, metrics);
|
|
||||||
int imageWidth = Math.max(1, (int) Math.ceil(pane.width()));
|
|
||||||
int imageHeight = Math.max(1, (int) Math.ceil(pane.height()));
|
|
||||||
if (cache.image == null || cache.canvas == null || cache.imageWidth != imageWidth || cache.imageHeight != imageHeight || !cacheKey.equals(cache.key)) {
|
|
||||||
cache.canvas = new Canvas(imageWidth, imageHeight);
|
|
||||||
drawPaneContent(cache.canvas.getGraphicsContext2D(), pane, font, metrics, 0.0, 0.0, imageWidth, imageHeight, true);
|
|
||||||
cache.image = new WritableImage(imageWidth, imageHeight);
|
|
||||||
cache.canvas.snapshot(null, cache.image);
|
|
||||||
cache.imageWidth = imageWidth;
|
|
||||||
cache.imageHeight = imageHeight;
|
|
||||||
cache.key = cacheKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
gc.drawImage(cache.image, pane.x(), pane.y());
|
|
||||||
}
|
|
||||||
|
|
||||||
private void drawPaneContent(
|
|
||||||
GraphicsContext gc,
|
|
||||||
TerminalPane pane,
|
|
||||||
Font font,
|
|
||||||
FontMetrics metrics,
|
|
||||||
double x,
|
|
||||||
double y,
|
|
||||||
double width,
|
|
||||||
double height,
|
|
||||||
boolean clear
|
|
||||||
) {
|
|
||||||
if (clear) {
|
|
||||||
gc.clearRect(x, y, width, height);
|
|
||||||
}
|
|
||||||
gc.setFontSmoothingType(FontSmoothingType.LCD);
|
|
||||||
if (pane.floating()) {
|
|
||||||
gc.setGlobalAlpha(0.96);
|
|
||||||
}
|
|
||||||
gc.setFill(Color.rgb(9, 10, 12));
|
|
||||||
gc.fillRect(x, y, width, height);
|
|
||||||
gc.setGlobalAlpha(1.0);
|
|
||||||
|
|
||||||
gc.setStroke(workspace.isActive(pane) ? Color.rgb(87, 166, 255) : Color.rgb(52, 57, 65));
|
|
||||||
gc.setLineWidth(workspace.isActive(pane) ? 2.0 : 1.0);
|
|
||||||
gc.strokeRect(x + 0.5, y + 0.5, width - 1.0, height - 1.0);
|
|
||||||
|
|
||||||
gc.setFont(font);
|
|
||||||
|
|
||||||
int columns = Math.max(1, (int) ((width - 24.0) / metrics.cellWidth));
|
|
||||||
int rows = Math.max(1, (int) ((height - 24.0) / metrics.lineHeight));
|
|
||||||
pane.resize(columns, rows, (int) Math.round(metrics.cellWidth), (int) Math.round(metrics.lineHeight));
|
|
||||||
|
|
||||||
double left = x + 12.0;
|
|
||||||
double top = y + 12.0;
|
|
||||||
double baseline = top + metrics.baselineOffset;
|
|
||||||
|
|
||||||
RenderStateSnapshot snapshot = pane.renderSnapshot();
|
|
||||||
Map<KittyPlaceholderKey, KittyPlaceholderBounds> placeholderBounds = config.kittyGraphics()
|
|
||||||
? kittyPlaceholderBounds(snapshot)
|
|
||||||
: Map.of();
|
|
||||||
|
|
||||||
if (config.kittyGraphics()) {
|
|
||||||
drawKittyGraphics(gc, pane, KittyPlacementLayer.BELOW_TEXT, placeholderBounds, left, top, metrics.cellWidth, metrics.lineHeight);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (snapshot != null) {
|
|
||||||
for (RenderRow row : snapshot.renderRows()) {
|
|
||||||
drawRow(gc, row, left, top, baseline, metrics.cellWidth, metrics.lineHeight);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (snapshot != null) {
|
|
||||||
drawCursor(gc, snapshot, left, top, metrics.cellWidth, metrics.lineHeight);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (config.kittyGraphics()) {
|
|
||||||
drawKittyGraphics(gc, pane, KittyPlacementLayer.ABOVE_TEXT, placeholderBounds, left, top, metrics.cellWidth, metrics.lineHeight);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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();
|
|
||||||
|
|
||||||
Text cell = new Text("M");
|
|
||||||
cell.setFont(font);
|
|
||||||
double cellWidth = Math.max(1.0, cell.getLayoutBounds().getWidth());
|
|
||||||
return new FontMetrics(cellWidth, lineHeight, baselineOffset);
|
|
||||||
}
|
|
||||||
|
|
||||||
private Font currentFont() {
|
|
||||||
if (cachedFont == null || !fontFamily.equals(cachedFontFamily) || fontSize != cachedFontSize) {
|
|
||||||
cachedFont = Font.font(fontFamily, fontSize);
|
|
||||||
cachedMetrics = null;
|
|
||||||
cachedFontFamily = fontFamily;
|
|
||||||
cachedFontSize = fontSize;
|
|
||||||
}
|
|
||||||
return cachedFont;
|
|
||||||
}
|
|
||||||
|
|
||||||
private FontMetrics currentFontMetrics() {
|
|
||||||
if (cachedMetrics == null) {
|
|
||||||
cachedMetrics = measureFontMetrics(currentFont());
|
|
||||||
}
|
|
||||||
return cachedMetrics;
|
|
||||||
}
|
|
||||||
|
|
||||||
private String renderKey(double width, double height, FontMetrics metrics, List<TerminalPane> panes) {
|
|
||||||
StringBuilder builder = new StringBuilder();
|
|
||||||
builder.append(width).append(':')
|
|
||||||
.append(height).append(':')
|
|
||||||
.append(workspace.version()).append(':')
|
|
||||||
.append(fontFamily).append(':')
|
|
||||||
.append(fontSize).append(':')
|
|
||||||
.append(metrics.cellWidth).append(':')
|
|
||||||
.append(metrics.lineHeight);
|
|
||||||
for (TerminalPane pane : panes) {
|
|
||||||
builder.append('|')
|
|
||||||
.append(System.identityHashCode(pane)).append(',')
|
|
||||||
.append(pane.renderVersion()).append(',')
|
|
||||||
.append(workspace.isActive(pane)).append(',')
|
|
||||||
.append(pane.x()).append(',')
|
|
||||||
.append(pane.y()).append(',')
|
|
||||||
.append(pane.width()).append(',')
|
|
||||||
.append(pane.height());
|
|
||||||
}
|
|
||||||
return builder.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
private String paneCacheKey(TerminalPane pane, FontMetrics metrics) {
|
|
||||||
return pane.renderVersion()
|
|
||||||
+ ":" + workspace.isActive(pane)
|
|
||||||
+ ":" + pane.width()
|
|
||||||
+ ":" + pane.height()
|
|
||||||
+ ":" + fontFamily
|
|
||||||
+ ":" + fontSize
|
|
||||||
+ ":" + metrics.cellWidth
|
|
||||||
+ ":" + metrics.lineHeight
|
|
||||||
+ ":" + config.kittyGraphics();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Map<KittyPlaceholderKey, KittyPlaceholderBounds> kittyPlaceholderBounds(RenderStateSnapshot snapshot) {
|
|
||||||
if (snapshot == null) {
|
|
||||||
return Map.of();
|
|
||||||
}
|
|
||||||
|
|
||||||
Map<KittyPlaceholderKey, KittyPlaceholderBounds> result = new HashMap<>();
|
|
||||||
for (RenderRow row : snapshot.renderRows()) {
|
|
||||||
for (RenderCell cell : row.cells()) {
|
|
||||||
cell.kittyPlaceholder().ifPresent(placeholder -> {
|
|
||||||
KittyPlaceholderKey key = new KittyPlaceholderKey(placeholder.imageId(), placeholder.placementId());
|
|
||||||
result.computeIfAbsent(key, ignored -> new KittyPlaceholderBounds()).include(row.row(), cell.column(), placeholder);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
private 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 = currentFontMetrics();
|
|
||||||
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()) {
|
|
||||||
if (cell.kittyPlaceholder().isPresent()) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
double x = left + (cell.column() * cellWidth);
|
|
||||||
double cellTop = top + (row.row() * lineHeight);
|
|
||||||
cell.background().ifPresent(background -> {
|
|
||||||
gc.setFill(toFxColor(background));
|
|
||||||
gc.fillRect(x, cellTop, cellWidth, lineHeight);
|
|
||||||
});
|
|
||||||
if (cell.selected()) {
|
|
||||||
gc.setFill(SELECTED_BACKGROUND);
|
|
||||||
gc.fillRect(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 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,
|
|
||||||
KittyPlacementLayer layer,
|
|
||||||
Map<KittyPlaceholderKey, KittyPlaceholderBounds> placeholderBounds,
|
|
||||||
double originX,
|
|
||||||
double originY,
|
|
||||||
double cellWidth,
|
|
||||||
double lineHeight
|
|
||||||
) {
|
|
||||||
pane.kittyGraphics().ifPresent(graphics -> {
|
|
||||||
for (KittyPlacement placement : graphics.placements(layer)) {
|
|
||||||
Image image = imageFor(placement);
|
|
||||||
if (image == null) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (placement.virtual()) {
|
|
||||||
drawVirtualKittyPlacement(gc, placement, image, placeholderBounds, originX, originY, cellWidth, lineHeight);
|
|
||||||
} else {
|
|
||||||
drawPinnedKittyPlacement(gc, placement, image, originX, originY, cellWidth, lineHeight);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void drawPinnedKittyPlacement(
|
|
||||||
GraphicsContext gc,
|
|
||||||
KittyPlacement placement,
|
|
||||||
Image image,
|
|
||||||
double originX,
|
|
||||||
double originY,
|
|
||||||
double cellWidth,
|
|
||||||
double lineHeight
|
|
||||||
) {
|
|
||||||
KittyRenderInfo renderInfo = placement.renderInfo().orElse(null);
|
|
||||||
if (renderInfo == null || !renderInfo.viewportVisible()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
double sourceX = renderInfo.sourceX();
|
|
||||||
double sourceY = renderInfo.sourceY();
|
|
||||||
double sourceWidth = renderInfo.sourceWidth();
|
|
||||||
double sourceHeight = renderInfo.sourceHeight();
|
|
||||||
if (sourceWidth <= 0.0 || sourceHeight <= 0.0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
double x = originX + (renderInfo.viewportColumn() * cellWidth) + placement.xOffset();
|
|
||||||
double y = originY + (renderInfo.viewportRow() * lineHeight) + placement.yOffset();
|
|
||||||
double width = renderInfo.pixelWidth() > 0 ? renderInfo.pixelWidth() : renderInfo.gridColumns() * cellWidth;
|
|
||||||
double height = renderInfo.pixelHeight() > 0 ? renderInfo.pixelHeight() : renderInfo.gridRows() * lineHeight;
|
|
||||||
if (width <= 0.0 || height <= 0.0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
gc.drawImage(image, sourceX, sourceY, sourceWidth, sourceHeight, x, y, width, height);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void drawVirtualKittyPlacement(
|
|
||||||
GraphicsContext gc,
|
|
||||||
KittyPlacement placement,
|
|
||||||
Image image,
|
|
||||||
Map<KittyPlaceholderKey, KittyPlaceholderBounds> placeholderBounds,
|
|
||||||
double originX,
|
|
||||||
double originY,
|
|
||||||
double cellWidth,
|
|
||||||
double lineHeight
|
|
||||||
) {
|
|
||||||
KittyPlaceholderBounds bounds = placeholderBounds.get(new KittyPlaceholderKey(placement.imageId(), placement.placementId()));
|
|
||||||
if (bounds == null) {
|
|
||||||
bounds = placeholderBounds.get(new KittyPlaceholderKey(placement.imageId(), 0));
|
|
||||||
}
|
|
||||||
if (bounds == null && placement.placementId() == 0) {
|
|
||||||
bounds = placeholderBounds.entrySet().stream()
|
|
||||||
.filter(entry -> entry.getKey().imageId() == placement.imageId())
|
|
||||||
.map(Map.Entry::getValue)
|
|
||||||
.findFirst()
|
|
||||||
.orElse(null);
|
|
||||||
}
|
|
||||||
if (bounds == null || bounds.isEmpty()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
SourceRect source = sourceRect(placement, image);
|
|
||||||
if (source.width() <= 0.0 || source.height() <= 0.0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
long gridColumns = gridColumns(placement, bounds);
|
|
||||||
long gridRows = gridRows(placement, bounds);
|
|
||||||
double sourceCellWidth = source.width() / Math.max(1L, gridColumns);
|
|
||||||
double sourceCellHeight = source.height() / Math.max(1L, gridRows);
|
|
||||||
|
|
||||||
double sourceX = source.x() + (bounds.minSourceColumn * sourceCellWidth);
|
|
||||||
double sourceY = source.y() + (bounds.minSourceRow * sourceCellHeight);
|
|
||||||
double sourceWidth = bounds.sourceColumns() * sourceCellWidth;
|
|
||||||
double sourceHeight = bounds.sourceRows() * sourceCellHeight;
|
|
||||||
double x = originX + (bounds.minColumn * cellWidth);
|
|
||||||
double y = originY + (bounds.minRow * lineHeight);
|
|
||||||
double availableWidth = bounds.columns() * cellWidth;
|
|
||||||
double availableHeight = bounds.rows() * lineHeight;
|
|
||||||
|
|
||||||
if (sourceWidth <= 0.0 || sourceHeight <= 0.0 || availableWidth <= 0.0 || availableHeight <= 0.0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
double scale = Math.min(availableWidth / sourceWidth, availableHeight / sourceHeight);
|
|
||||||
double width = sourceWidth * scale;
|
|
||||||
double height = sourceHeight * scale;
|
|
||||||
gc.drawImage(image, sourceX, sourceY, sourceWidth, sourceHeight, x, y, width, height);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static long gridColumns(KittyPlacement placement, KittyPlaceholderBounds bounds) {
|
|
||||||
if (placement.columns() > 0) {
|
|
||||||
return placement.columns();
|
|
||||||
}
|
|
||||||
return Math.max(bounds.maxSourceColumn + 1, bounds.sourceColumns());
|
|
||||||
}
|
|
||||||
|
|
||||||
private static long gridRows(KittyPlacement placement, KittyPlaceholderBounds bounds) {
|
|
||||||
if (placement.rows() > 0) {
|
|
||||||
return placement.rows();
|
|
||||||
}
|
|
||||||
return Math.max(bounds.maxSourceRow + 1, bounds.sourceRows());
|
|
||||||
}
|
|
||||||
|
|
||||||
private static SourceRect sourceRect(KittyPlacement placement, Image image) {
|
|
||||||
double sourceX = placement.sourceX();
|
|
||||||
double sourceY = placement.sourceY();
|
|
||||||
double sourceWidth = placement.sourceWidth() > 0 ? placement.sourceWidth() : image.getWidth() - sourceX;
|
|
||||||
double sourceHeight = placement.sourceHeight() > 0 ? placement.sourceHeight() : image.getHeight() - sourceY;
|
|
||||||
return new SourceRect(sourceX, sourceY, Math.min(sourceWidth, image.getWidth() - sourceX), Math.min(sourceHeight, image.getHeight() - sourceY));
|
|
||||||
}
|
|
||||||
|
|
||||||
private Image imageFor(KittyPlacement placement) {
|
|
||||||
return placement.image().map(snapshot -> {
|
|
||||||
byte[] data = snapshot.data();
|
|
||||||
KittyImageKey key = KittyImageKey.of(snapshot, data);
|
|
||||||
Image cached = kittyImageCache.get(key);
|
|
||||||
if (cached != null) {
|
|
||||||
return cached;
|
|
||||||
}
|
|
||||||
|
|
||||||
kittyImageCache.keySet().removeIf(existing -> existing.id() == snapshot.id());
|
|
||||||
Image decoded = decodeImage(snapshot, data);
|
|
||||||
if (decoded != null) {
|
|
||||||
kittyImageCache.put(key, decoded);
|
|
||||||
}
|
|
||||||
return decoded;
|
|
||||||
}).orElse(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
private boolean paneHasKittyGraphics(TerminalPane pane) {
|
|
||||||
return pane.kittyGraphics()
|
|
||||||
.map(graphics -> !graphics.placements().isEmpty())
|
|
||||||
.orElse(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
private Image decodeImage(KittyImageSnapshot snapshot, byte[] data) {
|
|
||||||
if (snapshot.compression() != KittyImageCompression.NONE) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (snapshot.format() == KittyImageFormat.PNG) {
|
|
||||||
return new Image(new ByteArrayInputStream(data));
|
|
||||||
}
|
|
||||||
|
|
||||||
int width = Math.toIntExact(snapshot.width());
|
|
||||||
int height = Math.toIntExact(snapshot.height());
|
|
||||||
WritableImage image = new WritableImage(width, height);
|
|
||||||
|
|
||||||
if (snapshot.format() == KittyImageFormat.RGBA) {
|
|
||||||
image.getPixelWriter().setPixels(0, 0, width, height, PixelFormat.getByteBgraInstance(), rgbaToBgra(data), 0, width * 4);
|
|
||||||
} else if (snapshot.format() == KittyImageFormat.RGB) {
|
|
||||||
image.getPixelWriter().setPixels(0, 0, width, height, PixelFormat.getByteRgbInstance(), data, 0, width * 3);
|
|
||||||
}
|
|
||||||
return image;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static byte[] rgbaToBgra(byte[] rgba) {
|
|
||||||
byte[] bgra = new byte[rgba.length];
|
|
||||||
for (int i = 0; i + 3 < rgba.length; i += 4) {
|
|
||||||
bgra[i] = rgba[i + 2];
|
|
||||||
bgra[i + 1] = rgba[i + 1];
|
|
||||||
bgra[i + 2] = rgba[i];
|
|
||||||
bgra[i + 3] = rgba[i + 3];
|
|
||||||
}
|
|
||||||
return bgra;
|
|
||||||
}
|
|
||||||
|
|
||||||
private record FontMetrics(double cellWidth, double lineHeight, double baselineOffset) {
|
|
||||||
}
|
|
||||||
|
|
||||||
private record MouseTarget(MouseEncoderSize size, long screenWidth, long screenHeight) {
|
|
||||||
}
|
|
||||||
|
|
||||||
private record KittyImageKey(long id, long number, long width, long height, KittyImageFormat format, int dataLength, long fingerprint) {
|
|
||||||
private static KittyImageKey of(KittyImageSnapshot snapshot, byte[] data) {
|
|
||||||
return new KittyImageKey(
|
|
||||||
snapshot.id(),
|
|
||||||
snapshot.number(),
|
|
||||||
snapshot.width(),
|
|
||||||
snapshot.height(),
|
|
||||||
snapshot.format(),
|
|
||||||
data.length,
|
|
||||||
fingerprint(data)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static long fingerprint(byte[] data) {
|
|
||||||
long hash = 0xcbf29ce484222325L;
|
|
||||||
for (byte value : data) {
|
|
||||||
hash ^= Byte.toUnsignedInt(value);
|
|
||||||
hash *= 0x100000001b3L;
|
|
||||||
}
|
|
||||||
return hash;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private record KittyPlaceholderKey(long imageId, long placementId) {
|
|
||||||
}
|
|
||||||
|
|
||||||
private record SourceRect(double x, double y, double width, double height) {
|
|
||||||
}
|
|
||||||
|
|
||||||
private static final class KittyPlaceholderBounds {
|
|
||||||
private int minRow = Integer.MAX_VALUE;
|
|
||||||
private int maxRow = Integer.MIN_VALUE;
|
|
||||||
private int minColumn = Integer.MAX_VALUE;
|
|
||||||
private int maxColumn = Integer.MIN_VALUE;
|
|
||||||
private long minSourceRow = Long.MAX_VALUE;
|
|
||||||
private long maxSourceRow = Long.MIN_VALUE;
|
|
||||||
private long minSourceColumn = Long.MAX_VALUE;
|
|
||||||
private long maxSourceColumn = Long.MIN_VALUE;
|
|
||||||
|
|
||||||
private void include(int row, int column, KittyPlaceholder placeholder) {
|
|
||||||
minRow = Math.min(minRow, row);
|
|
||||||
maxRow = Math.max(maxRow, row);
|
|
||||||
minColumn = Math.min(minColumn, column);
|
|
||||||
maxColumn = Math.max(maxColumn, column);
|
|
||||||
minSourceRow = Math.min(minSourceRow, placeholder.sourceRow());
|
|
||||||
maxSourceRow = Math.max(maxSourceRow, placeholder.sourceRow());
|
|
||||||
minSourceColumn = Math.min(minSourceColumn, placeholder.sourceColumn());
|
|
||||||
maxSourceColumn = Math.max(maxSourceColumn, placeholder.sourceColumn());
|
|
||||||
}
|
|
||||||
|
|
||||||
private boolean isEmpty() {
|
|
||||||
return minRow == Integer.MAX_VALUE;
|
|
||||||
}
|
|
||||||
|
|
||||||
private int rows() {
|
|
||||||
return maxRow - minRow + 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
private int columns() {
|
|
||||||
return maxColumn - minColumn + 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
private long sourceRows() {
|
|
||||||
return maxSourceRow - minSourceRow + 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
private long sourceColumns() {
|
|
||||||
return maxSourceColumn - minSourceColumn + 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static final class PaneRenderCache {
|
|
||||||
private Canvas canvas;
|
|
||||||
private WritableImage image;
|
|
||||||
private int imageWidth;
|
|
||||||
private int imageHeight;
|
|
||||||
private String key;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
86
src/main/java/com/gregor/jprototerm/TerminalMetrics.java
Normal file
86
src/main/java/com/gregor/jprototerm/TerminalMetrics.java
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
package com.gregor.jprototerm;
|
||||||
|
|
||||||
|
import javafx.scene.text.Font;
|
||||||
|
import javafx.scene.text.Text;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cell geometry shared by the {@link Compositor} and every {@link TerminalPane}.
|
||||||
|
*
|
||||||
|
* <p>The nominal cell width/height come from measuring the font, but a grid can't use
|
||||||
|
* fractional cells, so the measured size is snapped to whole (logical) pixels here — that
|
||||||
|
* snapping is why the value isn't purely a property of the font. The compositor owns the
|
||||||
|
* single instance (it holds the canvas, which is the pixel context), hands it to panes so
|
||||||
|
* they can turn their rect into a column/row count themselves, and re-measures it on a font
|
||||||
|
* change so every pane observes the new geometry through the shared reference.
|
||||||
|
*/
|
||||||
|
public final class TerminalMetrics {
|
||||||
|
/** Inset, in pixels, between a pane's edge and its content on every side. */
|
||||||
|
public static final double PADDING = 12.0;
|
||||||
|
|
||||||
|
private String fontFamily;
|
||||||
|
private double fontSize;
|
||||||
|
private Font font;
|
||||||
|
private double cellWidth;
|
||||||
|
private double lineHeight;
|
||||||
|
private double baselineOffset;
|
||||||
|
|
||||||
|
public TerminalMetrics(String fontFamily, double fontSize) {
|
||||||
|
setFont(fontFamily, fontSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setFont(String fontFamily, double fontSize) {
|
||||||
|
this.fontFamily = fontFamily;
|
||||||
|
this.fontSize = fontSize;
|
||||||
|
this.font = Font.font(fontFamily, fontSize);
|
||||||
|
measure(font);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String fontFamily() {
|
||||||
|
return fontFamily;
|
||||||
|
}
|
||||||
|
|
||||||
|
public double fontSize() {
|
||||||
|
return fontSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Font font() {
|
||||||
|
return font;
|
||||||
|
}
|
||||||
|
|
||||||
|
public double cellWidth() {
|
||||||
|
return cellWidth;
|
||||||
|
}
|
||||||
|
|
||||||
|
public double lineHeight() {
|
||||||
|
return lineHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
public double baselineOffset() {
|
||||||
|
return baselineOffset;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Columns that fit in a pane of the given pixel width (after subtracting the padding). */
|
||||||
|
public int columnsFor(double widthPx) {
|
||||||
|
return Math.max(1, (int) ((widthPx - 2 * PADDING) / cellWidth));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rows that fit in a pane of the given pixel height (after subtracting the padding). */
|
||||||
|
public int rowsFor(double heightPx) {
|
||||||
|
return Math.max(1, (int) ((heightPx - 2 * PADDING) / lineHeight));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void measure(Font font) {
|
||||||
|
Text text = new Text("┃MgÅjy");
|
||||||
|
text.setFont(font);
|
||||||
|
// Snap the cell size to whole pixels so cells tile on integer boundaries. Fractional
|
||||||
|
// cell metrics put every cell edge on a sub-pixel position, leaving anti-aliased
|
||||||
|
// seams that show up as a faint grid behind the themed cell backgrounds. Rounding
|
||||||
|
// leaves a few pixels of unused space at the right/bottom edge, which is fine.
|
||||||
|
this.lineHeight = Math.max(1.0, Math.round(text.getLayoutBounds().getHeight()));
|
||||||
|
this.baselineOffset = -text.getLayoutBounds().getMinY();
|
||||||
|
|
||||||
|
Text cell = new Text("M");
|
||||||
|
cell.setFont(font);
|
||||||
|
this.cellWidth = Math.max(1.0, Math.round(cell.getLayoutBounds().getWidth()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,27 +1,46 @@
|
|||||||
package com.gregor.jprototerm;
|
package com.gregor.jprototerm;
|
||||||
|
|
||||||
|
import dev.jlibghostty.DeviceAttributes;
|
||||||
import dev.jlibghostty.Ghostty;
|
import dev.jlibghostty.Ghostty;
|
||||||
import dev.jlibghostty.KittyGraphics;
|
import dev.jlibghostty.KittyGraphics;
|
||||||
import dev.jlibghostty.MouseAction;
|
import dev.jlibghostty.MouseAction;
|
||||||
import dev.jlibghostty.MouseEncoder;
|
import dev.jlibghostty.MouseEncoder;
|
||||||
import dev.jlibghostty.MouseEncoderSize;
|
import dev.jlibghostty.MouseEncoderSize;
|
||||||
import dev.jlibghostty.MouseInput;
|
import dev.jlibghostty.MouseInput;
|
||||||
|
import dev.jlibghostty.RenderState;
|
||||||
import dev.jlibghostty.RenderStateSnapshot;
|
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 dev.jlibghostty.DeviceAttributes;
|
import javafx.scene.canvas.GraphicsContext;
|
||||||
|
import javafx.scene.shape.Shape;
|
||||||
|
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.concurrent.atomic.AtomicReference;
|
|
||||||
|
|
||||||
public final class TerminalPane implements AutoCloseable {
|
/**
|
||||||
|
* One terminal: owns its ghostty {@link Terminal}, the {@link ShellSession}/pty driving it,
|
||||||
|
* and its on-screen geometry and grid. It does not draw itself — it is a {@link RenderTarget}
|
||||||
|
* that a {@link TerminalRenderer} paints. {@link #paintFull}/{@link #paintIncremental} are the
|
||||||
|
* only rendering API exposed to the {@link Compositor}, and they just delegate to that
|
||||||
|
* renderer; the compositor decides z-order and which rect each pane occupies.
|
||||||
|
*/
|
||||||
|
public final class TerminalPane implements AutoCloseable, RenderTarget {
|
||||||
private final Terminal terminal;
|
private final Terminal terminal;
|
||||||
|
private final TerminalMetrics metrics;
|
||||||
|
private final boolean kittyEnabled;
|
||||||
|
// Run on every content change so the owning tab can bump its content version — the
|
||||||
|
// compositor's O(1) "did the current tab change?" gate.
|
||||||
|
private final Runnable onContentChange;
|
||||||
|
private final TerminalRenderer renderer;
|
||||||
private final MouseEncoder mouseEncoder = new MouseEncoder();
|
private final MouseEncoder mouseEncoder = new MouseEncoder();
|
||||||
private final AtomicReference<RenderStateSnapshot> renderSnapshot = new AtomicReference<>();
|
// A persistent render state (reused across frames) is what makes ghostty's per-row dirty
|
||||||
|
// tracking meaningful: update() accumulates dirty since the last resetDirty().
|
||||||
|
private final RenderState renderState = new RenderState();
|
||||||
|
private RenderStateSnapshot cachedSnapshot;
|
||||||
private ShellSession session;
|
private ShellSession session;
|
||||||
private boolean floating;
|
// Clip region for rendering (rect minus the panes covering this one), set at layout time;
|
||||||
private boolean visible = true;
|
// null means clip to the plain bounds. See RenderTarget#clip().
|
||||||
|
private Shape clip;
|
||||||
private double x;
|
private double x;
|
||||||
private double y;
|
private double y;
|
||||||
private double width;
|
private double width;
|
||||||
@@ -30,22 +49,50 @@ public final class TerminalPane implements AutoCloseable {
|
|||||||
private int rows;
|
private int rows;
|
||||||
private int pixelWidth;
|
private int pixelWidth;
|
||||||
private int pixelHeight;
|
private int pixelHeight;
|
||||||
private long renderVersion;
|
private long contentVersion;
|
||||||
|
private long snapshotVersion = -1;
|
||||||
|
|
||||||
private TerminalPane(Terminal terminal, int columns, int rows) {
|
private TerminalPane(Terminal terminal, TerminalMetrics metrics, boolean kittyEnabled,
|
||||||
|
Runnable onContentChange, TerminalRenderer renderer, int columns, int rows) {
|
||||||
this.terminal = terminal;
|
this.terminal = terminal;
|
||||||
|
this.metrics = metrics;
|
||||||
|
this.kittyEnabled = kittyEnabled;
|
||||||
|
this.onContentChange = onContentChange;
|
||||||
|
this.renderer = renderer;
|
||||||
this.columns = columns;
|
this.columns = columns;
|
||||||
this.rows = rows;
|
this.rows = rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static TerminalPane create(int columns, int rows, long maxScrollback) {
|
/**
|
||||||
Terminal terminal = Ghostty.open(new TerminalOptions(columns, rows, maxScrollback));
|
* Opens a pane sized to fit the given pixel rect: the shared cell metrics decide how many
|
||||||
|
* columns and rows fit, and that grid is handed to ghostty and the shell at start-up. A
|
||||||
|
* non-positive size falls back to the configured default grid (used before the first
|
||||||
|
* layout, when no rect is known yet). The pane owns the shell session it starts and runs
|
||||||
|
* {@code onContentChange} on every content change.
|
||||||
|
*/
|
||||||
|
public static TerminalPane create(AppConfig config, TerminalMetrics metrics, Runnable onContentChange, double widthPx, double heightPx) {
|
||||||
|
int columns = widthPx > 0 ? metrics.columnsFor(widthPx) : config.columns();
|
||||||
|
int rows = heightPx > 0 ? metrics.rowsFor(heightPx) : config.rows();
|
||||||
|
Terminal terminal = Ghostty.open(new TerminalOptions(columns, rows, config.maxScrollback()));
|
||||||
terminal.setDeviceAttributesProvider(DeviceAttributes::xtermCompatible);
|
terminal.setDeviceAttributesProvider(DeviceAttributes::xtermCompatible);
|
||||||
TerminalPane pane = new TerminalPane(terminal, columns, rows);
|
TerminalPane pane = new TerminalPane(terminal, metrics, config.kittyGraphics(), onContentChange,
|
||||||
|
new GhosttyTerminalRenderer(metrics), columns, rows);
|
||||||
pane.refresh();
|
pane.refresh();
|
||||||
|
pane.attach(ShellSession.start(config.shell(), config.envOverride(), pane, columns, rows));
|
||||||
return pane;
|
return pane;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private 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 write(String text) {
|
public void write(String text) {
|
||||||
synchronized (terminal) {
|
synchronized (terminal) {
|
||||||
terminal.write(text);
|
terminal.write(text);
|
||||||
@@ -60,17 +107,6 @@ public final class TerminalPane implements AutoCloseable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
public void send(String text) {
|
||||||
scrollViewportToBottom();
|
scrollViewportToBottom();
|
||||||
if (session != null) {
|
if (session != null) {
|
||||||
@@ -104,15 +140,48 @@ public final class TerminalPane implements AutoCloseable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void scrollViewportToBottom() {
|
private void scrollViewportToBottom() {
|
||||||
synchronized (terminal) {
|
synchronized (terminal) {
|
||||||
terminal.scrollViewport(ScrollViewport.bottom());
|
terminal.scrollViewport(ScrollViewport.bottom());
|
||||||
refresh();
|
refresh();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public RenderStateSnapshot renderSnapshot() {
|
/**
|
||||||
return renderSnapshot.get();
|
* Incremental snapshot: cells are marshalled only for rows that changed since the last
|
||||||
|
* frame (global dirty == PARTIAL), reused across calls for the same content version.
|
||||||
|
* Snapshotting is deferred here rather than done in refresh(), so a burst of writes
|
||||||
|
* between two frames collapses into a single snapshot.
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public RenderStateSnapshot snapshot() {
|
||||||
|
return takeSnapshot(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full snapshot with every row's cells populated. Used where the whole pane is redrawn
|
||||||
|
* regardless of dirty state (the kitty-graphics path).
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public RenderStateSnapshot snapshotFull() {
|
||||||
|
return takeSnapshot(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private RenderStateSnapshot takeSnapshot(boolean full) {
|
||||||
|
synchronized (terminal) {
|
||||||
|
if (full) {
|
||||||
|
renderState.update(terminal);
|
||||||
|
cachedSnapshot = renderState.snapshot();
|
||||||
|
renderState.resetDirty();
|
||||||
|
snapshotVersion = contentVersion;
|
||||||
|
} else if (snapshotVersion != contentVersion) {
|
||||||
|
renderState.update(terminal);
|
||||||
|
cachedSnapshot = renderState.snapshotIncremental();
|
||||||
|
renderState.resetDirty();
|
||||||
|
snapshotVersion = contentVersion;
|
||||||
|
}
|
||||||
|
return cachedSnapshot;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public String scrollbackText() {
|
public String scrollbackText() {
|
||||||
@@ -121,44 +190,39 @@ public final class TerminalPane implements AutoCloseable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public long renderVersion() {
|
/** This pane's own content revision, bumped on every change (see {@link #refresh()}). */
|
||||||
return renderVersion;
|
public long contentVersion() {
|
||||||
|
return contentVersion;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean kittyEnabled() {
|
||||||
|
return kittyEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
public Optional<KittyGraphics> kittyGraphics() {
|
public Optional<KittyGraphics> kittyGraphics() {
|
||||||
synchronized (terminal) {
|
synchronized (terminal) {
|
||||||
return terminal.kittyGraphics();
|
return terminal.kittyGraphics();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public boolean floating() {
|
@Override
|
||||||
return floating;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setFloating(boolean floating) {
|
|
||||||
this.floating = floating;
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean visible() {
|
|
||||||
return visible;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setVisible(boolean visible) {
|
|
||||||
this.visible = visible;
|
|
||||||
}
|
|
||||||
|
|
||||||
public double x() {
|
public double x() {
|
||||||
return x;
|
return x;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
public double y() {
|
public double y() {
|
||||||
return y;
|
return y;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
public double width() {
|
public double width() {
|
||||||
return width;
|
return width;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
public double height() {
|
public double height() {
|
||||||
return height;
|
return height;
|
||||||
}
|
}
|
||||||
@@ -170,7 +234,24 @@ public final class TerminalPane implements AutoCloseable {
|
|||||||
this.height = height;
|
this.height = height;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void resize(int columns, int rows, int pixelWidth, int pixelHeight) {
|
/** Set the clip region applied on the next paints (see {@link RenderTarget#clip()}). */
|
||||||
|
public void setClip(Shape clip) {
|
||||||
|
this.clip = clip;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Shape clip() {
|
||||||
|
return clip;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Recompute the ghostty grid from the current bounds and the shared cell metrics. */
|
||||||
|
public void fitToBounds() {
|
||||||
|
int columns = metrics.columnsFor(width);
|
||||||
|
int rows = metrics.rowsFor(height);
|
||||||
|
resize(columns, rows, (int) Math.round(metrics.cellWidth()), (int) Math.round(metrics.lineHeight()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void resize(int columns, int rows, int pixelWidth, int pixelHeight) {
|
||||||
if (columns <= 0 || rows <= 0 || pixelWidth <= 0 || pixelHeight <= 0) {
|
if (columns <= 0 || rows <= 0 || pixelWidth <= 0 || pixelHeight <= 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -192,8 +273,21 @@ public final class TerminalPane implements AutoCloseable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void refresh() {
|
private void refresh() {
|
||||||
renderSnapshot.set(terminal.renderSnapshot());
|
// Mark this pane's content dirty (the snapshot is computed lazily in the paint path,
|
||||||
renderVersion++;
|
// so a burst of writes collapses into one snapshot per frame) and tell the owning tab
|
||||||
|
// one of its panes changed.
|
||||||
|
contentVersion++;
|
||||||
|
onContentChange.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Paint the whole pane; see {@link TerminalRenderer#paintFull}. */
|
||||||
|
public void paintFull(GraphicsContext gc, boolean active) {
|
||||||
|
renderer.paintFull(gc, this, active);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Repaint what changed; see {@link TerminalRenderer#paintIncremental}. */
|
||||||
|
public void paintIncremental(GraphicsContext gc, boolean active) {
|
||||||
|
renderer.paintIncremental(gc, this, active);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -203,6 +297,7 @@ public final class TerminalPane implements AutoCloseable {
|
|||||||
session = null;
|
session = null;
|
||||||
}
|
}
|
||||||
mouseEncoder.close();
|
mouseEncoder.close();
|
||||||
|
renderState.close();
|
||||||
terminal.close();
|
terminal.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
60
src/main/java/com/gregor/jprototerm/TerminalRenderer.java
Normal file
60
src/main/java/com/gregor/jprototerm/TerminalRenderer.java
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
package com.gregor.jprototerm;
|
||||||
|
|
||||||
|
import javafx.scene.canvas.GraphicsContext;
|
||||||
|
import javafx.scene.shape.ClosePath;
|
||||||
|
import javafx.scene.shape.LineTo;
|
||||||
|
import javafx.scene.shape.MoveTo;
|
||||||
|
import javafx.scene.shape.Path;
|
||||||
|
import javafx.scene.shape.PathElement;
|
||||||
|
import javafx.scene.shape.Shape;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draws a {@link RenderTarget} onto a JavaFX canvas. The {@link Compositor} owns positioning
|
||||||
|
* and z-order; a renderer only fills the target's rect, clipped to the target's {@link
|
||||||
|
* RenderTarget#clip() clip region} so a repaint can never bleed over a pane on top.
|
||||||
|
* Implementations can change the look entirely — {@link GhosttyTerminalRenderer} is the real
|
||||||
|
* terminal renderer; a debug renderer could outline pane bounds instead.
|
||||||
|
*
|
||||||
|
* <p>A renderer may hold per-target state (e.g. a decoded-image cache), so an instance belongs
|
||||||
|
* to a single {@link TerminalPane}.
|
||||||
|
*/
|
||||||
|
abstract class TerminalRenderer {
|
||||||
|
/** Paint the whole target into its rect, clipped to its clip region. */
|
||||||
|
abstract void paintFull(GraphicsContext gc, RenderTarget target, boolean active);
|
||||||
|
|
||||||
|
/** Repaint only what changed since the last frame, clipped to the target's clip region. */
|
||||||
|
abstract void paintIncremental(GraphicsContext gc, RenderTarget target, boolean active);
|
||||||
|
|
||||||
|
protected static void clipRect(GraphicsContext gc, double x, double y, double width, double height) {
|
||||||
|
gc.beginPath();
|
||||||
|
gc.rect(x, y, width, height);
|
||||||
|
gc.clip();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clip to {@code region} if given (the pane's rect minus the panes covering it, computed by
|
||||||
|
* {@code Shape.subtract} at layout), otherwise to the plain rect. The region is a rectilinear
|
||||||
|
* path, so it replays onto the canvas as move/line/close segments.
|
||||||
|
*/
|
||||||
|
protected static void clip(GraphicsContext gc, double x, double y, double width, double height, Shape region) {
|
||||||
|
if (region == null) {
|
||||||
|
clipRect(gc, x, y, width, height);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var elements = ((Path) region).getElements();
|
||||||
|
gc.beginPath();
|
||||||
|
if (elements.isEmpty()) {
|
||||||
|
gc.rect(x, y, 0.0, 0.0); // fully covered: clip to nothing
|
||||||
|
}
|
||||||
|
for (PathElement element : elements) {
|
||||||
|
if (element instanceof MoveTo moveTo) {
|
||||||
|
gc.moveTo(moveTo.getX(), moveTo.getY());
|
||||||
|
} else if (element instanceof LineTo lineTo) {
|
||||||
|
gc.lineTo(lineTo.getX(), lineTo.getY());
|
||||||
|
} else if (element instanceof ClosePath) {
|
||||||
|
gc.closePath();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
gc.clip();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,293 +0,0 @@
|
|||||||
package com.gregor.jprototerm;
|
|
||||||
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.Comparator;
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
public final class TerminalWorkspace implements AutoCloseable {
|
|
||||||
private final AppConfig config;
|
|
||||||
private final List<TerminalPane> panes = new ArrayList<>();
|
|
||||||
private int activeIndex;
|
|
||||||
private int hiddenFloatingFocusIndex = -1;
|
|
||||||
private long version;
|
|
||||||
|
|
||||||
public TerminalWorkspace(AppConfig config) {
|
|
||||||
this.config = config;
|
|
||||||
panes.add(openPane(false));
|
|
||||||
}
|
|
||||||
|
|
||||||
public TerminalPane activePane() {
|
|
||||||
return panes.get(activeIndex);
|
|
||||||
}
|
|
||||||
|
|
||||||
public List<TerminalPane> 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 long version() {
|
|
||||||
return version;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void focus(TerminalPane pane) {
|
|
||||||
int index = panes.indexOf(pane);
|
|
||||||
if (index >= 0 && pane.visible() && activeIndex != index) {
|
|
||||||
activeIndex = index;
|
|
||||||
version++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void layout(double width, double height) {
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
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(
|
|
||||||
Math.min(width - floatingWidth - 12.0, ((width - floatingWidth) / 2.0) + offset),
|
|
||||||
Math.min(height - floatingHeight - 12.0, ((height - floatingHeight) / 2.0) + offset),
|
|
||||||
floatingWidth,
|
|
||||||
floatingHeight
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void navigate(Direction direction) {
|
|
||||||
TerminalPane current = activePane();
|
|
||||||
if (current.floating() && navigateFloatingStack(direction)) {
|
|
||||||
version++;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
panes.stream()
|
|
||||||
.filter(TerminalPane::visible)
|
|
||||||
.filter(pane -> pane != current)
|
|
||||||
.filter(pane -> directionFilter(direction, current, pane))
|
|
||||||
.min(Comparator.comparingDouble(pane -> distance(current, pane)))
|
|
||||||
.ifPresent(pane -> {
|
|
||||||
activeIndex = panes.indexOf(pane);
|
|
||||||
version++;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public void toggleFloating() {
|
|
||||||
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();
|
|
||||||
version++;
|
|
||||||
} else {
|
|
||||||
floating.forEach(pane -> pane.setVisible(true));
|
|
||||||
activeIndex = visibleIndexOrFallback(hiddenFloatingFocusIndex, panes.indexOf(floating.get(floating.size() - 1)));
|
|
||||||
hiddenFloatingFocusIndex = -1;
|
|
||||||
version++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void createFloatingPane() {
|
|
||||||
TerminalPane pane = openPane(true);
|
|
||||||
panes.add(pane);
|
|
||||||
activeIndex = panes.size() - 1;
|
|
||||||
version++;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void nextFloatingPane() {
|
|
||||||
TerminalPane next = nextFloatingAfter(activeIndex);
|
|
||||||
next.setVisible(true);
|
|
||||||
activeIndex = panes.indexOf(next);
|
|
||||||
version++;
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
version++;
|
|
||||||
}
|
|
||||||
|
|
||||||
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.maxScrollback());
|
|
||||||
pane.setFloating(floating);
|
|
||||||
pane.attach(ShellSession.start(config.shell(), pane, config.columns(), config.rows()));
|
|
||||||
return pane;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static boolean directionFilter(Direction direction, TerminalPane current, TerminalPane candidate) {
|
|
||||||
double currentCenterX = current.x() + current.width() / 2.0;
|
|
||||||
double currentCenterY = current.y() + current.height() / 2.0;
|
|
||||||
double candidateCenterX = candidate.x() + candidate.width() / 2.0;
|
|
||||||
double candidateCenterY = candidate.y() + candidate.height() / 2.0;
|
|
||||||
|
|
||||||
return switch (direction) {
|
|
||||||
case LEFT -> candidateCenterX < currentCenterX;
|
|
||||||
case DOWN -> candidateCenterY > currentCenterY;
|
|
||||||
case UP -> candidateCenterY < currentCenterY;
|
|
||||||
case RIGHT -> candidateCenterX > currentCenterX;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private static double distance(TerminalPane current, TerminalPane candidate) {
|
|
||||||
double dx = (current.x() + current.width() / 2.0) - (candidate.x() + candidate.width() / 2.0);
|
|
||||||
double dy = (current.y() + current.height() / 2.0) - (candidate.y() + candidate.height() / 2.0);
|
|
||||||
return Math.sqrt(dx * dx + dy * dy);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void close() {
|
|
||||||
for (TerminalPane pane : panes) {
|
|
||||||
pane.close();
|
|
||||||
}
|
|
||||||
panes.clear();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user