Compare commits
85 Commits
b98a18b49f
...
add-worktr
| Author | SHA1 | Date | |
|---|---|---|---|
| 64263ee3fb | |||
| f621366b0c | |||
| 77fb5e7e11 | |||
| 08fcb93e01 | |||
| 8954482222 | |||
| 692511e445 | |||
| ec769f0f92 | |||
| c762e53bf4 | |||
| 217e865448 | |||
| 55a1f2cf6d | |||
| fd672f36f7 | |||
| 281e34e098 | |||
| c6c385c756 | |||
| f6b7669798 | |||
| 81b26516fe | |||
| 0fcba6a97d | |||
| a5dca9ca46 | |||
| 96674a3bf5 | |||
| c0ce81f125 | |||
| dcb70243aa | |||
| c2ccd056af | |||
| 11734d89f7 | |||
| 4ed2b82f2f | |||
| 64d86fe487 | |||
| 556ec9787b | |||
| b6a65c9f4a | |||
| 44edff25d9 | |||
| cc694a257a | |||
| 1895a48550 | |||
| 40230dd8f7 | |||
| a31cf06cbd | |||
| 6cf9afd664 | |||
| 75cbea61dd | |||
| dbb5dc350b | |||
| 1776aa251a | |||
| 0be3662a93 | |||
| 8f70c4bf45 | |||
| 6738051da1 | |||
| 65f69d5c75 | |||
| 85f2d86c09 | |||
| 5f0edcbe31 | |||
| ebf87c0bff | |||
| a51bee3b43 | |||
| aa5ca0451c | |||
| 8ac07218fe | |||
| 6bf69e8572 | |||
| 07585a314c | |||
| bdb33450f1 | |||
|
|
2c020bb6cb | ||
|
|
71a533ec34 | ||
|
|
54b08c7eca | ||
|
|
2fcdb286af | ||
|
|
e6848ec684 | ||
|
|
38822d66b8 | ||
|
|
586150de59 | ||
|
|
494d2c40cf | ||
|
|
a99cbdc61a | ||
|
|
86f7174eee | ||
|
|
137db24023 | ||
|
|
d8faf8d6df | ||
|
|
9903e9174f | ||
|
|
9b7247a4e0 | ||
|
|
f5562baf5f | ||
|
|
3017b99f87 | ||
|
|
0958c93b4f | ||
|
|
9c98d87783 | ||
|
|
76c731578f | ||
|
|
95619f5b4c | ||
|
|
174cfc00d3 | ||
|
|
29e84c9830 | ||
|
|
a7baa08e68 | ||
|
|
76f539d34a | ||
|
|
ba884cd0a2 | ||
|
|
7dbbf89b27 | ||
|
|
e2850f067e | ||
|
|
022cf22463 | ||
|
|
250b182060 | ||
|
|
ebba6cc44f | ||
|
|
4c3449129c | ||
|
|
40d6287867 | ||
|
|
ff21bf3544 | ||
|
|
c03d9245d0 | ||
|
|
0915c649bd | ||
|
|
4a06a89400 | ||
|
|
4de2d31e91 |
18
.classpath
18
.classpath
@@ -1,18 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<classpath>
|
|
||||||
<classpathentry kind="src" output="bin/main" path="src/main/java">
|
|
||||||
<attributes>
|
|
||||||
<attribute name="gradle_scope" value="main"/>
|
|
||||||
<attribute name="gradle_used_by_scope" value="main,test"/>
|
|
||||||
</attributes>
|
|
||||||
</classpathentry>
|
|
||||||
<classpathentry kind="src" output="bin/main" path="src/main/resources">
|
|
||||||
<attributes>
|
|
||||||
<attribute name="gradle_scope" value="main"/>
|
|
||||||
<attribute name="gradle_used_by_scope" value="main,test"/>
|
|
||||||
</attributes>
|
|
||||||
</classpathentry>
|
|
||||||
<classpathentry kind="con" path="org.eclipse.jdt.launching.JRE_CONTAINER/org.eclipse.jdt.internal.debug.ui.launcher.StandardVMType/JavaSE-21/"/>
|
|
||||||
<classpathentry kind="con" path="org.eclipse.buildship.core.gradleclasspathcontainer"/>
|
|
||||||
<classpathentry kind="output" path="bin/default"/>
|
|
||||||
</classpath>
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
019e6999-b7c8-7591-a8aa-ea51b89a7f7e
|
|
||||||
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
*.mp4 filter=lfs diff=lfs merge=lfs -text
|
||||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -11,4 +11,10 @@ devenv.local.yaml
|
|||||||
# pre-commit
|
# pre-commit
|
||||||
.pre-commit-config.yaml
|
.pre-commit-config.yaml
|
||||||
build
|
build
|
||||||
build
|
.gradle
|
||||||
|
bin
|
||||||
|
.settings
|
||||||
|
.project
|
||||||
|
.worktrees
|
||||||
|
.classpath
|
||||||
|
.codexsession
|
||||||
|
|||||||
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.
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 @@
|
|||||||
#Thu May 28 14:41:41 CEST 2026
|
|
||||||
gradle.version=9.5.1
|
|
||||||
Binary file not shown.
Binary file not shown.
34
.project
34
.project
@@ -1,34 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<projectDescription>
|
|
||||||
<name>jprototerm</name>
|
|
||||||
<comment>Project jprototerm created by Buildship.</comment>
|
|
||||||
<projects>
|
|
||||||
</projects>
|
|
||||||
<buildSpec>
|
|
||||||
<buildCommand>
|
|
||||||
<name>org.eclipse.jdt.core.javabuilder</name>
|
|
||||||
<arguments>
|
|
||||||
</arguments>
|
|
||||||
</buildCommand>
|
|
||||||
<buildCommand>
|
|
||||||
<name>org.eclipse.buildship.core.gradleprojectbuilder</name>
|
|
||||||
<arguments>
|
|
||||||
</arguments>
|
|
||||||
</buildCommand>
|
|
||||||
</buildSpec>
|
|
||||||
<natures>
|
|
||||||
<nature>org.eclipse.jdt.core.javanature</nature>
|
|
||||||
<nature>org.eclipse.buildship.core.gradleprojectnature</nature>
|
|
||||||
</natures>
|
|
||||||
<filteredResources>
|
|
||||||
<filter>
|
|
||||||
<id>1779917652126</id>
|
|
||||||
<name></name>
|
|
||||||
<type>30</type>
|
|
||||||
<matcher>
|
|
||||||
<id>org.eclipse.core.resources.regexFilterMatcher</id>
|
|
||||||
<arguments>node_modules|\.git|__CREATED_BY_JAVA_LANGUAGE_SERVER__</arguments>
|
|
||||||
</matcher>
|
|
||||||
</filter>
|
|
||||||
</filteredResources>
|
|
||||||
</projectDescription>
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
arguments=--init-script /home/anon/Src/eclipse.jdt.ls/org.eclipse.jdt.ls.product/target/repository/configuration/org.eclipse.osgi/58/0/.cp/gradle/init/init.gradle
|
|
||||||
auto.sync=false
|
|
||||||
build.scans.enabled=false
|
|
||||||
connection.gradle.distribution=GRADLE_DISTRIBUTION(LOCAL_INSTALLATION(/home/anon/.sdkman/candidates/gradle/current))
|
|
||||||
connection.project.dir=
|
|
||||||
eclipse.preferences.version=1
|
|
||||||
gradle.user.home=
|
|
||||||
java.home=/nix/store/c3pl7bqrx3d2rc3dh98z6yaj0mv1p52g-openjdk-21.0.10+7/lib/openjdk
|
|
||||||
jvm.arguments=
|
|
||||||
offline.mode=false
|
|
||||||
override.workspace.settings=true
|
|
||||||
show.console.view=true
|
|
||||||
show.executions.view=true
|
|
||||||
99
README.md
99
README.md
@@ -2,7 +2,17 @@
|
|||||||
|
|
||||||
JavaFX canvas terminal prototype using `jlibghostty` for terminal emulation and Nix for
|
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)
|
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.
|
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.
|
||||||
|
|
||||||
|
<video src="https://gitea.gregorlohaus.com/gregor/jprototerm/media/branch/main/demo.mp4" controls></video>
|
||||||
|
|
||||||
## Build
|
## Build
|
||||||
|
|
||||||
@@ -11,13 +21,24 @@ nix build
|
|||||||
./result/bin/jprototerm
|
./result/bin/jprototerm
|
||||||
```
|
```
|
||||||
|
|
||||||
Install it into a profile (works on NixOS and on a plain Debian box with Nix installed):
|
Install it into a profile (see the caution above on host support):
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
nix profile add .
|
nix profile add .
|
||||||
jprototerm
|
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
|
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**
|
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
|
the system OpenGL/graphics drivers. `libGL` is supplied by the host at runtime through a GL
|
||||||
@@ -41,6 +62,57 @@ gradle run
|
|||||||
|
|
||||||
The Gradle project is the source of truth for the JavaFX build.
|
The Gradle project is the source of truth for the JavaFX build.
|
||||||
|
|
||||||
|
## Daemon (optional, faster launches)
|
||||||
|
|
||||||
|
Cold start pays for JVM + JavaFX + GL/X11 init every time. The optional daemon keeps one JVM
|
||||||
|
(one toolkit) running and hosts every window in it, so a `jprototerm` launch just asks the
|
||||||
|
daemon to open a window — it appears without paying that startup cost again.
|
||||||
|
|
||||||
|
Run it once in the background:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
jprototerm --daemon &
|
||||||
|
```
|
||||||
|
|
||||||
|
After that, a bare `jprototerm` connects to the daemon and opens a window in the current
|
||||||
|
directory. If no daemon is running, `jprototerm` falls back to a standalone in-process window
|
||||||
|
(today's behavior), so it always works.
|
||||||
|
|
||||||
|
For development testing, use `jprototerm --standalone` to skip the daemon even when one is
|
||||||
|
running.
|
||||||
|
|
||||||
|
To start the daemon automatically with your graphical session, enable the bundled **user**
|
||||||
|
service (it's a user service, not a system one, because X11 needs a display — which only
|
||||||
|
exists after you log in):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
mkdir -p ~/.config/systemd/user
|
||||||
|
ln -sf "$(dirname "$(readlink -f "$(command -v jprototerm)")")/../share/systemd/user/jprototerm.service" \
|
||||||
|
~/.config/systemd/user/jprototerm.service
|
||||||
|
systemctl --user enable --now jprototerm.service
|
||||||
|
```
|
||||||
|
After upgrading via nix profile upgrade:
|
||||||
|
```sh
|
||||||
|
systemctl --user disable jprototerm
|
||||||
|
ln -sf "$(dirname "$(readlink -f "$(command -v jprototerm)")")/../share/systemd/user/jprototerm.service" \
|
||||||
|
~/.config/systemd/user/jprototerm.service
|
||||||
|
systemctl --user enable --now jprototerm.service
|
||||||
|
systemctl --user restart jprototerm.service
|
||||||
|
```
|
||||||
|
|
||||||
|
If the daemon can't reach your display (e.g. `systemctl --user status jprototerm` shows it
|
||||||
|
failing to open a window), import the session variables once and restart it:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
systemctl --user import-environment DISPLAY XAUTHORITY
|
||||||
|
systemctl --user restart jprototerm.service
|
||||||
|
```
|
||||||
|
|
||||||
|
Closing a window (the WM close button, or the close-pane key on the last pane) tears that
|
||||||
|
window down — its shell processes are signalled with the configured `close_signal` — without
|
||||||
|
affecting other windows or the daemon. Stop the daemon (and all its windows) with
|
||||||
|
`systemctl --user stop jprototerm.service`, or `pkill -f 'jprototerm --daemon'`.
|
||||||
|
|
||||||
## Config
|
## Config
|
||||||
|
|
||||||
Configuration is read from:
|
Configuration is read from:
|
||||||
@@ -77,6 +149,9 @@ enabled = true
|
|||||||
[scrollback]
|
[scrollback]
|
||||||
editor_command = "vi {file}"
|
editor_command = "vi {file}"
|
||||||
|
|
||||||
|
[worktree]
|
||||||
|
relative_worktree_path = "./.worktrees"
|
||||||
|
|
||||||
[env.override]
|
[env.override]
|
||||||
ZELLIJ_SESSION_NAME = ""
|
ZELLIJ_SESSION_NAME = ""
|
||||||
|
|
||||||
@@ -86,21 +161,35 @@ 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"
|
||||||
|
create_worktree = "ALT+W"
|
||||||
```
|
```
|
||||||
|
|
||||||
## 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`
|
||||||
|
- `Alt+w`: edit a worktree name, then run `git worktree add <relative_worktree_path>/<name>`
|
||||||
|
from the previously focused pane's working directory
|
||||||
- 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.
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ columns = 100
|
|||||||
rows = 30
|
rows = 30
|
||||||
max_scrollback = 100000
|
max_scrollback = 100000
|
||||||
shell = "/bin/bash"
|
shell = "/bin/bash"
|
||||||
|
# Signal sent to a pane's shell process when the pane is closed (e.g. ALT+X).
|
||||||
|
# Use SIGKILL to force-kill instead of asking the shell to terminate.
|
||||||
|
close_signal = "SIGTERM"
|
||||||
font_family = "JetBrainsMono Nerd Font"
|
font_family = "JetBrainsMono Nerd Font"
|
||||||
font_size = 15
|
font_size = 15
|
||||||
|
|
||||||
@@ -16,6 +19,9 @@ enabled = true
|
|||||||
[scrollback]
|
[scrollback]
|
||||||
editor_command = "vi {file}"
|
editor_command = "vi {file}"
|
||||||
|
|
||||||
|
[worktree]
|
||||||
|
relative_worktree_path = "./.worktrees"
|
||||||
|
|
||||||
[env.override]
|
[env.override]
|
||||||
ZELLIJ_SESSION_NAME = ""
|
ZELLIJ_SESSION_NAME = ""
|
||||||
|
|
||||||
@@ -25,8 +31,14 @@ 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"
|
||||||
|
promote_floating = "ALT+P"
|
||||||
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"
|
||||||
|
create_worktree = "ALT+W"
|
||||||
|
paste = "CTRL+SHIFT+V"
|
||||||
|
|||||||
65
devenv.lock
65
devenv.lock
@@ -1,65 +0,0 @@
|
|||||||
{
|
|
||||||
"nodes": {
|
|
||||||
"devenv": {
|
|
||||||
"locked": {
|
|
||||||
"dir": "src/modules",
|
|
||||||
"lastModified": 1779749056,
|
|
||||||
"narHash": "sha256-AtocdrunzuxTvSDn+82RntEhrs6TicM6Z4/zNQS9KKg=",
|
|
||||||
"owner": "cachix",
|
|
||||||
"repo": "devenv",
|
|
||||||
"rev": "099ac65fcef79e88127bdc06adbd1ea94255274a",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"dir": "src/modules",
|
|
||||||
"owner": "cachix",
|
|
||||||
"repo": "devenv",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"nixpkgs": {
|
|
||||||
"inputs": {
|
|
||||||
"nixpkgs-src": "nixpkgs-src"
|
|
||||||
},
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1778507786,
|
|
||||||
"narHash": "sha256-HzSQCKMsMr8r55LwM1JuzIOB+8bzk0FEv6sItKvsfoY=",
|
|
||||||
"owner": "cachix",
|
|
||||||
"repo": "devenv-nixpkgs",
|
|
||||||
"rev": "8f24a228a782e24576b155d1e39f0d914b380691",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"owner": "cachix",
|
|
||||||
"ref": "rolling",
|
|
||||||
"repo": "devenv-nixpkgs",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"nixpkgs-src": {
|
|
||||||
"flake": false,
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1778274207,
|
|
||||||
"narHash": "sha256-I4puXmX1iovcCHZlRmztO3vW0mAbbRvq4F8wgIMQ1MM=",
|
|
||||||
"owner": "NixOS",
|
|
||||||
"repo": "nixpkgs",
|
|
||||||
"rev": "b3da656039dc7a6240f27b2ef8cc6a3ef3bccae7",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"owner": "NixOS",
|
|
||||||
"ref": "nixpkgs-unstable",
|
|
||||||
"repo": "nixpkgs",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"root": {
|
|
||||||
"inputs": {
|
|
||||||
"devenv": "devenv",
|
|
||||||
"nixpkgs": "nixpkgs"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"root": "root",
|
|
||||||
"version": 7
|
|
||||||
}
|
|
||||||
66
devenv.nix
66
devenv.nix
@@ -1,66 +0,0 @@
|
|||||||
{ pkgs, lib, config, inputs, ... }:
|
|
||||||
|
|
||||||
let
|
|
||||||
system = pkgs.stdenv.hostPlatform.system;
|
|
||||||
|
|
||||||
jlibghostty = builtins.getFlake
|
|
||||||
"git+https://gitea.gregorlohaus.com/gregor/jlibghostty.git";
|
|
||||||
|
|
||||||
jlib = jlibghostty.packages.${system}.jlibghostty;
|
|
||||||
hostNvidiaLibs = ".devenv/host-nvidia-libs";
|
|
||||||
in
|
|
||||||
{
|
|
||||||
packages = [
|
|
||||||
pkgs.git
|
|
||||||
pkgs.gradle_9
|
|
||||||
pkgs.jdk25
|
|
||||||
pkgs.jdt-language-server
|
|
||||||
pkgs.openjfx
|
|
||||||
|
|
||||||
pkgs.glib
|
|
||||||
pkgs.xorg.libXxf86vm
|
|
||||||
pkgs.xorg.libXrender
|
|
||||||
pkgs.xorg.libXtst
|
|
||||||
pkgs.xorg.libXi
|
|
||||||
pkgs.xorg.libXrandr
|
|
||||||
|
|
||||||
pkgs.libGL
|
|
||||||
pkgs.gtk3
|
|
||||||
pkgs.alsa-lib
|
|
||||||
pkgs.mesa-demos
|
|
||||||
];
|
|
||||||
|
|
||||||
env.LD_LIBRARY_PATH = "${hostNvidiaLibs}:" + lib.makeLibraryPath [
|
|
||||||
pkgs.openjfx
|
|
||||||
|
|
||||||
pkgs.glib
|
|
||||||
pkgs.xorg.libXxf86vm
|
|
||||||
pkgs.xorg.libXrender
|
|
||||||
pkgs.xorg.libXtst
|
|
||||||
pkgs.xorg.libXi
|
|
||||||
pkgs.xorg.libXrandr
|
|
||||||
|
|
||||||
pkgs.libGL
|
|
||||||
pkgs.gtk3
|
|
||||||
pkgs.alsa-lib
|
|
||||||
] + ":/usr/lib/x86_64-linux-gnu/nvidia/current";
|
|
||||||
env.__GLX_VENDOR_LIBRARY_NAME = "nvidia";
|
|
||||||
env.__EGL_VENDOR_LIBRARY_FILENAMES = "/usr/share/glvnd/egl_vendor.d/10_nvidia.json";
|
|
||||||
env.JLIBGHOSTTY_MAVEN_REPO = "${jlib}/maven";
|
|
||||||
|
|
||||||
enterShell = ''
|
|
||||||
mkdir -p ${hostNvidiaLibs}
|
|
||||||
for lib in \
|
|
||||||
/usr/lib/x86_64-linux-gnu/libnvidia*.so* \
|
|
||||||
/usr/lib/x86_64-linux-gnu/libGLX_nvidia.so* \
|
|
||||||
/usr/lib/x86_64-linux-gnu/libEGL_nvidia.so* \
|
|
||||||
/usr/lib/x86_64-linux-gnu/nvidia/current/libnvidia*.so* \
|
|
||||||
/usr/lib/x86_64-linux-gnu/nvidia/current/libGLX_nvidia.so* \
|
|
||||||
/usr/lib/x86_64-linux-gnu/nvidia/current/libEGL_nvidia.so*
|
|
||||||
do
|
|
||||||
if [ -e "$lib" ]; then
|
|
||||||
ln -sfn "$lib" ${hostNvidiaLibs}/"$(basename "$lib")"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
'';
|
|
||||||
}
|
|
||||||
18
devenv.yaml
18
devenv.yaml
@@ -1,18 +0,0 @@
|
|||||||
# yaml-language-server: $schema=https://devenv.sh/devenv.schema.json
|
|
||||||
inputs:
|
|
||||||
nixpkgs:
|
|
||||||
url: github:cachix/devenv-nixpkgs/rolling
|
|
||||||
|
|
||||||
# If you're using non-OSS software, you can set allowUnfree to true.
|
|
||||||
# allowUnfree: true
|
|
||||||
|
|
||||||
# If you're not willing to allow unsupported packages:
|
|
||||||
# allowUnsupportedSystem: false
|
|
||||||
|
|
||||||
# If you're willing to use a package that's vulnerable
|
|
||||||
# permittedInsecurePackages:
|
|
||||||
# - "openssl-1.1.1w"
|
|
||||||
|
|
||||||
# If you have more than one devenv you can merge them
|
|
||||||
#imports:
|
|
||||||
# - ./backend
|
|
||||||
8
flake.lock
generated
8
flake.lock
generated
@@ -70,11 +70,11 @@
|
|||||||
"nixpkgs": "nixpkgs"
|
"nixpkgs": "nixpkgs"
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1780050576,
|
"lastModified": 1780363584,
|
||||||
"narHash": "sha256-u06xuO3QnLDpajIOZwDdhwI0HGzMuXG7x1pR+4Zb+RA=",
|
"narHash": "sha256-BN6kwSBnlavuySut6vvLsfwkfKBjrPvFgJeeMMTXNtg=",
|
||||||
"ref": "refs/heads/main",
|
"ref": "refs/heads/main",
|
||||||
"rev": "d558d554b360a76d03c2fc09d327e3ec4aade878",
|
"rev": "1cd908e5d0070fdc1c86fc6b300cf1d6dbb5d184",
|
||||||
"revCount": 17,
|
"revCount": 25,
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://gitea.gregorlohaus.com/gregor/jlibghostty.git"
|
"url": "https://gitea.gregorlohaus.com/gregor/jlibghostty.git"
|
||||||
},
|
},
|
||||||
|
|||||||
47
flake.nix
47
flake.nix
@@ -107,7 +107,35 @@
|
|||||||
classpath="$classpath''${classpath:+:}$jar"
|
classpath="$classpath''${classpath:+:}$jar"
|
||||||
done
|
done
|
||||||
|
|
||||||
|
# The CDS archive records launch-time module/classpath properties, including
|
||||||
|
# Nix store paths. Key it by this build's launch shape so stale archives from a
|
||||||
|
# previous package path cannot be reused, and pass the flags on java's command
|
||||||
|
# line so terminal child processes do not inherit them through JAVA_TOOL_OPTIONS.
|
||||||
|
cdsArchive="app-$(printf '%s\n' \
|
||||||
|
"${pkgs.jdk25}" \
|
||||||
|
"$out" \
|
||||||
|
"$classpath" \
|
||||||
|
"$out/share/jprototerm/javafx" \
|
||||||
|
"--enable-native-access=ALL-UNNAMED,javafx.graphics" \
|
||||||
|
"--add-modules=javafx.controls,javafx.fxml" \
|
||||||
|
"com.gregor.jprototerm.Main" \
|
||||||
|
| sha256sum | cut -c1-16).jsa"
|
||||||
|
|
||||||
|
# Prism frees GPU textures (and the X11 pixmaps behind them) only from phantom-ref
|
||||||
|
# Disposers that run during a GC. This daemon is one long-lived JVM whose ~140MB heap
|
||||||
|
# never nears the multi-GB default ceiling, so G1 almost never collects, the Disposers
|
||||||
|
# never run, and orphaned render resources (closed panes/windows, resized backbuffers)
|
||||||
|
# pile up until the X server's VRAM is exhausted and the whole session freezes. -Xmx
|
||||||
|
# makes output churn drive GC; G1PeriodicGCInterval runs a concurrent (low-pause) GC on
|
||||||
|
# an idle timer so an idle daemon still reclaims. The live heap is tiny, so GC is cheap.
|
||||||
makeWrapper "${pkgs.jdk25}/bin/java" "$out/bin/jprototerm" \
|
makeWrapper "${pkgs.jdk25}/bin/java" "$out/bin/jprototerm" \
|
||||||
|
--run 'if [ "$#" -eq 0 ]; then if [ -n "''${XDG_RUNTIME_DIR:-}" ]; then jprototermSock="$XDG_RUNTIME_DIR/jprototerm/daemon.sock"; else jprototermSock="/tmp/jprototerm-''${USER:-user}/daemon.sock"; fi; if [ -S "$jprototermSock" ] && printf "%s\n" "$(pwd)" | ${pkgs.socat}/bin/socat - UNIX-CONNECT:"$jprototermSock" >/dev/null 2>&1; then exit 0; fi; fi' \
|
||||||
|
--run 'export JPROTOTERM_HOST_LD_LIBRARY_PATH="''${LD_LIBRARY_PATH:-}"' \
|
||||||
|
--run 'cdsDir="''${XDG_CACHE_HOME:-$HOME/.cache}/jprototerm"; mkdir -p "$cdsDir"' \
|
||||||
|
--add-flags "-XX:+AutoCreateSharedArchive" \
|
||||||
|
--add-flags "-XX:SharedArchiveFile=\$cdsDir/$cdsArchive" \
|
||||||
|
--add-flags "-Xmx512m" \
|
||||||
|
--add-flags "-XX:G1PeriodicGCInterval=5000" \
|
||||||
--add-flags "--enable-native-access=ALL-UNNAMED,javafx.graphics" \
|
--add-flags "--enable-native-access=ALL-UNNAMED,javafx.graphics" \
|
||||||
--add-flags "--module-path $out/share/jprototerm/javafx" \
|
--add-flags "--module-path $out/share/jprototerm/javafx" \
|
||||||
--add-flags "--add-modules javafx.controls,javafx.fxml" \
|
--add-flags "--add-modules javafx.controls,javafx.fxml" \
|
||||||
@@ -118,6 +146,25 @@
|
|||||||
--set JLIBGHOSTTY_LIBRARY "${ghosttyVt}/lib/libghostty-vt.so" \
|
--set JLIBGHOSTTY_LIBRARY "${ghosttyVt}/lib/libghostty-vt.so" \
|
||||||
--set GDK_BACKEND x11
|
--set GDK_BACKEND x11
|
||||||
|
|
||||||
|
# Optional background daemon: one JVM hosts every window, so client launches skip
|
||||||
|
# cold JVM/JavaFX/GL startup. A *user* service tied to graphical-session.target (X11
|
||||||
|
# needs a display, which only exists after login). Enable instructions are in README.
|
||||||
|
mkdir -p "$out/share/systemd/user"
|
||||||
|
cat > "$out/share/systemd/user/jprototerm.service" <<EOF
|
||||||
|
[Unit]
|
||||||
|
Description=jprototerm terminal daemon
|
||||||
|
PartOf=graphical-session.target
|
||||||
|
After=graphical-session.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
ExecStart=$out/bin/jprototerm --daemon
|
||||||
|
Restart=on-failure
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=graphical-session.target
|
||||||
|
EOF
|
||||||
|
|
||||||
runHook postInstall
|
runHook postInstall
|
||||||
'';
|
'';
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import io.github.wasabithumb.jtoml.document.TomlDocument;
|
|||||||
import io.github.wasabithumb.jtoml.except.TomlException;
|
import io.github.wasabithumb.jtoml.except.TomlException;
|
||||||
import io.github.wasabithumb.jtoml.key.TomlKey;
|
import io.github.wasabithumb.jtoml.key.TomlKey;
|
||||||
import io.github.wasabithumb.jtoml.value.TomlValue;
|
import io.github.wasabithumb.jtoml.value.TomlValue;
|
||||||
|
import io.github.wasabithumb.jtoml.value.array.TomlArray;
|
||||||
import io.github.wasabithumb.jtoml.value.primitive.TomlPrimitive;
|
import io.github.wasabithumb.jtoml.value.primitive.TomlPrimitive;
|
||||||
import io.github.wasabithumb.jtoml.value.table.TomlTable;
|
import io.github.wasabithumb.jtoml.value.table.TomlTable;
|
||||||
|
|
||||||
@@ -12,6 +13,7 @@ import java.nio.file.Files;
|
|||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.nio.file.StandardOpenOption;
|
import java.nio.file.StandardOpenOption;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@@ -20,13 +22,15 @@ public record AppConfig(
|
|||||||
int columns,
|
int columns,
|
||||||
int rows,
|
int rows,
|
||||||
long maxScrollback,
|
long maxScrollback,
|
||||||
String shell,
|
List<String> shell,
|
||||||
String fontFamily,
|
String fontFamily,
|
||||||
double fontSize,
|
double fontSize,
|
||||||
double windowWidth,
|
double windowWidth,
|
||||||
double windowHeight,
|
double windowHeight,
|
||||||
boolean kittyGraphics,
|
boolean kittyGraphics,
|
||||||
String scrollbackEditorCommand,
|
String scrollbackEditorCommand,
|
||||||
|
String worktreeRelativePath,
|
||||||
|
String closeSignal,
|
||||||
Map<String, String> envOverride,
|
Map<String, String> envOverride,
|
||||||
Map<String, KeyBinding> keybindings
|
Map<String, KeyBinding> keybindings
|
||||||
) {
|
) {
|
||||||
@@ -36,18 +40,24 @@ public record AppConfig(
|
|||||||
"navigate_up",
|
"navigate_up",
|
||||||
"navigate_right",
|
"navigate_right",
|
||||||
"toggle_floating",
|
"toggle_floating",
|
||||||
"new_floating",
|
"new_pane",
|
||||||
"next_floating",
|
"next_floating",
|
||||||
|
"promote_floating",
|
||||||
"close_pane",
|
"close_pane",
|
||||||
|
"new_tab",
|
||||||
|
"previous_tab",
|
||||||
|
"next_tab",
|
||||||
"open_font_selector",
|
"open_font_selector",
|
||||||
"open_scrollback"
|
"open_scrollback",
|
||||||
|
"create_worktree",
|
||||||
|
"paste"
|
||||||
);
|
);
|
||||||
|
|
||||||
public static AppConfig load() {
|
public static AppConfig load() {
|
||||||
AppConfig defaults = defaults();
|
AppConfig defaults = defaults();
|
||||||
Path path = configPath();
|
Path path = configPath();
|
||||||
if (!Files.isRegularFile(path)) {
|
if (!Files.isRegularFile(path)) {
|
||||||
writeDefaultConfig(path, defaults);
|
save(path, defaults);
|
||||||
return defaults;
|
return defaults;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,13 +67,15 @@ public record AppConfig(
|
|||||||
intValue(document, "terminal.columns", defaults.columns),
|
intValue(document, "terminal.columns", defaults.columns),
|
||||||
intValue(document, "terminal.rows", defaults.rows),
|
intValue(document, "terminal.rows", defaults.rows),
|
||||||
longValue(document, "terminal.max_scrollback", defaults.maxScrollback),
|
longValue(document, "terminal.max_scrollback", defaults.maxScrollback),
|
||||||
stringValue(document, "terminal.shell", defaults.shell),
|
stringListValue(document, "terminal.shell", defaults.shell),
|
||||||
stringValue(document, "terminal.font_family", defaults.fontFamily),
|
stringValue(document, "terminal.font_family", defaults.fontFamily),
|
||||||
doubleValue(document, "terminal.font_size", defaults.fontSize),
|
doubleValue(document, "terminal.font_size", defaults.fontSize),
|
||||||
doubleValue(document, "window.width", defaults.windowWidth),
|
doubleValue(document, "window.width", defaults.windowWidth),
|
||||||
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),
|
||||||
|
stringValue(document, "worktree.relative_worktree_path", defaults.worktreeRelativePath),
|
||||||
|
closeSignalValue(document, defaults.closeSignal),
|
||||||
envOverride(document, defaults.envOverride),
|
envOverride(document, defaults.envOverride),
|
||||||
keybindings(document, defaults)
|
keybindings(document, defaults)
|
||||||
);
|
);
|
||||||
@@ -85,18 +97,26 @@ public record AppConfig(
|
|||||||
760.0,
|
760.0,
|
||||||
true,
|
true,
|
||||||
defaultScrollbackEditorCommand(),
|
defaultScrollbackEditorCommand(),
|
||||||
|
"./.worktrees",
|
||||||
|
"SIGTERM",
|
||||||
Map.of(),
|
Map.of(),
|
||||||
Map.of(
|
Map.ofEntries(
|
||||||
"navigate_left", KeyBinding.parse("ALT+H"),
|
Map.entry("navigate_left", KeyBinding.parse("ALT+H")),
|
||||||
"navigate_down", KeyBinding.parse("ALT+J"),
|
Map.entry("navigate_down", KeyBinding.parse("ALT+J")),
|
||||||
"navigate_up", KeyBinding.parse("ALT+K"),
|
Map.entry("navigate_up", KeyBinding.parse("ALT+K")),
|
||||||
"navigate_right", KeyBinding.parse("ALT+L"),
|
Map.entry("navigate_right", KeyBinding.parse("ALT+L")),
|
||||||
"toggle_floating", KeyBinding.parse("ALT+F"),
|
Map.entry("toggle_floating", KeyBinding.parse("ALT+F")),
|
||||||
"new_floating", KeyBinding.parse("ALT+SHIFT+F"),
|
Map.entry("new_pane", KeyBinding.parse("ALT+N")),
|
||||||
"next_floating", KeyBinding.parse("ALT+F12"),
|
Map.entry("next_floating", KeyBinding.parse("ALT+F12")),
|
||||||
"close_pane", KeyBinding.parse("ALT+X"),
|
Map.entry("promote_floating", KeyBinding.parse("ALT+P")),
|
||||||
"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")),
|
||||||
|
Map.entry("create_worktree", KeyBinding.parse("ALT+W")),
|
||||||
|
Map.entry("paste", KeyBinding.parse("CTRL+SHIFT+V"))
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -113,11 +133,23 @@ public record AppConfig(
|
|||||||
windowHeight,
|
windowHeight,
|
||||||
kittyGraphics,
|
kittyGraphics,
|
||||||
scrollbackEditorCommand,
|
scrollbackEditorCommand,
|
||||||
|
worktreeRelativePath,
|
||||||
|
closeSignal,
|
||||||
envOverride,
|
envOverride,
|
||||||
keybindings
|
keybindings
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The {@link #closeSignal} as a Linux signal number, sent to a pane's shell process when the
|
||||||
|
* pane is closed (e.g. via the close-pane key). Falls back to SIGTERM (15) if the configured
|
||||||
|
* name is somehow unresolvable.
|
||||||
|
*/
|
||||||
|
public int closeSignalNumber() {
|
||||||
|
int number = LinuxPty.signalNumber(closeSignal);
|
||||||
|
return number < 0 ? 15 : number;
|
||||||
|
}
|
||||||
|
|
||||||
public void save() {
|
public void save() {
|
||||||
save(configPath(), this);
|
save(configPath(), this);
|
||||||
}
|
}
|
||||||
@@ -130,8 +162,10 @@ public record AppConfig(
|
|||||||
return Path.of(System.getProperty("user.home"), ".config", "jprototerm", "config.toml");
|
return Path.of(System.getProperty("user.home"), ".config", "jprototerm", "config.toml");
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String defaultShell() {
|
private static List<String> defaultShell() {
|
||||||
return "/bin/bash";
|
// The executable plus its arguments, spawned verbatim. -i makes bash interactive; a
|
||||||
|
// different shell can use whatever flags it needs (or none) by editing this list.
|
||||||
|
return List.of("/bin/bash", "-i");
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String defaultScrollbackEditorCommand() {
|
private static String defaultScrollbackEditorCommand() {
|
||||||
@@ -142,6 +176,23 @@ public record AppConfig(
|
|||||||
return editor.trim() + " {file}";
|
return editor.trim() + " {file}";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads {@code terminal.close_signal}, normalising it to a canonical {@code SIG*} name. An
|
||||||
|
* unknown or unset value keeps {@code fallback} so a typo can't leave a pane unkillable.
|
||||||
|
*/
|
||||||
|
private static String closeSignalValue(TomlTable table, String fallback) {
|
||||||
|
String value = stringValue(table, "terminal.close_signal", null);
|
||||||
|
if (value == null) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
if (LinuxPty.signalNumber(value) < 0) {
|
||||||
|
System.err.println("Unknown terminal.close_signal '" + value + "', using " + fallback);
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
String normalized = value.trim().toUpperCase(java.util.Locale.ROOT);
|
||||||
|
return normalized.startsWith("SIG") ? normalized : "SIG" + normalized;
|
||||||
|
}
|
||||||
|
|
||||||
private static Map<String, KeyBinding> keybindings(TomlTable table, AppConfig defaults) {
|
private static Map<String, KeyBinding> keybindings(TomlTable table, AppConfig defaults) {
|
||||||
Map<String, KeyBinding> parsed = new LinkedHashMap<>();
|
Map<String, KeyBinding> parsed = new LinkedHashMap<>();
|
||||||
for (String key : KEYBINDING_KEYS) {
|
for (String key : KEYBINDING_KEYS) {
|
||||||
@@ -150,10 +201,6 @@ public record AppConfig(
|
|||||||
return Map.copyOf(parsed);
|
return Map.copyOf(parsed);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void writeDefaultConfig(Path path, AppConfig defaults) {
|
|
||||||
save(path, defaults);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void save(Path path, AppConfig config) {
|
private static void save(Path path, AppConfig config) {
|
||||||
try {
|
try {
|
||||||
Path parent = path.getParent();
|
Path parent = path.getParent();
|
||||||
@@ -178,7 +225,8 @@ public record AppConfig(
|
|||||||
builder.append("columns = ").append(columns).append('\n');
|
builder.append("columns = ").append(columns).append('\n');
|
||||||
builder.append("rows = ").append(rows).append('\n');
|
builder.append("rows = ").append(rows).append('\n');
|
||||||
builder.append("max_scrollback = ").append(maxScrollback).append('\n');
|
builder.append("max_scrollback = ").append(maxScrollback).append('\n');
|
||||||
builder.append("shell = ").append(quoted(shell)).append('\n');
|
builder.append("shell = ").append(quotedList(shell)).append('\n');
|
||||||
|
builder.append("close_signal = ").append(quoted(closeSignal)).append('\n');
|
||||||
builder.append("font_family = ").append(quoted(fontFamily)).append('\n');
|
builder.append("font_family = ").append(quoted(fontFamily)).append('\n');
|
||||||
builder.append("font_size = ").append(trimDouble(fontSize)).append("\n\n");
|
builder.append("font_size = ").append(trimDouble(fontSize)).append("\n\n");
|
||||||
builder.append("[window]\n");
|
builder.append("[window]\n");
|
||||||
@@ -188,6 +236,8 @@ 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("[worktree]\n");
|
||||||
|
builder.append("relative_worktree_path = ").append(quoted(worktreeRelativePath)).append("\n\n");
|
||||||
builder.append("[env.override]\n");
|
builder.append("[env.override]\n");
|
||||||
for (Map.Entry<String, String> entry : envOverride.entrySet()) {
|
for (Map.Entry<String, String> entry : envOverride.entrySet()) {
|
||||||
builder.append(entry.getKey()).append(" = ").append(quoted(entry.getValue())).append('\n');
|
builder.append(entry.getKey()).append(" = ").append(quoted(entry.getValue())).append('\n');
|
||||||
@@ -203,6 +253,17 @@ public record AppConfig(
|
|||||||
return builder.toString();
|
return builder.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static String quotedList(List<String> values) {
|
||||||
|
StringBuilder builder = new StringBuilder("[");
|
||||||
|
for (int i = 0; i < values.size(); i++) {
|
||||||
|
if (i > 0) {
|
||||||
|
builder.append(", ");
|
||||||
|
}
|
||||||
|
builder.append(quoted(values.get(i)));
|
||||||
|
}
|
||||||
|
return builder.append("]").toString();
|
||||||
|
}
|
||||||
|
|
||||||
private static String quoted(String value) {
|
private static String quoted(String value) {
|
||||||
return "\"" + value
|
return "\"" + value
|
||||||
.replace("\\", "\\\\")
|
.replace("\\", "\\\\")
|
||||||
@@ -262,17 +323,29 @@ public record AppConfig(
|
|||||||
return primitive == null ? fallback : primitive.asString();
|
return primitive == null ? fallback : primitive.asString();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static int intValue(TomlTable table, String key, int fallback) {
|
/** Reads a TOML array of strings (e.g. {@code shell = ["/bin/bash", "-i"]}), or the fallback. */
|
||||||
TomlPrimitive primitive = primitive(table, key);
|
private static List<String> stringListValue(TomlTable table, String key, List<String> fallback) {
|
||||||
if (primitive == null) {
|
TomlValue value = table.get(key);
|
||||||
|
if (value == null || !value.isArray()) {
|
||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
|
List<String> result = new ArrayList<>();
|
||||||
|
for (TomlValue element : value.asArray()) {
|
||||||
|
if (element.isPrimitive()) {
|
||||||
try {
|
try {
|
||||||
return primitive.asInteger();
|
result.add(element.asPrimitive().asString());
|
||||||
} catch (RuntimeException ex) {
|
} catch (RuntimeException ignored) {
|
||||||
return fallback;
|
// Skip non-string entries; a shell command line is a list of strings.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
// An empty or all-invalid array would mean "no program to run"; keep the default instead.
|
||||||
|
return result.isEmpty() ? fallback : List.copyOf(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int intValue(TomlTable table, String key, int fallback) {
|
||||||
|
return (int) longValue(table, key, fallback);
|
||||||
|
}
|
||||||
|
|
||||||
private static long longValue(TomlTable table, String key, long fallback) {
|
private static long longValue(TomlTable table, String key, long fallback) {
|
||||||
TomlPrimitive primitive = primitive(table, key);
|
TomlPrimitive primitive = primitive(table, key);
|
||||||
|
|||||||
633
src/main/java/com/gregor/jprototerm/Compositor.java
Normal file
633
src/main/java/com/gregor/jprototerm/Compositor.java
Normal file
@@ -0,0 +1,633 @@
|
|||||||
|
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.Node;
|
||||||
|
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.HashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Owns the window's tabs and drives rendering and input. It composites only the current tab:
|
||||||
|
* 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();
|
||||||
|
// Kitty images are drawn as retained nodes layered over the canvas, not composited onto it.
|
||||||
|
private final KittyImageOverlay imageOverlay = new KittyImageOverlay();
|
||||||
|
private final AppConfig config;
|
||||||
|
private final TerminalMetrics metrics;
|
||||||
|
private final List<Tab> tabs = new ArrayList<>();
|
||||||
|
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<>();
|
||||||
|
// Off-screen panes (background tabs, hidden floating groups) keep their full-resolution pixel
|
||||||
|
// backbuffer + GPU image until released. We free them after a short grace period rather than the
|
||||||
|
// instant they're hidden, so rapidly flipping through tabs never thrashes the realloc/upload.
|
||||||
|
private static final long RELEASE_DELAY_NANOS = 750_000_000L;
|
||||||
|
// Hidden pane -> nanoTime it became hidden (the release timer); removed once released or shown.
|
||||||
|
private final Map<TerminalPane, Long> hiddenSince = new HashMap<>();
|
||||||
|
// Panes whose backbuffer is currently released, so we don't release again every frame.
|
||||||
|
private final Set<TerminalPane> released = new HashSet<>();
|
||||||
|
// layoutVersion at the last sweep: lets an idle, all-released steady state skip the scan.
|
||||||
|
private long lastSweepLayoutVersion = Long.MIN_VALUE;
|
||||||
|
// 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;
|
||||||
|
// Run when the last pane closes (so the window can quit). No-op until Main sets it.
|
||||||
|
private Runnable onEmpty = () -> {};
|
||||||
|
|
||||||
|
public Compositor(AppConfig config, TerminalMetrics metrics) {
|
||||||
|
this(config, metrics, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a compositor whose first tab's first pane starts in {@code workingDirectory} (e.g. the
|
||||||
|
* cwd a client passed when asking the daemon to open this window), or the user's home when
|
||||||
|
* {@code null}.
|
||||||
|
*/
|
||||||
|
public Compositor(AppConfig config, TerminalMetrics metrics, String workingDirectory) {
|
||||||
|
this.config = config;
|
||||||
|
this.metrics = metrics;
|
||||||
|
tabs.add(new Tab(config, metrics, workingDirectory, this::closePane));
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sets the callback run when the last pane closes (e.g. to quit the application). */
|
||||||
|
public void setOnEmpty(Runnable onEmpty) {
|
||||||
|
this.onEmpty = onEmpty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The kitty-image overlay, to be stacked directly above {@link #canvas()} in the window. */
|
||||||
|
public Node imageOverlay() {
|
||||||
|
return imageOverlay.node();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setFont(String family, double size) {
|
||||||
|
metrics.setFont(family, size);
|
||||||
|
paneContentVersion.clear();
|
||||||
|
layoutVersion++; // recomposite with the new metrics on the next frame
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Tabs and panes -------------------------------------------------------------
|
||||||
|
|
||||||
|
public boolean isEmpty() {
|
||||||
|
return tabs.isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The active pane of the current tab, or {@code null} when no tab is left. */
|
||||||
|
public TerminalPane activePane() {
|
||||||
|
return isEmpty() ? null : currentTab().activePane();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void navigate(Direction direction) {
|
||||||
|
if (!isEmpty() && currentTab().navigate(direction)) {
|
||||||
|
layoutVersion++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void toggleFloating() {
|
||||||
|
mutateCurrentTab(() -> currentTab().toggleFloating());
|
||||||
|
}
|
||||||
|
|
||||||
|
public void createPane() {
|
||||||
|
mutateCurrentTab(() -> currentTab().createPane());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens a floating pane running {@code command} directly (auto-closing when it exits), makes it
|
||||||
|
* active, and returns it (null when no tab exists).
|
||||||
|
*/
|
||||||
|
public TerminalPane openFloatingPane(String command) {
|
||||||
|
if (isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
TerminalPane pane = currentTab().createFloatingPane(command);
|
||||||
|
layoutVersion++;
|
||||||
|
return pane;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void nextFloatingPane() {
|
||||||
|
mutateCurrentTab(() -> currentTab().nextFloatingPane());
|
||||||
|
}
|
||||||
|
|
||||||
|
public void toggleActiveFloating() {
|
||||||
|
mutateCurrentTab(() -> currentTab().toggleActiveFloating());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run a structural change on the current tab and bump the layout version so the next frame
|
||||||
|
// recomposites. No-op when no tab is left.
|
||||||
|
private void mutateCurrentTab(Runnable change) {
|
||||||
|
if (isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
change.run();
|
||||||
|
layoutVersion++;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void closeActivePane() {
|
||||||
|
TerminalPane active = activePane();
|
||||||
|
if (active != null) {
|
||||||
|
closePane(active);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Closes a specific pane, wherever it lives. Driven both by the key-bound close (via
|
||||||
|
* {@link #closeActivePane()}) and by a pane whose process exited on its own. Drops the owning
|
||||||
|
* tab if it becomes empty, and fires {@link #setOnEmpty} when the last pane is gone. Must run on
|
||||||
|
* the FX thread.
|
||||||
|
*/
|
||||||
|
public void closePane(TerminalPane pane) {
|
||||||
|
for (int i = 0; i < tabs.size(); i++) {
|
||||||
|
Tab tab = tabs.get(i);
|
||||||
|
if (tab.closePane(pane)) {
|
||||||
|
if (tab.isEmpty()) {
|
||||||
|
// Closing a tab's last pane closes the tab. Keep currentTabIndex pointing at the
|
||||||
|
// same tab (or clamp it when the current/last tab went away).
|
||||||
|
tabs.remove(i);
|
||||||
|
if (i < currentTabIndex) {
|
||||||
|
currentTabIndex--;
|
||||||
|
} else if (currentTabIndex >= tabs.size()) {
|
||||||
|
currentTabIndex = Math.max(0, tabs.size() - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
layoutVersion++;
|
||||||
|
if (isEmpty()) {
|
||||||
|
onEmpty.run();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void newTab() {
|
||||||
|
// Open the new tab in the currently active pane's working directory, so it lands where the
|
||||||
|
// user currently is rather than always in home.
|
||||||
|
TerminalPane active = activePane();
|
||||||
|
String workingDirectory = active != null ? active.currentWorkingDirectory() : null;
|
||||||
|
tabs.add(new Tab(config, metrics, workingDirectory, this::closePane));
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signals and reaps every pane's shell process across all tabs, without tearing down render
|
||||||
|
* state. Intended for a JVM shutdown hook (SIGTERM/SIGINT/SIGHUP), so child shells get the
|
||||||
|
* configured close signal instead of being orphaned when jprototerm itself is killed. Safe to
|
||||||
|
* call off the FX thread and idempotent; see {@link TerminalPane#terminateSession()}.
|
||||||
|
*/
|
||||||
|
public void terminateSessions() {
|
||||||
|
for (Tab tab : List.copyOf(tabs)) {
|
||||||
|
tab.terminateSessions();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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() {
|
||||||
|
sweepHiddenPanes();
|
||||||
|
switch (nextFrameType()) {
|
||||||
|
case IDLE -> { }
|
||||||
|
case LAYOUT -> renderLayoutFrame();
|
||||||
|
case CONTENT -> renderContentFrame();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Free the backbuffer of any pane that has been off-screen past the grace period, and re-arm the
|
||||||
|
// timer for newly hidden panes. The next layout frame rebuilds a released pane (paintFull goes
|
||||||
|
// through ensure()), so showing a tab again is the only cost. Skips entirely once everything that
|
||||||
|
// can be hidden is already released and the layout hasn't changed, so an idle multi-tab window
|
||||||
|
// does no per-frame work here.
|
||||||
|
private void sweepHiddenPanes() {
|
||||||
|
if (layoutVersion == lastSweepLayoutVersion && hiddenSince.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastSweepLayoutVersion = layoutVersion;
|
||||||
|
|
||||||
|
// Fast path: a single tab compositing all of its panes has nothing off-screen.
|
||||||
|
if (tabs.size() <= 1 && (tabs.isEmpty() || !currentTab().hasHiddenPanes())) {
|
||||||
|
hiddenSince.clear();
|
||||||
|
released.clear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Set<TerminalPane> visible = new HashSet<>(currentPanes());
|
||||||
|
Set<TerminalPane> live = new HashSet<>();
|
||||||
|
long now = System.nanoTime();
|
||||||
|
for (Tab tab : tabs) {
|
||||||
|
for (TerminalPane pane : tab.allPanes()) {
|
||||||
|
live.add(pane);
|
||||||
|
if (visible.contains(pane)) {
|
||||||
|
hiddenSince.remove(pane);
|
||||||
|
released.remove(pane);
|
||||||
|
} else if (!released.contains(pane)) {
|
||||||
|
Long since = hiddenSince.putIfAbsent(pane, now);
|
||||||
|
if (since != null && now - since >= RELEASE_DELAY_NANOS) {
|
||||||
|
pane.releaseRenderResources();
|
||||||
|
released.add(pane);
|
||||||
|
hiddenSince.remove(pane);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Forget panes that have since closed.
|
||||||
|
hiddenSince.keySet().retainAll(live);
|
||||||
|
released.retainAll(live);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
paneContentVersion.put(pane, pane.paintFull(gc, isActive(pane)));
|
||||||
|
}
|
||||||
|
imageOverlay.sync(panes);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Repaint just the panes whose content changed, directly on the retained canvas. Each pane
|
||||||
|
// clips itself to its rect minus the panes above it, so a lower pane's repaint can't bleed
|
||||||
|
// over one stacked on top — no restore pass needed. Bounds and grids can't have changed
|
||||||
|
// without a layout frame, so a content frame reuses the existing layout untouched.
|
||||||
|
private void renderContentFrame() {
|
||||||
|
List<TerminalPane> panes = currentPanes();
|
||||||
|
GraphicsContext gc = beginFrame();
|
||||||
|
|
||||||
|
for (TerminalPane pane : panes) {
|
||||||
|
Long drawn = paneContentVersion.get(pane);
|
||||||
|
if (drawn != null && drawn == pane.contentVersion()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
paneContentVersion.put(pane, pane.paintIncremental(gc, isActive(pane)));
|
||||||
|
imageOverlay.updatePane(pane);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private GraphicsContext beginFrame() {
|
||||||
|
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(); // released outside every pane (e.g. mid-drag): route to the active one
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseButton button = pressedButton == MouseButton.UNKNOWN ? mouseButton(event) : pressedButton;
|
||||||
|
MouseTarget target = pane == null ? null : 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(); // dragged outside every pane: route to the active one
|
||||||
|
}
|
||||||
|
if (pane == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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++) {
|
||||||
|
if (!send(pane, target, MouseInput.press(wheelButton, ex, ey, modifiers), mouseButtonPressed, event)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
sent = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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) {
|
||||||
|
}
|
||||||
|
}
|
||||||
183
src/main/java/com/gregor/jprototerm/Daemon.java
Normal file
183
src/main/java/com/gregor/jprototerm/Daemon.java
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
package com.gregor.jprototerm;
|
||||||
|
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.net.StandardProtocolFamily;
|
||||||
|
import java.net.UnixDomainSocketAddress;
|
||||||
|
import java.nio.ByteBuffer;
|
||||||
|
import java.nio.channels.ServerSocketChannel;
|
||||||
|
import java.nio.channels.SocketChannel;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.LinkOption;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.attribute.PosixFilePermissions;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single-instance server and its thin client, over a per-user Unix domain socket. The server hosts
|
||||||
|
* every window in one JVM (see {@link WindowManager}); a client invocation just asks it to open a
|
||||||
|
* window in the client's working directory and exits, so the window appears without paying cold
|
||||||
|
* JVM/JavaFX/GL startup.
|
||||||
|
*
|
||||||
|
* <p>Protocol is deliberately trivial: the client writes one UTF-8 line — the absolute working
|
||||||
|
* directory — and the server replies {@code OK\n}. The socket lives under {@code XDG_RUNTIME_DIR}
|
||||||
|
* (mode 0700), so only the owning user can connect.
|
||||||
|
*/
|
||||||
|
public final class Daemon {
|
||||||
|
// One request is a single line holding a filesystem path; anything bigger is bogus.
|
||||||
|
private static final int MAX_REQUEST_BYTES = 4096;
|
||||||
|
// The accept loop is single-threaded, so a client that stalls must not wedge the daemon.
|
||||||
|
private static final long READ_TIMEOUT_NANOS = 5_000_000_000L;
|
||||||
|
|
||||||
|
private Daemon() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Runs the server: brings up the toolkit, binds the socket, and serves window-open requests. */
|
||||||
|
public static void run() {
|
||||||
|
Path socket = socketPath();
|
||||||
|
try {
|
||||||
|
Files.createDirectories(socket.getParent());
|
||||||
|
secureDir(socket.getParent());
|
||||||
|
} catch (IOException ex) {
|
||||||
|
System.err.println("jprototerm: cannot secure socket dir " + socket.getParent() + ": " + ex.getMessage());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
WindowManager manager = WindowManager.start(WindowManager.Mode.DAEMON);
|
||||||
|
|
||||||
|
try (ServerSocketChannel server = bind(socket)) {
|
||||||
|
while (true) {
|
||||||
|
try {
|
||||||
|
handle(server.accept(), manager);
|
||||||
|
} catch (IOException ex) {
|
||||||
|
System.err.println("jprototerm: connection error: " + ex.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (IOException ex) {
|
||||||
|
System.err.println("jprototerm: daemon socket error: " + ex.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client side: connect to a running daemon and ask it to open a window in {@code workingDirectory}.
|
||||||
|
* Returns {@code true} if the daemon handled it, {@code false} if none is reachable (the caller
|
||||||
|
* then falls back to a standalone in-process window).
|
||||||
|
*/
|
||||||
|
public static boolean tryClient(String workingDirectory) {
|
||||||
|
Path socket = socketPath();
|
||||||
|
if (!Files.exists(socket)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try (SocketChannel channel = SocketChannel.open(UnixDomainSocketAddress.of(socket))) {
|
||||||
|
channel.write(ByteBuffer.wrap((workingDirectory + "\n").getBytes(StandardCharsets.UTF_8)));
|
||||||
|
// Best-effort wait for the ack so we don't race ahead of the window opening.
|
||||||
|
channel.read(ByteBuffer.allocate(16));
|
||||||
|
return true;
|
||||||
|
} catch (IOException ex) {
|
||||||
|
return false; // no daemon, or a stale socket file — fall back to standalone
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ServerSocketChannel bind(Path socket) throws IOException {
|
||||||
|
UnixDomainSocketAddress address = UnixDomainSocketAddress.of(socket);
|
||||||
|
ServerSocketChannel channel = ServerSocketChannel.open(StandardProtocolFamily.UNIX);
|
||||||
|
try {
|
||||||
|
channel.bind(address);
|
||||||
|
return channel;
|
||||||
|
} catch (IOException firstTry) {
|
||||||
|
// The path is taken. If a live daemon answers, this invocation lost the race; otherwise
|
||||||
|
// it's a stale socket from a crashed daemon, so remove it and rebind.
|
||||||
|
if (tryClient0(socket)) {
|
||||||
|
channel.close();
|
||||||
|
System.err.println("jprototerm: a daemon is already running");
|
||||||
|
System.exit(0);
|
||||||
|
}
|
||||||
|
Files.deleteIfExists(socket);
|
||||||
|
channel.bind(address);
|
||||||
|
return channel;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A bare connect probe used by {@link #bind} to tell a live daemon from a stale socket file. */
|
||||||
|
private static boolean tryClient0(Path socket) {
|
||||||
|
try (SocketChannel channel = SocketChannel.open(UnixDomainSocketAddress.of(socket))) {
|
||||||
|
return true;
|
||||||
|
} catch (IOException ex) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void handle(SocketChannel connection, WindowManager manager) throws IOException {
|
||||||
|
try (connection) {
|
||||||
|
String workingDirectory = readLine(connection);
|
||||||
|
manager.openWindow(workingDirectory == null || workingDirectory.isBlank()
|
||||||
|
? null
|
||||||
|
: workingDirectory.trim());
|
||||||
|
connection.configureBlocking(true);
|
||||||
|
connection.write(ByteBuffer.wrap("OK\n".getBytes(StandardCharsets.UTF_8)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reads the request line non-blocking with a deadline and a size cap: the accept loop is
|
||||||
|
// single-threaded, so a client that stalls or never sends a newline must fail the connection
|
||||||
|
// (an IOException logged by run()) rather than wedge the daemon or grow the buffer unbounded.
|
||||||
|
private static String readLine(SocketChannel channel) throws IOException {
|
||||||
|
channel.configureBlocking(false);
|
||||||
|
long deadline = System.nanoTime() + READ_TIMEOUT_NANOS;
|
||||||
|
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||||
|
ByteBuffer buffer = ByteBuffer.allocate(4096);
|
||||||
|
while (true) {
|
||||||
|
int n = channel.read(buffer);
|
||||||
|
if (n > 0) {
|
||||||
|
buffer.flip();
|
||||||
|
while (buffer.hasRemaining()) {
|
||||||
|
byte b = buffer.get();
|
||||||
|
if (b == '\n') {
|
||||||
|
return out.toString(StandardCharsets.UTF_8);
|
||||||
|
}
|
||||||
|
out.write(b);
|
||||||
|
if (out.size() > MAX_REQUEST_BYTES) {
|
||||||
|
throw new IOException("request line too long");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
buffer.clear();
|
||||||
|
} else if (n == -1) {
|
||||||
|
return out.size() == 0 ? null : out.toString(StandardCharsets.UTF_8);
|
||||||
|
} else {
|
||||||
|
if (System.nanoTime() >= deadline) {
|
||||||
|
throw new IOException("request timed out");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Thread.sleep(5);
|
||||||
|
} catch (InterruptedException ex) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
throw new IOException("interrupted while reading request");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Path socketPath() {
|
||||||
|
String runtimeDir = System.getenv("XDG_RUNTIME_DIR");
|
||||||
|
Path dir = runtimeDir != null && !runtimeDir.isBlank()
|
||||||
|
? Path.of(runtimeDir, "jprototerm")
|
||||||
|
: Path.of("/tmp", "jprototerm-" + System.getProperty("user.name", "user"));
|
||||||
|
return dir.resolve("daemon.sock");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make the socket dir private, and refuse to use it if it is not ours. The /tmp fallback
|
||||||
|
// path is predictable, so another user could have pre-created it (the classic /tmp race);
|
||||||
|
// binding a socket inside a directory someone else owns would hand them control of it.
|
||||||
|
private static void secureDir(Path dir) throws IOException {
|
||||||
|
try {
|
||||||
|
Files.setPosixFilePermissions(dir, PosixFilePermissions.fromString("rwx------"));
|
||||||
|
} catch (UnsupportedOperationException ignored) {
|
||||||
|
return; // not a POSIX filesystem: nothing more we can check
|
||||||
|
}
|
||||||
|
String owner = Files.getOwner(dir, LinkOption.NOFOLLOW_LINKS).getName();
|
||||||
|
String user = System.getProperty("user.name");
|
||||||
|
if (!owner.equals(user)) {
|
||||||
|
throw new IOException(dir + " is owned by '" + owner + "', not '" + user + "'");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1117
src/main/java/com/gregor/jprototerm/GhosttyTerminalRenderer.java
Normal file
1117
src/main/java/com/gregor/jprototerm/GhosttyTerminalRenderer.java
Normal file
File diff suppressed because it is too large
Load Diff
104
src/main/java/com/gregor/jprototerm/GlyphCache.java
Normal file
104
src/main/java/com/gregor/jprototerm/GlyphCache.java
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
package com.gregor.jprototerm;
|
||||||
|
|
||||||
|
import javafx.scene.SnapshotParameters;
|
||||||
|
import javafx.scene.canvas.Canvas;
|
||||||
|
import javafx.scene.canvas.GraphicsContext;
|
||||||
|
import javafx.scene.image.PixelReader;
|
||||||
|
import javafx.scene.image.WritableImage;
|
||||||
|
import javafx.scene.paint.Color;
|
||||||
|
import javafx.scene.text.Font;
|
||||||
|
import javafx.scene.text.FontSmoothingType;
|
||||||
|
import javafx.scene.text.Text;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rasterized glyph alpha masks for one window's font, shared by every pane's renderer. The atlas is
|
||||||
|
* a pure function of the window's {@link TerminalMetrics} (font family/size, snapped cell geometry,
|
||||||
|
* baseline), and all panes in a window observe the same metrics, so a single shared cache lets N
|
||||||
|
* panes reuse one copy of each glyph instead of each rasterizing and retaining its own. It also
|
||||||
|
* means a pane whose backbuffer was released (see {@link GhosttyTerminalRenderer}) does not have to
|
||||||
|
* re-rasterize glyphs when it is shown again.
|
||||||
|
*
|
||||||
|
* <p>Rasterizing goes through JavaFX ({@link Canvas#snapshot}), so {@link #glyph} must be called on
|
||||||
|
* the FX thread — which is where all rendering happens. The cache self-invalidates when the metrics
|
||||||
|
* change (e.g. a font switch): the next lookup notices and clears.
|
||||||
|
*/
|
||||||
|
final class GlyphCache {
|
||||||
|
record Glyph(int width, int height, byte[] alpha) {
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bounds the atlas so pathological glyph diversity (e.g. a dump of distinct CJK/emoji cells)
|
||||||
|
// can't grow it without limit; on overflow it clears and rebuilds on demand, like any
|
||||||
|
// metrics change.
|
||||||
|
private static final int MAX_GLYPHS = 4096;
|
||||||
|
|
||||||
|
private final TerminalMetrics metrics;
|
||||||
|
private final Map<String, Glyph> glyphs = new HashMap<>();
|
||||||
|
// The metrics snapshot the cached glyphs were rasterized for; a mismatch clears the cache.
|
||||||
|
private Font font;
|
||||||
|
private int cellWidth;
|
||||||
|
private int lineHeight;
|
||||||
|
private double baseline;
|
||||||
|
|
||||||
|
GlyphCache(TerminalMetrics metrics) {
|
||||||
|
this.metrics = metrics;
|
||||||
|
}
|
||||||
|
|
||||||
|
Glyph glyph(String text) {
|
||||||
|
ensureCurrent();
|
||||||
|
Glyph cached = glyphs.get(text);
|
||||||
|
if (cached != null) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
if (glyphs.size() >= MAX_GLYPHS) {
|
||||||
|
glyphs.clear();
|
||||||
|
}
|
||||||
|
Glyph rendered = renderGlyph(text);
|
||||||
|
glyphs.put(text, rendered);
|
||||||
|
return rendered;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drop the rasterized masks if the font/cell geometry changed since they were built. Cheap to
|
||||||
|
// call per lookup: a no-op unless the window's metrics actually changed under us.
|
||||||
|
private void ensureCurrent() {
|
||||||
|
Font currentFont = metrics.font();
|
||||||
|
int currentCellWidth = Math.max(1, (int) Math.round(metrics.cellWidth()));
|
||||||
|
int currentLineHeight = Math.max(1, (int) Math.round(metrics.lineHeight()));
|
||||||
|
double currentBaseline = metrics.baselineOffset();
|
||||||
|
if (currentFont != font || currentCellWidth != cellWidth
|
||||||
|
|| currentLineHeight != lineHeight || currentBaseline != baseline) {
|
||||||
|
font = currentFont;
|
||||||
|
cellWidth = currentCellWidth;
|
||||||
|
lineHeight = currentLineHeight;
|
||||||
|
baseline = currentBaseline;
|
||||||
|
glyphs.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Glyph renderGlyph(String value) {
|
||||||
|
Text measured = new Text(value);
|
||||||
|
measured.setFont(font);
|
||||||
|
int glyphWidth = Math.max(cellWidth, (int) Math.ceil(measured.getLayoutBounds().getWidth()) + 2);
|
||||||
|
Canvas canvas = new Canvas(glyphWidth, lineHeight);
|
||||||
|
GraphicsContext gc = canvas.getGraphicsContext2D();
|
||||||
|
gc.setFontSmoothingType(FontSmoothingType.GRAY);
|
||||||
|
gc.setFont(font);
|
||||||
|
gc.setFill(Color.WHITE);
|
||||||
|
gc.fillText(value, 0.0, baseline);
|
||||||
|
|
||||||
|
SnapshotParameters parameters = new SnapshotParameters();
|
||||||
|
parameters.setFill(Color.TRANSPARENT);
|
||||||
|
WritableImage snapshot = canvas.snapshot(parameters, null);
|
||||||
|
PixelReader reader = snapshot.getPixelReader();
|
||||||
|
byte[] alpha = new byte[glyphWidth * lineHeight];
|
||||||
|
for (int y = 0; y < lineHeight; y++) {
|
||||||
|
int offset = y * glyphWidth;
|
||||||
|
for (int x = 0; x < glyphWidth; x++) {
|
||||||
|
alpha[offset + x] = (byte) ((reader.getArgb(x, y) >>> 24) & 0xff);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new Glyph(glyphWidth, lineHeight, alpha);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,17 +5,19 @@ import javafx.scene.input.KeyEvent;
|
|||||||
|
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
|
|
||||||
public record KeyBinding(boolean alt, boolean control, boolean shift, KeyCode code) {
|
public record KeyBinding(boolean alt, boolean control, boolean shift, boolean meta, KeyCode code) {
|
||||||
public static KeyBinding parse(String value) {
|
public static KeyBinding parse(String value) {
|
||||||
boolean alt = false;
|
boolean alt = false;
|
||||||
boolean control = false;
|
boolean control = false;
|
||||||
boolean shift = false;
|
boolean shift = false;
|
||||||
|
boolean meta = false;
|
||||||
KeyCode code = null;
|
KeyCode code = null;
|
||||||
|
|
||||||
for (String part : value.split("\\+")) {
|
for (String part : value.split("\\+")) {
|
||||||
String token = part.trim().toUpperCase(Locale.ROOT);
|
String token = part.trim().toUpperCase(Locale.ROOT);
|
||||||
switch (token) {
|
switch (token) {
|
||||||
case "ALT", "META" -> alt = true;
|
case "ALT" -> alt = true;
|
||||||
|
case "META", "SUPER" -> meta = true;
|
||||||
case "CTRL", "CONTROL" -> control = true;
|
case "CTRL", "CONTROL" -> control = true;
|
||||||
case "SHIFT" -> shift = true;
|
case "SHIFT" -> shift = true;
|
||||||
default -> code = keyCode(token);
|
default -> code = keyCode(token);
|
||||||
@@ -25,13 +27,14 @@ public record KeyBinding(boolean alt, boolean control, boolean shift, KeyCode co
|
|||||||
if (code == null) {
|
if (code == null) {
|
||||||
throw new IllegalArgumentException("Key binding has no key code: " + value);
|
throw new IllegalArgumentException("Key binding has no key code: " + value);
|
||||||
}
|
}
|
||||||
return new KeyBinding(alt, control, shift, code);
|
return new KeyBinding(alt, control, shift, meta, code);
|
||||||
}
|
}
|
||||||
|
|
||||||
public boolean matches(KeyEvent event) {
|
public boolean matches(KeyEvent event) {
|
||||||
return event.isAltDown() == alt
|
return event.isAltDown() == alt
|
||||||
&& event.isControlDown() == control
|
&& event.isControlDown() == control
|
||||||
&& event.isShiftDown() == shift
|
&& event.isShiftDown() == shift
|
||||||
|
&& event.isMetaDown() == meta
|
||||||
&& event.getCode() == code;
|
&& event.getCode() == code;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,6 +47,9 @@ public record KeyBinding(boolean alt, boolean control, boolean shift, KeyCode co
|
|||||||
if (alt) {
|
if (alt) {
|
||||||
builder.append("ALT+");
|
builder.append("ALT+");
|
||||||
}
|
}
|
||||||
|
if (meta) {
|
||||||
|
builder.append("META+");
|
||||||
|
}
|
||||||
if (shift) {
|
if (shift) {
|
||||||
builder.append("SHIFT+");
|
builder.append("SHIFT+");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ final class KeyEncoder {
|
|||||||
return switch (code) {
|
return switch (code) {
|
||||||
case ENTER -> "\r";
|
case ENTER -> "\r";
|
||||||
case BACK_SPACE -> "\u007f";
|
case BACK_SPACE -> "\u007f";
|
||||||
case TAB -> "\t";
|
case TAB -> event.isShiftDown() ? "\u001b[Z" : "\t";
|
||||||
case ESCAPE -> "\u001b";
|
case ESCAPE -> "\u001b";
|
||||||
case UP -> "\u001b[A";
|
case UP -> "\u001b[A";
|
||||||
case DOWN -> "\u001b[B";
|
case DOWN -> "\u001b[B";
|
||||||
|
|||||||
32
src/main/java/com/gregor/jprototerm/KittyImageNode.java
Normal file
32
src/main/java/com/gregor/jprototerm/KittyImageNode.java
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
package com.gregor.jprototerm;
|
||||||
|
|
||||||
|
import javafx.scene.image.Image;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single kitty image to display, produced by the renderer and consumed by {@link
|
||||||
|
* KittyImageOverlay}. Images are not painted onto the canvas; each becomes a retained
|
||||||
|
* {@code ImageView} node positioned over the pane. The {@code source*} fields are the region of
|
||||||
|
* {@link #image()} to show (in image pixels); the {@code x/y/width/height} are where to put it,
|
||||||
|
* in scene coordinates (the same space the pane's clip {@code Shape} lives in).
|
||||||
|
*
|
||||||
|
* <p>{@code imageId}+{@code placementId} identify the placement so the overlay can reuse the
|
||||||
|
* same node across frames instead of recreating it.
|
||||||
|
*/
|
||||||
|
record KittyImageNode(
|
||||||
|
long imageId,
|
||||||
|
long placementId,
|
||||||
|
Image image,
|
||||||
|
double sourceX,
|
||||||
|
double sourceY,
|
||||||
|
double sourceWidth,
|
||||||
|
double sourceHeight,
|
||||||
|
double x,
|
||||||
|
double y,
|
||||||
|
double width,
|
||||||
|
double height
|
||||||
|
) {
|
||||||
|
/** Stable per-pane key for node reuse. Packs the two u32 ids without collision. */
|
||||||
|
long key() {
|
||||||
|
return (imageId << 32) | (placementId & 0xffffffffL);
|
||||||
|
}
|
||||||
|
}
|
||||||
151
src/main/java/com/gregor/jprototerm/KittyImageOverlay.java
Normal file
151
src/main/java/com/gregor/jprototerm/KittyImageOverlay.java
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
package com.gregor.jprototerm;
|
||||||
|
|
||||||
|
import javafx.geometry.Rectangle2D;
|
||||||
|
import javafx.scene.Group;
|
||||||
|
import javafx.scene.Node;
|
||||||
|
import javafx.scene.image.ImageView;
|
||||||
|
import javafx.scene.layout.Pane;
|
||||||
|
import javafx.scene.shape.Rectangle;
|
||||||
|
import javafx.scene.shape.Shape;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.Iterator;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders kitty graphics images as retained scene-graph nodes layered over the {@link Compositor}
|
||||||
|
* canvas, instead of compositing them onto the canvas. Each pane gets a {@link Group} clipped to
|
||||||
|
* that pane's region (the same clip {@code Shape} the canvas renderer uses), and each visible
|
||||||
|
* image placement is an {@link ImageView} inside it, reused across frames so an unchanged image
|
||||||
|
* costs nothing to redraw.
|
||||||
|
*
|
||||||
|
* <p>The overlay {@link #node()} is mouse-transparent and sits above the canvas in the window's
|
||||||
|
* {@code StackPane}; its children use scene coordinates, which line up with the canvas because
|
||||||
|
* both fill the same root.
|
||||||
|
*/
|
||||||
|
final class KittyImageOverlay {
|
||||||
|
private final Pane root = new Pane();
|
||||||
|
private final Map<TerminalPane, PaneOverlay> overlays = new HashMap<>();
|
||||||
|
|
||||||
|
KittyImageOverlay() {
|
||||||
|
// Input belongs to the canvas underneath; the overlay only shows pixels.
|
||||||
|
root.setMouseTransparent(true);
|
||||||
|
root.setManaged(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
Node node() {
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full reconcile to {@code panes} (bottom-to-top): drop overlays for panes that went away,
|
||||||
|
* refresh each surviving/added pane's images and clip, and order the per-pane groups to match
|
||||||
|
* the pane z-order. Called on layout frames, after the panes have painted.
|
||||||
|
*/
|
||||||
|
void sync(List<TerminalPane> panes) {
|
||||||
|
Iterator<Map.Entry<TerminalPane, PaneOverlay>> it = overlays.entrySet().iterator();
|
||||||
|
while (it.hasNext()) {
|
||||||
|
Map.Entry<TerminalPane, PaneOverlay> entry = it.next();
|
||||||
|
if (!panes.contains(entry.getKey())) {
|
||||||
|
root.getChildren().remove(entry.getValue().group);
|
||||||
|
it.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (TerminalPane pane : panes) {
|
||||||
|
updatePane(pane);
|
||||||
|
}
|
||||||
|
// Only panes that actually have images get a group; order those to match pane z-order.
|
||||||
|
List<Node> ordered = new ArrayList<>(panes.size());
|
||||||
|
for (TerminalPane pane : panes) {
|
||||||
|
PaneOverlay overlay = overlays.get(pane);
|
||||||
|
if (overlay != null) {
|
||||||
|
ordered.add(overlay.group);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!root.getChildren().equals(ordered)) {
|
||||||
|
root.getChildren().setAll(ordered);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh one pane's images and clip (called on content frames for each repainted pane).
|
||||||
|
* Creates the pane's group if this is the first time it has shown an image.
|
||||||
|
*/
|
||||||
|
void updatePane(TerminalPane pane) {
|
||||||
|
List<KittyImageNode> images = pane.kittyImages();
|
||||||
|
PaneOverlay overlay = overlays.get(pane);
|
||||||
|
if (overlay == null) {
|
||||||
|
if (images.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
overlay = new PaneOverlay();
|
||||||
|
overlays.put(pane, overlay);
|
||||||
|
root.getChildren().add(overlay.group);
|
||||||
|
}
|
||||||
|
overlay.group.setClip(clipFor(pane));
|
||||||
|
reconcile(overlay, images);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void reconcile(PaneOverlay overlay, List<KittyImageNode> images) {
|
||||||
|
Set<Long> seen = new HashSet<>();
|
||||||
|
for (KittyImageNode node : images) {
|
||||||
|
long key = node.key();
|
||||||
|
seen.add(key);
|
||||||
|
ImageView view = overlay.views.get(key);
|
||||||
|
if (view == null) {
|
||||||
|
view = new ImageView();
|
||||||
|
view.setManaged(false);
|
||||||
|
view.setSmooth(true);
|
||||||
|
view.setPreserveRatio(false);
|
||||||
|
overlay.views.put(key, view);
|
||||||
|
overlay.group.getChildren().add(view);
|
||||||
|
}
|
||||||
|
apply(view, node);
|
||||||
|
}
|
||||||
|
if (overlay.views.size() == seen.size()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Iterator<Map.Entry<Long, ImageView>> it = overlay.views.entrySet().iterator();
|
||||||
|
while (it.hasNext()) {
|
||||||
|
Map.Entry<Long, ImageView> entry = it.next();
|
||||||
|
if (!seen.contains(entry.getKey())) {
|
||||||
|
overlay.group.getChildren().remove(entry.getValue());
|
||||||
|
it.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void apply(ImageView view, KittyImageNode node) {
|
||||||
|
if (view.getImage() != node.image()) {
|
||||||
|
view.setImage(node.image());
|
||||||
|
}
|
||||||
|
view.setViewport(new Rectangle2D(node.sourceX(), node.sourceY(), node.sourceWidth(), node.sourceHeight()));
|
||||||
|
view.setFitWidth(node.width());
|
||||||
|
view.setFitHeight(node.height());
|
||||||
|
view.setLayoutX(node.x());
|
||||||
|
view.setLayoutY(node.y());
|
||||||
|
}
|
||||||
|
|
||||||
|
// The pane's occlusion clip when one is set (rect minus covering panes), else the pane's
|
||||||
|
// plain bounds so an image can't spill outside its pane. Matches Tab's pixel snapping.
|
||||||
|
private static Shape clipFor(TerminalPane pane) {
|
||||||
|
Shape clip = pane.clip();
|
||||||
|
if (clip != null) {
|
||||||
|
return clip;
|
||||||
|
}
|
||||||
|
return new Rectangle(Math.round(pane.x()), Math.round(pane.y()), pane.width(), pane.height());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class PaneOverlay {
|
||||||
|
private final Group group = new Group();
|
||||||
|
private final Map<Long, ImageView> views = new HashMap<>();
|
||||||
|
|
||||||
|
private PaneOverlay() {
|
||||||
|
group.setManaged(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,9 +9,14 @@ import java.lang.foreign.MemorySegment;
|
|||||||
import java.lang.foreign.SymbolLookup;
|
import java.lang.foreign.SymbolLookup;
|
||||||
import java.lang.foreign.ValueLayout;
|
import java.lang.foreign.ValueLayout;
|
||||||
import java.lang.invoke.MethodHandle;
|
import java.lang.invoke.MethodHandle;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.ExecutorService;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A Linux PTY backed by libc via the Foreign Function & Memory API.
|
* A Linux PTY backed by libc via the Foreign Function & Memory API.
|
||||||
@@ -30,6 +35,11 @@ import java.util.Map;
|
|||||||
public final class LinuxPty implements AutoCloseable {
|
public final class LinuxPty implements AutoCloseable {
|
||||||
static final Linker LINKER = Linker.nativeLinker();
|
static final Linker LINKER = Linker.nativeLinker();
|
||||||
private static final SymbolLookup LIBC = LINKER.defaultLookup();
|
private static final SymbolLookup LIBC = LINKER.defaultLookup();
|
||||||
|
private static final ExecutorService REAPER = Executors.newCachedThreadPool(runnable -> {
|
||||||
|
Thread thread = new Thread(runnable, "pty-reaper");
|
||||||
|
thread.setDaemon(true);
|
||||||
|
return thread;
|
||||||
|
});
|
||||||
|
|
||||||
static final AddressLayout C_POINTER = (AddressLayout) LINKER.canonicalLayouts().get("void*");
|
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.OfShort C_SHORT = (ValueLayout.OfShort) LINKER.canonicalLayouts().get("short");
|
||||||
@@ -60,7 +70,10 @@ public final class LinuxPty implements AutoCloseable {
|
|||||||
private static final long TIOCSWINSZ = 0x5414L;
|
private static final long TIOCSWINSZ = 0x5414L;
|
||||||
private static final short POSIX_SPAWN_SETSID = 0x80;
|
private static final short POSIX_SPAWN_SETSID = 0x80;
|
||||||
private static final int SIGHUP = 1;
|
private static final int SIGHUP = 1;
|
||||||
|
private static final int SIGINT = 2;
|
||||||
|
private static final int SIGQUIT = 3;
|
||||||
private static final int SIGKILL = 9;
|
private static final int SIGKILL = 9;
|
||||||
|
private static final int SIGTERM = 15;
|
||||||
private static final int WNOHANG = 1;
|
private static final int WNOHANG = 1;
|
||||||
|
|
||||||
// struct winsize { unsigned short ws_row, ws_col, ws_xpixel, ws_ypixel; }
|
// struct winsize { unsigned short ws_row, ws_col, ws_xpixel, ws_ypixel; }
|
||||||
@@ -74,6 +87,7 @@ public final class LinuxPty implements AutoCloseable {
|
|||||||
private static final long SPAWN_ACTIONS_SIZE = 256;
|
private static final long SPAWN_ACTIONS_SIZE = 256;
|
||||||
private static final long SPAWN_ATTR_SIZE = 512;
|
private static final long SPAWN_ATTR_SIZE = 512;
|
||||||
|
|
||||||
|
private static final MethodHandle TCGETPGRP = handle("tcgetpgrp", FD_INT_INT);
|
||||||
private static final MethodHandle POSIX_OPENPT = handle("posix_openpt", FD_INT_INT);
|
private static final MethodHandle POSIX_OPENPT = handle("posix_openpt", FD_INT_INT);
|
||||||
private static final MethodHandle GRANTPT = handle("grantpt", FD_INT_INT);
|
private static final MethodHandle GRANTPT = handle("grantpt", FD_INT_INT);
|
||||||
private static final MethodHandle UNLOCKPT = handle("unlockpt", FD_INT_INT);
|
private static final MethodHandle UNLOCKPT = handle("unlockpt", FD_INT_INT);
|
||||||
@@ -97,14 +111,40 @@ public final class LinuxPty implements AutoCloseable {
|
|||||||
|
|
||||||
private final Arena arena = Arena.ofShared();
|
private final Arena arena = Arena.ofShared();
|
||||||
private final MemorySegment readBuffer = arena.allocate(65536);
|
private final MemorySegment readBuffer = arena.allocate(65536);
|
||||||
|
private final MemorySegment writeBuffer = arena.allocate(65536);
|
||||||
private final Object writeLock = new Object();
|
private final Object writeLock = new Object();
|
||||||
private final int masterFd;
|
private final int masterFd;
|
||||||
private final int pid;
|
private final int pid;
|
||||||
|
private final int closeSignal;
|
||||||
private volatile boolean closed;
|
private volatile boolean closed;
|
||||||
|
|
||||||
private LinuxPty(int masterFd, int pid) {
|
private LinuxPty(int masterFd, int pid, int closeSignal) {
|
||||||
this.masterFd = masterFd;
|
this.masterFd = masterFd;
|
||||||
this.pid = pid;
|
this.pid = pid;
|
||||||
|
this.closeSignal = closeSignal;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves a signal name (e.g. {@code "SIGTERM"}, {@code "TERM"}, {@code "SIGKILL"}) to its
|
||||||
|
* Linux signal number, or {@code -1} if the name is not one we recognise. Case-insensitive and
|
||||||
|
* tolerant of a missing {@code SIG} prefix.
|
||||||
|
*/
|
||||||
|
public static int signalNumber(String name) {
|
||||||
|
if (name == null) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
String normalized = name.trim().toUpperCase(java.util.Locale.ROOT);
|
||||||
|
if (normalized.startsWith("SIG")) {
|
||||||
|
normalized = normalized.substring(3);
|
||||||
|
}
|
||||||
|
return switch (normalized) {
|
||||||
|
case "HUP" -> SIGHUP;
|
||||||
|
case "INT" -> SIGINT;
|
||||||
|
case "QUIT" -> SIGQUIT;
|
||||||
|
case "KILL" -> SIGKILL;
|
||||||
|
case "TERM" -> SIGTERM;
|
||||||
|
default -> -1;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -113,8 +153,10 @@ public final class LinuxPty implements AutoCloseable {
|
|||||||
* @param argv command and arguments (e.g. {@code {"/bin/zsh", "-i"}})
|
* @param argv command and arguments (e.g. {@code {"/bin/zsh", "-i"}})
|
||||||
* @param environment environment for the child, as KEY=VALUE pairs
|
* @param environment environment for the child, as KEY=VALUE pairs
|
||||||
* @param workingDirectory directory the child starts in, or {@code null} to inherit
|
* @param workingDirectory directory the child starts in, or {@code null} to inherit
|
||||||
|
* @param closeSignal signal number sent to the child on {@link #close()} (e.g. SIGTERM)
|
||||||
*/
|
*/
|
||||||
public static LinuxPty spawn(String[] argv, Map<String, String> environment, String workingDirectory) {
|
public static LinuxPty spawn(String[] argv, Map<String, String> environment, String workingDirectory,
|
||||||
|
int closeSignal) {
|
||||||
Arena setup = Arena.ofConfined();
|
Arena setup = Arena.ofConfined();
|
||||||
try {
|
try {
|
||||||
int master = check(callInt(POSIX_OPENPT, O_RDWR | O_NOCTTY), "posix_openpt");
|
int master = check(callInt(POSIX_OPENPT, O_RDWR | O_NOCTTY), "posix_openpt");
|
||||||
@@ -153,7 +195,7 @@ public final class LinuxPty implements AutoCloseable {
|
|||||||
if (rc != 0) {
|
if (rc != 0) {
|
||||||
throw new IllegalStateException("posix_spawnp failed for " + argv[0] + " (rc=" + rc + ")");
|
throw new IllegalStateException("posix_spawnp failed for " + argv[0] + " (rc=" + rc + ")");
|
||||||
}
|
}
|
||||||
return new LinuxPty(master, pidOut.get(C_INT, 0));
|
return new LinuxPty(master, pidOut.get(C_INT, 0), closeSignal);
|
||||||
} finally {
|
} finally {
|
||||||
callInt(ATTR_DESTROY, attr);
|
callInt(ATTR_DESTROY, attr);
|
||||||
callInt(FA_DESTROY, actions);
|
callInt(FA_DESTROY, actions);
|
||||||
@@ -186,19 +228,41 @@ public final class LinuxPty implements AutoCloseable {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
synchronized (writeLock) {
|
synchronized (writeLock) {
|
||||||
try (Arena a = Arena.ofConfined()) {
|
int offset = 0;
|
||||||
MemorySegment buf = a.allocate(data.length);
|
|
||||||
MemorySegment.copy(data, 0, buf, ValueLayout.JAVA_BYTE, 0, data.length);
|
|
||||||
long offset = 0;
|
|
||||||
while (offset < data.length) {
|
while (offset < data.length) {
|
||||||
long n = callLong(WRITE, masterFd, buf.asSlice(offset), data.length - offset);
|
int chunk = (int) Math.min(writeBuffer.byteSize(), data.length - offset);
|
||||||
if (n < 0) {
|
MemorySegment.copy(data, offset, writeBuffer, ValueLayout.JAVA_BYTE, 0, chunk);
|
||||||
|
|
||||||
|
long written = 0;
|
||||||
|
while (written < chunk) {
|
||||||
|
long n = callLong(WRITE, masterFd, writeBuffer.asSlice(written), chunk - written);
|
||||||
|
if (n <= 0) {
|
||||||
throw new IllegalStateException("write to pty failed");
|
throw new IllegalStateException("write to pty failed");
|
||||||
}
|
}
|
||||||
offset += n;
|
written += n;
|
||||||
|
}
|
||||||
|
offset += chunk;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Best-effort current working directory of the terminal's foreground process group, read from
|
||||||
|
* {@code /proc}. This tracks the directory the user is actually in (a {@code cd} in the shell,
|
||||||
|
* or a child program that changed dir), so a newly opened pane can start there. Falls back to
|
||||||
|
* the shell's own pid, and returns {@code null} if it cannot be determined.
|
||||||
|
*/
|
||||||
|
public String currentWorkingDirectory() {
|
||||||
|
if (closed) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
int pgid = callInt(TCGETPGRP, masterFd);
|
||||||
|
int target = pgid > 0 ? pgid : pid;
|
||||||
|
try {
|
||||||
|
return Files.readSymbolicLink(Path.of("/proc", Integer.toString(target), "cwd")).toString();
|
||||||
|
} catch (IOException | RuntimeException ex) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Resizes the terminal window. */
|
/** Resizes the terminal window. */
|
||||||
@@ -218,15 +282,48 @@ public final class LinuxPty implements AutoCloseable {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void close() {
|
public void close() {
|
||||||
if (closed) {
|
if (!markClosed()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
closed = true;
|
closeMaster();
|
||||||
callKill(pid, SIGHUP);
|
try {
|
||||||
callInt(CLOSE, masterFd);
|
|
||||||
reap();
|
reap();
|
||||||
|
} finally {
|
||||||
arena.close();
|
arena.close();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Send the configured close signal and close the master fd now; reap off the caller thread. */
|
||||||
|
public void closeDetached() {
|
||||||
|
if (!markClosed()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
closeMaster();
|
||||||
|
REAPER.submit(() -> {
|
||||||
|
try {
|
||||||
|
reap();
|
||||||
|
} finally {
|
||||||
|
arena.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private synchronized boolean markClosed() {
|
||||||
|
if (closed) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
closed = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void closeMaster() {
|
||||||
|
// Note: closing the master fd does NOT wake a reader thread blocked in read() on it —
|
||||||
|
// the reader unblocks via EOF when the child exits and the slave end closes. The signal
|
||||||
|
// here usually does that; if the child ignores it, the SIGKILL escalation in reap()
|
||||||
|
// guarantees it shortly after.
|
||||||
|
callKill(pid, closeSignal);
|
||||||
|
callInt(CLOSE, masterFd);
|
||||||
|
}
|
||||||
|
|
||||||
private void reap() {
|
private void reap() {
|
||||||
try (Arena a = Arena.ofConfined()) {
|
try (Arena a = Arena.ofConfined()) {
|
||||||
|
|||||||
@@ -1,185 +1,36 @@
|
|||||||
package com.gregor.jprototerm;
|
package com.gregor.jprototerm;
|
||||||
|
|
||||||
import javafx.animation.AnimationTimer;
|
/**
|
||||||
import javafx.application.Application;
|
* Entry point and mode dispatch. A bare invocation is a thin client: it hands the request to a
|
||||||
import javafx.scene.Scene;
|
* running {@link Daemon}, or, if none is reachable, opens a single standalone window in this process
|
||||||
import javafx.scene.control.ButtonType;
|
* (today's behavior). {@code --daemon} runs the long-lived server that hosts every window in one
|
||||||
import javafx.scene.control.ComboBox;
|
* JVM, so client launches skip cold JVM/JavaFX/GL startup. {@code --standalone} skips daemon client
|
||||||
import javafx.scene.control.Dialog;
|
* mode and always opens an in-process window, which is useful while testing development builds.
|
||||||
import javafx.scene.control.Label;
|
*/
|
||||||
import javafx.scene.control.Spinner;
|
public final class Main {
|
||||||
import javafx.scene.control.SpinnerValueFactory;
|
private Main() {
|
||||||
import javafx.scene.input.KeyEvent;
|
|
||||||
import javafx.scene.layout.GridPane;
|
|
||||||
import javafx.scene.layout.StackPane;
|
|
||||||
import javafx.scene.text.Font;
|
|
||||||
import javafx.stage.Stage;
|
|
||||||
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.nio.file.Files;
|
|
||||||
import java.nio.file.Path;
|
|
||||||
|
|
||||||
public final class Main extends Application {
|
|
||||||
private TerminalWorkspace workspace;
|
|
||||||
private TerminalCanvasView terminalView;
|
|
||||||
private AppConfig config;
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void start(Stage stage) {
|
|
||||||
config = AppConfig.load();
|
|
||||||
|
|
||||||
workspace = new TerminalWorkspace(config);
|
|
||||||
terminalView = new TerminalCanvasView(workspace, config);
|
|
||||||
|
|
||||||
StackPane root = new StackPane(terminalView.canvas());
|
|
||||||
terminalView.canvas().widthProperty().bind(root.widthProperty());
|
|
||||||
terminalView.canvas().heightProperty().bind(root.heightProperty());
|
|
||||||
|
|
||||||
Scene scene = new Scene(root, config.windowWidth(), config.windowHeight());
|
|
||||||
scene.addEventFilter(KeyEvent.KEY_PRESSED, this::handlePressed);
|
|
||||||
scene.addEventFilter(KeyEvent.KEY_TYPED, event -> handleTyped(event));
|
|
||||||
|
|
||||||
new AnimationTimer() {
|
|
||||||
@Override
|
|
||||||
public void handle(long now) {
|
|
||||||
terminalView.render();
|
|
||||||
}
|
|
||||||
}.start();
|
|
||||||
|
|
||||||
stage.setTitle("jprototerm");
|
|
||||||
stage.setScene(scene);
|
|
||||||
stage.setOnCloseRequest(event -> {
|
|
||||||
workspace.close();
|
|
||||||
});
|
|
||||||
stage.show();
|
|
||||||
terminalView.canvas().requestFocus();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void handlePressed(KeyEvent event) {
|
|
||||||
if (config.keybindings().get("navigate_left").matches(event)) {
|
|
||||||
workspace.navigate(Direction.LEFT);
|
|
||||||
event.consume();
|
|
||||||
} else if (config.keybindings().get("navigate_down").matches(event)) {
|
|
||||||
workspace.navigate(Direction.DOWN);
|
|
||||||
event.consume();
|
|
||||||
} else if (config.keybindings().get("navigate_up").matches(event)) {
|
|
||||||
workspace.navigate(Direction.UP);
|
|
||||||
event.consume();
|
|
||||||
} else if (config.keybindings().get("navigate_right").matches(event)) {
|
|
||||||
workspace.navigate(Direction.RIGHT);
|
|
||||||
event.consume();
|
|
||||||
} else if (config.keybindings().get("toggle_floating").matches(event)) {
|
|
||||||
workspace.toggleFloating();
|
|
||||||
event.consume();
|
|
||||||
} else if (config.keybindings().get("new_floating").matches(event)) {
|
|
||||||
workspace.createFloatingPane();
|
|
||||||
event.consume();
|
|
||||||
} else if (config.keybindings().get("next_floating").matches(event)) {
|
|
||||||
workspace.nextFloatingPane();
|
|
||||||
event.consume();
|
|
||||||
} else if (config.keybindings().get("close_pane").matches(event)) {
|
|
||||||
workspace.closeActivePane();
|
|
||||||
event.consume();
|
|
||||||
} else if (config.keybindings().get("open_font_selector").matches(event)) {
|
|
||||||
openFontSelector();
|
|
||||||
event.consume();
|
|
||||||
} else if (config.keybindings().get("open_scrollback").matches(event)) {
|
|
||||||
openScrollbackInEditor();
|
|
||||||
event.consume();
|
|
||||||
} else {
|
|
||||||
String encoded = KeyEncoder.encode(event);
|
|
||||||
if (encoded != null) {
|
|
||||||
workspace.activePane().send(encoded);
|
|
||||||
event.consume();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void handleTyped(KeyEvent event) {
|
|
||||||
if (event.isAltDown() || event.isControlDown() || event.isMetaDown()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
String text = event.getCharacter();
|
|
||||||
if (text != null && !text.isEmpty() && text.charAt(0) >= 0x20 && text.charAt(0) != 0x7f) {
|
|
||||||
workspace.activePane().send(text);
|
|
||||||
event.consume();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void openFontSelector() {
|
|
||||||
Dialog<ButtonType> dialog = new Dialog<>();
|
|
||||||
dialog.setTitle("Font");
|
|
||||||
dialog.getDialogPane().getButtonTypes().addAll(ButtonType.OK, ButtonType.CANCEL);
|
|
||||||
|
|
||||||
ComboBox<String> family = new ComboBox<>();
|
|
||||||
family.getItems().setAll(Font.getFamilies());
|
|
||||||
family.setEditable(true);
|
|
||||||
family.setMaxWidth(Double.MAX_VALUE);
|
|
||||||
family.setValue(config.fontFamily());
|
|
||||||
|
|
||||||
Spinner<Double> size = new Spinner<>();
|
|
||||||
size.setEditable(true);
|
|
||||||
size.setValueFactory(new SpinnerValueFactory.DoubleSpinnerValueFactory(6.0, 48.0, config.fontSize(), 0.5));
|
|
||||||
|
|
||||||
GridPane content = new GridPane();
|
|
||||||
content.setHgap(10.0);
|
|
||||||
content.setVgap(10.0);
|
|
||||||
content.add(new Label("Family"), 0, 0);
|
|
||||||
content.add(family, 1, 0);
|
|
||||||
content.add(new Label("Size"), 0, 1);
|
|
||||||
content.add(size, 1, 1);
|
|
||||||
dialog.getDialogPane().setContent(content);
|
|
||||||
|
|
||||||
dialog.showAndWait()
|
|
||||||
.filter(button -> button == ButtonType.OK)
|
|
||||||
.ifPresent(ignored -> {
|
|
||||||
String selectedFamily = family.getEditor().getText();
|
|
||||||
if (selectedFamily == null || selectedFamily.isBlank()) {
|
|
||||||
selectedFamily = family.getValue();
|
|
||||||
}
|
|
||||||
if (selectedFamily == null || selectedFamily.isBlank()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
double selectedSize = size.getValue();
|
|
||||||
config = config.withFont(selectedFamily.trim(), selectedSize);
|
|
||||||
config.save();
|
|
||||||
terminalView.setFont(config.fontFamily(), config.fontSize());
|
|
||||||
terminalView.canvas().requestFocus();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private void openScrollbackInEditor() {
|
|
||||||
try {
|
|
||||||
Path file = Files.createTempFile("jprototerm-scrollback-", ".txt");
|
|
||||||
Files.writeString(file, workspace.activePane().scrollbackText());
|
|
||||||
file.toFile().deleteOnExit();
|
|
||||||
|
|
||||||
workspace.activePane().send(scrollbackEditorCommand(file) + "\r");
|
|
||||||
} catch (IOException ex) {
|
|
||||||
System.err.println("Could not open scrollback in editor: " + ex.getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private String scrollbackEditorCommand(Path file) {
|
|
||||||
String quotedFile = shellQuote(file.toString());
|
|
||||||
String command = config.scrollbackEditorCommand();
|
|
||||||
if (command == null || command.isBlank()) {
|
|
||||||
command = "vi {file}";
|
|
||||||
}
|
|
||||||
if (command.contains("{file}")) {
|
|
||||||
return command.replace("{file}", quotedFile);
|
|
||||||
}
|
|
||||||
return command + " " + quotedFile;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String shellQuote(String value) {
|
|
||||||
return "'" + value.replace("'", "'\"'\"'") + "'";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void main(String[] args) {
|
public static void main(String[] args) {
|
||||||
|
// Match the renderer order the app was tuned for; honor an explicit override if present.
|
||||||
System.setProperty("prism.order", System.getProperty("prism.order", "es2,sw"));
|
System.setProperty("prism.order", System.getProperty("prism.order", "es2,sw"));
|
||||||
launch(Main.class, args);
|
|
||||||
|
boolean standalone = false;
|
||||||
|
for (String arg : args) {
|
||||||
|
if (arg.equals("--daemon")) {
|
||||||
|
Daemon.run();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (arg.equals("--standalone")) {
|
||||||
|
standalone = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String workingDirectory = System.getProperty("user.dir");
|
||||||
|
if (!standalone && Daemon.tryClient(workingDirectory)) {
|
||||||
|
return; // a running daemon opened the window
|
||||||
|
}
|
||||||
|
// No daemon reachable: fall back to a standalone window; the JVM exits when it closes.
|
||||||
|
WindowManager.start(WindowManager.Mode.STANDALONE).openWindow(workingDirectory);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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,9 +1,8 @@
|
|||||||
package com.gregor.jprototerm;
|
package com.gregor.jprototerm;
|
||||||
|
|
||||||
import javafx.application.Platform;
|
|
||||||
|
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.concurrent.ExecutorService;
|
import java.util.concurrent.ExecutorService;
|
||||||
import java.util.concurrent.Executors;
|
import java.util.concurrent.Executors;
|
||||||
@@ -22,23 +21,100 @@ public final class ShellSession implements AutoCloseable {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public static ShellSession start(String shell, Map<String, String> envOverride, TerminalPane pane, int columns, int rows) {
|
/**
|
||||||
|
* Starts the configured shell. {@code shellCommand} is the executable plus its arguments (e.g.
|
||||||
|
* {@code ["/bin/bash", "-i"]}), spawned verbatim — any interactive flag is the user's choice in
|
||||||
|
* config, not assumed here.
|
||||||
|
*/
|
||||||
|
public static ShellSession start(List<String> shellCommand, Map<String, String> envOverride, TerminalPane pane,
|
||||||
|
int columns, int rows, String workingDirectory, int closeSignal) {
|
||||||
try {
|
try {
|
||||||
|
return spawn(shellCommand.toArray(new String[0]), envOverride, columns, rows, workingDirectory,
|
||||||
|
closeSignal);
|
||||||
|
} catch (RuntimeException ex) {
|
||||||
|
pane.write("failed to start shell: " + ex.getMessage() + "\r\n");
|
||||||
|
throw new IllegalStateException("Could not start shell " + String.join(" ", shellCommand), ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts a session whose first and only process is {@code /bin/sh -c command}, so the program
|
||||||
|
* runs deterministically from the start rather than being typed into an interactive shell —
|
||||||
|
* there is no startup/rc race to lose or mangle the input. When the process exits the pty
|
||||||
|
* closes and the pane auto-closes. {@code /bin/sh -c} is used (not the user's configured shell)
|
||||||
|
* because it is the portable way to run a command line and does not depend on shell-specific
|
||||||
|
* flags. {@code command} must not be null.
|
||||||
|
*/
|
||||||
|
public static ShellSession startCommand(Map<String, String> envOverride, TerminalPane pane,
|
||||||
|
int columns, int rows, String workingDirectory, String command, int closeSignal) {
|
||||||
|
try {
|
||||||
|
return spawn(new String[] {"/bin/sh", "-c", command}, envOverride, columns, rows, workingDirectory,
|
||||||
|
closeSignal);
|
||||||
|
} catch (RuntimeException ex) {
|
||||||
|
pane.write("failed to run command: " + ex.getMessage() + "\r\n");
|
||||||
|
throw new IllegalStateException("Could not run command: " + command, ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ShellSession spawn(String[] argv, Map<String, String> envOverride,
|
||||||
|
int columns, int rows, String workingDirectory, int closeSignal) {
|
||||||
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);
|
environment.putAll(envOverride);
|
||||||
|
|
||||||
LinuxPty pty = LinuxPty.spawn(
|
LinuxPty pty = LinuxPty.spawn(
|
||||||
new String[] {shell, "-i"},
|
argv,
|
||||||
environment,
|
environment,
|
||||||
System.getProperty("user.home"));
|
workingDirectory != null ? workingDirectory : System.getProperty("user.home"),
|
||||||
|
closeSignal);
|
||||||
ShellSession session = new ShellSession(pty);
|
ShellSession session = new ShellSession(pty);
|
||||||
session.resize(columns, rows);
|
session.resize(columns, rows);
|
||||||
return session;
|
return session;
|
||||||
} catch (RuntimeException ex) {
|
}
|
||||||
pane.write("failed to start shell: " + ex.getMessage() + "\r\n");
|
|
||||||
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");
|
||||||
|
sanitizeJavaToolOptions(environment);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void sanitizeJavaToolOptions(Map<String, String> environment) {
|
||||||
|
String javaToolOptions = environment.get("JAVA_TOOL_OPTIONS");
|
||||||
|
if (javaToolOptions == null
|
||||||
|
|| !javaToolOptions.contains("-XX:SharedArchiveFile=")
|
||||||
|
|| !javaToolOptions.contains("/jprototerm/app")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String sanitized = javaToolOptions
|
||||||
|
.replaceAll("(^|\\s)-XX:\\+AutoCreateSharedArchive(?=\\s|$)", " ")
|
||||||
|
.replaceAll("(^|\\s)-XX:SharedArchiveFile=\\S*/jprototerm/app\\S*(?=\\s|$)", " ")
|
||||||
|
.trim();
|
||||||
|
if (sanitized.isEmpty()) {
|
||||||
|
environment.remove("JAVA_TOOL_OPTIONS");
|
||||||
|
} else {
|
||||||
|
environment.put("JAVA_TOOL_OPTIONS", sanitized);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,6 +122,11 @@ public final class ShellSession implements AutoCloseable {
|
|||||||
reader.submit(() -> readOutput(pane));
|
reader.submit(() -> readOutput(pane));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Best-effort current working directory of the running shell, or {@code null} if unknown. */
|
||||||
|
public String currentWorkingDirectory() {
|
||||||
|
return closed ? null : pty.currentWorkingDirectory();
|
||||||
|
}
|
||||||
|
|
||||||
public void resize(int columns, int rows) {
|
public void resize(int columns, int rows) {
|
||||||
if (closed) {
|
if (closed) {
|
||||||
return;
|
return;
|
||||||
@@ -69,31 +150,57 @@ public final class ShellSession implements AutoCloseable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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 = pty.read(buffer)) != -1) {
|
while ((read = pty.read(buffer)) != -1) {
|
||||||
if (!closed) {
|
if (closed) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
byte[] bytes = new byte[read];
|
byte[] bytes = new byte[read];
|
||||||
System.arraycopy(buffer, 0, bytes, 0, read);
|
System.arraycopy(buffer, 0, bytes, 0, read);
|
||||||
Platform.runLater(() -> {
|
// Feed the terminal model straight from the reader thread. terminal access is
|
||||||
if (!closed) {
|
// 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);
|
pane.write(bytes);
|
||||||
}
|
}
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (RuntimeException 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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// The stream ended without us closing the session, so the process exited on its own (the
|
||||||
|
// user typed `exit`, or a one-shot command pane finished). Let the pane tear itself down.
|
||||||
|
if (!closed) {
|
||||||
|
pane.handleSessionExit();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void close() {
|
public void close() {
|
||||||
closed = true;
|
if (!markClosed()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
reader.shutdownNow();
|
reader.shutdownNow();
|
||||||
pty.close();
|
pty.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Signal and disconnect the pty immediately, but leave child reaping to a background thread. */
|
||||||
|
public void closeDetached() {
|
||||||
|
if (!markClosed()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
reader.shutdownNow();
|
||||||
|
pty.closeDetached();
|
||||||
|
}
|
||||||
|
|
||||||
|
private synchronized boolean markClosed() {
|
||||||
|
if (closed) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
closed = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
51
src/main/java/com/gregor/jprototerm/StartupTiming.java
Normal file
51
src/main/java/com/gregor/jprototerm/StartupTiming.java
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
package com.gregor.jprototerm;
|
||||||
|
|
||||||
|
import java.lang.management.ManagementFactory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opt-in startup phase timing, enabled with {@code -Djprototerm.timing=true} (e.g. via
|
||||||
|
* {@code JAVA_TOOL_OPTIONS}); otherwise every method is a cheap no-op and prints nothing.
|
||||||
|
*
|
||||||
|
* <p>Each {@link #mark(String)} prints one line to stderr with the time since the previous mark and
|
||||||
|
* the total since JVM start, so a cold launch breaks down into its phases — toolkit/GL init vs
|
||||||
|
* config load vs font loading vs first frame. The anchor is the JVM's own start time (the closest
|
||||||
|
* proxy we have to "process start"), so the first mark includes JVM bootstrap and JavaFX toolkit
|
||||||
|
* init, which is usually the dominant cost.
|
||||||
|
*/
|
||||||
|
final class StartupTiming {
|
||||||
|
private static final boolean ENABLED = Boolean.getBoolean("jprototerm.timing");
|
||||||
|
// Epoch millis; getStartTime() is the JVM's start, the earliest timestamp we can anchor to.
|
||||||
|
private static final long JVM_START_MILLIS = ManagementFactory.getRuntimeMXBean().getStartTime();
|
||||||
|
private static long lastMillis = -1;
|
||||||
|
private static boolean firstFrameSeen;
|
||||||
|
|
||||||
|
private StartupTiming() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records a phase boundary, printing the delta since the previous mark and since JVM start.
|
||||||
|
* Synchronized because marks come from both the launcher thread and the FX thread.
|
||||||
|
*/
|
||||||
|
static synchronized void mark(String phase) {
|
||||||
|
if (!ENABLED) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
long sinceStart = now - JVM_START_MILLIS;
|
||||||
|
long sinceLast = lastMillis < 0 ? sinceStart : now - lastMillis;
|
||||||
|
lastMillis = now;
|
||||||
|
System.err.printf("[timing] %-22s +%5d ms (%5d ms since JVM start)%n", phase, sinceLast, sinceStart);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records the first rendered frame exactly once, then becomes a no-op. Safe and cheap to call
|
||||||
|
* from the render loop every frame (it only ever touches FX-thread state).
|
||||||
|
*/
|
||||||
|
static synchronized void firstFrame() {
|
||||||
|
if (!ENABLED || firstFrameSeen) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
firstFrameSeen = true;
|
||||||
|
mark("first frame");
|
||||||
|
}
|
||||||
|
}
|
||||||
455
src/main/java/com/gregor/jprototerm/Tab.java
Normal file
455
src/main/java/com/gregor/jprototerm/Tab.java
Normal file
@@ -0,0 +1,455 @@
|
|||||||
|
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.concurrent.atomic.AtomicLong;
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
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 {
|
||||||
|
// Floating-pane sizing policy: a fraction of the tab's size with a floor so panes stay
|
||||||
|
// usable in small windows, cascaded diagonally per pane, kept off the window edge.
|
||||||
|
private static final double FLOATING_SIZE_FRACTION = 0.58;
|
||||||
|
private static final double FLOATING_MIN_WIDTH = 420.0;
|
||||||
|
private static final double FLOATING_MIN_HEIGHT = 260.0;
|
||||||
|
private static final double FLOATING_CASCADE_OFFSET = 28.0;
|
||||||
|
private static final double FLOATING_EDGE_MARGIN = 12.0;
|
||||||
|
|
||||||
|
private final AppConfig config;
|
||||||
|
private final TerminalMetrics metrics;
|
||||||
|
// Notified (on the FX thread) when one of this tab's panes' process exits on its own, so the
|
||||||
|
// compositor can close that pane and reap the tab/app if it was the last one.
|
||||||
|
private final Consumer<TerminalPane> onPaneExit;
|
||||||
|
private final List<TerminalPane> tiled = new ArrayList<>();
|
||||||
|
private final List<TerminalPane> floating = new ArrayList<>();
|
||||||
|
private boolean floatingVisible;
|
||||||
|
private TerminalPane active;
|
||||||
|
private final String initialWorkingDirectory;
|
||||||
|
// The floating pane to re-focus when the group is shown again, and to prefer when promoting
|
||||||
|
// after the last tiled pane closes.
|
||||||
|
private TerminalPane lastFocusedFloating;
|
||||||
|
// The tiled pane to re-focus when the floating group is hidden.
|
||||||
|
private TerminalPane lastFocusedTiled;
|
||||||
|
// Last laid-out size, so a newly opened pane can be created at roughly its eventual rect
|
||||||
|
// (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 final AtomicLong contentVersion = new AtomicLong();
|
||||||
|
|
||||||
|
Tab(AppConfig config, TerminalMetrics metrics, Consumer<TerminalPane> onPaneExit) {
|
||||||
|
this(config, metrics, null, onPaneExit);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a tab whose first pane starts in {@code initialWorkingDirectory} (e.g. the cwd of the
|
||||||
|
* pane that was active when this tab was opened), or the user's home when {@code null}.
|
||||||
|
*/
|
||||||
|
Tab(AppConfig config, TerminalMetrics metrics, String initialWorkingDirectory,
|
||||||
|
Consumer<TerminalPane> onPaneExit) {
|
||||||
|
this.config = config;
|
||||||
|
this.metrics = metrics;
|
||||||
|
this.onPaneExit = onPaneExit;
|
||||||
|
this.lastWidth = config.windowWidth();
|
||||||
|
this.lastHeight = config.windowHeight();
|
||||||
|
this.initialWorkingDirectory = initialWorkingDirectory;
|
||||||
|
TerminalPane first = openPane(false);
|
||||||
|
tiled.add(first);
|
||||||
|
active = first;
|
||||||
|
}
|
||||||
|
|
||||||
|
TerminalPane activePane() {
|
||||||
|
return active;
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean isEmpty() {
|
||||||
|
return tiled.isEmpty() && floating.isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
long contentVersion() {
|
||||||
|
return contentVersion.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
ordered.addAll(floatingOrder());
|
||||||
|
return List.copyOf(ordered);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Floating panes bottom-to-top: insertion order, with the active pane moved to the top.
|
||||||
|
// Single source of the stacking order, so the clips assigned in assignClips() always match
|
||||||
|
// the compositing order in panes().
|
||||||
|
private List<TerminalPane> floatingOrder() {
|
||||||
|
List<TerminalPane> order = new ArrayList<>(floating.size());
|
||||||
|
for (TerminalPane pane : floating) {
|
||||||
|
if (pane != active) {
|
||||||
|
order.add(pane);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (floating.contains(active)) {
|
||||||
|
order.add(active);
|
||||||
|
}
|
||||||
|
return order;
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean isActive(TerminalPane pane) {
|
||||||
|
return pane != null && pane == active;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every pane this tab owns, composited or not (tiled then floating). */
|
||||||
|
List<TerminalPane> allPanes() {
|
||||||
|
List<TerminalPane> all = new ArrayList<>(tiled.size() + floating.size());
|
||||||
|
all.addAll(tiled);
|
||||||
|
all.addAll(floating);
|
||||||
|
return all;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether this tab owns panes that {@link #panes()} does not currently composite. */
|
||||||
|
boolean hasHiddenPanes() {
|
||||||
|
return !floatingVisible && !floating.isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
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(FLOATING_MIN_WIDTH, width * FLOATING_SIZE_FRACTION);
|
||||||
|
double floatingHeight = Math.max(FLOATING_MIN_HEIGHT, availHeight * FLOATING_SIZE_FRACTION);
|
||||||
|
for (int i = 0; i < floating.size(); i++) {
|
||||||
|
double offset = i * FLOATING_CASCADE_OFFSET;
|
||||||
|
floating.get(i).bounds(
|
||||||
|
Math.min(width - floatingWidth - FLOATING_EDGE_MARGIN,
|
||||||
|
((width - floatingWidth) / 2.0) + offset),
|
||||||
|
Math.min(height - floatingHeight - FLOATING_EDGE_MARGIN,
|
||||||
|
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()) {
|
||||||
|
allPanes().forEach(pane -> pane.setClip(null));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Walk the floating stack top-to-bottom, accumulating the union of the panes above
|
||||||
|
// each one. The topmost pane has nothing above it and keeps an unclipped bounds.
|
||||||
|
List<TerminalPane> order = floatingOrder();
|
||||||
|
Shape above = null;
|
||||||
|
for (int i = order.size() - 1; i >= 0; i--) {
|
||||||
|
TerminalPane pane = order.get(i);
|
||||||
|
Rectangle rect = rectOf(pane);
|
||||||
|
pane.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.contains(lastFocusedTiled) ? lastFocusedTiled : 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()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Promotes the active floating pane to a tiled pane, joining the tiled row. No-op otherwise. */
|
||||||
|
void toggleActiveFloating() {
|
||||||
|
TerminalPane toggled = active;
|
||||||
|
if (floating.remove(toggled)) {
|
||||||
|
lastFocusedFloating = floating.isEmpty() ? null : floating.get(floating.size() - 1);
|
||||||
|
tiled.add(toggled);
|
||||||
|
floatingVisible = false;
|
||||||
|
setActive(toggled);
|
||||||
|
} else if (tiled.remove(toggled)) {
|
||||||
|
lastFocusedTiled = tiled.isEmpty() ? null : tiled.get(tiled.size() -1);
|
||||||
|
floating.add(toggled);
|
||||||
|
floatingVisible = true;
|
||||||
|
setActive(toggled);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Closes {@code closing} (the active pane on a key-bound close, or any pane whose process just
|
||||||
|
* exited) and re-selects the active pane only when the one that closed was active. Returns
|
||||||
|
* false when the pane is not in this tab. Leaves the tab empty ({@code active == null}) when its
|
||||||
|
* last pane closes, so the compositor can drop it.
|
||||||
|
*/
|
||||||
|
boolean closePane(TerminalPane closing) {
|
||||||
|
boolean wasFloating = floating.remove(closing);
|
||||||
|
boolean wasTiled = !wasFloating && tiled.remove(closing);
|
||||||
|
if (!wasFloating && !wasTiled) {
|
||||||
|
return false; // not one of this tab's panes (already gone)
|
||||||
|
}
|
||||||
|
boolean wasActive = closing == active;
|
||||||
|
if (closing == lastFocusedFloating) {
|
||||||
|
lastFocusedFloating = null;
|
||||||
|
}
|
||||||
|
if (closing == lastFocusedTiled) {
|
||||||
|
lastFocusedTiled = null;
|
||||||
|
}
|
||||||
|
closing.close();
|
||||||
|
|
||||||
|
if (tiled.isEmpty() && floating.isEmpty()) {
|
||||||
|
active = null; // tab is now empty; the compositor drops it
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 = floating.isEmpty() ? null : floating.get(nextFocussed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (floating.isEmpty()) {
|
||||||
|
floatingVisible = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only the active pane closing forces a re-selection; closing a background pane (e.g. one
|
||||||
|
// whose process exited while another is focused) leaves focus where it is.
|
||||||
|
if (wasActive) {
|
||||||
|
setActive(wasFloating && floatingVisible
|
||||||
|
? floating.get(floating.size() - 1)
|
||||||
|
: tiled.contains(lastFocusedTiled) ? lastFocusedTiled : tiled.get(0));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void setActive(TerminalPane pane) {
|
||||||
|
active = pane;
|
||||||
|
if (floating.contains(pane)) {
|
||||||
|
lastFocusedFloating = pane;
|
||||||
|
} else if (tiled.contains(pane)) {
|
||||||
|
lastFocusedTiled = pane;
|
||||||
|
// A tiled pane gaining focus hides the floating group: leaving it shown while a tiled
|
||||||
|
// pane is active strands focus behind the overlay and disables navigation.
|
||||||
|
floatingVisible = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void createFloatingPane() {
|
||||||
|
addFloating(openPane(true));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens a floating pane whose process runs {@code command} directly (auto-closing when it
|
||||||
|
* exits), rather than an interactive shell. Used for one-shot panes like the scrollback editor.
|
||||||
|
*/
|
||||||
|
TerminalPane createFloatingPane(String command) {
|
||||||
|
double[] size = paneSize(true);
|
||||||
|
return addFloating(register(TerminalPane.createWithCommand(
|
||||||
|
config, metrics, this::markContentChanged, size[0], size[1], paneWorkingDirectory(), command)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private TerminalPane addFloating(TerminalPane pane) {
|
||||||
|
floating.add(pane);
|
||||||
|
floatingVisible = true;
|
||||||
|
setActive(pane);
|
||||||
|
return 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.incrementAndGet();
|
||||||
|
}
|
||||||
|
|
||||||
|
private TerminalPane openPane(boolean asFloating) {
|
||||||
|
double[] size = paneSize(asFloating);
|
||||||
|
return register(TerminalPane.create(
|
||||||
|
config, metrics, this::markContentChanged, size[0], size[1], paneWorkingDirectory()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private double[] paneSize(boolean asFloating) {
|
||||||
|
double availHeight = lastHeight - lastTopInset;
|
||||||
|
if (asFloating) {
|
||||||
|
return new double[] {
|
||||||
|
Math.max(FLOATING_MIN_WIDTH, lastWidth * FLOATING_SIZE_FRACTION),
|
||||||
|
Math.max(FLOATING_MIN_HEIGHT, availHeight * FLOATING_SIZE_FRACTION)};
|
||||||
|
}
|
||||||
|
// A new tiled pane joins the row, so each gets 1/(n+1) of the width.
|
||||||
|
return new double[] {lastWidth / (tiled.size() + 1), availHeight};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open a new pane in the active pane's working directory, so a split/new pane lands where the
|
||||||
|
// user currently is. With no active pane yet (the tab's first pane), fall back to the directory
|
||||||
|
// this tab was opened in. null (cwd unknown) falls back to home downstream.
|
||||||
|
private String paneWorkingDirectory() {
|
||||||
|
return active != null ? active.currentWorkingDirectory() : initialWorkingDirectory;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wire the pane's self-exit (process ended) back to the compositor so it gets reaped.
|
||||||
|
private TerminalPane register(TerminalPane pane) {
|
||||||
|
pane.setOnExit(() -> onPaneExit.accept(pane));
|
||||||
|
return pane;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean directionFilter(Direction direction, TerminalPane current, TerminalPane candidate) {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signals and reaps every pane's shell process without tearing down render state. Safe to call
|
||||||
|
* off the FX thread (see {@link TerminalPane#terminateSession()}); iterates snapshots so a
|
||||||
|
* concurrent close on the FX thread can't trigger a {@link java.util.ConcurrentModificationException}.
|
||||||
|
*/
|
||||||
|
public void terminateSessions() {
|
||||||
|
List.copyOf(tiled).forEach(TerminalPane::terminateSession);
|
||||||
|
List.copyOf(floating).forEach(TerminalPane::terminateSession);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,799 +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()));
|
|
||||||
|
|
||||||
// Allocate the offscreen buffers only when the pane size changes. Reallocating a
|
|
||||||
// full-pane Canvas + WritableImage on every content change churns ~20 MB per frame,
|
|
||||||
// which the native image's serial GC turns into Full-GC frame drops.
|
|
||||||
if (cache.canvas == null || cache.image == null || cache.imageWidth != imageWidth || cache.imageHeight != imageHeight) {
|
|
||||||
cache.canvas = new Canvas(imageWidth, imageHeight);
|
|
||||||
cache.image = new WritableImage(imageWidth, imageHeight);
|
|
||||||
cache.imageWidth = imageWidth;
|
|
||||||
cache.imageHeight = imageHeight;
|
|
||||||
cache.key = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Redraw and re-snapshot into the existing buffers only when content changed.
|
|
||||||
if (!cacheKey.equals(cache.key)) {
|
|
||||||
GraphicsContext cacheGc = cache.canvas.getGraphicsContext2D();
|
|
||||||
cacheGc.clearRect(0, 0, imageWidth, imageHeight);
|
|
||||||
drawPaneContent(cacheGc, pane, font, metrics, 0.0, 0.0, imageWidth, imageHeight, true);
|
|
||||||
cache.canvas.snapshot(null, cache.image);
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
94
src/main/java/com/gregor/jprototerm/TerminalMetrics.java
Normal file
94
src/main/java/com/gregor/jprototerm/TerminalMetrics.java
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
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;
|
||||||
|
// One rasterized-glyph atlas per window, shared by every pane's renderer (the masks are a pure
|
||||||
|
// function of the font geometry below). It self-invalidates when these metrics change.
|
||||||
|
private final GlyphCache glyphCache = new GlyphCache(this);
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The window's shared glyph atlas (see {@link GlyphCache}). */
|
||||||
|
public GlyphCache glyphCache() {
|
||||||
|
return glyphCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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,53 @@
|
|||||||
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.application.Platform;
|
||||||
|
import javafx.scene.canvas.GraphicsContext;
|
||||||
|
import javafx.scene.shape.Shape;
|
||||||
|
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.concurrent.atomic.AtomicReference;
|
import java.util.concurrent.atomic.AtomicLong;
|
||||||
|
import java.util.function.Supplier;
|
||||||
|
|
||||||
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
|
||||||
private ShellSession session;
|
// tracking meaningful: update() accumulates dirty since the last resetDirty().
|
||||||
private boolean floating;
|
private final RenderState renderState = new RenderState();
|
||||||
private boolean visible = true;
|
private RenderStateSnapshot cachedSnapshot;
|
||||||
|
private volatile ShellSession session;
|
||||||
|
// Run once (on the FX thread) when this pane's process exits on its own, so the owning tab can
|
||||||
|
// remove it. Set by the Tab that creates the pane; null until then.
|
||||||
|
private Runnable onExit;
|
||||||
|
private boolean exited;
|
||||||
|
// Clip region for rendering (rect minus the panes covering this one), set at layout time;
|
||||||
|
// 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 +56,106 @@ 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 final AtomicLong contentVersion = new AtomicLong();
|
||||||
|
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. The shell starts in {@code workingDirectory}
|
||||||
|
* (e.g. the active pane's cwd), or the user's home when {@code null}.
|
||||||
|
*/
|
||||||
|
public static TerminalPane create(AppConfig config, TerminalMetrics metrics, Runnable onContentChange,
|
||||||
|
double widthPx, double heightPx, String workingDirectory) {
|
||||||
|
TerminalPane pane = newPane(config, metrics, onContentChange, widthPx, heightPx);
|
||||||
|
attachOrShowError(pane, () -> ShellSession.start(config.shell(), config.envOverride(), pane,
|
||||||
|
pane.columns, pane.rows, workingDirectory, config.closeSignalNumber()));
|
||||||
|
return pane;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens a pane whose process runs {@code command} directly (via {@code /bin/sh -c}) instead of
|
||||||
|
* an interactive shell. The pane auto-closes when the command exits. See
|
||||||
|
* {@link ShellSession#startCommand}.
|
||||||
|
*/
|
||||||
|
public static TerminalPane createWithCommand(AppConfig config, TerminalMetrics metrics, Runnable onContentChange,
|
||||||
|
double widthPx, double heightPx, String workingDirectory, String command) {
|
||||||
|
TerminalPane pane = newPane(config, metrics, onContentChange, widthPx, heightPx);
|
||||||
|
attachOrShowError(pane, () -> ShellSession.startCommand(config.envOverride(), pane,
|
||||||
|
pane.columns, pane.rows, workingDirectory, command, config.closeSignalNumber()));
|
||||||
|
return pane;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start the pane's process, but never let a spawn failure (e.g. a bad `shell` in config)
|
||||||
|
// propagate and crash window/pane creation. ShellSession has already written the error into
|
||||||
|
// the pane, so the pane opens showing it; with no session attached it is inert (sends are
|
||||||
|
// dropped) and the user closes it with the close-pane key.
|
||||||
|
private static void attachOrShowError(TerminalPane pane, Supplier<ShellSession> start) {
|
||||||
|
try {
|
||||||
|
pane.attach(start.get());
|
||||||
|
} catch (RuntimeException ex) {
|
||||||
|
System.err.println("jprototerm: " + ex.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TerminalPane newPane(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();
|
||||||
return pane;
|
return pane;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Sets the callback run when this pane's process exits on its own (see {@link #handleSessionExit}). */
|
||||||
|
public void setOnExit(Runnable onExit) {
|
||||||
|
this.onExit = onExit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called from the shell reader thread when the pty stream ends without us closing it (the
|
||||||
|
* process exited). Hops to the FX thread and fires {@link #onExit} once, so tab/compositor
|
||||||
|
* mutation happens on the thread that owns the layout.
|
||||||
|
*/
|
||||||
|
void handleSessionExit() {
|
||||||
|
Platform.runLater(() -> {
|
||||||
|
if (exited) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
exited = true;
|
||||||
|
if (onExit != null) {
|
||||||
|
onExit.run();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void attach(ShellSession session) {
|
||||||
|
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 +170,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) {
|
||||||
@@ -78,6 +177,19 @@ public final class TerminalPane implements AutoCloseable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Paste text to the shell: ghostty sanitises it (stripping anything that could smuggle in
|
||||||
|
* control sequences) and wraps it in the bracketed-paste markers, then it goes to the pty
|
||||||
|
* like typed input. We always request bracketed mode — every modern shell and editor enables
|
||||||
|
* DECSET 2004, and the jlibghostty API does not expose querying the terminal's live mode.
|
||||||
|
*/
|
||||||
|
public void paste(String text) {
|
||||||
|
if (text == null || text.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
send(Ghostty.encodePaste(text, true));
|
||||||
|
}
|
||||||
|
|
||||||
public boolean sendMouse(MouseInput input, MouseEncoderSize size, boolean anyButtonPressed) {
|
public boolean sendMouse(MouseInput input, MouseEncoderSize size, boolean anyButtonPressed) {
|
||||||
synchronized (terminal) {
|
synchronized (terminal) {
|
||||||
mouseEncoder.syncFromTerminal(terminal);
|
mouseEncoder.syncFromTerminal(terminal);
|
||||||
@@ -104,15 +216,49 @@ 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) {
|
||||||
|
long version = contentVersion.get();
|
||||||
|
if (full) {
|
||||||
|
renderState.update(terminal);
|
||||||
|
cachedSnapshot = renderState.snapshot();
|
||||||
|
renderState.resetDirty();
|
||||||
|
snapshotVersion = version;
|
||||||
|
} else if (snapshotVersion != version) {
|
||||||
|
renderState.update(terminal);
|
||||||
|
cachedSnapshot = renderState.snapshotIncremental();
|
||||||
|
renderState.resetDirty();
|
||||||
|
snapshotVersion = version;
|
||||||
|
}
|
||||||
|
return cachedSnapshot;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public String scrollbackText() {
|
public String scrollbackText() {
|
||||||
@@ -121,44 +267,45 @@ public final class TerminalPane implements AutoCloseable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public long renderVersion() {
|
/** Best-effort current working directory of this pane's shell, or {@code null} if unknown. */
|
||||||
return renderVersion;
|
public String currentWorkingDirectory() {
|
||||||
|
ShellSession current = session;
|
||||||
|
return current != null ? current.currentWorkingDirectory() : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** This pane's own content revision, bumped on every change (see {@link #refresh()}). */
|
||||||
|
public long contentVersion() {
|
||||||
|
return contentVersion.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
@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 +317,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,17 +356,64 @@ 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.incrementAndGet();
|
||||||
|
onContentChange.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop this pane's large, rebuildable render buffers because it is no longer being composited
|
||||||
|
* (e.g. it sits in a background tab or a hidden floating group). The pixel backbuffer and its GPU
|
||||||
|
* image are freed; the shell, ghostty terminal and shared glyph atlas are untouched, and the next
|
||||||
|
* {@link #paintFull} rebuilds the buffer. Safe to call repeatedly. See {@link TerminalRenderer#release}.
|
||||||
|
*/
|
||||||
|
public void releaseRenderResources() {
|
||||||
|
renderer.release();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Paint the whole pane; see {@link TerminalRenderer#paintFull}. */
|
||||||
|
public long paintFull(GraphicsContext gc, boolean active) {
|
||||||
|
renderer.paintFull(gc, this, active);
|
||||||
|
return snapshotVersion;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Repaint what changed; see {@link TerminalRenderer#paintIncremental}. */
|
||||||
|
public long paintIncremental(GraphicsContext gc, boolean active) {
|
||||||
|
renderer.paintIncremental(gc, this, active);
|
||||||
|
return snapshotVersion;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kitty image placements from the most recent paint, in scene coordinates. The compositor
|
||||||
|
* renders these as overlay nodes clipped to this pane (see {@link KittyImageOverlay}).
|
||||||
|
*/
|
||||||
|
public java.util.List<KittyImageNode> kittyImages() {
|
||||||
|
return renderer.kittyImages();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void close() {
|
public void close() {
|
||||||
if (session != null) {
|
if (session != null) {
|
||||||
session.close();
|
session.closeDetached();
|
||||||
session = null;
|
session = null;
|
||||||
}
|
}
|
||||||
mouseEncoder.close();
|
mouseEncoder.close();
|
||||||
|
renderState.close();
|
||||||
terminal.close();
|
terminal.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signals and reaps just the shell process, leaving the render/native state untouched. Unlike
|
||||||
|
* {@link #close()} this is safe to call off the FX thread — notably from a JVM shutdown hook,
|
||||||
|
* which runs concurrently with the live render loop — because it only touches the pty (a child
|
||||||
|
* process and fd), not ghostty's terminal handles. Idempotent; the OS reclaims the rest on exit.
|
||||||
|
*/
|
||||||
|
public void terminateSession() {
|
||||||
|
ShellSession current = session;
|
||||||
|
if (current != null) {
|
||||||
|
current.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
70
src/main/java/com/gregor/jprototerm/TerminalRenderer.java
Normal file
70
src/main/java/com/gregor/jprototerm/TerminalRenderer.java
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
package com.gregor.jprototerm;
|
||||||
|
|
||||||
|
import javafx.scene.canvas.GraphicsContext;
|
||||||
|
import javafx.scene.shape.ClosePath;
|
||||||
|
import javafx.scene.shape.LineTo;
|
||||||
|
import javafx.scene.shape.MoveTo;
|
||||||
|
import javafx.scene.shape.Path;
|
||||||
|
import javafx.scene.shape.PathElement;
|
||||||
|
import javafx.scene.shape.Shape;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draws a {@link RenderTarget} onto a JavaFX canvas. The {@link Compositor} owns positioning
|
||||||
|
* and z-order; a renderer only fills the target's rect, clipped to the target's {@link
|
||||||
|
* RenderTarget#clip() clip region} so a repaint can never bleed over a pane on top.
|
||||||
|
* Implementations can change the look entirely — {@link GhosttyTerminalRenderer} is the real
|
||||||
|
* terminal renderer; a debug renderer could outline pane bounds instead.
|
||||||
|
*
|
||||||
|
* <p>A renderer may hold per-target state (e.g. a decoded-image cache), so an instance belongs
|
||||||
|
* to a single {@link TerminalPane}.
|
||||||
|
*/
|
||||||
|
abstract class TerminalRenderer {
|
||||||
|
/** Paint the whole target into its rect, clipped to its clip region. */
|
||||||
|
abstract void paintFull(GraphicsContext gc, RenderTarget target, boolean active);
|
||||||
|
|
||||||
|
/** Repaint only what changed since the last frame, clipped to the target's clip region. */
|
||||||
|
abstract void paintIncremental(GraphicsContext gc, RenderTarget target, boolean active);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The kitty image placements produced by the most recent paint, for the compositor to render
|
||||||
|
* as overlay nodes above the canvas. Empty unless the last paint found visible images.
|
||||||
|
*/
|
||||||
|
java.util.List<KittyImageNode> kittyImages() {
|
||||||
|
return java.util.List.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Release any large, rebuildable render buffers (e.g. a pane's pixel backbuffer) because the
|
||||||
|
* target is no longer being composited. A no-op by default; the next paint must rebuild whatever
|
||||||
|
* was dropped. Called off the paint path, so it must not assume a frame is in progress.
|
||||||
|
*/
|
||||||
|
void release() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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) {
|
||||||
|
gc.beginPath();
|
||||||
|
if (region == null) {
|
||||||
|
gc.rect(x, y, width, height);
|
||||||
|
} else {
|
||||||
|
var elements = ((Path) region).getElements();
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
311
src/main/java/com/gregor/jprototerm/TerminalWindow.java
Normal file
311
src/main/java/com/gregor/jprototerm/TerminalWindow.java
Normal file
@@ -0,0 +1,311 @@
|
|||||||
|
package com.gregor.jprototerm;
|
||||||
|
|
||||||
|
import javafx.animation.AnimationTimer;
|
||||||
|
import javafx.geometry.Rectangle2D;
|
||||||
|
import javafx.scene.Scene;
|
||||||
|
import javafx.scene.control.ButtonType;
|
||||||
|
import javafx.scene.control.ComboBox;
|
||||||
|
import javafx.scene.control.Dialog;
|
||||||
|
import javafx.scene.control.Label;
|
||||||
|
import javafx.scene.control.Spinner;
|
||||||
|
import javafx.scene.control.SpinnerValueFactory;
|
||||||
|
import javafx.scene.input.Clipboard;
|
||||||
|
import javafx.scene.input.KeyEvent;
|
||||||
|
import javafx.scene.layout.GridPane;
|
||||||
|
import javafx.scene.layout.StackPane;
|
||||||
|
import javafx.scene.text.Font;
|
||||||
|
import javafx.stage.Screen;
|
||||||
|
import javafx.stage.Stage;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One top-level terminal window: its own {@link Stage}, {@link Compositor}, config/metrics, render
|
||||||
|
* loop and input handling. Many of these live in a single JVM under a {@link WindowManager}; closing
|
||||||
|
* one tears down only that window (its shells, its Stage) and leaves the rest — and, in daemon mode,
|
||||||
|
* the JVM — running. Built on the FX thread.
|
||||||
|
*/
|
||||||
|
final class TerminalWindow {
|
||||||
|
private final WindowManager manager;
|
||||||
|
private final TerminalMetrics metrics;
|
||||||
|
private final Compositor compositor;
|
||||||
|
private final Stage stage;
|
||||||
|
private final AnimationTimer renderLoop;
|
||||||
|
// Key-bound actions by their config keybinding name, checked in this order on each key press
|
||||||
|
// (the keys must match AppConfig's keybinding keys).
|
||||||
|
private final Map<String, Runnable> keyActions = new LinkedHashMap<>();
|
||||||
|
private AppConfig config;
|
||||||
|
private boolean closed;
|
||||||
|
|
||||||
|
TerminalWindow(WindowManager manager, String workingDirectory) {
|
||||||
|
this.manager = manager;
|
||||||
|
// Each window loads config independently, so edits (and per-window font changes) apply to
|
||||||
|
// newly opened windows without disturbing existing ones.
|
||||||
|
config = AppConfig.load();
|
||||||
|
StartupTiming.mark("config loaded");
|
||||||
|
metrics = new TerminalMetrics(config.fontFamily(), config.fontSize());
|
||||||
|
StartupTiming.mark("fonts loaded");
|
||||||
|
compositor = new Compositor(config, metrics, workingDirectory);
|
||||||
|
StartupTiming.mark("compositor ready");
|
||||||
|
|
||||||
|
// The last pane closing closes this window (not the JVM); see teardown().
|
||||||
|
compositor.setOnEmpty(this::teardown);
|
||||||
|
|
||||||
|
keyActions.put("navigate_left", () -> compositor.navigate(Direction.LEFT));
|
||||||
|
keyActions.put("navigate_down", () -> compositor.navigate(Direction.DOWN));
|
||||||
|
keyActions.put("navigate_up", () -> compositor.navigate(Direction.UP));
|
||||||
|
keyActions.put("navigate_right", () -> compositor.navigate(Direction.RIGHT));
|
||||||
|
keyActions.put("toggle_floating", compositor::toggleFloating);
|
||||||
|
keyActions.put("new_pane", compositor::createPane);
|
||||||
|
keyActions.put("next_floating", compositor::nextFloatingPane);
|
||||||
|
keyActions.put("promote_floating", compositor::toggleActiveFloating);
|
||||||
|
// Closing the last pane closes this window, via the compositor's onEmpty hook.
|
||||||
|
keyActions.put("close_pane", compositor::closeActivePane);
|
||||||
|
keyActions.put("new_tab", compositor::newTab);
|
||||||
|
keyActions.put("previous_tab", compositor::previousTab);
|
||||||
|
keyActions.put("next_tab", compositor::nextTab);
|
||||||
|
keyActions.put("open_font_selector", this::openFontSelector);
|
||||||
|
keyActions.put("open_scrollback", this::openScrollbackInEditor);
|
||||||
|
keyActions.put("create_worktree", this::createWorktreeInEditor);
|
||||||
|
keyActions.put("paste", this::pasteFromClipboard);
|
||||||
|
|
||||||
|
StackPane root = new StackPane(compositor.canvas(), compositor.imageOverlay());
|
||||||
|
compositor.canvas().widthProperty().bind(root.widthProperty());
|
||||||
|
compositor.canvas().heightProperty().bind(root.heightProperty());
|
||||||
|
|
||||||
|
Scene scene = new Scene(root, config.windowWidth(), config.windowHeight());
|
||||||
|
scene.addEventFilter(KeyEvent.KEY_PRESSED, this::handlePressed);
|
||||||
|
scene.addEventFilter(KeyEvent.KEY_TYPED, this::handleTyped);
|
||||||
|
|
||||||
|
renderLoop = new AnimationTimer() {
|
||||||
|
@Override
|
||||||
|
public void handle(long now) {
|
||||||
|
compositor.render();
|
||||||
|
StartupTiming.firstFrame();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
renderLoop.start();
|
||||||
|
|
||||||
|
stage = new Stage();
|
||||||
|
stage.setTitle("jprototerm");
|
||||||
|
stage.setScene(scene);
|
||||||
|
// The X11 WM close button: tear this window down explicitly. With implicit exit disabled
|
||||||
|
// (see WindowManager) nothing else would reap the shells or drop the window otherwise.
|
||||||
|
stage.setOnCloseRequest(event -> teardown());
|
||||||
|
centreOnActiveScreen(stage, config.windowWidth(), config.windowHeight());
|
||||||
|
stage.show();
|
||||||
|
StartupTiming.mark("stage shown");
|
||||||
|
// Ask the window manager to raise and focus the new window so the user can type right
|
||||||
|
// away; the canvas requestFocus() below only routes events within the scene.
|
||||||
|
stage.toFront();
|
||||||
|
stage.requestFocus();
|
||||||
|
compositor.canvas().requestFocus();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fully tears this window down (FX thread, idempotent): stops rendering, closes the compositor —
|
||||||
|
* which signals pane shells via the configured {@code close_signal} — disposes the Stage, and
|
||||||
|
* notifies the manager so it can drop the window (and, in standalone mode, exit the JVM). Both
|
||||||
|
* the WM close button and the last-pane-closed hook route through here.
|
||||||
|
*/
|
||||||
|
void teardown() {
|
||||||
|
if (closed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
closed = true;
|
||||||
|
renderLoop.stop();
|
||||||
|
compositor.close();
|
||||||
|
stage.close();
|
||||||
|
manager.onWindowClosed(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Signals and reaps this window's shell processes without touching render state (off-FX safe). */
|
||||||
|
void terminateSessions() {
|
||||||
|
compositor.terminateSessions();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handlePressed(KeyEvent event) {
|
||||||
|
for (Map.Entry<String, Runnable> action : keyActions.entrySet()) {
|
||||||
|
if (config.keybindings().get(action.getKey()).matches(event)) {
|
||||||
|
action.getValue().run();
|
||||||
|
event.consume();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
String encoded = KeyEncoder.encode(event);
|
||||||
|
if (encoded != null) {
|
||||||
|
sendToActivePane(encoded, event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleTyped(KeyEvent event) {
|
||||||
|
if (event.isAltDown() || event.isControlDown() || event.isMetaDown()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String text = event.getCharacter();
|
||||||
|
if (text != null && !text.isEmpty() && text.charAt(0) >= 0x20 && text.charAt(0) != 0x7f) {
|
||||||
|
sendToActivePane(text, event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Key handlers run on every keystroke, including any that race the window's teardown, so
|
||||||
|
// tolerate the no-pane-left state instead of assuming one exists.
|
||||||
|
private void sendToActivePane(String text, KeyEvent event) {
|
||||||
|
TerminalPane active = compositor.activePane();
|
||||||
|
if (active != null) {
|
||||||
|
active.send(text);
|
||||||
|
event.consume();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void pasteFromClipboard() {
|
||||||
|
TerminalPane active = compositor.activePane();
|
||||||
|
Clipboard clipboard = Clipboard.getSystemClipboard();
|
||||||
|
if (active != null && clipboard.hasString()) {
|
||||||
|
active.paste(clipboard.getString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void openFontSelector() {
|
||||||
|
Dialog<ButtonType> dialog = new Dialog<>();
|
||||||
|
dialog.setTitle("Font");
|
||||||
|
dialog.getDialogPane().getButtonTypes().addAll(ButtonType.OK, ButtonType.CANCEL);
|
||||||
|
|
||||||
|
ComboBox<String> family = new ComboBox<>();
|
||||||
|
family.getItems().setAll(Font.getFamilies());
|
||||||
|
family.setEditable(true);
|
||||||
|
family.setMaxWidth(Double.MAX_VALUE);
|
||||||
|
family.setValue(config.fontFamily());
|
||||||
|
|
||||||
|
Spinner<Double> size = new Spinner<>();
|
||||||
|
size.setEditable(true);
|
||||||
|
size.setValueFactory(new SpinnerValueFactory.DoubleSpinnerValueFactory(6.0, 48.0, config.fontSize(), 0.5));
|
||||||
|
|
||||||
|
GridPane content = new GridPane();
|
||||||
|
content.setHgap(10.0);
|
||||||
|
content.setVgap(10.0);
|
||||||
|
content.add(new Label("Family"), 0, 0);
|
||||||
|
content.add(family, 1, 0);
|
||||||
|
content.add(new Label("Size"), 0, 1);
|
||||||
|
content.add(size, 1, 1);
|
||||||
|
dialog.getDialogPane().setContent(content);
|
||||||
|
|
||||||
|
dialog.showAndWait()
|
||||||
|
.filter(button -> button == ButtonType.OK)
|
||||||
|
.ifPresent(ignored -> {
|
||||||
|
String selectedFamily = family.getEditor().getText();
|
||||||
|
if (selectedFamily == null || selectedFamily.isBlank()) {
|
||||||
|
selectedFamily = family.getValue();
|
||||||
|
}
|
||||||
|
if (selectedFamily == null || selectedFamily.isBlank()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
double selectedSize = size.getValue();
|
||||||
|
config = config.withFont(selectedFamily.trim(), selectedSize);
|
||||||
|
config.save();
|
||||||
|
compositor.setFont(config.fontFamily(), config.fontSize());
|
||||||
|
compositor.canvas().requestFocus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void openScrollbackInEditor() {
|
||||||
|
// Capture the active pane's scrollback before opening the floating pane, since that
|
||||||
|
// makes the new pane active.
|
||||||
|
TerminalPane active = compositor.activePane();
|
||||||
|
if (active == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Path file = Files.createTempFile("jprototerm-scrollback-", ".txt");
|
||||||
|
Files.writeString(file, active.scrollbackText());
|
||||||
|
|
||||||
|
// Run the editor as the floating pane's process (via /bin/sh -c) rather than typing the
|
||||||
|
// command into an interactive shell. The command runs deterministically from the start
|
||||||
|
// — no shell startup/rc race — and the pane auto-closes when the editor exits. The
|
||||||
|
// trailing rm removes the file (which holds terminal contents) when the editor exits;
|
||||||
|
// deleteOnExit would leak files for the JVM's whole lifetime in daemon mode.
|
||||||
|
compositor.openFloatingPane(editorCommand(file) + "; rm -f " + shellQuote(file.toString()));
|
||||||
|
} catch (IOException ex) {
|
||||||
|
System.err.println("Could not open scrollback in editor: " + ex.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void createWorktreeInEditor() {
|
||||||
|
// The floating pane command inherits the active pane's cwd at creation time, so the git
|
||||||
|
// worktree command runs from the pane that was focused before this shortcut opened.
|
||||||
|
TerminalPane active = compositor.activePane();
|
||||||
|
if (active == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Path file = Files.createTempFile("jprototerm-worktree-", ".txt");
|
||||||
|
Files.writeString(file, "");
|
||||||
|
|
||||||
|
compositor.openFloatingPane(worktreeEditorCommand(file));
|
||||||
|
} catch (IOException ex) {
|
||||||
|
System.err.println("Could not create worktree from editor input: " + ex.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String editorCommand(Path file) {
|
||||||
|
String quotedFile = shellQuote(file.toString());
|
||||||
|
String command = config.scrollbackEditorCommand();
|
||||||
|
if (command == null || command.isBlank()) {
|
||||||
|
command = "vi {file}";
|
||||||
|
}
|
||||||
|
if (command.contains("{file}")) {
|
||||||
|
return command.replace("{file}", quotedFile);
|
||||||
|
}
|
||||||
|
return command + " " + quotedFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String worktreeEditorCommand(Path file) {
|
||||||
|
String quotedFile = shellQuote(file.toString());
|
||||||
|
String relativePath = config.worktreeRelativePath();
|
||||||
|
if (relativePath == null || relativePath.isBlank()) {
|
||||||
|
relativePath = "./.worktrees";
|
||||||
|
}
|
||||||
|
|
||||||
|
return editorCommand(file)
|
||||||
|
+ "; editor_status=$?"
|
||||||
|
+ "; name=$(cat " + quotedFile + ")"
|
||||||
|
+ "; if [ \"$editor_status\" -eq 0 ] && [ -n \"$name\" ]; then"
|
||||||
|
+ " git worktree add " + shellQuote(relativePath) + "/\"$name\""
|
||||||
|
+ "; git_status=$?"
|
||||||
|
+ "; else git_status=$editor_status"
|
||||||
|
+ "; fi"
|
||||||
|
+ "; rm -f " + quotedFile
|
||||||
|
+ "; exit \"$git_status\"";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String shellQuote(String value) {
|
||||||
|
return "'" + value.replace("'", "'\"'\"'") + "'";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Centre the stage within the screen the mouse pointer is on (the best proxy for the
|
||||||
|
// "active" monitor on X11, which exposes no focused-monitor concept to JavaFX).
|
||||||
|
private static void centreOnActiveScreen(Stage stage, double width, double height) {
|
||||||
|
Rectangle2D bounds = activeScreen().getVisualBounds();
|
||||||
|
stage.setX(bounds.getMinX() + ((bounds.getWidth() - width) / 2.0));
|
||||||
|
stage.setY(bounds.getMinY() + ((bounds.getHeight() - height) / 2.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Screen activeScreen() {
|
||||||
|
int[] at = X11Pointer.query();
|
||||||
|
if (at != null) {
|
||||||
|
// libX11 and JavaFX share a coordinate space on the X11 virtual screen.
|
||||||
|
List<Screen> screens = Screen.getScreensForRectangle(at[0], at[1], 1.0, 1.0);
|
||||||
|
if (!screens.isEmpty()) {
|
||||||
|
return screens.get(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Screen.getPrimary();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(), config.envOverride(), 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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
69
src/main/java/com/gregor/jprototerm/WindowManager.java
Normal file
69
src/main/java/com/gregor/jprototerm/WindowManager.java
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
package com.gregor.jprototerm;
|
||||||
|
|
||||||
|
import javafx.application.Platform;
|
||||||
|
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.concurrent.CopyOnWriteArraySet;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Owns the JavaFX toolkit lifecycle and the set of live {@link TerminalWindow}s for one JVM. A
|
||||||
|
* single JVM hosts every window, so the expensive toolkit/GL init is paid once; opening another
|
||||||
|
* window is then just a new {@link javafx.stage.Stage}.
|
||||||
|
*
|
||||||
|
* <p>Two modes differ only in the empty policy: in {@link Mode#STANDALONE} (today's behavior, used
|
||||||
|
* as the fallback when no daemon is reachable) the JVM exits once the last window closes; in
|
||||||
|
* {@link Mode#DAEMON} the toolkit stays alive with zero windows, waiting for the next client
|
||||||
|
* request. {@link Platform#setImplicitExit(boolean) implicit exit} is disabled in both so the
|
||||||
|
* toolkit never tears itself down behind our back — every exit is an explicit decision here.
|
||||||
|
*/
|
||||||
|
public final class WindowManager {
|
||||||
|
public enum Mode {
|
||||||
|
STANDALONE,
|
||||||
|
DAEMON
|
||||||
|
}
|
||||||
|
|
||||||
|
private final Mode mode;
|
||||||
|
// Mutated on the FX thread (register/deregister), iterated from the shutdown-hook thread.
|
||||||
|
private final Set<TerminalWindow> windows = new CopyOnWriteArraySet<>();
|
||||||
|
|
||||||
|
private WindowManager(Mode mode) {
|
||||||
|
this.mode = mode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Brings up the JavaFX toolkit (once per JVM) and returns a manager in {@code mode}. Registers a
|
||||||
|
* shutdown hook that reaps every window's shell processes, so child shells are terminated rather
|
||||||
|
* than orphaned if the JVM is killed (SIGTERM/SIGINT/SIGHUP) — see
|
||||||
|
* {@link Compositor#terminateSessions()}.
|
||||||
|
*/
|
||||||
|
public static WindowManager start(Mode mode) {
|
||||||
|
WindowManager manager = new WindowManager(mode);
|
||||||
|
Platform.setImplicitExit(false);
|
||||||
|
Platform.startup(() -> StartupTiming.mark("toolkit ready"));
|
||||||
|
Runtime.getRuntime().addShutdownHook(new Thread(manager::terminateAllSessions, "shell-cleanup"));
|
||||||
|
return manager;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Opens a new window (on the FX thread) whose first pane starts in {@code workingDirectory}. */
|
||||||
|
public void openWindow(String workingDirectory) {
|
||||||
|
Platform.runLater(() -> windows.add(new TerminalWindow(this, workingDirectory)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called by a window when it has finished tearing down (FX thread). Drops it from the registry
|
||||||
|
* and, in standalone mode, exits the JVM once none remain.
|
||||||
|
*/
|
||||||
|
void onWindowClosed(TerminalWindow window) {
|
||||||
|
windows.remove(window);
|
||||||
|
if (mode == Mode.STANDALONE && windows.isEmpty()) {
|
||||||
|
Platform.exit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Signals and reaps every live window's shell processes. Safe to call off the FX thread. */
|
||||||
|
void terminateAllSessions() {
|
||||||
|
for (TerminalWindow window : windows) {
|
||||||
|
window.terminateSessions();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
66
src/main/java/com/gregor/jprototerm/X11Pointer.java
Normal file
66
src/main/java/com/gregor/jprototerm/X11Pointer.java
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
package com.gregor.jprototerm;
|
||||||
|
|
||||||
|
import java.lang.foreign.Arena;
|
||||||
|
import java.lang.foreign.FunctionDescriptor;
|
||||||
|
import java.lang.foreign.Linker;
|
||||||
|
import java.lang.foreign.MemorySegment;
|
||||||
|
import java.lang.foreign.SymbolLookup;
|
||||||
|
import java.lang.invoke.MethodHandle;
|
||||||
|
|
||||||
|
import static java.lang.foreign.ValueLayout.ADDRESS;
|
||||||
|
import static java.lang.foreign.ValueLayout.JAVA_INT;
|
||||||
|
import static java.lang.foreign.ValueLayout.JAVA_LONG;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads the X11 pointer location directly via libX11 ({@code XQueryPointer}). Unlike AWT's
|
||||||
|
* {@code MouseInfo}, this never calls {@code XSetErrorHandler}, so it doesn't trip GDK's
|
||||||
|
* "XSetErrorHandler called with a GDK error trap pushed" warning when JavaFX's GTK backend is
|
||||||
|
* already up. Returns {@code null} when not on X11 or libX11 can't be loaded.
|
||||||
|
*/
|
||||||
|
final class X11Pointer {
|
||||||
|
private X11Pointer() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** {@code {x, y}} of the pointer in X root-window (virtual screen) space, or {@code null}. */
|
||||||
|
static int[] query() {
|
||||||
|
try (Arena arena = Arena.ofConfined()) {
|
||||||
|
Linker linker = Linker.nativeLinker();
|
||||||
|
SymbolLookup x11 = SymbolLookup.libraryLookup("libX11.so.6", arena);
|
||||||
|
MethodHandle openDisplay = linker.downcallHandle(x11.find("XOpenDisplay").orElseThrow(),
|
||||||
|
FunctionDescriptor.of(ADDRESS, ADDRESS));
|
||||||
|
MethodHandle defaultRootWindow = linker.downcallHandle(x11.find("XDefaultRootWindow").orElseThrow(),
|
||||||
|
FunctionDescriptor.of(JAVA_LONG, ADDRESS));
|
||||||
|
MethodHandle queryPointer = linker.downcallHandle(x11.find("XQueryPointer").orElseThrow(),
|
||||||
|
FunctionDescriptor.of(JAVA_INT, ADDRESS, JAVA_LONG,
|
||||||
|
ADDRESS, ADDRESS, ADDRESS, ADDRESS, ADDRESS, ADDRESS, ADDRESS));
|
||||||
|
MethodHandle closeDisplay = linker.downcallHandle(x11.find("XCloseDisplay").orElseThrow(),
|
||||||
|
FunctionDescriptor.of(JAVA_INT, ADDRESS));
|
||||||
|
|
||||||
|
MemorySegment display = (MemorySegment) openDisplay.invoke(MemorySegment.NULL);
|
||||||
|
if (display.address() == 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
long root = (long) defaultRootWindow.invoke(display);
|
||||||
|
MemorySegment rootReturn = arena.allocate(JAVA_LONG);
|
||||||
|
MemorySegment childReturn = arena.allocate(JAVA_LONG);
|
||||||
|
MemorySegment rootX = arena.allocate(JAVA_INT);
|
||||||
|
MemorySegment rootY = arena.allocate(JAVA_INT);
|
||||||
|
MemorySegment winX = arena.allocate(JAVA_INT);
|
||||||
|
MemorySegment winY = arena.allocate(JAVA_INT);
|
||||||
|
MemorySegment mask = arena.allocate(JAVA_INT);
|
||||||
|
int onSameScreen = (int) queryPointer.invoke(display, root,
|
||||||
|
rootReturn, childReturn, rootX, rootY, winX, winY, mask);
|
||||||
|
if (onSameScreen == 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return new int[] { rootX.get(JAVA_INT, 0), rootY.get(JAVA_INT, 0) };
|
||||||
|
} finally {
|
||||||
|
closeDisplay.invoke(display);
|
||||||
|
}
|
||||||
|
} catch (Throwable ignored) {
|
||||||
|
// Not X11, libX11 missing, or the call failed — caller falls back to the primary screen.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user