Compare commits
26 Commits
add-worktr
...
cc9ac43ffa
| Author | SHA1 | Date | |
|---|---|---|---|
| cc9ac43ffa | |||
| 93d53fcef6 | |||
| b0ec6c7014 | |||
| 3c913fefd3 | |||
| 263bcf36b7 | |||
| 8e060b27ca | |||
| 6613f1f746 | |||
| 5728733f5f | |||
| 6e3e88919e | |||
| 57103bb98b | |||
| cb95a7188d | |||
|
|
5ca192b7be | ||
|
|
e99a6ee33e | ||
|
|
4923ea5527 | ||
|
|
1f7394d75a | ||
|
|
50641d0a6a | ||
|
|
51f64e7ca8 | ||
|
|
528afafcda | ||
|
|
093a09da39 | ||
|
|
59ab33bc01 | ||
|
|
d8447d9e29 | ||
|
|
dba6474491 | ||
|
|
743f312921 | ||
|
|
3054b3ec77 | ||
|
|
2bcaf951df | ||
|
|
beba14c3ea |
18
.classpath
Normal file
18
.classpath
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<classpath>
|
||||||
|
<classpathentry kind="src" output="bin/main" path="src/main/java">
|
||||||
|
<attributes>
|
||||||
|
<attribute name="gradle_scope" value="main"/>
|
||||||
|
<attribute name="gradle_used_by_scope" value="main,test"/>
|
||||||
|
</attributes>
|
||||||
|
</classpathentry>
|
||||||
|
<classpathentry kind="src" output="bin/main" path="src/main/resources">
|
||||||
|
<attributes>
|
||||||
|
<attribute name="gradle_scope" value="main"/>
|
||||||
|
<attribute name="gradle_used_by_scope" value="main,test"/>
|
||||||
|
</attributes>
|
||||||
|
</classpathentry>
|
||||||
|
<classpathentry kind="con" path="org.eclipse.jdt.launching.JRE_CONTAINER/org.eclipse.jdt.internal.debug.ui.launcher.StandardVMType/JavaSE-21/"/>
|
||||||
|
<classpathentry kind="con" path="org.eclipse.buildship.core.gradleclasspathcontainer"/>
|
||||||
|
<classpathentry kind="output" path="bin/default"/>
|
||||||
|
</classpath>
|
||||||
1
.codexsession
Normal file
1
.codexsession
Normal file
@@ -0,0 +1 @@
|
|||||||
|
019e6999-b7c8-7591-a8aa-ea51b89a7f7e
|
||||||
1
.gitattributes
vendored
1
.gitattributes
vendored
@@ -1 +0,0 @@
|
|||||||
*.mp4 filter=lfs diff=lfs merge=lfs -text
|
|
||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -11,10 +11,7 @@ devenv.local.yaml
|
|||||||
# pre-commit
|
# pre-commit
|
||||||
.pre-commit-config.yaml
|
.pre-commit-config.yaml
|
||||||
build
|
build
|
||||||
|
build
|
||||||
.gradle
|
.gradle
|
||||||
bin
|
bin
|
||||||
.settings
|
|
||||||
.project
|
|
||||||
.worktrees
|
.worktrees
|
||||||
.classpath
|
|
||||||
.codexsession
|
|
||||||
|
|||||||
34
.project
Normal file
34
.project
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<projectDescription>
|
||||||
|
<name>jprototerm</name>
|
||||||
|
<comment>Project jprototerm created by Buildship.</comment>
|
||||||
|
<projects>
|
||||||
|
</projects>
|
||||||
|
<buildSpec>
|
||||||
|
<buildCommand>
|
||||||
|
<name>org.eclipse.jdt.core.javabuilder</name>
|
||||||
|
<arguments>
|
||||||
|
</arguments>
|
||||||
|
</buildCommand>
|
||||||
|
<buildCommand>
|
||||||
|
<name>org.eclipse.buildship.core.gradleprojectbuilder</name>
|
||||||
|
<arguments>
|
||||||
|
</arguments>
|
||||||
|
</buildCommand>
|
||||||
|
</buildSpec>
|
||||||
|
<natures>
|
||||||
|
<nature>org.eclipse.jdt.core.javanature</nature>
|
||||||
|
<nature>org.eclipse.buildship.core.gradleprojectnature</nature>
|
||||||
|
</natures>
|
||||||
|
<filteredResources>
|
||||||
|
<filter>
|
||||||
|
<id>1779917652126</id>
|
||||||
|
<name></name>
|
||||||
|
<type>30</type>
|
||||||
|
<matcher>
|
||||||
|
<id>org.eclipse.core.resources.regexFilterMatcher</id>
|
||||||
|
<arguments>node_modules|\.git|__CREATED_BY_JAVA_LANGUAGE_SERVER__</arguments>
|
||||||
|
</matcher>
|
||||||
|
</filter>
|
||||||
|
</filteredResources>
|
||||||
|
</projectDescription>
|
||||||
13
.settings/org.eclipse.buildship.core.prefs
Normal file
13
.settings/org.eclipse.buildship.core.prefs
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
arguments=--init-script /home/anon/Src/eclipse.jdt.ls/org.eclipse.jdt.ls.product/target/repository/configuration/org.eclipse.osgi/58/0/.cp/gradle/init/init.gradle
|
||||||
|
auto.sync=false
|
||||||
|
build.scans.enabled=false
|
||||||
|
connection.gradle.distribution=GRADLE_DISTRIBUTION(LOCAL_INSTALLATION(/home/anon/.sdkman/candidates/gradle/current))
|
||||||
|
connection.project.dir=
|
||||||
|
eclipse.preferences.version=1
|
||||||
|
gradle.user.home=
|
||||||
|
java.home=/nix/store/c3pl7bqrx3d2rc3dh98z6yaj0mv1p52g-openjdk-21.0.10+7/lib/openjdk
|
||||||
|
jvm.arguments=
|
||||||
|
offline.mode=false
|
||||||
|
override.workspace.settings=true
|
||||||
|
show.console.view=true
|
||||||
|
show.executions.view=true
|
||||||
80
README.md
80
README.md
@@ -12,8 +12,6 @@ floating panes and tabs.
|
|||||||
> setups, or Mesa/AMD/Intel GPUs. I'm happy to accept pull requests that broaden host
|
> setups, or Mesa/AMD/Intel GPUs. I'm happy to accept pull requests that broaden host
|
||||||
> support.
|
> support.
|
||||||
|
|
||||||
<video src="https://gitea.gregorlohaus.com/gregor/jprototerm/media/branch/main/demo.mp4" controls></video>
|
|
||||||
|
|
||||||
## Build
|
## Build
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -62,57 +60,6 @@ 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:
|
||||||
@@ -149,9 +96,6 @@ 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 = ""
|
||||||
|
|
||||||
@@ -162,14 +106,12 @@ navigate_up = "ALT+K"
|
|||||||
navigate_right = "ALT+L"
|
navigate_right = "ALT+L"
|
||||||
toggle_floating = "ALT+F"
|
toggle_floating = "ALT+F"
|
||||||
new_pane = "ALT+N"
|
new_pane = "ALT+N"
|
||||||
next_floating = "ALT+F12"
|
|
||||||
close_pane = "ALT+X"
|
close_pane = "ALT+X"
|
||||||
new_tab = "ALT+A"
|
new_tab = "ALT+A"
|
||||||
previous_tab = "ALT+SHIFT+H"
|
previous_tab = "ALT+SHIFT+H"
|
||||||
next_tab = "ALT+SHIFT+L"
|
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
|
||||||
@@ -178,18 +120,34 @@ create_worktree = "ALT+W"
|
|||||||
- `Alt+n`: new pane — a floating pane when floating panes are shown, otherwise a new tiled
|
- `Alt+n`: new pane — a floating pane when floating panes are shown, otherwise a new tiled
|
||||||
pane (tiled panes are split equally across the width)
|
pane (tiled panes are split equally across the width)
|
||||||
- `Alt+f`: show or hide all floating panes
|
- `Alt+f`: show or hide all floating panes
|
||||||
- `Alt+F12`: cycle floating panes
|
|
||||||
- `Alt+x`: close the active pane; closing a tab's last pane closes the tab, and closing the
|
- `Alt+x`: close the active pane; closing a tab's last pane closes the tab, and closing the
|
||||||
last pane of the last tab quits
|
last pane of the last tab quits
|
||||||
- `Alt+a`: new tab
|
- `Alt+a`: new tab
|
||||||
- `Alt+Shift+h` / `Alt+Shift+l`: previous / next 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
|
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
|
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.
|
while floating panes exist promotes the most recently active floating pane to a tiled pane.
|
||||||
|
|
||||||
|
## Diagnostics
|
||||||
|
|
||||||
|
Two render-debugging flags are off by default and add no overhead unless enabled. Pass them
|
||||||
|
as JVM system properties (each also has an environment-variable equivalent). With the
|
||||||
|
packaged binary the JVM picks them up from `JDK_JAVA_OPTIONS`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
JDK_JAVA_OPTIONS="-Djprototerm.profile=true" ./result/bin/jprototerm
|
||||||
|
```
|
||||||
|
|
||||||
|
- `-Djprototerm.profile=true` (or `JPROTOTERM_PROFILE=1`): print a `[render-profile]` line to
|
||||||
|
stderr every N renders with the per-frame cost of each render stage — `snapshot` (terminal
|
||||||
|
state marshalling), `fingerprint` (change detection), `draw` (canvas painting), and the
|
||||||
|
`frame-total`. Set `-Djprototerm.profile.frames=<N>` to change the dump interval (default
|
||||||
|
120).
|
||||||
|
- `-Djprototerm.debugRepaint=true` (or `JPROTOTERM_DEBUG_REPAINT=1`): paint each per-column
|
||||||
|
repaint run's cleared span red instead of clearing it. Repainted regions flash red, so you
|
||||||
|
can see exactly which cells are being redrawn each frame.
|
||||||
|
|||||||
@@ -3,9 +3,6 @@ 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
|
||||||
|
|
||||||
@@ -19,9 +16,6 @@ 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 = ""
|
||||||
|
|
||||||
@@ -32,13 +26,6 @@ navigate_up = "ALT+K"
|
|||||||
navigate_right = "ALT+L"
|
navigate_right = "ALT+L"
|
||||||
toggle_floating = "ALT+F"
|
toggle_floating = "ALT+F"
|
||||||
new_pane = "ALT+N"
|
new_pane = "ALT+N"
|
||||||
next_floating = "ALT+F12"
|
|
||||||
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
Normal file
65
devenv.lock
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
{
|
||||||
|
"nodes": {
|
||||||
|
"devenv": {
|
||||||
|
"locked": {
|
||||||
|
"dir": "src/modules",
|
||||||
|
"lastModified": 1779749056,
|
||||||
|
"narHash": "sha256-AtocdrunzuxTvSDn+82RntEhrs6TicM6Z4/zNQS9KKg=",
|
||||||
|
"owner": "cachix",
|
||||||
|
"repo": "devenv",
|
||||||
|
"rev": "099ac65fcef79e88127bdc06adbd1ea94255274a",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"dir": "src/modules",
|
||||||
|
"owner": "cachix",
|
||||||
|
"repo": "devenv",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nixpkgs": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs-src": "nixpkgs-src"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1778507786,
|
||||||
|
"narHash": "sha256-HzSQCKMsMr8r55LwM1JuzIOB+8bzk0FEv6sItKvsfoY=",
|
||||||
|
"owner": "cachix",
|
||||||
|
"repo": "devenv-nixpkgs",
|
||||||
|
"rev": "8f24a228a782e24576b155d1e39f0d914b380691",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "cachix",
|
||||||
|
"ref": "rolling",
|
||||||
|
"repo": "devenv-nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nixpkgs-src": {
|
||||||
|
"flake": false,
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1778274207,
|
||||||
|
"narHash": "sha256-I4puXmX1iovcCHZlRmztO3vW0mAbbRvq4F8wgIMQ1MM=",
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "b3da656039dc7a6240f27b2ef8cc6a3ef3bccae7",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "NixOS",
|
||||||
|
"ref": "nixpkgs-unstable",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"inputs": {
|
||||||
|
"devenv": "devenv",
|
||||||
|
"nixpkgs": "nixpkgs"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": "root",
|
||||||
|
"version": 7
|
||||||
|
}
|
||||||
66
devenv.nix
Normal file
66
devenv.nix
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
{ pkgs, lib, config, inputs, ... }:
|
||||||
|
|
||||||
|
let
|
||||||
|
system = pkgs.stdenv.hostPlatform.system;
|
||||||
|
|
||||||
|
jlibghostty = builtins.getFlake
|
||||||
|
"git+https://gitea.gregorlohaus.com/gregor/jlibghostty.git";
|
||||||
|
|
||||||
|
jlib = jlibghostty.packages.${system}.jlibghostty;
|
||||||
|
hostNvidiaLibs = ".devenv/host-nvidia-libs";
|
||||||
|
in
|
||||||
|
{
|
||||||
|
packages = [
|
||||||
|
pkgs.git
|
||||||
|
pkgs.gradle_9
|
||||||
|
pkgs.jdk25
|
||||||
|
pkgs.jdt-language-server
|
||||||
|
pkgs.openjfx
|
||||||
|
|
||||||
|
pkgs.glib
|
||||||
|
pkgs.xorg.libXxf86vm
|
||||||
|
pkgs.xorg.libXrender
|
||||||
|
pkgs.xorg.libXtst
|
||||||
|
pkgs.xorg.libXi
|
||||||
|
pkgs.xorg.libXrandr
|
||||||
|
|
||||||
|
pkgs.libGL
|
||||||
|
pkgs.gtk3
|
||||||
|
pkgs.alsa-lib
|
||||||
|
pkgs.mesa-demos
|
||||||
|
];
|
||||||
|
|
||||||
|
env.LD_LIBRARY_PATH = "${hostNvidiaLibs}:" + lib.makeLibraryPath [
|
||||||
|
pkgs.openjfx
|
||||||
|
|
||||||
|
pkgs.glib
|
||||||
|
pkgs.xorg.libXxf86vm
|
||||||
|
pkgs.xorg.libXrender
|
||||||
|
pkgs.xorg.libXtst
|
||||||
|
pkgs.xorg.libXi
|
||||||
|
pkgs.xorg.libXrandr
|
||||||
|
|
||||||
|
pkgs.libGL
|
||||||
|
pkgs.gtk3
|
||||||
|
pkgs.alsa-lib
|
||||||
|
] + ":/usr/lib/x86_64-linux-gnu/nvidia/current";
|
||||||
|
env.__GLX_VENDOR_LIBRARY_NAME = "nvidia";
|
||||||
|
env.__EGL_VENDOR_LIBRARY_FILENAMES = "/usr/share/glvnd/egl_vendor.d/10_nvidia.json";
|
||||||
|
env.JLIBGHOSTTY_MAVEN_REPO = "${jlib}/maven";
|
||||||
|
|
||||||
|
enterShell = ''
|
||||||
|
mkdir -p ${hostNvidiaLibs}
|
||||||
|
for lib in \
|
||||||
|
/usr/lib/x86_64-linux-gnu/libnvidia*.so* \
|
||||||
|
/usr/lib/x86_64-linux-gnu/libGLX_nvidia.so* \
|
||||||
|
/usr/lib/x86_64-linux-gnu/libEGL_nvidia.so* \
|
||||||
|
/usr/lib/x86_64-linux-gnu/nvidia/current/libnvidia*.so* \
|
||||||
|
/usr/lib/x86_64-linux-gnu/nvidia/current/libGLX_nvidia.so* \
|
||||||
|
/usr/lib/x86_64-linux-gnu/nvidia/current/libEGL_nvidia.so*
|
||||||
|
do
|
||||||
|
if [ -e "$lib" ]; then
|
||||||
|
ln -sfn "$lib" ${hostNvidiaLibs}/"$(basename "$lib")"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
'';
|
||||||
|
}
|
||||||
18
devenv.yaml
Normal file
18
devenv.yaml
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# yaml-language-server: $schema=https://devenv.sh/devenv.schema.json
|
||||||
|
inputs:
|
||||||
|
nixpkgs:
|
||||||
|
url: github:cachix/devenv-nixpkgs/rolling
|
||||||
|
|
||||||
|
# If you're using non-OSS software, you can set allowUnfree to true.
|
||||||
|
# allowUnfree: true
|
||||||
|
|
||||||
|
# If you're not willing to allow unsupported packages:
|
||||||
|
# allowUnsupportedSystem: false
|
||||||
|
|
||||||
|
# If you're willing to use a package that's vulnerable
|
||||||
|
# permittedInsecurePackages:
|
||||||
|
# - "openssl-1.1.1w"
|
||||||
|
|
||||||
|
# If you have more than one devenv you can merge them
|
||||||
|
#imports:
|
||||||
|
# - ./backend
|
||||||
8
flake.lock
generated
8
flake.lock
generated
@@ -70,11 +70,11 @@
|
|||||||
"nixpkgs": "nixpkgs"
|
"nixpkgs": "nixpkgs"
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1780363584,
|
"lastModified": 1780258814,
|
||||||
"narHash": "sha256-BN6kwSBnlavuySut6vvLsfwkfKBjrPvFgJeeMMTXNtg=",
|
"narHash": "sha256-8rxL7xaZ/loYg3zdt0w5+hfNyHFVknDZN360NzrtCsQ=",
|
||||||
"ref": "refs/heads/main",
|
"ref": "refs/heads/main",
|
||||||
"rev": "1cd908e5d0070fdc1c86fc6b300cf1d6dbb5d184",
|
"rev": "6a3d5aa0b0b1f738c958e2a2f0249574c07d9c4d",
|
||||||
"revCount": 25,
|
"revCount": 23,
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://gitea.gregorlohaus.com/gregor/jlibghostty.git"
|
"url": "https://gitea.gregorlohaus.com/gregor/jlibghostty.git"
|
||||||
},
|
},
|
||||||
|
|||||||
46
flake.nix
46
flake.nix
@@ -107,35 +107,8 @@
|
|||||||
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 '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" \
|
||||||
@@ -146,25 +119,6 @@
|
|||||||
--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,7 +5,6 @@ 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;
|
||||||
|
|
||||||
@@ -13,7 +12,6 @@ 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;
|
||||||
@@ -22,15 +20,13 @@ public record AppConfig(
|
|||||||
int columns,
|
int columns,
|
||||||
int rows,
|
int rows,
|
||||||
long maxScrollback,
|
long maxScrollback,
|
||||||
List<String> shell,
|
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
|
||||||
) {
|
) {
|
||||||
@@ -41,23 +37,19 @@ public record AppConfig(
|
|||||||
"navigate_right",
|
"navigate_right",
|
||||||
"toggle_floating",
|
"toggle_floating",
|
||||||
"new_pane",
|
"new_pane",
|
||||||
"next_floating",
|
|
||||||
"promote_floating",
|
|
||||||
"close_pane",
|
"close_pane",
|
||||||
"new_tab",
|
"new_tab",
|
||||||
"previous_tab",
|
"previous_tab",
|
||||||
"next_tab",
|
"next_tab",
|
||||||
"open_font_selector",
|
"open_font_selector",
|
||||||
"open_scrollback",
|
"open_scrollback"
|
||||||
"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)) {
|
||||||
save(path, defaults);
|
writeDefaultConfig(path, defaults);
|
||||||
return defaults;
|
return defaults;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,15 +59,13 @@ 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),
|
||||||
stringListValue(document, "terminal.shell", defaults.shell),
|
stringValue(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)
|
||||||
);
|
);
|
||||||
@@ -97,8 +87,6 @@ public record AppConfig(
|
|||||||
760.0,
|
760.0,
|
||||||
true,
|
true,
|
||||||
defaultScrollbackEditorCommand(),
|
defaultScrollbackEditorCommand(),
|
||||||
"./.worktrees",
|
|
||||||
"SIGTERM",
|
|
||||||
Map.of(),
|
Map.of(),
|
||||||
Map.ofEntries(
|
Map.ofEntries(
|
||||||
Map.entry("navigate_left", KeyBinding.parse("ALT+H")),
|
Map.entry("navigate_left", KeyBinding.parse("ALT+H")),
|
||||||
@@ -107,16 +95,12 @@ public record AppConfig(
|
|||||||
Map.entry("navigate_right", KeyBinding.parse("ALT+L")),
|
Map.entry("navigate_right", KeyBinding.parse("ALT+L")),
|
||||||
Map.entry("toggle_floating", KeyBinding.parse("ALT+F")),
|
Map.entry("toggle_floating", KeyBinding.parse("ALT+F")),
|
||||||
Map.entry("new_pane", KeyBinding.parse("ALT+N")),
|
Map.entry("new_pane", KeyBinding.parse("ALT+N")),
|
||||||
Map.entry("next_floating", KeyBinding.parse("ALT+F12")),
|
|
||||||
Map.entry("promote_floating", KeyBinding.parse("ALT+P")),
|
|
||||||
Map.entry("close_pane", KeyBinding.parse("ALT+X")),
|
Map.entry("close_pane", KeyBinding.parse("ALT+X")),
|
||||||
Map.entry("new_tab", KeyBinding.parse("ALT+A")),
|
Map.entry("new_tab", KeyBinding.parse("ALT+A")),
|
||||||
Map.entry("previous_tab", KeyBinding.parse("ALT+SHIFT+H")),
|
Map.entry("previous_tab", KeyBinding.parse("ALT+SHIFT+H")),
|
||||||
Map.entry("next_tab", KeyBinding.parse("ALT+SHIFT+L")),
|
Map.entry("next_tab", KeyBinding.parse("ALT+SHIFT+L")),
|
||||||
Map.entry("open_font_selector", KeyBinding.parse("ALT+T")),
|
Map.entry("open_font_selector", KeyBinding.parse("ALT+T")),
|
||||||
Map.entry("open_scrollback", KeyBinding.parse("ALT+S")),
|
Map.entry("open_scrollback", KeyBinding.parse("ALT+S"))
|
||||||
Map.entry("create_worktree", KeyBinding.parse("ALT+W")),
|
|
||||||
Map.entry("paste", KeyBinding.parse("CTRL+SHIFT+V"))
|
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -133,23 +117,11 @@ 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);
|
||||||
}
|
}
|
||||||
@@ -162,10 +134,8 @@ 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 List<String> defaultShell() {
|
private static String defaultShell() {
|
||||||
// The executable plus its arguments, spawned verbatim. -i makes bash interactive; a
|
return "/bin/bash";
|
||||||
// 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() {
|
||||||
@@ -176,23 +146,6 @@ 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) {
|
||||||
@@ -201,6 +154,10 @@ 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();
|
||||||
@@ -225,8 +182,7 @@ public record AppConfig(
|
|||||||
builder.append("columns = ").append(columns).append('\n');
|
builder.append("columns = ").append(columns).append('\n');
|
||||||
builder.append("rows = ").append(rows).append('\n');
|
builder.append("rows = ").append(rows).append('\n');
|
||||||
builder.append("max_scrollback = ").append(maxScrollback).append('\n');
|
builder.append("max_scrollback = ").append(maxScrollback).append('\n');
|
||||||
builder.append("shell = ").append(quotedList(shell)).append('\n');
|
builder.append("shell = ").append(quoted(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");
|
||||||
@@ -236,8 +192,6 @@ 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');
|
||||||
@@ -253,17 +207,6 @@ 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("\\", "\\\\")
|
||||||
@@ -323,29 +266,17 @@ public record AppConfig(
|
|||||||
return primitive == null ? fallback : primitive.asString();
|
return primitive == null ? fallback : primitive.asString();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Reads a TOML array of strings (e.g. {@code shell = ["/bin/bash", "-i"]}), or the fallback. */
|
private static int intValue(TomlTable table, String key, int fallback) {
|
||||||
private static List<String> stringListValue(TomlTable table, String key, List<String> fallback) {
|
TomlPrimitive primitive = primitive(table, key);
|
||||||
TomlValue value = table.get(key);
|
if (primitive == null) {
|
||||||
if (value == null || !value.isArray()) {
|
|
||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
List<String> result = new ArrayList<>();
|
|
||||||
for (TomlValue element : value.asArray()) {
|
|
||||||
if (element.isPrimitive()) {
|
|
||||||
try {
|
try {
|
||||||
result.add(element.asPrimitive().asString());
|
return primitive.asInteger();
|
||||||
} catch (RuntimeException ignored) {
|
} catch (RuntimeException ex) {
|
||||||
// Skip non-string entries; a shell command line is a list of strings.
|
return fallback;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
// 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);
|
||||||
|
|||||||
@@ -4,117 +4,79 @@ import dev.jlibghostty.KeyModifiers;
|
|||||||
import dev.jlibghostty.MouseButton;
|
import dev.jlibghostty.MouseButton;
|
||||||
import dev.jlibghostty.MouseEncoderSize;
|
import dev.jlibghostty.MouseEncoderSize;
|
||||||
import dev.jlibghostty.MouseInput;
|
import dev.jlibghostty.MouseInput;
|
||||||
import javafx.geometry.VPos;
|
import javafx.geometry.Pos;
|
||||||
import javafx.scene.Node;
|
import javafx.scene.Parent;
|
||||||
import javafx.scene.canvas.Canvas;
|
import javafx.scene.control.Label;
|
||||||
import javafx.scene.canvas.GraphicsContext;
|
|
||||||
import javafx.scene.input.InputEvent;
|
import javafx.scene.input.InputEvent;
|
||||||
import javafx.scene.input.MouseEvent;
|
import javafx.scene.input.MouseEvent;
|
||||||
import javafx.scene.input.ScrollEvent;
|
import javafx.scene.input.ScrollEvent;
|
||||||
import javafx.scene.input.ScrollEvent.VerticalTextScrollUnits;
|
import javafx.scene.input.ScrollEvent.VerticalTextScrollUnits;
|
||||||
|
import javafx.scene.layout.Background;
|
||||||
|
import javafx.scene.layout.BackgroundFill;
|
||||||
|
import javafx.scene.layout.CornerRadii;
|
||||||
|
import javafx.scene.layout.HBox;
|
||||||
|
import javafx.scene.layout.Pane;
|
||||||
import javafx.scene.paint.Color;
|
import javafx.scene.paint.Color;
|
||||||
import javafx.scene.text.Font;
|
|
||||||
import javafx.scene.text.FontSmoothingType;
|
|
||||||
import javafx.scene.text.TextAlignment;
|
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Objects;
|
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Owns the window's tabs and drives rendering and input. It composites only the current tab:
|
* Owns the window's tabs and exposes the terminal surface as a JavaFX scene graph. Each
|
||||||
* each frame it lays that tab out, paints the panes bottom-to-top (so the active floating pane
|
* terminal pane is mounted as its own node, so JavaFX child order handles stacking and clipping
|
||||||
* lands on top) and lets each pane paint its own content, clipped to the region the layout gave
|
* between panes. The pane model still owns terminals, ptys, cell geometry, and snapshots; this
|
||||||
* it. The cross-pane concerns live here — the dirty-frame bookkeeping, the tab strip, routing
|
* class handles tab/pane lifecycle, layout, focus, mouse routing, and frame scheduling.
|
||||||
* mouse/scroll to the pane under the pointer, and the tab/pane lifecycle that {@link Main}'s key
|
|
||||||
* bindings invoke.
|
|
||||||
*/
|
*/
|
||||||
public final class Compositor {
|
public final class Compositor {
|
||||||
// Canvas background shown wherever no pane covers (gaps). Painted on a full recomposite.
|
|
||||||
private static final Color GAP_BACKGROUND = Color.rgb(16, 16, 18);
|
private static final Color GAP_BACKGROUND = Color.rgb(16, 16, 18);
|
||||||
private static final Color TAB_TEXT = Color.rgb(225, 229, 235);
|
private static final Color TAB_TEXT = Color.rgb(225, 229, 235);
|
||||||
// Thin tab strip shown at the top when more than one tab is open.
|
private static final Color TAB_INACTIVE_TEXT = Color.rgb(128, 136, 148);
|
||||||
|
private static final Color TAB_ACTIVE_BACKGROUND = Color.rgb(45, 55, 72);
|
||||||
|
private static final Color TAB_INACTIVE_BACKGROUND = Color.rgb(22, 24, 28);
|
||||||
private static final double TAB_BAR_HEIGHT = 22.0;
|
private static final double TAB_BAR_HEIGHT = 22.0;
|
||||||
|
|
||||||
private final Canvas canvas = new Canvas();
|
private final Pane root = new Pane();
|
||||||
// Kitty images are drawn as retained nodes layered over the canvas, not composited onto it.
|
private final Pane paneLayer = new Pane();
|
||||||
private final KittyImageOverlay imageOverlay = new KittyImageOverlay();
|
private final HBox tabBar = new HBox(1.0);
|
||||||
private final AppConfig config;
|
private final AppConfig config;
|
||||||
private final TerminalMetrics metrics;
|
private final TerminalMetrics metrics;
|
||||||
private final List<Tab> tabs = new ArrayList<>();
|
private final List<Tab> tabs = new ArrayList<>();
|
||||||
|
private final Map<TerminalPane, TerminalPaneNode> nodes = new HashMap<>();
|
||||||
private int currentTabIndex;
|
private int currentTabIndex;
|
||||||
// Bumped on any structural change (tab switch, pane add/close/focus/move) so render()
|
private boolean sceneDirty = true;
|
||||||
// knows to recomposite. Terminal *content* changes are tracked separately through each
|
|
||||||
// tab's content version.
|
|
||||||
private long layoutVersion;
|
|
||||||
// Last content version drawn to the canvas per pane, so a content frame repaints only
|
|
||||||
// the panes that actually changed.
|
|
||||||
private final Map<TerminalPane, Long> paneContentVersion = new HashMap<>();
|
|
||||||
// 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 lastWidth = -1.0;
|
||||||
private double lastHeight = -1.0;
|
private double lastHeight = -1.0;
|
||||||
private String lastFontFamily;
|
|
||||||
private double lastFontSize = -1.0;
|
|
||||||
private long lastLayoutVersion = Long.MIN_VALUE;
|
|
||||||
private long lastContentVersion = Long.MIN_VALUE;
|
private long lastContentVersion = Long.MIN_VALUE;
|
||||||
private boolean mouseButtonPressed;
|
private boolean mouseButtonPressed;
|
||||||
private MouseButton pressedButton = MouseButton.UNKNOWN;
|
private MouseButton pressedButton = MouseButton.UNKNOWN;
|
||||||
// Run when the last pane closes (so the window can quit). No-op until Main sets it.
|
|
||||||
private Runnable onEmpty = () -> {};
|
|
||||||
|
|
||||||
public Compositor(AppConfig config, TerminalMetrics metrics) {
|
public Compositor(AppConfig config, TerminalMetrics metrics) {
|
||||||
this(config, 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.config = config;
|
||||||
this.metrics = metrics;
|
this.metrics = metrics;
|
||||||
tabs.add(new Tab(config, metrics, workingDirectory, this::closePane));
|
tabs.add(new Tab(config, metrics));
|
||||||
canvas.setFocusTraversable(true);
|
|
||||||
canvas.setOnMousePressed(this::handleMousePressed);
|
root.setFocusTraversable(true);
|
||||||
canvas.setOnMouseReleased(this::handleMouseReleased);
|
root.setBackground(new Background(new BackgroundFill(GAP_BACKGROUND, CornerRadii.EMPTY, null)));
|
||||||
canvas.setOnMouseDragged(this::handleMouseDragged);
|
root.getChildren().setAll(paneLayer, tabBar);
|
||||||
canvas.setOnMouseMoved(this::handleMouseMoved);
|
root.setOnMousePressed(event -> root.requestFocus());
|
||||||
canvas.setOnScroll(this::handleScroll);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public Canvas canvas() {
|
public Parent node() {
|
||||||
return canvas;
|
return root;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Sets the callback run when the last pane closes (e.g. to quit the application). */
|
public void requestFocus() {
|
||||||
public void setOnEmpty(Runnable onEmpty) {
|
root.requestFocus();
|
||||||
this.onEmpty = onEmpty;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The kitty-image overlay, to be stacked directly above {@link #canvas()} in the window. */
|
|
||||||
public Node imageOverlay() {
|
|
||||||
return imageOverlay.node();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setFont(String family, double size) {
|
public void setFont(String family, double size) {
|
||||||
metrics.setFont(family, size);
|
metrics.setFont(family, size);
|
||||||
paneContentVersion.clear();
|
nodes.values().forEach(TerminalPaneNode::discard);
|
||||||
layoutVersion++; // recomposite with the new metrics on the next frame
|
markSceneDirty();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Tabs and panes -------------------------------------------------------------
|
// ---- Tabs and panes -------------------------------------------------------------
|
||||||
@@ -123,113 +85,63 @@ public final class Compositor {
|
|||||||
return tabs.isEmpty();
|
return tabs.isEmpty();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The active pane of the current tab, or {@code null} when no tab is left. */
|
|
||||||
public TerminalPane activePane() {
|
public TerminalPane activePane() {
|
||||||
return isEmpty() ? null : currentTab().activePane();
|
return currentTab().activePane();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void navigate(Direction direction) {
|
public void navigate(Direction direction) {
|
||||||
if (!isEmpty() && currentTab().navigate(direction)) {
|
if (!isEmpty() && currentTab().navigate(direction)) {
|
||||||
layoutVersion++;
|
markSceneDirty();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void toggleFloating() {
|
public void toggleFloating() {
|
||||||
mutateCurrentTab(() -> currentTab().toggleFloating());
|
if (isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
currentTab().toggleFloating();
|
||||||
|
markSceneDirty();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void createPane() {
|
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()) {
|
if (isEmpty()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
change.run();
|
currentTab().createPane();
|
||||||
layoutVersion++;
|
markSceneDirty();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void closeActivePane() {
|
public void closeActivePane() {
|
||||||
TerminalPane active = activePane();
|
if (isEmpty()) {
|
||||||
if (active != null) {
|
return;
|
||||||
closePane(active);
|
|
||||||
}
|
}
|
||||||
}
|
currentTab().closeActivePane();
|
||||||
|
if (currentTab().isEmpty()) {
|
||||||
/**
|
tabs.remove(currentTabIndex);
|
||||||
* Closes a specific pane, wherever it lives. Driven both by the key-bound close (via
|
if (currentTabIndex >= tabs.size()) {
|
||||||
* {@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);
|
currentTabIndex = Math.max(0, tabs.size() - 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
layoutVersion++;
|
markSceneDirty();
|
||||||
if (isEmpty()) {
|
|
||||||
onEmpty.run();
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void newTab() {
|
public void newTab() {
|
||||||
// Open the new tab in the currently active pane's working directory, so it lands where the
|
tabs.add(new Tab(config, metrics));
|
||||||
// user currently is rather than always in home.
|
|
||||||
TerminalPane active = activePane();
|
|
||||||
String workingDirectory = active != null ? active.currentWorkingDirectory() : null;
|
|
||||||
tabs.add(new Tab(config, metrics, workingDirectory, this::closePane));
|
|
||||||
currentTabIndex = tabs.size() - 1;
|
currentTabIndex = tabs.size() - 1;
|
||||||
layoutVersion++;
|
markSceneDirty();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void nextTab() {
|
public void nextTab() {
|
||||||
if (tabs.size() > 1) {
|
if (tabs.size() > 1) {
|
||||||
currentTabIndex = (currentTabIndex + 1) % tabs.size();
|
currentTabIndex = (currentTabIndex + 1) % tabs.size();
|
||||||
layoutVersion++;
|
markSceneDirty();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void previousTab() {
|
public void previousTab() {
|
||||||
if (tabs.size() > 1) {
|
if (tabs.size() > 1) {
|
||||||
currentTabIndex = (currentTabIndex - 1 + tabs.size()) % tabs.size();
|
currentTabIndex = (currentTabIndex - 1 + tabs.size()) % tabs.size();
|
||||||
layoutVersion++;
|
markSceneDirty();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -238,18 +150,8 @@ public final class Compositor {
|
|||||||
tab.close();
|
tab.close();
|
||||||
}
|
}
|
||||||
tabs.clear();
|
tabs.clear();
|
||||||
}
|
nodes.clear();
|
||||||
|
paneLayer.getChildren().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() {
|
private Tab currentTab() {
|
||||||
@@ -260,186 +162,140 @@ public final class Compositor {
|
|||||||
return tabs.isEmpty() ? List.of() : currentTab().panes();
|
return tabs.isEmpty() ? List.of() : currentTab().panes();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private List<TerminalPane> allOpenPanes() {
|
||||||
|
List<TerminalPane> panes = new ArrayList<>();
|
||||||
|
for (Tab tab : tabs) {
|
||||||
|
panes.addAll(tab.allPanes());
|
||||||
|
}
|
||||||
|
return panes;
|
||||||
|
}
|
||||||
|
|
||||||
private boolean isActive(TerminalPane pane) {
|
private boolean isActive(TerminalPane pane) {
|
||||||
return !tabs.isEmpty() && currentTab().isActive(pane);
|
return !tabs.isEmpty() && currentTab().isActive(pane);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void focus(TerminalPane pane) {
|
private void focus(TerminalPane pane) {
|
||||||
if (!tabs.isEmpty() && currentTab().focus(pane)) {
|
if (!tabs.isEmpty() && currentTab().focus(pane)) {
|
||||||
layoutVersion++;
|
markSceneDirty();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Rendering ------------------------------------------------------------------
|
// ---- Rendering ------------------------------------------------------------------
|
||||||
|
|
||||||
public void render() {
|
public void render() {
|
||||||
sweepHiddenPanes();
|
double width = root.getWidth();
|
||||||
switch (nextFrameType()) {
|
double height = root.getHeight();
|
||||||
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();
|
long contentVersion = tabs.isEmpty() ? 0 : currentTab().contentVersion();
|
||||||
|
boolean geometryChanged = width != lastWidth || height != lastHeight;
|
||||||
boolean layoutChanged = width != lastWidth || height != lastHeight
|
|
||||||
|| metrics.fontSize() != lastFontSize || !Objects.equals(metrics.fontFamily(), lastFontFamily)
|
|
||||||
|| layoutVersion != lastLayoutVersion;
|
|
||||||
boolean contentChanged = contentVersion != lastContentVersion;
|
boolean contentChanged = contentVersion != lastContentVersion;
|
||||||
|
boolean syncScene = sceneDirty || geometryChanged;
|
||||||
|
|
||||||
|
if (!syncScene && !contentChanged) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
lastWidth = width;
|
lastWidth = width;
|
||||||
lastHeight = height;
|
lastHeight = height;
|
||||||
lastFontFamily = metrics.fontFamily();
|
|
||||||
lastFontSize = metrics.fontSize();
|
|
||||||
lastLayoutVersion = layoutVersion;
|
|
||||||
lastContentVersion = contentVersion;
|
lastContentVersion = contentVersion;
|
||||||
|
sceneDirty = false;
|
||||||
if (layoutChanged) {
|
if (syncScene) {
|
||||||
return FrameType.LAYOUT;
|
syncSceneGraph(width, height);
|
||||||
}
|
}
|
||||||
if (contentChanged) {
|
renderVisiblePanes();
|
||||||
return FrameType.CONTENT;
|
|
||||||
}
|
|
||||||
return FrameType.IDLE;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Full recomposite onto the retained canvas: lay the tab out, clear to the gap colour,
|
private void markSceneDirty() {
|
||||||
// draw the tab strip, then paint every pane bottom-to-top (panes() puts the active
|
sceneDirty = true;
|
||||||
// floating pane last == on top).
|
}
|
||||||
private void renderLayoutFrame() {
|
|
||||||
|
private void syncSceneGraph(double width, double height) {
|
||||||
double topInset = tabs.size() > 1 ? TAB_BAR_HEIGHT : 0.0;
|
double topInset = tabs.size() > 1 ? TAB_BAR_HEIGHT : 0.0;
|
||||||
|
|
||||||
|
paneLayer.resizeRelocate(0.0, 0.0, width, height);
|
||||||
|
updateTabBar(width, topInset);
|
||||||
|
|
||||||
if (!tabs.isEmpty()) {
|
if (!tabs.isEmpty()) {
|
||||||
currentTab().layout(canvas.getWidth(), canvas.getHeight(), topInset);
|
currentTab().layout(width, height, topInset);
|
||||||
}
|
}
|
||||||
|
|
||||||
List<TerminalPane> panes = currentPanes();
|
List<TerminalPane> panes = currentPanes();
|
||||||
// Sync each pane's ghostty grid to its (possibly new) bounds; a no-op when unchanged.
|
retainNodes(allOpenPanes());
|
||||||
|
List<TerminalPaneNode> orderedNodes = new ArrayList<>(panes.size());
|
||||||
for (TerminalPane pane : panes) {
|
for (TerminalPane pane : panes) {
|
||||||
pane.fitToBounds();
|
pane.fitToBounds();
|
||||||
|
TerminalPaneNode node = nodeFor(pane);
|
||||||
|
node.resizeRelocate(Math.round(pane.x()), Math.round(pane.y()), pane.width(), pane.height());
|
||||||
|
orderedNodes.add(node);
|
||||||
|
}
|
||||||
|
paneLayer.getChildren().setAll(orderedNodes);
|
||||||
}
|
}
|
||||||
|
|
||||||
GraphicsContext gc = beginFrame();
|
private void renderVisiblePanes() {
|
||||||
paneContentVersion.keySet().retainAll(panes);
|
for (TerminalPane pane : currentPanes()) {
|
||||||
gc.setFill(GAP_BACKGROUND);
|
TerminalPaneNode node = nodes.get(pane);
|
||||||
gc.fillRect(0, 0, canvas.getWidth(), canvas.getHeight());
|
if (node != null) {
|
||||||
if (topInset > 0.0) {
|
node.renderIncremental(isActive(pane));
|
||||||
drawTabBar(gc, canvas.getWidth(), topInset);
|
|
||||||
}
|
}
|
||||||
for (TerminalPane pane : panes) {
|
|
||||||
paneContentVersion.put(pane, pane.paintFull(gc, isActive(pane)));
|
|
||||||
}
|
|
||||||
imageOverlay.sync(panes);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Repaint just the panes whose content changed, directly on the retained canvas. Each pane
|
|
||||||
// clips itself to its rect minus the panes above it, so a lower pane's repaint can't bleed
|
|
||||||
// over one stacked on top — no restore pass needed. Bounds and grids can't have changed
|
|
||||||
// without a layout frame, so a content frame reuses the existing layout untouched.
|
|
||||||
private void renderContentFrame() {
|
|
||||||
List<TerminalPane> panes = currentPanes();
|
|
||||||
GraphicsContext gc = beginFrame();
|
|
||||||
|
|
||||||
for (TerminalPane pane : panes) {
|
|
||||||
Long drawn = paneContentVersion.get(pane);
|
|
||||||
if (drawn != null && drawn == pane.contentVersion()) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
paneContentVersion.put(pane, pane.paintIncremental(gc, isActive(pane)));
|
|
||||||
imageOverlay.updatePane(pane);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private GraphicsContext beginFrame() {
|
private TerminalPaneNode nodeFor(TerminalPane pane) {
|
||||||
GraphicsContext gc = canvas.getGraphicsContext2D();
|
return nodes.computeIfAbsent(pane, this::createNode);
|
||||||
gc.setFontSmoothingType(FontSmoothingType.LCD); // the per-cell renderer relies on LCD
|
|
||||||
return gc;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Thin tab strip: one equal-width segment per tab, the current one highlighted, with a
|
private TerminalPaneNode createNode(TerminalPane pane) {
|
||||||
// small 1-based number centred in each segment.
|
TerminalPaneNode node = new TerminalPaneNode(pane, metrics);
|
||||||
private void drawTabBar(GraphicsContext gc, double width, double barHeight) {
|
node.setOnMousePressed(event -> handleMousePressed(pane, event));
|
||||||
int count = tabs.size();
|
node.setOnMouseReleased(event -> handleMouseReleased(pane, event));
|
||||||
Font barFont = Font.font(metrics.fontFamily(), Math.max(9.0, Math.min(13.0, barHeight * 0.62)));
|
node.setOnMouseDragged(event -> handleMouseDragged(pane, event));
|
||||||
gc.setFont(barFont);
|
node.setOnMouseMoved(event -> handleMouseMoved(pane, event));
|
||||||
gc.setFontSmoothingType(FontSmoothingType.GRAY);
|
node.setOnScroll(event -> handleScroll(pane, event));
|
||||||
gc.setTextAlign(TextAlignment.CENTER);
|
return node;
|
||||||
gc.setTextBaseline(VPos.CENTER);
|
}
|
||||||
|
|
||||||
double gap = 1.0;
|
private void retainNodes(List<TerminalPane> openPanes) {
|
||||||
double segmentWidth = width / count;
|
Set<TerminalPane> open = new HashSet<>(openPanes);
|
||||||
for (int i = 0; i < count; i++) {
|
nodes.keySet().removeIf(pane -> !open.contains(pane));
|
||||||
double x = i * segmentWidth;
|
}
|
||||||
|
|
||||||
|
private void updateTabBar(double width, double barHeight) {
|
||||||
|
tabBar.setVisible(barHeight > 0.0);
|
||||||
|
tabBar.setManaged(false);
|
||||||
|
tabBar.resizeRelocate(0.0, 0.0, width, barHeight);
|
||||||
|
tabBar.getChildren().clear();
|
||||||
|
if (barHeight <= 0.0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
double segmentWidth = width / tabs.size();
|
||||||
|
for (int i = 0; i < tabs.size(); i++) {
|
||||||
|
Label label = new Label(Integer.toString(i + 1));
|
||||||
boolean current = i == currentTabIndex;
|
boolean current = i == currentTabIndex;
|
||||||
gc.setFill(current ? Color.rgb(45, 55, 72) : Color.rgb(22, 24, 28));
|
label.setAlignment(Pos.CENTER);
|
||||||
gc.fillRect(x, 0.0, segmentWidth - gap, barHeight);
|
label.setTextFill(current ? TAB_TEXT : TAB_INACTIVE_TEXT);
|
||||||
gc.setFill(current ? TAB_TEXT : Color.rgb(128, 136, 148));
|
label.setBackground(new Background(new BackgroundFill(
|
||||||
gc.fillText(Integer.toString(i + 1), x + (segmentWidth - gap) / 2.0, barHeight / 2.0);
|
current ? TAB_ACTIVE_BACKGROUND : TAB_INACTIVE_BACKGROUND,
|
||||||
|
CornerRadii.EMPTY,
|
||||||
|
null)));
|
||||||
|
label.setFont(javafx.scene.text.Font.font(metrics.fontFamily(), Math.max(9.0, Math.min(13.0, barHeight * 0.62))));
|
||||||
|
label.setMinSize(0.0, barHeight);
|
||||||
|
label.setPrefSize(Math.max(0.0, segmentWidth - 1.0), barHeight);
|
||||||
|
label.setMaxSize(Double.MAX_VALUE, barHeight);
|
||||||
|
final int index = i;
|
||||||
|
label.setOnMousePressed(event -> {
|
||||||
|
currentTabIndex = index;
|
||||||
|
markSceneDirty();
|
||||||
|
root.requestFocus();
|
||||||
|
event.consume();
|
||||||
|
});
|
||||||
|
tabBar.getChildren().add(label);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restore the defaults the cell renderer relies on (left-aligned, baseline, LCD).
|
|
||||||
gc.setTextAlign(TextAlignment.LEFT);
|
|
||||||
gc.setTextBaseline(VPos.BASELINE);
|
|
||||||
gc.setFontSmoothingType(FontSmoothingType.LCD);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Input ----------------------------------------------------------------------
|
// ---- Input ----------------------------------------------------------------------
|
||||||
|
|
||||||
private void handleMousePressed(MouseEvent event) {
|
private void handleMousePressed(TerminalPane pane, MouseEvent event) {
|
||||||
canvas.requestFocus();
|
root.requestFocus();
|
||||||
TerminalPane pane = paneAt(event.getX(), event.getY());
|
|
||||||
if (pane == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
focus(pane);
|
focus(pane);
|
||||||
pressedButton = mouseButton(event);
|
pressedButton = mouseButton(event);
|
||||||
mouseButtonPressed = true;
|
mouseButtonPressed = true;
|
||||||
@@ -447,61 +303,38 @@ public final class Compositor {
|
|||||||
if (target == null) {
|
if (target == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
send(pane, target, MouseInput.press(pressedButton, localX(event.getX(), pane, target), localY(event.getY(), pane, target), modifiers(event)), true, event);
|
send(pane, target, MouseInput.press(pressedButton, localX(event.getX(), target), localY(event.getY(), target), modifiers(event)), true, event);
|
||||||
}
|
|
||||||
|
|
||||||
private void handleMouseReleased(MouseEvent event) {
|
|
||||||
TerminalPane pane = paneAt(event.getX(), event.getY());
|
|
||||||
if (pane == null) {
|
|
||||||
pane = activePane(); // released outside every pane (e.g. mid-drag): route to the active one
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void handleMouseReleased(TerminalPane pane, MouseEvent event) {
|
||||||
MouseButton button = pressedButton == MouseButton.UNKNOWN ? mouseButton(event) : pressedButton;
|
MouseButton button = pressedButton == MouseButton.UNKNOWN ? mouseButton(event) : pressedButton;
|
||||||
MouseTarget target = pane == null ? null : mouseTarget(pane);
|
MouseTarget target = mouseTarget(pane);
|
||||||
if (target != null) {
|
if (target != null) {
|
||||||
send(pane, target, MouseInput.release(button, localX(event.getX(), pane, target), localY(event.getY(), pane, target), modifiers(event)), false, event);
|
send(pane, target, MouseInput.release(button, localX(event.getX(), target), localY(event.getY(), target), modifiers(event)), false, event);
|
||||||
}
|
}
|
||||||
mouseButtonPressed = false;
|
mouseButtonPressed = false;
|
||||||
pressedButton = MouseButton.UNKNOWN;
|
pressedButton = MouseButton.UNKNOWN;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void handleMouseDragged(MouseEvent event) {
|
private void handleMouseDragged(TerminalPane pane, MouseEvent event) {
|
||||||
TerminalPane pane = paneAt(event.getX(), event.getY());
|
|
||||||
if (pane == null) {
|
|
||||||
pane = activePane(); // dragged outside every pane: route to the active one
|
|
||||||
}
|
|
||||||
if (pane == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
MouseButton button = pressedButton == MouseButton.UNKNOWN ? mouseButton(event) : pressedButton;
|
MouseButton button = pressedButton == MouseButton.UNKNOWN ? mouseButton(event) : pressedButton;
|
||||||
MouseTarget target = mouseTarget(pane);
|
MouseTarget target = mouseTarget(pane);
|
||||||
if (target == null) {
|
if (target == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
send(pane, target, MouseInput.drag(button, localX(event.getX(), pane, target), localY(event.getY(), pane, target), modifiers(event)), true, event);
|
send(pane, target, MouseInput.drag(button, localX(event.getX(), target), localY(event.getY(), target), modifiers(event)), true, event);
|
||||||
}
|
|
||||||
|
|
||||||
private void handleMouseMoved(MouseEvent event) {
|
|
||||||
TerminalPane pane = paneAt(event.getX(), event.getY());
|
|
||||||
if (pane == null) {
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void handleMouseMoved(TerminalPane pane, MouseEvent event) {
|
||||||
MouseTarget target = mouseTarget(pane);
|
MouseTarget target = mouseTarget(pane);
|
||||||
if (target == null) {
|
if (target == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
send(pane, target, MouseInput.motion(localX(event.getX(), pane, target), localY(event.getY(), pane, target), modifiers(event)), mouseButtonPressed, event);
|
send(pane, target, MouseInput.motion(localX(event.getX(), target), localY(event.getY(), target), modifiers(event)), mouseButtonPressed, event);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void handleScroll(ScrollEvent event) {
|
private void handleScroll(TerminalPane pane, ScrollEvent event) {
|
||||||
TerminalPane pane = paneAt(event.getX(), event.getY());
|
root.requestFocus();
|
||||||
if (pane == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
canvas.requestFocus();
|
|
||||||
focus(pane);
|
focus(pane);
|
||||||
int direction = scrollDirection(event);
|
int direction = scrollDirection(event);
|
||||||
if (direction == 0) {
|
if (direction == 0) {
|
||||||
@@ -513,9 +346,8 @@ public final class Compositor {
|
|||||||
MouseTarget target = mouseTarget(pane);
|
MouseTarget target = mouseTarget(pane);
|
||||||
boolean sent = false;
|
boolean sent = false;
|
||||||
if (target != null) {
|
if (target != null) {
|
||||||
// The wheel sends one button press per scrolled row; resolve the position once.
|
double ex = localX(event.getX(), target);
|
||||||
double ex = localX(event.getX(), pane, target);
|
double ey = localY(event.getY(), target);
|
||||||
double ey = localY(event.getY(), pane, target);
|
|
||||||
KeyModifiers modifiers = modifiers(event);
|
KeyModifiers modifiers = modifiers(event);
|
||||||
for (int i = 0; i < rows; i++) {
|
for (int i = 0; i < rows; i++) {
|
||||||
if (!send(pane, target, MouseInput.press(wheelButton, ex, ey, modifiers), mouseButtonPressed, event)) {
|
if (!send(pane, target, MouseInput.press(wheelButton, ex, ey, modifiers), mouseButtonPressed, event)) {
|
||||||
@@ -525,14 +357,11 @@ public final class Compositor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!sent) {
|
if (!sent) {
|
||||||
// Not consumed by the app (e.g. mouse reporting off): scroll the local viewport.
|
|
||||||
pane.scrollViewport(direction > 0 ? -rows : rows);
|
pane.scrollViewport(direction > 0 ? -rows : rows);
|
||||||
event.consume();
|
event.consume();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Forward an already-positioned mouse event to the pane, consuming it if the pane (i.e.
|
|
||||||
// the app running in it) acted on it. Returns whether it was sent.
|
|
||||||
private boolean send(TerminalPane pane, MouseTarget target, MouseInput input, boolean anyButtonPressed, InputEvent event) {
|
private boolean send(TerminalPane pane, MouseTarget target, MouseInput input, boolean anyButtonPressed, InputEvent event) {
|
||||||
boolean sent = pane.sendMouse(input, target.size(), anyButtonPressed);
|
boolean sent = pane.sendMouse(input, target.size(), anyButtonPressed);
|
||||||
if (sent) {
|
if (sent) {
|
||||||
@@ -541,17 +370,6 @@ public final class Compositor {
|
|||||||
return sent;
|
return sent;
|
||||||
}
|
}
|
||||||
|
|
||||||
private TerminalPane paneAt(double x, double y) {
|
|
||||||
List<TerminalPane> panes = currentPanes();
|
|
||||||
for (int i = panes.size() - 1; i >= 0; i--) {
|
|
||||||
TerminalPane pane = panes.get(i);
|
|
||||||
if (x >= pane.x() && x < pane.x() + pane.width() && y >= pane.y() && y < pane.y() + pane.height()) {
|
|
||||||
return pane;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private MouseTarget mouseTarget(TerminalPane pane) {
|
private MouseTarget mouseTarget(TerminalPane pane) {
|
||||||
if (pane.width() <= 2 * TerminalMetrics.PADDING || pane.height() <= 2 * TerminalMetrics.PADDING) {
|
if (pane.width() <= 2 * TerminalMetrics.PADDING || pane.height() <= 2 * TerminalMetrics.PADDING) {
|
||||||
return null;
|
return null;
|
||||||
@@ -566,14 +384,12 @@ public final class Compositor {
|
|||||||
return new MouseTarget(MouseEncoderSize.of(screenWidth, screenHeight, cellWidth, cellHeight), screenWidth, screenHeight);
|
return new MouseTarget(MouseEncoderSize.of(screenWidth, screenHeight, cellWidth, cellHeight), screenWidth, screenHeight);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve a canvas-space pointer position to a pane-local pixel coordinate, clamped to
|
private static double localX(double nodeX, MouseTarget target) {
|
||||||
// the pane's reported screen size (what ghostty's mouse encoder expects).
|
return clamp(nodeX - TerminalMetrics.PADDING, 0.0, target.screenWidth() - 1.0);
|
||||||
private static double localX(double canvasX, TerminalPane pane, MouseTarget target) {
|
|
||||||
return clamp(canvasX - pane.x() - TerminalMetrics.PADDING, 0.0, target.screenWidth() - 1.0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static double localY(double canvasY, TerminalPane pane, MouseTarget target) {
|
private static double localY(double nodeY, MouseTarget target) {
|
||||||
return clamp(canvasY - pane.y() - TerminalMetrics.PADDING, 0.0, target.screenHeight() - 1.0);
|
return clamp(nodeY - TerminalMetrics.PADDING, 0.0, target.screenHeight() - 1.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static double clamp(double value, double min, double max) {
|
private static double clamp(double value, double min, double max) {
|
||||||
@@ -621,13 +437,6 @@ public final class Compositor {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// What one render() pass should do, decided from the change trackers in nextFrame().
|
|
||||||
private enum FrameType {
|
|
||||||
IDLE, // nothing changed since the last frame
|
|
||||||
LAYOUT, // geometry/font/tab/pane set changed: clear and repaint everything
|
|
||||||
CONTENT // only terminal content changed: repaint the panes that changed
|
|
||||||
}
|
|
||||||
|
|
||||||
private record MouseTarget(MouseEncoderSize size, long screenWidth, long screenHeight) {
|
private record MouseTarget(MouseEncoderSize size, long screenWidth, long screenHeight) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,183 +0,0 @@
|
|||||||
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 + "'");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,104 +0,0 @@
|
|||||||
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,19 +5,17 @@ import javafx.scene.input.KeyEvent;
|
|||||||
|
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
|
|
||||||
public record KeyBinding(boolean alt, boolean control, boolean shift, boolean meta, KeyCode code) {
|
public record KeyBinding(boolean alt, boolean control, boolean shift, 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" -> alt = true;
|
case "ALT", "META" -> 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);
|
||||||
@@ -27,14 +25,13 @@ public record KeyBinding(boolean alt, boolean control, boolean shift, boolean me
|
|||||||
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, meta, code);
|
return new KeyBinding(alt, control, shift, 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,9 +44,6 @@ public record KeyBinding(boolean alt, boolean control, boolean shift, boolean me
|
|||||||
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+");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
package com.gregor.jprototerm;
|
|
||||||
|
|
||||||
import javafx.scene.image.Image;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A single kitty image to display, produced by the renderer and consumed by {@link
|
|
||||||
* KittyImageOverlay}. Images are not painted onto the canvas; each becomes a retained
|
|
||||||
* {@code ImageView} node positioned over the pane. The {@code source*} fields are the region of
|
|
||||||
* {@link #image()} to show (in image pixels); the {@code x/y/width/height} are where to put it,
|
|
||||||
* in scene coordinates (the same space the pane's clip {@code Shape} lives in).
|
|
||||||
*
|
|
||||||
* <p>{@code imageId}+{@code placementId} identify the placement so the overlay can reuse the
|
|
||||||
* same node across frames instead of recreating it.
|
|
||||||
*/
|
|
||||||
record KittyImageNode(
|
|
||||||
long imageId,
|
|
||||||
long placementId,
|
|
||||||
Image image,
|
|
||||||
double sourceX,
|
|
||||||
double sourceY,
|
|
||||||
double sourceWidth,
|
|
||||||
double sourceHeight,
|
|
||||||
double x,
|
|
||||||
double y,
|
|
||||||
double width,
|
|
||||||
double height
|
|
||||||
) {
|
|
||||||
/** Stable per-pane key for node reuse. Packs the two u32 ids without collision. */
|
|
||||||
long key() {
|
|
||||||
return (imageId << 32) | (placementId & 0xffffffffL);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,151 +0,0 @@
|
|||||||
package com.gregor.jprototerm;
|
|
||||||
|
|
||||||
import javafx.geometry.Rectangle2D;
|
|
||||||
import javafx.scene.Group;
|
|
||||||
import javafx.scene.Node;
|
|
||||||
import javafx.scene.image.ImageView;
|
|
||||||
import javafx.scene.layout.Pane;
|
|
||||||
import javafx.scene.shape.Rectangle;
|
|
||||||
import javafx.scene.shape.Shape;
|
|
||||||
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.HashMap;
|
|
||||||
import java.util.HashSet;
|
|
||||||
import java.util.Iterator;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.Set;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Renders kitty graphics images as retained scene-graph nodes layered over the {@link Compositor}
|
|
||||||
* canvas, instead of compositing them onto the canvas. Each pane gets a {@link Group} clipped to
|
|
||||||
* that pane's region (the same clip {@code Shape} the canvas renderer uses), and each visible
|
|
||||||
* image placement is an {@link ImageView} inside it, reused across frames so an unchanged image
|
|
||||||
* costs nothing to redraw.
|
|
||||||
*
|
|
||||||
* <p>The overlay {@link #node()} is mouse-transparent and sits above the canvas in the window's
|
|
||||||
* {@code StackPane}; its children use scene coordinates, which line up with the canvas because
|
|
||||||
* both fill the same root.
|
|
||||||
*/
|
|
||||||
final class KittyImageOverlay {
|
|
||||||
private final Pane root = new Pane();
|
|
||||||
private final Map<TerminalPane, PaneOverlay> overlays = new HashMap<>();
|
|
||||||
|
|
||||||
KittyImageOverlay() {
|
|
||||||
// Input belongs to the canvas underneath; the overlay only shows pixels.
|
|
||||||
root.setMouseTransparent(true);
|
|
||||||
root.setManaged(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
Node node() {
|
|
||||||
return root;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Full reconcile to {@code panes} (bottom-to-top): drop overlays for panes that went away,
|
|
||||||
* refresh each surviving/added pane's images and clip, and order the per-pane groups to match
|
|
||||||
* the pane z-order. Called on layout frames, after the panes have painted.
|
|
||||||
*/
|
|
||||||
void sync(List<TerminalPane> panes) {
|
|
||||||
Iterator<Map.Entry<TerminalPane, PaneOverlay>> it = overlays.entrySet().iterator();
|
|
||||||
while (it.hasNext()) {
|
|
||||||
Map.Entry<TerminalPane, PaneOverlay> entry = it.next();
|
|
||||||
if (!panes.contains(entry.getKey())) {
|
|
||||||
root.getChildren().remove(entry.getValue().group);
|
|
||||||
it.remove();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (TerminalPane pane : panes) {
|
|
||||||
updatePane(pane);
|
|
||||||
}
|
|
||||||
// Only panes that actually have images get a group; order those to match pane z-order.
|
|
||||||
List<Node> ordered = new ArrayList<>(panes.size());
|
|
||||||
for (TerminalPane pane : panes) {
|
|
||||||
PaneOverlay overlay = overlays.get(pane);
|
|
||||||
if (overlay != null) {
|
|
||||||
ordered.add(overlay.group);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!root.getChildren().equals(ordered)) {
|
|
||||||
root.getChildren().setAll(ordered);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Refresh one pane's images and clip (called on content frames for each repainted pane).
|
|
||||||
* Creates the pane's group if this is the first time it has shown an image.
|
|
||||||
*/
|
|
||||||
void updatePane(TerminalPane pane) {
|
|
||||||
List<KittyImageNode> images = pane.kittyImages();
|
|
||||||
PaneOverlay overlay = overlays.get(pane);
|
|
||||||
if (overlay == null) {
|
|
||||||
if (images.isEmpty()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
overlay = new PaneOverlay();
|
|
||||||
overlays.put(pane, overlay);
|
|
||||||
root.getChildren().add(overlay.group);
|
|
||||||
}
|
|
||||||
overlay.group.setClip(clipFor(pane));
|
|
||||||
reconcile(overlay, images);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void reconcile(PaneOverlay overlay, List<KittyImageNode> images) {
|
|
||||||
Set<Long> seen = new HashSet<>();
|
|
||||||
for (KittyImageNode node : images) {
|
|
||||||
long key = node.key();
|
|
||||||
seen.add(key);
|
|
||||||
ImageView view = overlay.views.get(key);
|
|
||||||
if (view == null) {
|
|
||||||
view = new ImageView();
|
|
||||||
view.setManaged(false);
|
|
||||||
view.setSmooth(true);
|
|
||||||
view.setPreserveRatio(false);
|
|
||||||
overlay.views.put(key, view);
|
|
||||||
overlay.group.getChildren().add(view);
|
|
||||||
}
|
|
||||||
apply(view, node);
|
|
||||||
}
|
|
||||||
if (overlay.views.size() == seen.size()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
Iterator<Map.Entry<Long, ImageView>> it = overlay.views.entrySet().iterator();
|
|
||||||
while (it.hasNext()) {
|
|
||||||
Map.Entry<Long, ImageView> entry = it.next();
|
|
||||||
if (!seen.contains(entry.getKey())) {
|
|
||||||
overlay.group.getChildren().remove(entry.getValue());
|
|
||||||
it.remove();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void apply(ImageView view, KittyImageNode node) {
|
|
||||||
if (view.getImage() != node.image()) {
|
|
||||||
view.setImage(node.image());
|
|
||||||
}
|
|
||||||
view.setViewport(new Rectangle2D(node.sourceX(), node.sourceY(), node.sourceWidth(), node.sourceHeight()));
|
|
||||||
view.setFitWidth(node.width());
|
|
||||||
view.setFitHeight(node.height());
|
|
||||||
view.setLayoutX(node.x());
|
|
||||||
view.setLayoutY(node.y());
|
|
||||||
}
|
|
||||||
|
|
||||||
// The pane's occlusion clip when one is set (rect minus covering panes), else the pane's
|
|
||||||
// plain bounds so an image can't spill outside its pane. Matches Tab's pixel snapping.
|
|
||||||
private static Shape clipFor(TerminalPane pane) {
|
|
||||||
Shape clip = pane.clip();
|
|
||||||
if (clip != null) {
|
|
||||||
return clip;
|
|
||||||
}
|
|
||||||
return new Rectangle(Math.round(pane.x()), Math.round(pane.y()), pane.width(), pane.height());
|
|
||||||
}
|
|
||||||
|
|
||||||
private static final class PaneOverlay {
|
|
||||||
private final Group group = new Group();
|
|
||||||
private final Map<Long, ImageView> views = new HashMap<>();
|
|
||||||
|
|
||||||
private PaneOverlay() {
|
|
||||||
group.setManaged(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -9,14 +9,9 @@ import java.lang.foreign.MemorySegment;
|
|||||||
import java.lang.foreign.SymbolLookup;
|
import java.lang.foreign.SymbolLookup;
|
||||||
import java.lang.foreign.ValueLayout;
|
import java.lang.foreign.ValueLayout;
|
||||||
import java.lang.invoke.MethodHandle;
|
import java.lang.invoke.MethodHandle;
|
||||||
import java.io.IOException;
|
|
||||||
import java.nio.file.Files;
|
|
||||||
import java.nio.file.Path;
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
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.
|
||||||
@@ -35,11 +30,6 @@ import java.util.concurrent.Executors;
|
|||||||
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");
|
||||||
@@ -70,10 +60,7 @@ 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; }
|
||||||
@@ -87,7 +74,6 @@ public final class LinuxPty implements AutoCloseable {
|
|||||||
private static final long SPAWN_ACTIONS_SIZE = 256;
|
private static final long SPAWN_ACTIONS_SIZE = 256;
|
||||||
private static final long SPAWN_ATTR_SIZE = 512;
|
private static final long SPAWN_ATTR_SIZE = 512;
|
||||||
|
|
||||||
private static final MethodHandle TCGETPGRP = handle("tcgetpgrp", FD_INT_INT);
|
|
||||||
private static final MethodHandle POSIX_OPENPT = handle("posix_openpt", FD_INT_INT);
|
private static final MethodHandle POSIX_OPENPT = handle("posix_openpt", FD_INT_INT);
|
||||||
private static final MethodHandle GRANTPT = handle("grantpt", FD_INT_INT);
|
private static final MethodHandle GRANTPT = handle("grantpt", FD_INT_INT);
|
||||||
private static final MethodHandle UNLOCKPT = handle("unlockpt", FD_INT_INT);
|
private static final MethodHandle UNLOCKPT = handle("unlockpt", FD_INT_INT);
|
||||||
@@ -111,40 +97,14 @@ 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, int closeSignal) {
|
private LinuxPty(int masterFd, int pid) {
|
||||||
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;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -153,10 +113,8 @@ 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");
|
||||||
@@ -195,7 +153,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), closeSignal);
|
return new LinuxPty(master, pidOut.get(C_INT, 0));
|
||||||
} finally {
|
} finally {
|
||||||
callInt(ATTR_DESTROY, attr);
|
callInt(ATTR_DESTROY, attr);
|
||||||
callInt(FA_DESTROY, actions);
|
callInt(FA_DESTROY, actions);
|
||||||
@@ -228,41 +186,19 @@ public final class LinuxPty implements AutoCloseable {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
synchronized (writeLock) {
|
synchronized (writeLock) {
|
||||||
int offset = 0;
|
try (Arena a = Arena.ofConfined()) {
|
||||||
|
MemorySegment buf = a.allocate(data.length);
|
||||||
|
MemorySegment.copy(data, 0, buf, ValueLayout.JAVA_BYTE, 0, data.length);
|
||||||
|
long offset = 0;
|
||||||
while (offset < data.length) {
|
while (offset < data.length) {
|
||||||
int chunk = (int) Math.min(writeBuffer.byteSize(), data.length - offset);
|
long n = callLong(WRITE, masterFd, buf.asSlice(offset), data.length - offset);
|
||||||
MemorySegment.copy(data, offset, writeBuffer, ValueLayout.JAVA_BYTE, 0, chunk);
|
if (n < 0) {
|
||||||
|
|
||||||
long written = 0;
|
|
||||||
while (written < chunk) {
|
|
||||||
long n = callLong(WRITE, masterFd, writeBuffer.asSlice(written), chunk - written);
|
|
||||||
if (n <= 0) {
|
|
||||||
throw new IllegalStateException("write to pty failed");
|
throw new IllegalStateException("write to pty failed");
|
||||||
}
|
}
|
||||||
written += n;
|
offset += n;
|
||||||
}
|
|
||||||
offset += chunk;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Best-effort current working directory of the terminal's foreground process group, read from
|
|
||||||
* {@code /proc}. This tracks the directory the user is actually in (a {@code cd} in the shell,
|
|
||||||
* or a child program that changed dir), so a newly opened pane can start there. Falls back to
|
|
||||||
* the shell's own pid, and returns {@code null} if it cannot be determined.
|
|
||||||
*/
|
|
||||||
public String currentWorkingDirectory() {
|
|
||||||
if (closed) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
int pgid = callInt(TCGETPGRP, masterFd);
|
|
||||||
int target = pgid > 0 ? pgid : pid;
|
|
||||||
try {
|
|
||||||
return Files.readSymbolicLink(Path.of("/proc", Integer.toString(target), "cwd")).toString();
|
|
||||||
} catch (IOException | RuntimeException ex) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Resizes the terminal window. */
|
/** Resizes the terminal window. */
|
||||||
@@ -282,47 +218,14 @@ public final class LinuxPty implements AutoCloseable {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void close() {
|
public void close() {
|
||||||
if (!markClosed()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
closeMaster();
|
|
||||||
try {
|
|
||||||
reap();
|
|
||||||
} finally {
|
|
||||||
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) {
|
if (closed) {
|
||||||
return false;
|
return;
|
||||||
}
|
}
|
||||||
closed = true;
|
closed = true;
|
||||||
return true;
|
callKill(pid, SIGHUP);
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
callInt(CLOSE, masterFd);
|
||||||
|
reap();
|
||||||
|
arena.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void reap() {
|
private void reap() {
|
||||||
|
|||||||
@@ -1,36 +1,192 @@
|
|||||||
package com.gregor.jprototerm;
|
package com.gregor.jprototerm;
|
||||||
|
|
||||||
/**
|
import javafx.animation.AnimationTimer;
|
||||||
* Entry point and mode dispatch. A bare invocation is a thin client: it hands the request to a
|
import javafx.application.Application;
|
||||||
* running {@link Daemon}, or, if none is reachable, opens a single standalone window in this process
|
import javafx.application.Platform;
|
||||||
* (today's behavior). {@code --daemon} runs the long-lived server that hosts every window in one
|
import javafx.scene.Scene;
|
||||||
* JVM, so client launches skip cold JVM/JavaFX/GL startup. {@code --standalone} skips daemon client
|
import javafx.scene.control.ButtonType;
|
||||||
* mode and always opens an in-process window, which is useful while testing development builds.
|
import javafx.scene.control.ComboBox;
|
||||||
*/
|
import javafx.scene.control.Dialog;
|
||||||
public final class Main {
|
import javafx.scene.control.Label;
|
||||||
private Main() {
|
import javafx.scene.control.Spinner;
|
||||||
|
import javafx.scene.control.SpinnerValueFactory;
|
||||||
|
import javafx.scene.input.KeyEvent;
|
||||||
|
import javafx.scene.layout.GridPane;
|
||||||
|
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 Compositor compositor;
|
||||||
|
private TerminalMetrics metrics;
|
||||||
|
private AppConfig config;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void start(Stage stage) {
|
||||||
|
config = AppConfig.load();
|
||||||
|
|
||||||
|
metrics = new TerminalMetrics(config.fontFamily(), config.fontSize());
|
||||||
|
compositor = new Compositor(config, metrics);
|
||||||
|
|
||||||
|
Scene scene = new Scene(compositor.node(), 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) {
|
||||||
|
compositor.render();
|
||||||
|
}
|
||||||
|
}.start();
|
||||||
|
|
||||||
|
stage.setTitle("jprototerm");
|
||||||
|
stage.setScene(scene);
|
||||||
|
stage.setOnCloseRequest(event -> {
|
||||||
|
compositor.close();
|
||||||
|
});
|
||||||
|
stage.show();
|
||||||
|
compositor.requestFocus();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handlePressed(KeyEvent event) {
|
||||||
|
if (config.keybindings().get("navigate_left").matches(event)) {
|
||||||
|
compositor.navigate(Direction.LEFT);
|
||||||
|
event.consume();
|
||||||
|
} else if (config.keybindings().get("navigate_down").matches(event)) {
|
||||||
|
compositor.navigate(Direction.DOWN);
|
||||||
|
event.consume();
|
||||||
|
} else if (config.keybindings().get("navigate_up").matches(event)) {
|
||||||
|
compositor.navigate(Direction.UP);
|
||||||
|
event.consume();
|
||||||
|
} else if (config.keybindings().get("navigate_right").matches(event)) {
|
||||||
|
compositor.navigate(Direction.RIGHT);
|
||||||
|
event.consume();
|
||||||
|
} else if (config.keybindings().get("toggle_floating").matches(event)) {
|
||||||
|
compositor.toggleFloating();
|
||||||
|
event.consume();
|
||||||
|
} else if (config.keybindings().get("new_pane").matches(event)) {
|
||||||
|
compositor.createPane();
|
||||||
|
event.consume();
|
||||||
|
} else if (config.keybindings().get("close_pane").matches(event)) {
|
||||||
|
compositor.closeActivePane();
|
||||||
|
event.consume();
|
||||||
|
if (compositor.isEmpty()) {
|
||||||
|
// Closing the last pane quits the app.
|
||||||
|
compositor.close();
|
||||||
|
Platform.exit();
|
||||||
|
}
|
||||||
|
} else if (config.keybindings().get("new_tab").matches(event)) {
|
||||||
|
compositor.newTab();
|
||||||
|
event.consume();
|
||||||
|
} else if (config.keybindings().get("previous_tab").matches(event)) {
|
||||||
|
compositor.previousTab();
|
||||||
|
event.consume();
|
||||||
|
} else if (config.keybindings().get("next_tab").matches(event)) {
|
||||||
|
compositor.nextTab();
|
||||||
|
event.consume();
|
||||||
|
} 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) {
|
||||||
|
compositor.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) {
|
||||||
|
compositor.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();
|
||||||
|
compositor.setFont(config.fontFamily(), config.fontSize());
|
||||||
|
compositor.requestFocus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void openScrollbackInEditor() {
|
||||||
|
try {
|
||||||
|
Path file = Files.createTempFile("jprototerm-scrollback-", ".txt");
|
||||||
|
Files.writeString(file, compositor.activePane().scrollbackText());
|
||||||
|
file.toFile().deleteOnExit();
|
||||||
|
|
||||||
|
compositor.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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
79
src/main/java/com/gregor/jprototerm/RenderProfiler.java
Normal file
79
src/main/java/com/gregor/jprototerm/RenderProfiler.java
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
package com.gregor.jprototerm;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lightweight render profiler, disabled unless {@code -Djprototerm.profile=true} (or the
|
||||||
|
* {@code JPROTOTERM_PROFILE=1} environment variable) is set. It accumulates wall-clock nanos
|
||||||
|
* into a handful of buckets and prints aggregate per-frame stats to stderr every
|
||||||
|
* {@code jprototerm.profile.frames} render invocations (default 120).
|
||||||
|
*
|
||||||
|
* <p>All render work runs on the JavaFX application thread, so the accumulators are plain
|
||||||
|
* fields with no synchronization.
|
||||||
|
*
|
||||||
|
* <p>Caveat: JavaFX canvas drawing is deferred to the QuantumRenderer thread, so the
|
||||||
|
* {@link #DRAW} bucket measures only the cost of <em>recording</em> draw commands, not the
|
||||||
|
* GPU paint. Pair this with {@code -Djavafx.pulseLogger=true} to see the render-thread side.
|
||||||
|
*/
|
||||||
|
final class RenderProfiler {
|
||||||
|
static final int SNAPSHOT = 0;
|
||||||
|
static final int FINGERPRINT = 1;
|
||||||
|
static final int DRAW = 2;
|
||||||
|
static final int FRAME = 3;
|
||||||
|
static final int UPDATE = 4;
|
||||||
|
static final int MARSHAL = 5;
|
||||||
|
private static final int BUCKETS = 6;
|
||||||
|
private static final String[] NAMES =
|
||||||
|
{"snapshot", "fingerprint", "draw", "frame-total", "update", "marshal"};
|
||||||
|
|
||||||
|
private static final boolean ENABLED =
|
||||||
|
Boolean.getBoolean("jprototerm.profile") || "1".equals(System.getenv("JPROTOTERM_PROFILE"));
|
||||||
|
private static final int DUMP_FRAMES = Integer.getInteger("jprototerm.profile.frames", 120);
|
||||||
|
|
||||||
|
private static final long[] totalNanos = new long[BUCKETS];
|
||||||
|
private static final long[] counts = new long[BUCKETS];
|
||||||
|
private static int frames;
|
||||||
|
|
||||||
|
private RenderProfiler() {
|
||||||
|
}
|
||||||
|
|
||||||
|
static boolean enabled() {
|
||||||
|
return ENABLED;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns a start timestamp, or 0 when profiling is disabled. */
|
||||||
|
static long start() {
|
||||||
|
return ENABLED ? System.nanoTime() : 0L;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Records the time elapsed since {@code startNanos} into {@code bucket}. */
|
||||||
|
static void stop(int bucket, long startNanos) {
|
||||||
|
if (!ENABLED) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
totalNanos[bucket] += System.nanoTime() - startNanos;
|
||||||
|
counts[bucket]++;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Marks the end of one render invocation; dumps and resets every {@code DUMP_FRAMES}. */
|
||||||
|
static void frame() {
|
||||||
|
if (!ENABLED) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (++frames < DUMP_FRAMES) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
dump();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void dump() {
|
||||||
|
StringBuilder sb = new StringBuilder(192);
|
||||||
|
sb.append("[render-profile] ").append(frames).append(" renders");
|
||||||
|
for (int i = 0; i < BUCKETS; i++) {
|
||||||
|
double totalMs = totalNanos[i] / 1_000_000.0;
|
||||||
|
sb.append(String.format(" | %s %.3fms/f (n=%d)", NAMES[i], totalMs / frames, counts[i]));
|
||||||
|
totalNanos[i] = 0;
|
||||||
|
counts[i] = 0;
|
||||||
|
}
|
||||||
|
System.err.println(sb);
|
||||||
|
frames = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
package com.gregor.jprototerm;
|
|
||||||
|
|
||||||
import dev.jlibghostty.KittyGraphics;
|
|
||||||
import dev.jlibghostty.RenderStateSnapshot;
|
|
||||||
import javafx.scene.shape.Shape;
|
|
||||||
|
|
||||||
import java.util.Optional;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The read-only view of a pane that a {@link TerminalRenderer} draws: its on-screen rect, its
|
|
||||||
* current render snapshot, and its kitty-graphics state. Decoupling the renderer from
|
|
||||||
* {@link TerminalPane} through this interface lets the renderer be swapped (e.g. a debug
|
|
||||||
* renderer that just outlines bounds and clip bands) and unit-tested against a synthetic
|
|
||||||
* target without a real terminal.
|
|
||||||
*/
|
|
||||||
interface RenderTarget {
|
|
||||||
double x();
|
|
||||||
|
|
||||||
double y();
|
|
||||||
|
|
||||||
double width();
|
|
||||||
|
|
||||||
double height();
|
|
||||||
|
|
||||||
/** Whether kitty graphics should be drawn for this target at all. */
|
|
||||||
boolean kittyEnabled();
|
|
||||||
|
|
||||||
Optional<KittyGraphics> kittyGraphics();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Incremental snapshot: only rows that changed since the last frame are populated. May be
|
|
||||||
* {@code null} before the first snapshot exists.
|
|
||||||
*/
|
|
||||||
RenderStateSnapshot snapshot();
|
|
||||||
|
|
||||||
/** Full snapshot with every row populated, regardless of dirty state. */
|
|
||||||
RenderStateSnapshot snapshotFull();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The region this target may draw into, or {@code null} to clip to its plain rect. Set at
|
|
||||||
* layout time (a tiled pane gets its rect minus the floating panes that cover it), so the
|
|
||||||
* renderer can clip its own output and never paint over a pane on top.
|
|
||||||
*/
|
|
||||||
Shape clip();
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,6 @@ package com.gregor.jprototerm;
|
|||||||
|
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.concurrent.ExecutorService;
|
import java.util.concurrent.ExecutorService;
|
||||||
import java.util.concurrent.Executors;
|
import java.util.concurrent.Executors;
|
||||||
@@ -21,43 +20,8 @@ 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");
|
||||||
@@ -65,13 +29,16 @@ public final class ShellSession implements AutoCloseable {
|
|||||||
environment.putAll(envOverride);
|
environment.putAll(envOverride);
|
||||||
|
|
||||||
LinuxPty pty = LinuxPty.spawn(
|
LinuxPty pty = LinuxPty.spawn(
|
||||||
argv,
|
new String[] {shell, "-i"},
|
||||||
environment,
|
environment,
|
||||||
workingDirectory != null ? workingDirectory : System.getProperty("user.home"),
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -96,37 +63,12 @@ public final class ShellSession implements AutoCloseable {
|
|||||||
// These are jprototerm's own runtime settings, not the user's shell environment.
|
// These are jprototerm's own runtime settings, not the user's shell environment.
|
||||||
environment.remove("GDK_BACKEND");
|
environment.remove("GDK_BACKEND");
|
||||||
environment.remove("JLIBGHOSTTY_LIBRARY");
|
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void startReading(TerminalPane pane) {
|
public void startReading(TerminalPane pane) {
|
||||||
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;
|
||||||
@@ -171,36 +113,12 @@ public final class ShellSession implements AutoCloseable {
|
|||||||
pane.write("\r\nshell output stopped: " + ex.getMessage() + "\r\n");
|
pane.write("\r\nshell output stopped: " + ex.getMessage() + "\r\n");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// The stream ended without us closing the session, so the process exited on its own (the
|
|
||||||
// user typed `exit`, or a one-shot command pane finished). Let the pane tear itself down.
|
|
||||||
if (!closed) {
|
|
||||||
pane.handleSessionExit();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void close() {
|
public void close() {
|
||||||
if (!markClosed()) {
|
closed = true;
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,51 +0,0 @@
|
|||||||
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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,13 +1,8 @@
|
|||||||
package com.gregor.jprototerm;
|
package com.gregor.jprototerm;
|
||||||
|
|
||||||
import javafx.scene.shape.Rectangle;
|
|
||||||
import javafx.scene.shape.Shape;
|
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Comparator;
|
import java.util.Comparator;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.concurrent.atomic.AtomicLong;
|
|
||||||
import java.util.function.Consumer;
|
|
||||||
import java.util.stream.Stream;
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -18,29 +13,15 @@ import java.util.stream.Stream;
|
|||||||
* return whether they actually changed anything so it can bump its layout version.
|
* return whether they actually changed anything so it can bump its layout version.
|
||||||
*/
|
*/
|
||||||
final class Tab implements AutoCloseable {
|
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 AppConfig config;
|
||||||
private final TerminalMetrics metrics;
|
private final TerminalMetrics metrics;
|
||||||
// Notified (on the FX thread) when one of this tab's panes' process exits on its own, so the
|
|
||||||
// compositor can close that pane and reap the tab/app if it was the last one.
|
|
||||||
private final Consumer<TerminalPane> onPaneExit;
|
|
||||||
private final List<TerminalPane> tiled = new ArrayList<>();
|
private final List<TerminalPane> tiled = new ArrayList<>();
|
||||||
private final List<TerminalPane> floating = new ArrayList<>();
|
private final List<TerminalPane> floating = new ArrayList<>();
|
||||||
private boolean floatingVisible;
|
private boolean floatingVisible;
|
||||||
private TerminalPane active;
|
private TerminalPane active;
|
||||||
private final String initialWorkingDirectory;
|
|
||||||
// The floating pane to re-focus when the group is shown again, and to prefer when promoting
|
// The floating pane to re-focus when the group is shown again, and to prefer when promoting
|
||||||
// after the last tiled pane closes.
|
// after the last tiled pane closes.
|
||||||
private TerminalPane lastFocusedFloating;
|
private TerminalPane lastFocusedFloating;
|
||||||
// The tiled pane to re-focus when the floating group is hidden.
|
|
||||||
private TerminalPane lastFocusedTiled;
|
|
||||||
// Last laid-out size, so a newly opened pane can be created at roughly its eventual rect
|
// Last laid-out size, so a newly opened pane can be created at roughly its eventual rect
|
||||||
// (and thus grid). Seeded from the configured window size for the first pane, which is
|
// (and thus grid). Seeded from the configured window size for the first pane, which is
|
||||||
// opened before any layout pass runs.
|
// opened before any layout pass runs.
|
||||||
@@ -49,24 +30,13 @@ final class Tab implements AutoCloseable {
|
|||||||
private double lastTopInset;
|
private double lastTopInset;
|
||||||
// Bumped whenever one of this tab's panes changes content; the compositor reads the current
|
// Bumped whenever one of this tab's panes changes content; the compositor reads the current
|
||||||
// tab's value each frame as an O(1) "anything to repaint?" check.
|
// tab's value each frame as an O(1) "anything to repaint?" check.
|
||||||
private final AtomicLong contentVersion = new AtomicLong();
|
private long contentVersion;
|
||||||
|
|
||||||
Tab(AppConfig config, TerminalMetrics metrics, Consumer<TerminalPane> onPaneExit) {
|
Tab(AppConfig config, TerminalMetrics metrics) {
|
||||||
this(config, metrics, null, onPaneExit);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a tab whose first pane starts in {@code initialWorkingDirectory} (e.g. the cwd of the
|
|
||||||
* pane that was active when this tab was opened), or the user's home when {@code null}.
|
|
||||||
*/
|
|
||||||
Tab(AppConfig config, TerminalMetrics metrics, String initialWorkingDirectory,
|
|
||||||
Consumer<TerminalPane> onPaneExit) {
|
|
||||||
this.config = config;
|
this.config = config;
|
||||||
this.metrics = metrics;
|
this.metrics = metrics;
|
||||||
this.onPaneExit = onPaneExit;
|
|
||||||
this.lastWidth = config.windowWidth();
|
this.lastWidth = config.windowWidth();
|
||||||
this.lastHeight = config.windowHeight();
|
this.lastHeight = config.windowHeight();
|
||||||
this.initialWorkingDirectory = initialWorkingDirectory;
|
|
||||||
TerminalPane first = openPane(false);
|
TerminalPane first = openPane(false);
|
||||||
tiled.add(first);
|
tiled.add(first);
|
||||||
active = first;
|
active = first;
|
||||||
@@ -81,7 +51,7 @@ final class Tab implements AutoCloseable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
long contentVersion() {
|
long contentVersion() {
|
||||||
return contentVersion.get();
|
return contentVersion;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -94,41 +64,26 @@ final class Tab implements AutoCloseable {
|
|||||||
}
|
}
|
||||||
List<TerminalPane> ordered = new ArrayList<>(tiled.size() + floating.size());
|
List<TerminalPane> ordered = new ArrayList<>(tiled.size() + floating.size());
|
||||||
ordered.addAll(tiled);
|
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) {
|
for (TerminalPane pane : floating) {
|
||||||
if (pane != active) {
|
if (pane != active) {
|
||||||
order.add(pane);
|
ordered.add(pane);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (floating.contains(active)) {
|
if (floating.contains(active)) {
|
||||||
order.add(active);
|
ordered.add(active); // active floating pane on top
|
||||||
}
|
}
|
||||||
return order;
|
return List.copyOf(ordered);
|
||||||
}
|
}
|
||||||
|
|
||||||
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> allPanes() {
|
||||||
List<TerminalPane> all = new ArrayList<>(tiled.size() + floating.size());
|
List<TerminalPane> all = new ArrayList<>(tiled.size() + floating.size());
|
||||||
all.addAll(tiled);
|
all.addAll(tiled);
|
||||||
all.addAll(floating);
|
all.addAll(floating);
|
||||||
return all;
|
return List.copyOf(all);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Whether this tab owns panes that {@link #panes()} does not currently composite. */
|
boolean isActive(TerminalPane pane) {
|
||||||
boolean hasHiddenPanes() {
|
return pane != null && pane == active;
|
||||||
return !floatingVisible && !floating.isEmpty();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
boolean focus(TerminalPane pane) {
|
boolean focus(TerminalPane pane) {
|
||||||
@@ -150,54 +105,16 @@ final class Tab implements AutoCloseable {
|
|||||||
tiled.get(i).bounds(i * tileWidth, topInset, tileWidth, availHeight);
|
tiled.get(i).bounds(i * tileWidth, topInset, tileWidth, availHeight);
|
||||||
}
|
}
|
||||||
|
|
||||||
double floatingWidth = Math.max(FLOATING_MIN_WIDTH, width * FLOATING_SIZE_FRACTION);
|
double floatingWidth = Math.max(420, width * 0.58);
|
||||||
double floatingHeight = Math.max(FLOATING_MIN_HEIGHT, availHeight * FLOATING_SIZE_FRACTION);
|
double floatingHeight = Math.max(260, availHeight * 0.58);
|
||||||
for (int i = 0; i < floating.size(); i++) {
|
for (int i = 0; i < floating.size(); i++) {
|
||||||
double offset = i * FLOATING_CASCADE_OFFSET;
|
double offset = i * 28.0;
|
||||||
floating.get(i).bounds(
|
floating.get(i).bounds(
|
||||||
Math.min(width - floatingWidth - FLOATING_EDGE_MARGIN,
|
Math.min(width - floatingWidth - 12.0, ((width - floatingWidth) / 2.0) + offset),
|
||||||
((width - floatingWidth) / 2.0) + offset),
|
Math.min(height - floatingHeight - 12.0, topInset + ((availHeight - floatingHeight) / 2.0) + offset),
|
||||||
Math.min(height - floatingHeight - FLOATING_EDGE_MARGIN,
|
|
||||||
topInset + ((availHeight - floatingHeight) / 2.0) + offset),
|
|
||||||
floatingWidth,
|
floatingWidth,
|
||||||
floatingHeight);
|
floatingHeight);
|
||||||
}
|
}
|
||||||
|
|
||||||
assignClips();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Give each pane its clip region for the next paints, so repainting a pane on a content
|
|
||||||
// frame can never bleed over one stacked on top of it. Each pane is clipped to its rect
|
|
||||||
// minus the union of the panes above it: floating panes are clipped by the floating panes
|
|
||||||
// higher in the stack, and tiled panes by the whole floating group. When nothing floats,
|
|
||||||
// every pane clips to its plain bounds.
|
|
||||||
private void assignClips() {
|
|
||||||
if (!floatingVisible || floating.isEmpty()) {
|
|
||||||
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) {
|
boolean navigate(Direction direction) {
|
||||||
@@ -224,7 +141,7 @@ final class Tab implements AutoCloseable {
|
|||||||
if (floatingVisible) {
|
if (floatingVisible) {
|
||||||
floatingVisible = false;
|
floatingVisible = false;
|
||||||
if (floating.contains(active)) {
|
if (floating.contains(active)) {
|
||||||
setActive(tiled.contains(lastFocusedTiled) ? lastFocusedTiled : tiled.get(0));
|
setActive(tiled.get(0));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
floatingVisible = true;
|
floatingVisible = true;
|
||||||
@@ -243,56 +160,20 @@ final class Tab implements AutoCloseable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void nextFloatingPane() {
|
void closeActivePane() {
|
||||||
if (floating.isEmpty()) {
|
TerminalPane closing = active;
|
||||||
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 wasFloating = floating.remove(closing);
|
||||||
boolean wasTiled = !wasFloating && tiled.remove(closing);
|
if (!wasFloating) {
|
||||||
if (!wasFloating && !wasTiled) {
|
tiled.remove(closing);
|
||||||
return false; // not one of this tab's panes (already gone)
|
|
||||||
}
|
}
|
||||||
boolean wasActive = closing == active;
|
|
||||||
if (closing == lastFocusedFloating) {
|
if (closing == lastFocusedFloating) {
|
||||||
lastFocusedFloating = null;
|
lastFocusedFloating = null;
|
||||||
}
|
}
|
||||||
if (closing == lastFocusedTiled) {
|
|
||||||
lastFocusedTiled = null;
|
|
||||||
}
|
|
||||||
closing.close();
|
closing.close();
|
||||||
|
|
||||||
if (tiled.isEmpty() && floating.isEmpty()) {
|
if (tiled.isEmpty() && floating.isEmpty()) {
|
||||||
active = null; // tab is now empty; the compositor drops it
|
active = null; // tab is now empty; the compositor drops it
|
||||||
return true;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Always keep a tiled base: if the last tiled pane just closed, promote a floating one
|
// Always keep a tiled base: if the last tiled pane just closed, promote a floating one
|
||||||
@@ -304,54 +185,31 @@ final class Tab implements AutoCloseable {
|
|||||||
floating.remove(promote);
|
floating.remove(promote);
|
||||||
tiled.add(promote);
|
tiled.add(promote);
|
||||||
if (promote == lastFocusedFloating) {
|
if (promote == lastFocusedFloating) {
|
||||||
|
lastFocusedFloating = null;
|
||||||
|
if (!floating.isEmpty()) {
|
||||||
lastFocusedFloating = floating.isEmpty() ? null : floating.get(nextFocussed);
|
lastFocusedFloating = floating.isEmpty() ? null : floating.get(nextFocussed);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if (floating.isEmpty()) {
|
if (floating.isEmpty()) {
|
||||||
floatingVisible = false;
|
floatingVisible = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only the active pane closing forces a re-selection; closing a background pane (e.g. one
|
setActive(wasFloating && floatingVisible ? floating.get(floating.size() - 1) : tiled.get(0));
|
||||||
// whose process exited while another is focused) leaves focus where it is.
|
|
||||||
if (wasActive) {
|
|
||||||
setActive(wasFloating && floatingVisible
|
|
||||||
? floating.get(floating.size() - 1)
|
|
||||||
: tiled.contains(lastFocusedTiled) ? lastFocusedTiled : tiled.get(0));
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void setActive(TerminalPane pane) {
|
private void setActive(TerminalPane pane) {
|
||||||
active = pane;
|
active = pane;
|
||||||
if (floating.contains(pane)) {
|
if (floating.contains(pane)) {
|
||||||
lastFocusedFloating = pane;
|
lastFocusedFloating = pane;
|
||||||
} else if (tiled.contains(pane)) {
|
|
||||||
lastFocusedTiled = pane;
|
|
||||||
// A tiled pane gaining focus hides the floating group: leaving it shown while a tiled
|
|
||||||
// pane is active strands focus behind the overlay and disables navigation.
|
|
||||||
floatingVisible = false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void createFloatingPane() {
|
private void createFloatingPane() {
|
||||||
addFloating(openPane(true));
|
TerminalPane pane = openPane(true);
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Opens a floating pane whose process runs {@code command} directly (auto-closing when it
|
|
||||||
* exits), rather than an interactive shell. Used for one-shot panes like the scrollback editor.
|
|
||||||
*/
|
|
||||||
TerminalPane createFloatingPane(String command) {
|
|
||||||
double[] size = paneSize(true);
|
|
||||||
return addFloating(register(TerminalPane.createWithCommand(
|
|
||||||
config, metrics, this::markContentChanged, size[0], size[1], paneWorkingDirectory(), command)));
|
|
||||||
}
|
|
||||||
|
|
||||||
private TerminalPane addFloating(TerminalPane pane) {
|
|
||||||
floating.add(pane);
|
floating.add(pane);
|
||||||
floatingVisible = true;
|
floatingVisible = true;
|
||||||
setActive(pane);
|
setActive(pane);
|
||||||
return pane;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean navigateFloatingStack(Direction direction) {
|
private boolean navigateFloatingStack(Direction direction) {
|
||||||
@@ -382,37 +240,22 @@ final class Tab implements AutoCloseable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void markContentChanged() {
|
private void markContentChanged() {
|
||||||
contentVersion.incrementAndGet();
|
contentVersion++;
|
||||||
}
|
}
|
||||||
|
|
||||||
private TerminalPane openPane(boolean asFloating) {
|
private TerminalPane openPane(boolean asFloating) {
|
||||||
double[] size = paneSize(asFloating);
|
|
||||||
return register(TerminalPane.create(
|
|
||||||
config, metrics, this::markContentChanged, size[0], size[1], paneWorkingDirectory()));
|
|
||||||
}
|
|
||||||
|
|
||||||
private double[] paneSize(boolean asFloating) {
|
|
||||||
double availHeight = lastHeight - lastTopInset;
|
double availHeight = lastHeight - lastTopInset;
|
||||||
|
double widthPx;
|
||||||
|
double heightPx;
|
||||||
if (asFloating) {
|
if (asFloating) {
|
||||||
return new double[] {
|
widthPx = Math.max(420, lastWidth * 0.58);
|
||||||
Math.max(FLOATING_MIN_WIDTH, lastWidth * FLOATING_SIZE_FRACTION),
|
heightPx = Math.max(260, availHeight * 0.58);
|
||||||
Math.max(FLOATING_MIN_HEIGHT, availHeight * FLOATING_SIZE_FRACTION)};
|
} else {
|
||||||
}
|
|
||||||
// A new tiled pane joins the row, so each gets 1/(n+1) of the width.
|
// A new tiled pane joins the row, so each gets 1/(n+1) of the width.
|
||||||
return new double[] {lastWidth / (tiled.size() + 1), availHeight};
|
widthPx = lastWidth / (tiled.size() + 1);
|
||||||
|
heightPx = availHeight;
|
||||||
}
|
}
|
||||||
|
return TerminalPane.create(config, metrics, this::markContentChanged, widthPx, heightPx);
|
||||||
// Open a new pane in the active pane's working directory, so a split/new pane lands where the
|
|
||||||
// user currently is. With no active pane yet (the tab's first pane), fall back to the directory
|
|
||||||
// this tab was opened in. null (cwd unknown) falls back to home downstream.
|
|
||||||
private String paneWorkingDirectory() {
|
|
||||||
return active != null ? active.currentWorkingDirectory() : initialWorkingDirectory;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wire the pane's self-exit (process ended) back to the compositor so it gets reaped.
|
|
||||||
private TerminalPane register(TerminalPane pane) {
|
|
||||||
pane.setOnExit(() -> onPaneExit.accept(pane));
|
|
||||||
return pane;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static boolean directionFilter(Direction direction, TerminalPane current, TerminalPane candidate) {
|
private static boolean directionFilter(Direction direction, TerminalPane current, TerminalPane candidate) {
|
||||||
@@ -442,14 +285,4 @@ final class Tab implements AutoCloseable {
|
|||||||
tiled.clear();
|
tiled.clear();
|
||||||
floating.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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,9 +23,6 @@ public final class TerminalMetrics {
|
|||||||
private double cellWidth;
|
private double cellWidth;
|
||||||
private double lineHeight;
|
private double lineHeight;
|
||||||
private double baselineOffset;
|
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) {
|
public TerminalMetrics(String fontFamily, double fontSize) {
|
||||||
setFont(fontFamily, fontSize);
|
setFont(fontFamily, fontSize);
|
||||||
@@ -62,11 +59,6 @@ public final class TerminalMetrics {
|
|||||||
return 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). */
|
/** Columns that fit in a pane of the given pixel width (after subtracting the padding). */
|
||||||
public int columnsFor(double widthPx) {
|
public int columnsFor(double widthPx) {
|
||||||
return Math.max(1, (int) ((widthPx - 2 * PADDING) / cellWidth));
|
return Math.max(1, (int) ((widthPx - 2 * PADDING) / cellWidth));
|
||||||
|
|||||||
@@ -12,42 +12,28 @@ import dev.jlibghostty.RenderStateSnapshot;
|
|||||||
import dev.jlibghostty.ScrollViewport;
|
import dev.jlibghostty.ScrollViewport;
|
||||||
import dev.jlibghostty.Terminal;
|
import dev.jlibghostty.Terminal;
|
||||||
import dev.jlibghostty.TerminalOptions;
|
import dev.jlibghostty.TerminalOptions;
|
||||||
import javafx.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.AtomicLong;
|
import java.util.concurrent.atomic.AtomicLong;
|
||||||
import java.util.function.Supplier;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One terminal: owns its ghostty {@link Terminal}, the {@link ShellSession}/pty driving it,
|
* One terminal: owns its ghostty {@link Terminal}, the {@link ShellSession}/pty driving it,
|
||||||
* and its on-screen geometry and grid. It does not draw itself — it is a {@link RenderTarget}
|
* and its on-screen geometry and grid. It does not draw itself; {@link TerminalPaneNode}
|
||||||
* that a {@link TerminalRenderer} paints. {@link #paintFull}/{@link #paintIncremental} are the
|
* reads snapshots from it and represents the visible rows and kitty graphics as JavaFX nodes.
|
||||||
* only rendering API exposed to the {@link Compositor}, and they just delegate to that
|
|
||||||
* renderer; the compositor decides z-order and which rect each pane occupies.
|
|
||||||
*/
|
*/
|
||||||
public final class TerminalPane implements AutoCloseable, RenderTarget {
|
public final class TerminalPane implements AutoCloseable {
|
||||||
private final Terminal terminal;
|
private final Terminal terminal;
|
||||||
private final TerminalMetrics metrics;
|
private final TerminalMetrics metrics;
|
||||||
private final boolean kittyEnabled;
|
private final boolean kittyEnabled;
|
||||||
// Run on every content change so the owning tab can bump its content version — the
|
// Run on every content change so the owning tab can bump its content version — the
|
||||||
// compositor's O(1) "did the current tab change?" gate.
|
// compositor's O(1) "did the current tab change?" gate.
|
||||||
private final Runnable onContentChange;
|
private final Runnable onContentChange;
|
||||||
private final TerminalRenderer renderer;
|
|
||||||
private final MouseEncoder mouseEncoder = new MouseEncoder();
|
private final MouseEncoder mouseEncoder = new MouseEncoder();
|
||||||
// A persistent render state (reused across frames) is what makes ghostty's per-row dirty
|
// A persistent render state (reused across frames) is what makes ghostty's per-row dirty
|
||||||
// tracking meaningful: update() accumulates dirty since the last resetDirty().
|
// tracking meaningful: update() accumulates dirty since the last resetDirty().
|
||||||
private final RenderState renderState = new RenderState();
|
private final RenderState renderState = new RenderState();
|
||||||
private RenderStateSnapshot cachedSnapshot;
|
private RenderStateSnapshot cachedSnapshot;
|
||||||
private volatile ShellSession session;
|
private ShellSession session;
|
||||||
// Run once (on the FX thread) when this pane's process exits on its own, so the owning tab can
|
|
||||||
// remove it. Set by the Tab that creates the pane; null until then.
|
|
||||||
private Runnable onExit;
|
|
||||||
private boolean exited;
|
|
||||||
// Clip region for rendering (rect minus the panes covering this one), set at layout time;
|
|
||||||
// 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;
|
||||||
@@ -57,15 +43,14 @@ public final class TerminalPane implements AutoCloseable, RenderTarget {
|
|||||||
private int pixelWidth;
|
private int pixelWidth;
|
||||||
private int pixelHeight;
|
private int pixelHeight;
|
||||||
private final AtomicLong contentVersion = new AtomicLong();
|
private final AtomicLong contentVersion = new AtomicLong();
|
||||||
private long snapshotVersion = -1;
|
private volatile long snapshotVersion = -1;
|
||||||
|
|
||||||
private TerminalPane(Terminal terminal, TerminalMetrics metrics, boolean kittyEnabled,
|
private TerminalPane(Terminal terminal, TerminalMetrics metrics, boolean kittyEnabled,
|
||||||
Runnable onContentChange, TerminalRenderer renderer, int columns, int rows) {
|
Runnable onContentChange, int columns, int rows) {
|
||||||
this.terminal = terminal;
|
this.terminal = terminal;
|
||||||
this.metrics = metrics;
|
this.metrics = metrics;
|
||||||
this.kittyEnabled = kittyEnabled;
|
this.kittyEnabled = kittyEnabled;
|
||||||
this.onContentChange = onContentChange;
|
this.onContentChange = onContentChange;
|
||||||
this.renderer = renderer;
|
|
||||||
this.columns = columns;
|
this.columns = columns;
|
||||||
this.rows = rows;
|
this.rows = rows;
|
||||||
}
|
}
|
||||||
@@ -75,76 +60,19 @@ public final class TerminalPane implements AutoCloseable, RenderTarget {
|
|||||||
* columns and rows fit, and that grid is handed to ghostty and the shell at start-up. A
|
* columns and rows fit, and that grid is handed to ghostty and the shell at start-up. A
|
||||||
* non-positive size falls back to the configured default grid (used before the first
|
* non-positive size falls back to the configured default grid (used before the first
|
||||||
* layout, when no rect is known yet). The pane owns the shell session it starts and runs
|
* layout, when no rect is known yet). The pane owns the shell session it starts and runs
|
||||||
* {@code onContentChange} on every content change. The shell starts in {@code workingDirectory}
|
* {@code onContentChange} on every content change.
|
||||||
* (e.g. the active pane's cwd), or the user's home when {@code null}.
|
|
||||||
*/
|
*/
|
||||||
public static TerminalPane create(AppConfig config, TerminalMetrics metrics, Runnable onContentChange,
|
public static TerminalPane create(AppConfig config, TerminalMetrics metrics, Runnable onContentChange, double widthPx, double heightPx) {
|
||||||
double widthPx, double heightPx, String workingDirectory) {
|
|
||||||
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 columns = widthPx > 0 ? metrics.columnsFor(widthPx) : config.columns();
|
||||||
int rows = heightPx > 0 ? metrics.rowsFor(heightPx) : config.rows();
|
int rows = heightPx > 0 ? metrics.rowsFor(heightPx) : config.rows();
|
||||||
Terminal terminal = Ghostty.open(new TerminalOptions(columns, rows, config.maxScrollback()));
|
Terminal terminal = Ghostty.open(new TerminalOptions(columns, rows, config.maxScrollback()));
|
||||||
terminal.setDeviceAttributesProvider(DeviceAttributes::xtermCompatible);
|
terminal.setDeviceAttributesProvider(DeviceAttributes::xtermCompatible);
|
||||||
TerminalPane pane = new TerminalPane(terminal, metrics, config.kittyGraphics(), onContentChange,
|
TerminalPane pane = new TerminalPane(terminal, metrics, config.kittyGraphics(), onContentChange, columns, rows);
|
||||||
new GhosttyTerminalRenderer(metrics), columns, rows);
|
|
||||||
pane.refresh();
|
pane.refresh();
|
||||||
|
pane.attach(ShellSession.start(config.shell(), config.envOverride(), pane, columns, rows));
|
||||||
return pane;
|
return pane;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Sets the callback run when this pane's process exits on its own (see {@link #handleSessionExit}). */
|
|
||||||
public void setOnExit(Runnable onExit) {
|
|
||||||
this.onExit = onExit;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called from the shell reader thread when the pty stream ends without us closing it (the
|
|
||||||
* process exited). Hops to the FX thread and fires {@link #onExit} once, so tab/compositor
|
|
||||||
* mutation happens on the thread that owns the layout.
|
|
||||||
*/
|
|
||||||
void handleSessionExit() {
|
|
||||||
Platform.runLater(() -> {
|
|
||||||
if (exited) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
exited = true;
|
|
||||||
if (onExit != null) {
|
|
||||||
onExit.run();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private void attach(ShellSession session) {
|
private void attach(ShellSession session) {
|
||||||
this.session = session;
|
this.session = session;
|
||||||
terminal.setPtyWriter(bytes -> {
|
terminal.setPtyWriter(bytes -> {
|
||||||
@@ -177,19 +105,6 @@ public final class TerminalPane implements AutoCloseable, RenderTarget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Paste text to the shell: ghostty sanitises it (stripping anything that could smuggle in
|
|
||||||
* control sequences) and wraps it in the bracketed-paste markers, then it goes to the pty
|
|
||||||
* like typed input. We always request bracketed mode — every modern shell and editor enables
|
|
||||||
* DECSET 2004, and the jlibghostty API does not expose querying the terminal's live mode.
|
|
||||||
*/
|
|
||||||
public void paste(String text) {
|
|
||||||
if (text == null || text.isEmpty()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
send(Ghostty.encodePaste(text, true));
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean sendMouse(MouseInput input, MouseEncoderSize size, boolean anyButtonPressed) {
|
public boolean sendMouse(MouseInput input, MouseEncoderSize size, boolean anyButtonPressed) {
|
||||||
synchronized (terminal) {
|
synchronized (terminal) {
|
||||||
mouseEncoder.syncFromTerminal(terminal);
|
mouseEncoder.syncFromTerminal(terminal);
|
||||||
@@ -229,7 +144,6 @@ public final class TerminalPane implements AutoCloseable, RenderTarget {
|
|||||||
* Snapshotting is deferred here rather than done in refresh(), so a burst of writes
|
* Snapshotting is deferred here rather than done in refresh(), so a burst of writes
|
||||||
* between two frames collapses into a single snapshot.
|
* between two frames collapses into a single snapshot.
|
||||||
*/
|
*/
|
||||||
@Override
|
|
||||||
public RenderStateSnapshot snapshot() {
|
public RenderStateSnapshot snapshot() {
|
||||||
return takeSnapshot(false);
|
return takeSnapshot(false);
|
||||||
}
|
}
|
||||||
@@ -238,7 +152,6 @@ public final class TerminalPane implements AutoCloseable, RenderTarget {
|
|||||||
* Full snapshot with every row's cells populated. Used where the whole pane is redrawn
|
* Full snapshot with every row's cells populated. Used where the whole pane is redrawn
|
||||||
* regardless of dirty state (the kitty-graphics path).
|
* regardless of dirty state (the kitty-graphics path).
|
||||||
*/
|
*/
|
||||||
@Override
|
|
||||||
public RenderStateSnapshot snapshotFull() {
|
public RenderStateSnapshot snapshotFull() {
|
||||||
return takeSnapshot(true);
|
return takeSnapshot(true);
|
||||||
}
|
}
|
||||||
@@ -247,13 +160,21 @@ public final class TerminalPane implements AutoCloseable, RenderTarget {
|
|||||||
synchronized (terminal) {
|
synchronized (terminal) {
|
||||||
long version = contentVersion.get();
|
long version = contentVersion.get();
|
||||||
if (full) {
|
if (full) {
|
||||||
|
long updateStart = RenderProfiler.start();
|
||||||
renderState.update(terminal);
|
renderState.update(terminal);
|
||||||
|
RenderProfiler.stop(RenderProfiler.UPDATE, updateStart);
|
||||||
|
long marshalStart = RenderProfiler.start();
|
||||||
cachedSnapshot = renderState.snapshot();
|
cachedSnapshot = renderState.snapshot();
|
||||||
|
RenderProfiler.stop(RenderProfiler.MARSHAL, marshalStart);
|
||||||
renderState.resetDirty();
|
renderState.resetDirty();
|
||||||
snapshotVersion = version;
|
snapshotVersion = version;
|
||||||
} else if (snapshotVersion != version) {
|
} else if (snapshotVersion != version) {
|
||||||
|
long updateStart = RenderProfiler.start();
|
||||||
renderState.update(terminal);
|
renderState.update(terminal);
|
||||||
|
RenderProfiler.stop(RenderProfiler.UPDATE, updateStart);
|
||||||
|
long marshalStart = RenderProfiler.start();
|
||||||
cachedSnapshot = renderState.snapshotIncremental();
|
cachedSnapshot = renderState.snapshotIncremental();
|
||||||
|
RenderProfiler.stop(RenderProfiler.MARSHAL, marshalStart);
|
||||||
renderState.resetDirty();
|
renderState.resetDirty();
|
||||||
snapshotVersion = version;
|
snapshotVersion = version;
|
||||||
}
|
}
|
||||||
@@ -267,45 +188,37 @@ public final class TerminalPane implements AutoCloseable, RenderTarget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Best-effort current working directory of this pane's shell, or {@code null} if unknown. */
|
|
||||||
public String currentWorkingDirectory() {
|
|
||||||
ShellSession current = session;
|
|
||||||
return current != null ? current.currentWorkingDirectory() : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** This pane's own content revision, bumped on every change (see {@link #refresh()}). */
|
/** This pane's own content revision, bumped on every change (see {@link #refresh()}). */
|
||||||
public long contentVersion() {
|
public long contentVersion() {
|
||||||
return contentVersion.get();
|
return contentVersion.get();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
long snapshotVersion() {
|
||||||
|
return snapshotVersion;
|
||||||
|
}
|
||||||
|
|
||||||
public boolean kittyEnabled() {
|
public boolean kittyEnabled() {
|
||||||
return kittyEnabled;
|
return kittyEnabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
|
||||||
public Optional<KittyGraphics> kittyGraphics() {
|
public Optional<KittyGraphics> kittyGraphics() {
|
||||||
synchronized (terminal) {
|
synchronized (terminal) {
|
||||||
return terminal.kittyGraphics();
|
return terminal.kittyGraphics();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
|
||||||
public double x() {
|
public double x() {
|
||||||
return x;
|
return x;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
|
||||||
public double y() {
|
public double y() {
|
||||||
return y;
|
return y;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
|
||||||
public double width() {
|
public double width() {
|
||||||
return width;
|
return width;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
|
||||||
public double height() {
|
public double height() {
|
||||||
return height;
|
return height;
|
||||||
}
|
}
|
||||||
@@ -317,16 +230,6 @@ public final class TerminalPane implements AutoCloseable, RenderTarget {
|
|||||||
this.height = height;
|
this.height = height;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Set the clip region applied on the next paints (see {@link RenderTarget#clip()}). */
|
|
||||||
public void setClip(Shape clip) {
|
|
||||||
this.clip = clip;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public Shape clip() {
|
|
||||||
return clip;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Recompute the ghostty grid from the current bounds and the shared cell metrics. */
|
/** Recompute the ghostty grid from the current bounds and the shared cell metrics. */
|
||||||
public void fitToBounds() {
|
public void fitToBounds() {
|
||||||
int columns = metrics.columnsFor(width);
|
int columns = metrics.columnsFor(width);
|
||||||
@@ -363,57 +266,14 @@ public final class TerminalPane implements AutoCloseable, RenderTarget {
|
|||||||
onContentChange.run();
|
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.closeDetached();
|
session.close();
|
||||||
session = null;
|
session = null;
|
||||||
}
|
}
|
||||||
mouseEncoder.close();
|
mouseEncoder.close();
|
||||||
renderState.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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
1025
src/main/java/com/gregor/jprototerm/TerminalPaneNode.java
Normal file
1025
src/main/java/com/gregor/jprototerm/TerminalPaneNode.java
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,70 +0,0 @@
|
|||||||
package com.gregor.jprototerm;
|
|
||||||
|
|
||||||
import javafx.scene.canvas.GraphicsContext;
|
|
||||||
import javafx.scene.shape.ClosePath;
|
|
||||||
import javafx.scene.shape.LineTo;
|
|
||||||
import javafx.scene.shape.MoveTo;
|
|
||||||
import javafx.scene.shape.Path;
|
|
||||||
import javafx.scene.shape.PathElement;
|
|
||||||
import javafx.scene.shape.Shape;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Draws a {@link RenderTarget} onto a JavaFX canvas. The {@link Compositor} owns positioning
|
|
||||||
* and z-order; a renderer only fills the target's rect, clipped to the target's {@link
|
|
||||||
* RenderTarget#clip() clip region} so a repaint can never bleed over a pane on top.
|
|
||||||
* Implementations can change the look entirely — {@link GhosttyTerminalRenderer} is the real
|
|
||||||
* terminal renderer; a debug renderer could outline pane bounds instead.
|
|
||||||
*
|
|
||||||
* <p>A renderer may hold per-target state (e.g. a decoded-image cache), so an instance belongs
|
|
||||||
* to a single {@link TerminalPane}.
|
|
||||||
*/
|
|
||||||
abstract class TerminalRenderer {
|
|
||||||
/** Paint the whole target into its rect, clipped to its clip region. */
|
|
||||||
abstract void paintFull(GraphicsContext gc, RenderTarget target, boolean active);
|
|
||||||
|
|
||||||
/** Repaint only what changed since the last frame, clipped to the target's clip region. */
|
|
||||||
abstract void paintIncremental(GraphicsContext gc, RenderTarget target, boolean active);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The kitty image placements produced by the most recent paint, for the compositor to render
|
|
||||||
* as overlay nodes above the canvas. Empty unless the last paint found visible images.
|
|
||||||
*/
|
|
||||||
java.util.List<KittyImageNode> kittyImages() {
|
|
||||||
return java.util.List.of();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,311 +0,0 @@
|
|||||||
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,69 +0,0 @@
|
|||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
package com.gregor.jprototerm;
|
|
||||||
|
|
||||||
import java.lang.foreign.Arena;
|
|
||||||
import java.lang.foreign.FunctionDescriptor;
|
|
||||||
import java.lang.foreign.Linker;
|
|
||||||
import java.lang.foreign.MemorySegment;
|
|
||||||
import java.lang.foreign.SymbolLookup;
|
|
||||||
import java.lang.invoke.MethodHandle;
|
|
||||||
|
|
||||||
import static java.lang.foreign.ValueLayout.ADDRESS;
|
|
||||||
import static java.lang.foreign.ValueLayout.JAVA_INT;
|
|
||||||
import static java.lang.foreign.ValueLayout.JAVA_LONG;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reads the X11 pointer location directly via libX11 ({@code XQueryPointer}). Unlike AWT's
|
|
||||||
* {@code MouseInfo}, this never calls {@code XSetErrorHandler}, so it doesn't trip GDK's
|
|
||||||
* "XSetErrorHandler called with a GDK error trap pushed" warning when JavaFX's GTK backend is
|
|
||||||
* already up. Returns {@code null} when not on X11 or libX11 can't be loaded.
|
|
||||||
*/
|
|
||||||
final class X11Pointer {
|
|
||||||
private X11Pointer() {
|
|
||||||
}
|
|
||||||
|
|
||||||
/** {@code {x, y}} of the pointer in X root-window (virtual screen) space, or {@code null}. */
|
|
||||||
static int[] query() {
|
|
||||||
try (Arena arena = Arena.ofConfined()) {
|
|
||||||
Linker linker = Linker.nativeLinker();
|
|
||||||
SymbolLookup x11 = SymbolLookup.libraryLookup("libX11.so.6", arena);
|
|
||||||
MethodHandle openDisplay = linker.downcallHandle(x11.find("XOpenDisplay").orElseThrow(),
|
|
||||||
FunctionDescriptor.of(ADDRESS, ADDRESS));
|
|
||||||
MethodHandle defaultRootWindow = linker.downcallHandle(x11.find("XDefaultRootWindow").orElseThrow(),
|
|
||||||
FunctionDescriptor.of(JAVA_LONG, ADDRESS));
|
|
||||||
MethodHandle queryPointer = linker.downcallHandle(x11.find("XQueryPointer").orElseThrow(),
|
|
||||||
FunctionDescriptor.of(JAVA_INT, ADDRESS, JAVA_LONG,
|
|
||||||
ADDRESS, ADDRESS, ADDRESS, ADDRESS, ADDRESS, ADDRESS, ADDRESS));
|
|
||||||
MethodHandle closeDisplay = linker.downcallHandle(x11.find("XCloseDisplay").orElseThrow(),
|
|
||||||
FunctionDescriptor.of(JAVA_INT, ADDRESS));
|
|
||||||
|
|
||||||
MemorySegment display = (MemorySegment) openDisplay.invoke(MemorySegment.NULL);
|
|
||||||
if (display.address() == 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
long root = (long) defaultRootWindow.invoke(display);
|
|
||||||
MemorySegment rootReturn = arena.allocate(JAVA_LONG);
|
|
||||||
MemorySegment childReturn = arena.allocate(JAVA_LONG);
|
|
||||||
MemorySegment rootX = arena.allocate(JAVA_INT);
|
|
||||||
MemorySegment rootY = arena.allocate(JAVA_INT);
|
|
||||||
MemorySegment winX = arena.allocate(JAVA_INT);
|
|
||||||
MemorySegment winY = arena.allocate(JAVA_INT);
|
|
||||||
MemorySegment mask = arena.allocate(JAVA_INT);
|
|
||||||
int onSameScreen = (int) queryPointer.invoke(display, root,
|
|
||||||
rootReturn, childReturn, rootX, rootY, winX, winY, mask);
|
|
||||||
if (onSameScreen == 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return new int[] { rootX.get(JAVA_INT, 0), rootY.get(JAVA_INT, 0) };
|
|
||||||
} finally {
|
|
||||||
closeDisplay.invoke(display);
|
|
||||||
}
|
|
||||||
} catch (Throwable ignored) {
|
|
||||||
// Not X11, libX11 missing, or the call failed — caller falls back to the primary screen.
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user