90 Commits

Author SHA1 Message Date
6e3e88919e Revert "fix glyph-overhang artifacts in partial row repaint"
This reverts commit 57103bb98b.
2026-05-31 21:50:17 +02:00
57103bb98b fix glyph-overhang artifacts in partial row repaint
repaintColumns cleared and redrew only [start,end], but a neighbouring cell's
glyph can overhang into that span. The clearRect erased the overhang and the
neighbour was never redrawn, leaving black notches through the line 1-2 cells
before the cursor that survived until a full rerender.

Redraw text for a couple of extra cells on each side, clipped to the cleared
span, so overhang from just-outside cells is restored without touching their
own cell areas. Keeps the per-column repaint efficiency (vs the full-row
repaint debug toggle, which fixed the bars but repainted every dirty cell).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 21:48:34 +02:00
cb95a7188d update jlibghostty 2026-05-31 21:39:18 +02:00
Gregor Lohaus
5ca192b7be add full-row-repaint debug toggle
-Djprototerm.fullRowRepaint=true (or JPROTOTERM_FULL_ROW_REPAINT=1) bypasses the
per-column repaint in renderChanged and repaints the whole row, to bisect the
stale black-bar artifact that appears near the cursor and survives until a full
rerender.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 21:25:13 +02:00
Gregor Lohaus
e99a6ee33e split snapshot profiler bucket into update vs marshal
The snapshot bucket lumped ghostty's native dirty-state update together with
the Java-side cell marshaling. Time them separately to see which half of the
~7ms/frame snapshot cost (now the dominant frame cost after the detectShift
hoist) is the real target.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 21:18:07 +02:00
Gregor Lohaus
4923ea5527 hoist row hash out of detectShift delta scan
rowFingerprint(row) is invariant across the delta loop but was recomputed for
every candidate delta, making shift detection O(rows^2 x cols) on large changes
(full-screen scroll). Precompute each changed row's hash once, dropping it to
O(rows x cols). Profiling showed fingerprint hashing at ~74% of frame time under
heavy scroll, dominated by this loop.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 21:09:54 +02:00
Gregor Lohaus
1f7394d75a add opt-in render profiler instrumentation
Gated behind -Djprototerm.profile=true (or JPROTOTERM_PROFILE=1), accumulates
per-frame nanos into snapshot/fingerprint/draw/frame-total buckets and dumps
to stderr every N renders. Splits the three suspected render costs: native
snapshot marshaling, fingerprint hashing, and canvas draw recording.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 21:04:00 +02:00
Gregor Lohaus
50641d0a6a per-row cell-run repaint 2026-05-31 20:12:43 +02:00
Gregor Lohaus
51f64e7ca8 cache hidden panes 2026-05-31 19:56:09 +02:00
Gregor Lohaus
528afafcda no next floating pane keyboard shortcut 2026-05-31 19:52:24 +02:00
Gregor Lohaus
093a09da39 frame classifiaction not needed anymore 2026-05-31 19:46:55 +02:00
Gregor Lohaus
59ab33bc01 dont rerender full on every layout frame 2026-05-31 19:45:38 +02:00
Gregor Lohaus
d8447d9e29 port feasable performance improvements 2026-05-31 19:38:06 +02:00
Gregor Lohaus
dba6474491 apply race condition fix 2026-05-31 19:30:36 +02:00
Gregor Lohaus
743f312921 move unchanged rows 2026-05-31 18:55:53 +02:00
Gregor Lohaus
3054b3ec77 cleanup 2026-05-31 18:51:16 +02:00
Gregor Lohaus
2bcaf951df Render terminal rows as JavaFX nodes 2026-05-31 18:40:33 +02:00
Gregor Lohaus
beba14c3ea scene graph 2026-05-31 18:27:52 +02:00
Gregor Lohaus
f5562baf5f Merge branch 'refactor' 2026-05-31 16:27:21 +02:00
Gregor Lohaus
3017b99f87 recover pane 2026-05-31 16:24:43 +02:00
Gregor Lohaus
0958c93b4f recover tab 2026-05-31 16:23:36 +02:00
Gregor Lohaus
9c98d87783 recover abstract terminal renderer 2026-05-31 16:21:38 +02:00
Gregor Lohaus
76c731578f recover terminal metrics from helix buffer 2026-05-31 16:20:43 +02:00
Gregor Lohaus
95619f5b4c fuck did bad git reset hard to main, recovering from helix buffer 2026-05-31 16:19:37 +02:00
Gregor Lohaus
174cfc00d3 what is happening ?? 2026-05-31 16:15:37 +02:00
Gregor Lohaus
29e84c9830 remove unused old classes 2026-05-31 16:13:40 +02:00
Gregor Lohaus
a7baa08e68 add bin to gitignore 2026-05-31 15:45:55 +02:00
Gregor Lohaus
76f539d34a fix build error 2026-05-30 01:27:10 +02:00
Gregor Lohaus
ba884cd0a2 probably wrong fix 2026-05-30 01:23:16 +02:00
Gregor Lohaus
7dbbf89b27 non fix of clearing issue 2026-05-30 01:13:53 +02:00
Gregor Lohaus
e2850f067e readme update 2026-05-29 22:10:36 +02:00
Gregor Lohaus
022cf22463 tabbar, background coloring 2026-05-29 22:08:05 +02:00
Gregor Lohaus
250b182060 tabs 2026-05-29 21:41:25 +02:00
Gregor Lohaus
ebba6cc44f main pane splitting 2026-05-29 21:27:17 +02:00
Gregor Lohaus
4c3449129c no image caching, no transparency for performance 2026-05-29 21:18:16 +02:00
Gregor Lohaus
40d6287867 inverse bg 2026-05-29 20:37:16 +02:00
Gregor Lohaus
ff21bf3544 incremental render 2026-05-29 19:50:09 +02:00
Gregor Lohaus
c03d9245d0 remove .gradle 2026-05-29 13:01:11 +02:00
Gregor Lohaus
0915c649bd ignore .gradle 2026-05-29 13:00:19 +02:00
Gregor Lohaus
4a06a89400 update readme 2026-05-29 12:58:23 +02:00
Gregor Lohaus
4de2d31e91 clear env, dont inherit nix wrapper env 2026-05-29 12:51:45 +02:00
Gregor Lohaus
b98a18b49f f native image, just build a jar 2026-05-29 12:33:32 +02:00
Gregor Lohaus
08ad025f76 get rid of pty4j 2026-05-29 11:06:46 +02:00
Gregor Lohaus
c9fb8b5f0a pty4j meta 2026-05-29 10:43:04 +02:00
Gregor Lohaus
3b26a8d12c pty4j meta 2026-05-29 10:40:03 +02:00
Gregor Lohaus
f545375957 remove metadata here 2026-05-29 10:37:26 +02:00
Gregor Lohaus
224e8d0273 vm options 2026-05-29 10:32:35 +02:00
Gregor Lohaus
422b672dec metadata 2026-05-29 09:51:13 +02:00
Gregor Lohaus
2c7f71064c metadata 2026-05-29 09:48:28 +02:00
Gregor Lohaus
829eb66bbc gradle update script 2026-05-29 09:30:45 +02:00
Gregor Lohaus
8d36fbae35 fix jlibghostty metadata 2026-05-29 09:18:22 +02:00
Gregor Lohaus
f9454b28db super duper hacky shit 2026-05-29 00:05:09 +02:00
Gregor Lohaus
f75b8c29e0 super duper hacky shit 2026-05-28 23:52:23 +02:00
Gregor Lohaus
2816d99ce4 super duper hacky shit 2026-05-28 23:40:49 +02:00
Gregor Lohaus
a1e0c2b2d4 super duper hacky shit 2026-05-28 23:33:56 +02:00
Gregor Lohaus
3612de46bd super duper hacky shit 2026-05-28 23:29:09 +02:00
Gregor Lohaus
d588eb75a6 super duper hacky shit 2026-05-28 23:25:28 +02:00
Gregor Lohaus
7b8d30a058 super duper hacky shit 2026-05-28 23:20:18 +02:00
Gregor Lohaus
96a752566b super duper hacky shit 2026-05-28 22:57:08 +02:00
Gregor Lohaus
6ebf710031 super duper hacky shit 2026-05-28 22:53:41 +02:00
Gregor Lohaus
3f102a9ede remove gl from nix to check how linking fails 2026-05-28 22:49:59 +02:00
Gregor Lohaus
57f97e4119 remove gl from nix to check how linking fails 2026-05-28 22:46:07 +02:00
Gregor Lohaus
163c7b7279 those good damn graphics libraries :(((( 2026-05-28 22:37:42 +02:00
Gregor Lohaus
8669de2d32 those good damn graphics libraries :(((( 2026-05-28 22:31:47 +02:00
Gregor Lohaus
a1717438e4 gradle settings 2026-05-28 22:18:59 +02:00
Gregor Lohaus
d14fa5c1cb permission hack 2026-05-28 22:11:01 +02:00
Gregor Lohaus
6e1aff6200 perl dep 2026-05-28 22:10:23 +02:00
Gregor Lohaus
6e4ddbf9df patch with perl 2026-05-28 22:09:45 +02:00
Gregor Lohaus
ee127fd006 patch gluon 2026-05-28 22:08:58 +02:00
Gregor Lohaus
34ae351431 impure hack 2026-05-28 22:03:45 +02:00
Gregor Lohaus
191ec6d0e0 add gluon substrate as flake input 2026-05-28 21:57:10 +02:00
Gregor Lohaus
19f20a4039 add gluon substrate as flake input 2026-05-28 21:55:40 +02:00
Gregor Lohaus
0698016a65 add gluon substrate as flake input 2026-05-28 21:54:18 +02:00
Gregor Lohaus
0a84ec720a fix gradle deps, gradle task 2026-05-28 21:48:10 +02:00
Gregor Lohaus
e3405fee48 deps.json for gradle from nix 2026-05-28 21:42:26 +02:00
Gregor Lohaus
380996fe50 gluon java 2026-05-28 21:37:41 +02:00
Gregor Lohaus
c7f734bf64 flake 2026-05-28 21:33:37 +02:00
Gregor Lohaus
80cd318c1c scrollback editor shortcut 2026-05-28 13:53:44 +02:00
Gregor Lohaus
7dfff664fc preserve image aspect ratio 2026-05-28 13:41:27 +02:00
Gregor Lohaus
1665dcfaae images work 2026-05-28 13:37:18 +02:00
Gregor Lohaus
cf218e2afd scrollback 2026-05-28 12:48:28 +02:00
Gregor Lohaus
f07e524fbb pretty good 2026-05-28 02:07:44 +02:00
Gregor Lohaus
a3f4878fc7 stacking 2026-05-28 00:33:38 +02:00
Gregor Lohaus
82cc7f4729 graal metadata bullshit 2026-05-27 16:27:02 +02:00
Gregor Lohaus
9f8767bc88 remove deprecated drivers, expand jni metadata for graal 2026-05-27 16:25:23 +02:00
Gregor Lohaus
1ae1548db0 opengl drivers, jni metadata for javafx glass 2026-05-27 16:23:25 +02:00
Gregor Lohaus
4e9f1487cb fix x11 lib namnes 2026-05-27 16:22:13 +02:00
Gregor Lohaus
b60dcd5918 include gtk deps 2026-05-27 16:20:31 +02:00
Gregor Lohaus
addeed6f30 default to graphics accelaration 2026-05-27 16:18:04 +02:00
Gregor Lohaus
a2de5118c1 avoid glx es2 setup 2026-05-27 16:15:13 +02:00
37 changed files with 3559 additions and 688 deletions

18
.classpath Normal file
View File

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

1
.codexsession Normal file
View File

@@ -0,0 +1 @@
019e6999-b7c8-7591-a8aa-ea51b89a7f7e

15
.gitignore vendored
View File

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

3
.ignore Normal file
View File

@@ -0,0 +1,3 @@
.gradle
result
bin

34
.project Normal file
View File

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

View File

@@ -0,0 +1,13 @@
arguments=--init-script /home/anon/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

View File

@@ -1,24 +1,64 @@
# jprototerm # jprototerm
JavaFX canvas terminal prototype using `jlibghostty` for terminal emulation, Nix for the build environment, and GluonFX/GraalVM Native Image for the Linux binary. JavaFX canvas terminal prototype using `jlibghostty` for terminal emulation and Nix for
the build environment. It builds a plain JavaFX application (JDK 25, JavaFX 25 via Gradle)
packaged as a Nix derivation — no GraalVM/GluonFX native image. It supports tiled and
floating panes and tabs.
> [!CAUTION]
> `nix profile add` has only been tested on **Debian with the proprietary NVIDIA driver**.
> The runtime GL shim hardcodes Debian's `/lib/x86_64-linux-gnu` driver paths and selects
> the NVIDIA GLX/EGL vendor, so it likely won't work yet on other distros, Wayland-only
> setups, or Mesa/AMD/Intel GPUs. I'm happy to accept pull requests that broaden host
> support.
## Build ## Build
```sh ```sh
nix build nix build
./result/bin/jprototerm
``` ```
The package build compiles with Nix-provided OpenJFX 25, `jlibghostty`, JToml, and GraalVM Native Image directly so it does not depend on Gradle plugin resolution inside the Nix sandbox. Install it into a profile (see the caution above on host support):
```sh
nix profile add .
jprototerm
```
Or install straight from the remote — note the `git+https://` scheme (a bare `https://`
URL is treated as a tarball, not a git repo):
```sh
nix profile add git+https://gitea.gregorlohaus.com/gregor/jprototerm
```
Add `?ref=<branch-or-tag>` to pin a revision. The target machine needs Nix with the
`nix-command` and `flakes` features enabled and network access — the build fetches the
`jlibghostty`/`ghostty` flake inputs plus the JDK and Gradle from the binary caches.
The flake bundles everything the app needs — the JDK 25 runtime, the Maven JavaFX modules
and their native libraries, and the gtk/glib/freetype/X11 libraries they load — **except**
the system OpenGL/graphics drivers. `libGL` is supplied by the host at runtime through a GL
shim in the launcher wrapper, so the same closure runs against NixOS, Mesa, or vendor
(e.g. NVIDIA) GPU drivers.
Gradle dependencies are vendored in `deps.json` for the pure Nix sandbox. Regenerate it
after changing dependencies in `build.gradle` (the update script writes `deps.json` in the
current directory):
```sh
$(nix build .#gradleDepsUpdateScript --no-link --print-out-paths)
```
For development: For development:
```sh ```sh
nix develop nix develop
gradle -PjlibghosttyMavenRepo="$JLIBGHOSTTY_MAVEN_REPO" run gradle run
gradle -PjlibghosttyMavenRepo="$JLIBGHOSTTY_MAVEN_REPO" nativeCompile
``` ```
The Gradle project is kept for interactive development and IDE import. The Gradle project is the source of truth for the JavaFX build.
## Config ## Config
@@ -34,6 +74,8 @@ If `XDG_CONFIG_HOME` is unset, the fallback is:
$HOME/.config/jprototerm/config.toml $HOME/.config/jprototerm/config.toml
``` ```
If no config file exists, jprototerm writes the default config on startup.
Example, also available in `config.example.toml`: Example, also available in `config.example.toml`:
```toml ```toml
@@ -51,17 +93,42 @@ height = 760
[kitty_graphics] [kitty_graphics]
enabled = true enabled = true
[scrollback]
editor_command = "vi {file}"
[env.override]
ZELLIJ_SESSION_NAME = ""
[keybindings] [keybindings]
navigate_left = "ALT+H" navigate_left = "ALT+H"
navigate_down = "ALT+J" navigate_down = "ALT+J"
navigate_up = "ALT+K" navigate_up = "ALT+K"
navigate_right = "ALT+L" navigate_right = "ALT+L"
toggle_floating = "ALT+F" toggle_floating = "ALT+F"
new_pane = "ALT+N"
close_pane = "ALT+X"
new_tab = "ALT+A"
previous_tab = "ALT+SHIFT+H"
next_tab = "ALT+SHIFT+L"
open_font_selector = "ALT+T"
open_scrollback = "ALT+S"
``` ```
## Defaults ## Defaults
- `Alt+h/j/k/l`: navigate panes - `Alt+h/j/k/l`: navigate panes
- `Alt+f`: open or close a floating pane - `Alt+n`: new pane — a floating pane when floating panes are shown, otherwise a new tiled
- Font default: `Symbols Nerd Font Mono` pane (tiled panes are split equally across the width)
- `Alt+f`: show or hide all floating panes
- `Alt+x`: close the active pane; closing a tab's last pane closes the tab, and closing the
last pane of the last tab quits
- `Alt+a`: new tab
- `Alt+Shift+h` / `Alt+Shift+l`: previous / next tab
- `Alt+t`: open the font selector
- `Alt+s`: open the active pane scrollback in `$EDITOR`
- Font default: `JetBrainsMono Nerd Font`
- Kitty graphics protocol parsing is enabled by default - Kitty graphics protocol parsing is enabled by default
Each tab has its own stack of tiled and floating panes; only the active tab is rendered. A
thin tab bar appears at the top when more than one tab is open. Closing the last tiled pane
while floating panes exist promotes the most recently active floating pane to a tiled pane.

2
TODOS.md Normal file
View File

@@ -0,0 +1,2 @@
jlibghostty - why downcall metadata not propagated ?
jlibghostty - how need to change flake so consuming flakes dont have to depend on same ghostty flake ?

30
build.gradle Normal file
View File

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

View File

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

View File

@@ -1,6 +1,7 @@
[terminal] [terminal]
columns = 100 columns = 100
rows = 30 rows = 30
max_scrollback = 100000
shell = "/bin/bash" shell = "/bin/bash"
font_family = "JetBrainsMono Nerd Font" font_family = "JetBrainsMono Nerd Font"
font_size = 15 font_size = 15
@@ -12,9 +13,19 @@ height = 760
[kitty_graphics] [kitty_graphics]
enabled = true enabled = true
[scrollback]
editor_command = "vi {file}"
[env.override]
ZELLIJ_SESSION_NAME = ""
[keybindings] [keybindings]
navigate_left = "ALT+H" navigate_left = "ALT+H"
navigate_down = "ALT+J" navigate_down = "ALT+J"
navigate_up = "ALT+K" navigate_up = "ALT+K"
navigate_right = "ALT+L" navigate_right = "ALT+L"
toggle_floating = "ALT+F" toggle_floating = "ALT+F"
new_pane = "ALT+N"
close_pane = "ALT+X"
open_font_selector = "ALT+T"
open_scrollback = "ALT+S"

76
deps.json Normal file
View File

@@ -0,0 +1,76 @@
{
"!comment": "This is a nixpkgs Gradle dependency lockfile. For more details, refer to the Gradle section in the nixpkgs manual.",
"!version": 1,
"https://plugins.gradle.org/m2": {
"com/google/code/findbugs#jsr305/3.0.2": {
"jar": "sha256-dmrSoHg/JoeWLIrXTO7MOKKLn3Ki0IXuQ4t4E+ko0Mc=",
"pom": "sha256-GYidvfGyVLJgGl7mRbgUepdGRIgil2hMeYr+XWPXjf4="
},
"com/google/gradle#osdetector-gradle-plugin/1.7.3": {
"jar": "sha256-a0aS+ROiGx+2Axae54uo8+SrKvnXYq+cqIt5EmwcCtE=",
"pom": "sha256-hGDJUBJ8o1mHZhYeOLT/jWO01p+4MQoW4As1E1ABDBE="
},
"kr/motd/maven#os-maven-plugin/1.7.1": {
"jar": "sha256-9Hru+Ggh5SsrGHWJeL0EXwPXIikuMudHCCEixiKJUuA=",
"pom": "sha256-S3WABEIrljPdMY8p54Tx0YC9ilkgzVCvGTCGH21qVHY="
},
"org/openjfx#javafx-plugin/0.1.0": {
"jar": "sha256-Xq7sB5m0QGRrDKTP2iGaMttr4rpXktAyoNpKOlw4j6s=",
"module": "sha256-rf+3RA0kntF8BJOD1nBp+UU7F3gncMAFtoKkNBbYNmE=",
"pom": "sha256-NMjfVSfrWjXl8AmjzeH3oInEzkoOclgC8uy+UDu9PLY="
},
"org/openjfx/javafxplugin#org.openjfx.javafxplugin.gradle.plugin/0.1.0": {
"pom": "sha256-1tASf/Q2PQAXPDV6mByec+/wPDCl0Ohq2CtgVPrvqEE="
},
"org/sonatype/oss#oss-parent/7": {
"pom": "sha256-tR+IZ8kranIkmVV/w6H96ne9+e9XRyL+kM5DailVlFQ="
},
"org/sonatype/oss#oss-parent/9": {
"pom": "sha256-+0AmX5glSCEv+C42LllzKyGH7G8NgBgohcFO8fmCgno="
}
},
"https://repo.maven.apache.org/maven2": {
"io/github/wasabithumb#jtoml-api/1.5.2": {
"jar": "sha256-3MvElpV+cmdqWO+SAHVKCWDYs+KMUXwIYYk7ax85yWo=",
"module": "sha256-RUZYGHsxZfuGBi5TogMHhWcbRcCUBTUAPN8FZpPszlE=",
"pom": "sha256-VOaIPO8w4z9saTR8smoLTSoih2PUFOBhBio9eqpoqo8="
},
"io/github/wasabithumb#jtoml-internals/1.5.2": {
"jar": "sha256-k4z/Uxzugk2hXUIdeeNLTz//NeynHzPfHCDXzDHP1Ys=",
"module": "sha256-OsnYjM8Tylw/MNdw0/HRUWvXgrQHdnT09O5vYlaZENU=",
"pom": "sha256-qXtmwHMJBNIWgrewEoUq1FBZjs2eRuRO1wMLndgsndg="
},
"io/github/wasabithumb#jtoml/1.5.2": {
"jar": "sha256-zDtf6VVGSrvC8StneL9fKq9LyaJSfiDb86w52s+bRYs=",
"module": "sha256-TRoE8nqf0ULuQ4J1/u2+voUNf421lTOJ1SajE07F8/Y=",
"pom": "sha256-sG4IDPD+ItRgyQcfDiLqdd+wCd40JHcSLocA+jWX1p0="
},
"org/openjfx#javafx-base/25": {
"pom": "sha256-XFYpcqK673qkB7J9Wc4XOl6lCht7dRgEO3/I92/v5Tc="
},
"org/openjfx#javafx-base/25/linux": {
"jar": "sha256-MkJZRruLjbBxfPovsuAOIc1InzW5ZitvrKGLYVpKlmk="
},
"org/openjfx#javafx-controls/25": {
"pom": "sha256-74cad6gX7nuDrKWKKe6yv5h2AvRseKHRXEYAgzpq1uM="
},
"org/openjfx#javafx-controls/25/linux": {
"jar": "sha256-NzVeTZHGfoj9mBX2AeasW1Xd3p9em5P8j0qgRXfmkdM="
},
"org/openjfx#javafx-fxml/25": {
"pom": "sha256-RopsFNQeVHnwNK4v4FPwyJEpfqJoo8dtf/047zyrsio="
},
"org/openjfx#javafx-fxml/25/linux": {
"jar": "sha256-OUrjL2TBIsFPvRDvSb3efbsFVpt6uOf58XDIGSS5Wis="
},
"org/openjfx#javafx-graphics/25": {
"pom": "sha256-zB2jY7Id7uvymRWBk9qmIB+USw+Setv13DhL62qDOfQ="
},
"org/openjfx#javafx-graphics/25/linux": {
"jar": "sha256-PlGLwX7lWFWaKsWKX3/UUmFRCNnVCI9lsTvuk5nDcis="
},
"org/openjfx#javafx/25": {
"pom": "sha256-55IzCPyt1/LGiwcgQfR9jnNVIj2EZVnutceA3EuivxM="
}
}
}

65
devenv.lock Normal file
View File

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

66
devenv.nix Normal file
View File

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

18
devenv.yaml Normal file
View File

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

64
flake.lock generated
View File

@@ -64,53 +64,17 @@
"type": "github" "type": "github"
} }
}, },
"javafx-base": {
"flake": false,
"locked": {
"narHash": "sha256-96fttJUts/rFwKB7u5G8NWkK2NjJ3a6eIKbe1RTWkmM=",
"type": "file",
"url": "https://repo.maven.apache.org/maven2/org/openjfx/javafx-base/25/javafx-base-25-linux.jar"
},
"original": {
"type": "file",
"url": "https://repo.maven.apache.org/maven2/org/openjfx/javafx-base/25/javafx-base-25-linux.jar"
}
},
"javafx-controls": {
"flake": false,
"locked": {
"narHash": "sha256-2Cdc2/hPOjJmQidDjXu9vnlwAuawLn0cg/tLhzFfkUs=",
"type": "file",
"url": "https://repo.maven.apache.org/maven2/org/openjfx/javafx-controls/25/javafx-controls-25-linux.jar"
},
"original": {
"type": "file",
"url": "https://repo.maven.apache.org/maven2/org/openjfx/javafx-controls/25/javafx-controls-25-linux.jar"
}
},
"javafx-graphics": {
"flake": false,
"locked": {
"narHash": "sha256-w01IhRAQzcfTvwkqIQkjrI8ZPXT0VTEeijfzbqp3G0k=",
"type": "file",
"url": "https://repo.maven.apache.org/maven2/org/openjfx/javafx-graphics/25/javafx-graphics-25-linux.jar"
},
"original": {
"type": "file",
"url": "https://repo.maven.apache.org/maven2/org/openjfx/javafx-graphics/25/javafx-graphics-25-linux.jar"
}
},
"jlibghostty": { "jlibghostty": {
"inputs": { "inputs": {
"ghostty": "ghostty", "ghostty": "ghostty",
"nixpkgs": "nixpkgs" "nixpkgs": "nixpkgs"
}, },
"locked": { "locked": {
"lastModified": 1779889299, "lastModified": 1780256181,
"narHash": "sha256-B82MyhTvlfeszdcuM3F8YDSZYaxUom+m59oQKSoWjmQ=", "narHash": "sha256-/saXdnYMbAMfP7u6USSqtNkBIgqZhU+CPr3F8tUQhHU=",
"ref": "refs/heads/main", "ref": "refs/heads/main",
"rev": "eea43843002f8fae4fa4cb1c46b64339124bf6b2", "rev": "db5ee5d20daf8855de3a3b2fa9349eced70946f0",
"revCount": 6, "revCount": 21,
"type": "git", "type": "git",
"url": "https://gitea.gregorlohaus.com/gregor/jlibghostty.git" "url": "https://gitea.gregorlohaus.com/gregor/jlibghostty.git"
}, },
@@ -119,18 +83,6 @@
"url": "https://gitea.gregorlohaus.com/gregor/jlibghostty.git" "url": "https://gitea.gregorlohaus.com/gregor/jlibghostty.git"
} }
}, },
"jtoml-all": {
"flake": false,
"locked": {
"narHash": "sha256-KWrUaDVmnWzdkQxjgPFFNl8DOEvkCqWW3OmXU2sZHKw=",
"type": "file",
"url": "https://repo.maven.apache.org/maven2/io/github/wasabithumb/jtoml-all/1.5.2/jtoml-all-1.5.2.jar"
},
"original": {
"type": "file",
"url": "https://repo.maven.apache.org/maven2/io/github/wasabithumb/jtoml-all/1.5.2/jtoml-all-1.5.2.jar"
}
},
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1779560665, "lastModified": 1779560665,
@@ -165,11 +117,11 @@
}, },
"root": { "root": {
"inputs": { "inputs": {
"javafx-base": "javafx-base", "ghostty": [
"javafx-controls": "javafx-controls", "jlibghostty",
"javafx-graphics": "javafx-graphics", "ghostty"
],
"jlibghostty": "jlibghostty", "jlibghostty": "jlibghostty",
"jtoml-all": "jtoml-all",
"nixpkgs": "nixpkgs_2" "nixpkgs": "nixpkgs_2"
} }
}, },

227
flake.nix
View File

@@ -1,131 +1,150 @@
{ {
description = "JavaFX terminal using jlibghostty and GraalVM Native Image"; description = "jprototerm";
inputs = { inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
jlibghostty.url = "git+https://gitea.gregorlohaus.com/gregor/jlibghostty.git"; jlibghostty.url = "git+https://gitea.gregorlohaus.com/gregor/jlibghostty.git";
ghostty.follows = "jlibghostty/ghostty";
jtoml-all = {
url = "https://repo.maven.apache.org/maven2/io/github/wasabithumb/jtoml-all/1.5.2/jtoml-all-1.5.2.jar";
flake = false;
};
javafx-base = {
url = "https://repo.maven.apache.org/maven2/org/openjfx/javafx-base/25/javafx-base-25-linux.jar";
flake = false;
};
javafx-controls = {
url = "https://repo.maven.apache.org/maven2/org/openjfx/javafx-controls/25/javafx-controls-25-linux.jar";
flake = false;
};
javafx-graphics = {
url = "https://repo.maven.apache.org/maven2/org/openjfx/javafx-graphics/25/javafx-graphics-25-linux.jar";
flake = false;
};
}; };
outputs = { outputs = { self, nixpkgs, jlibghostty, ghostty }:
self,
nixpkgs,
jlibghostty,
jtoml-all,
javafx-base,
javafx-controls,
javafx-graphics
}:
let let
system = "x86_64-linux"; supportedSystems = [ "x86_64-linux" ];
pkgs = import nixpkgs { inherit system; }; forAllSystems = nixpkgs.lib.genAttrs supportedSystems;
jlib = jlibghostty.packages.${system}.jlibghostty; # Everything the JavaFX natives (and jlibghostty) dlopen at runtime, EXCEPT the
graalvm = pkgs.graalvmPackages.graalvm-ce; # system OpenGL/graphics drivers. libGL is intentionally left out: it is supplied
gradle = if pkgs ? gradle_9 then pkgs.gradle_9 else pkgs.gradle; # by the host at runtime via the GL shim in the wrapper below, so the same closure
openjfx = pkgs.javaPackages.openjfx25; # works on NixOS and on a plain Debian box with vendor GPU drivers installed.
runtimeLibsFor = pkgs: ghosttyVt: [
pkgs.glib
pkgs.gtk3
pkgs.pango
pkgs.cairo
pkgs.gdk-pixbuf
pkgs.harfbuzz
pkgs.freetype
pkgs.fontconfig.lib
pkgs.libx11
pkgs.libxext
pkgs.libxrender
pkgs.libxtst
pkgs.libxi
pkgs.libxcursor
pkgs.libxrandr
pkgs.libxinerama
pkgs.libxcb
pkgs.libxxf86vm
pkgs.zlib
ghosttyVt
];
in { in {
packages.${system}.default = pkgs.stdenvNoCC.mkDerivation { packages = forAllSystems (system:
pname = "jprototerm"; let
version = "0.1.0"; pkgs = import nixpkgs { inherit system; };
src = ./.;
nativeBuildInputs = [ jlib = jlibghostty.packages.${system}.jlibghostty;
graalvm ghosttyVt = ghostty.packages.${system}.libghostty-vt;
pkgs.makeWrapper
];
buildPhase = '' runtimeLibs = runtimeLibsFor pkgs ghosttyVt;
runHook preBuild
mkdir -p build/classes build/native-image build/lib build/javafx-modules jprototerm = pkgs.stdenv.mkDerivation (finalAttrs: {
pname = "jprototerm";
version = "0.1.0";
src = ./.;
find src/main/java -name '*.java' | sort > build/sources.txt nativeBuildInputs = [
cp ${jtoml-all} build/lib/jtoml-all.jar pkgs.jdk25
cp ${javafx-base} build/javafx-modules/javafx-base.jar pkgs.gradle_9
cp ${javafx-controls} build/javafx-modules/javafx-controls.jar pkgs.makeWrapper
cp ${javafx-graphics} build/javafx-modules/javafx-graphics.jar ];
javafx_module_path="build/javafx-modules"
jlib_classpath="$( buildInputs = runtimeLibs;
find ${jlib}/maven -type f -name '*.jar' \
! -name '*-sources.jar' \
! -name '*-javadoc.jar' \
| sort \
| paste -sd: -
)"
app_classpath="build/classes:build/lib/jtoml-all.jar:$jlib_classpath:build/javafx-modules/javafx-base.jar:build/javafx-modules/javafx-controls.jar:build/javafx-modules/javafx-graphics.jar"
javac \ mitmCache = pkgs.gradle_9.fetchDeps {
--release 25 \ pkg = finalAttrs.finalPackage;
--module-path "$javafx_module_path" \ data = ./deps.json;
--add-modules javafx.controls,javafx.graphics \ useBwrap = false;
-cp "build/lib/jtoml-all.jar:$jlib_classpath" \ };
-d build/classes \
@build/sources.txt
if [ -d src/main/resources ]; then # Builds build/install/jprototerm/{bin,lib} with every runtime jar, including
cp -R src/main/resources/. build/classes/ # the maven javafx-*-linux jars that carry the platform natives.
fi gradleBuildTask = "installDist";
gradleFlags = [
"--no-build-cache"
"--stacktrace"
"-Dorg.gradle.java.home=${pkgs.jdk25}"
];
native-image \ JAVA_HOME = "${pkgs.jdk25}";
--no-fallback \ JLIBGHOSTTY_MAVEN_REPO = "${jlib}/maven";
--enable-native-access=javafx.graphics \
--module-path "$javafx_module_path" \
--add-modules javafx.controls,javafx.graphics \
-cp "$app_classpath" \
-H:Class=com.gregor.jprototerm.Main \
-o build/native-image/jprototerm
runHook postBuild preBuild = ''
''; export HOME="$TMPDIR/home"
export GRADLE_OPTS="-Duser.home=$HOME ''${GRADLE_OPTS:-}"
'';
installPhase = '' preGradleUpdate = ''
runHook preInstall export HOME="$TMPDIR/home"
'';
mkdir -p $out/bin installPhase = ''
cp build/native-image/jprototerm $out/bin/jprototerm runHook preInstall
wrapProgram $out/bin/jprototerm \ mkdir -p "$out/share/jprototerm"
--set GDK_BACKEND x11 \ cp -a build/install/jprototerm/lib "$out/share/jprototerm/lib"
--set JAVA_TOOL_OPTIONS "-Dprism.order=sw" \
--prefix LD_LIBRARY_PATH : ${pkgs.lib.makeLibraryPath [ openjfx jlib ]}:${openjfx}/modules_libs/javafx.graphics \
--prefix PATH : ${pkgs.lib.makeBinPath [ pkgs.util-linux pkgs.bash ]}
runHook postInstall # JavaFX is a set of proper modular jars: put them on the module path and
''; # keep the application + plain dependency jars on the classpath, so the two
}; # worlds do not collide.
mkdir -p "$out/share/jprototerm/javafx"
mv "$out/share/jprototerm/lib"/javafx-*.jar "$out/share/jprototerm/javafx/"
devShells.${system}.default = pkgs.mkShell { # Build an explicit colon-separated classpath. A "lib/*" glob would be
packages = [ # expanded by the wrapper's shell before java sees it, breaking -cp.
graalvm classpath=""
gradle for jar in "$out"/share/jprototerm/lib/*.jar; do
pkgs.util-linux classpath="$classpath''${classpath:+:}$jar"
]; done
shellHook = '' makeWrapper "${pkgs.jdk25}/bin/java" "$out/bin/jprototerm" \
export JLIBGHOSTTY_MAVEN_REPO=${jlib}/maven --run 'export JPROTOTERM_HOST_LD_LIBRARY_PATH="''${LD_LIBRARY_PATH:-}"' \
echo "Use: gradle -PjlibghosttyMavenRepo=$JLIBGHOSTTY_MAVEN_REPO run" --add-flags "--enable-native-access=ALL-UNNAMED,javafx.graphics" \
''; --add-flags "--module-path $out/share/jprototerm/javafx" \
}; --add-flags "--add-modules javafx.controls,javafx.fxml" \
--add-flags "-cp $classpath" \
--add-flags "com.gregor.jprototerm.Main" \
--prefix LD_LIBRARY_PATH : "${pkgs.lib.makeLibraryPath runtimeLibs}" \
--run 'glShimDir="''${XDG_RUNTIME_DIR:-/tmp}/jprototerm-gl"; mkdir -p "$glShimDir"; for lib in /lib/x86_64-linux-gnu/libGL.so.1 /lib/x86_64-linux-gnu/libGLX.so.0 /lib/x86_64-linux-gnu/libGLdispatch.so.0 /usr/lib/x86_64-linux-gnu/libGLX_nvidia.so* /usr/lib/x86_64-linux-gnu/libEGL_nvidia.so* /usr/lib/x86_64-linux-gnu/libnvidia*.so* /usr/lib/x86_64-linux-gnu/nvidia/current/lib*.so*; do [ -e "$lib" ] && ln -sfn "$lib" "$glShimDir/$(basename "$lib")"; done; export LD_LIBRARY_PATH="$glShimDir''${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"; export __GLX_VENDOR_LIBRARY_NAME="''${__GLX_VENDOR_LIBRARY_NAME:-nvidia}"; if [ -e /usr/share/glvnd/egl_vendor.d/10_nvidia.json ]; then export __EGL_VENDOR_LIBRARY_FILENAMES="''${__EGL_VENDOR_LIBRARY_FILENAMES:-/usr/share/glvnd/egl_vendor.d/10_nvidia.json}"; fi' \
--set JLIBGHOSTTY_LIBRARY "${ghosttyVt}/lib/libghostty-vt.so" \
--set GDK_BACKEND x11
runHook postInstall
'';
});
in {
default = jprototerm;
gradleDepsUpdateScript = jprototerm.mitmCache.updateScript;
});
devShells = forAllSystems (system:
let
pkgs = import nixpkgs { inherit system; };
jlib = jlibghostty.packages.${system}.jlibghostty;
ghosttyVt = ghostty.packages.${system}.libghostty-vt;
runtimeLibs = runtimeLibsFor pkgs ghosttyVt;
in {
default = pkgs.mkShell {
packages = [
pkgs.gradle_9
pkgs.jdk25
pkgs.jdt-language-server
] ++ runtimeLibs;
JLIBGHOSTTY_MAVEN_REPO = "${jlib}/maven";
JLIBGHOSTTY_LIBRARY = "${ghosttyVt}/lib/libghostty-vt.so";
LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath runtimeLibs;
};
});
}; };
} }

8
settings.gradle Normal file
View File

@@ -0,0 +1,8 @@
pluginManagement {
repositories {
gradlePluginPortal()
mavenCentral()
}
}
rootProject.name = 'jprototerm'

View File

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

View File

@@ -3,29 +3,53 @@ package com.gregor.jprototerm;
import io.github.wasabithumb.jtoml.JToml; import io.github.wasabithumb.jtoml.JToml;
import io.github.wasabithumb.jtoml.document.TomlDocument; import io.github.wasabithumb.jtoml.document.TomlDocument;
import io.github.wasabithumb.jtoml.except.TomlException; import io.github.wasabithumb.jtoml.except.TomlException;
import io.github.wasabithumb.jtoml.key.TomlKey;
import io.github.wasabithumb.jtoml.value.TomlValue; import io.github.wasabithumb.jtoml.value.TomlValue;
import io.github.wasabithumb.jtoml.value.primitive.TomlPrimitive; import io.github.wasabithumb.jtoml.value.primitive.TomlPrimitive;
import io.github.wasabithumb.jtoml.value.table.TomlTable; import io.github.wasabithumb.jtoml.value.table.TomlTable;
import java.nio.file.Files; import java.nio.file.Files;
import java.io.IOException;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map; import java.util.Map;
public record AppConfig( public record AppConfig(
int columns, int columns,
int rows, int rows,
long maxScrollback,
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,
Map<String, String> envOverride,
Map<String, KeyBinding> keybindings Map<String, KeyBinding> keybindings
) { ) {
private static final List<String> KEYBINDING_KEYS = List.of(
"navigate_left",
"navigate_down",
"navigate_up",
"navigate_right",
"toggle_floating",
"new_pane",
"close_pane",
"new_tab",
"previous_tab",
"next_tab",
"open_font_selector",
"open_scrollback"
);
public static AppConfig load() { public static AppConfig load() {
AppConfig defaults = defaults(); AppConfig defaults = defaults();
Path path = configPath(); Path path = configPath();
if (!Files.isRegularFile(path)) { if (!Files.isRegularFile(path)) {
writeDefaultConfig(path, defaults);
return defaults; return defaults;
} }
@@ -34,19 +58,16 @@ public record AppConfig(
return new AppConfig( return new 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),
stringValue(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),
Map.of( stringValue(document, "scrollback.editor_command", defaults.scrollbackEditorCommand),
"navigate_left", binding(document, "keybindings.navigate_left", defaults.keybindings.get("navigate_left")), envOverride(document, defaults.envOverride),
"navigate_down", binding(document, "keybindings.navigate_down", defaults.keybindings.get("navigate_down")), keybindings(document, defaults)
"navigate_up", binding(document, "keybindings.navigate_up", defaults.keybindings.get("navigate_up")),
"navigate_right", binding(document, "keybindings.navigate_right", defaults.keybindings.get("navigate_right")),
"toggle_floating", binding(document, "keybindings.toggle_floating", defaults.keybindings.get("toggle_floating"))
)
); );
} catch (TomlException ex) { } catch (TomlException ex) {
System.err.println("Could not parse " + path + ": " + ex.getMessage()); System.err.println("Could not parse " + path + ": " + ex.getMessage());
@@ -58,22 +79,53 @@ public record AppConfig(
return new AppConfig( return new AppConfig(
100, 100,
30, 30,
100_000,
defaultShell(), defaultShell(),
"Symbols Nerd Font Mono", "JetBrainsMono Nerd Font",
15.0, 15.0,
1200.0, 1200.0,
760.0, 760.0,
true, true,
Map.of( defaultScrollbackEditorCommand(),
"navigate_left", KeyBinding.parse("ALT+H"), Map.of(),
"navigate_down", KeyBinding.parse("ALT+J"), Map.ofEntries(
"navigate_up", KeyBinding.parse("ALT+K"), Map.entry("navigate_left", KeyBinding.parse("ALT+H")),
"navigate_right", KeyBinding.parse("ALT+L"), Map.entry("navigate_down", KeyBinding.parse("ALT+J")),
"toggle_floating", KeyBinding.parse("ALT+F") Map.entry("navigate_up", KeyBinding.parse("ALT+K")),
Map.entry("navigate_right", KeyBinding.parse("ALT+L")),
Map.entry("toggle_floating", KeyBinding.parse("ALT+F")),
Map.entry("new_pane", KeyBinding.parse("ALT+N")),
Map.entry("close_pane", KeyBinding.parse("ALT+X")),
Map.entry("new_tab", KeyBinding.parse("ALT+A")),
Map.entry("previous_tab", KeyBinding.parse("ALT+SHIFT+H")),
Map.entry("next_tab", KeyBinding.parse("ALT+SHIFT+L")),
Map.entry("open_font_selector", KeyBinding.parse("ALT+T")),
Map.entry("open_scrollback", KeyBinding.parse("ALT+S"))
) )
); );
} }
public AppConfig withFont(String family, double size) {
return new AppConfig(
columns,
rows,
maxScrollback,
shell,
family,
size,
windowWidth,
windowHeight,
kittyGraphics,
scrollbackEditorCommand,
envOverride,
keybindings
);
}
public void save() {
save(configPath(), this);
}
public static Path configPath() { public static Path configPath() {
String configHome = System.getenv("XDG_CONFIG_HOME"); String configHome = System.getenv("XDG_CONFIG_HOME");
if (configHome != null && !configHome.isBlank()) { if (configHome != null && !configHome.isBlank()) {
@@ -83,8 +135,93 @@ public record AppConfig(
} }
private static String defaultShell() { private static String defaultShell() {
String shell = System.getenv("SHELL"); return "/bin/bash";
return shell == null || shell.isBlank() ? "/bin/sh" : shell; }
private static String defaultScrollbackEditorCommand() {
String editor = System.getenv("EDITOR");
if (editor == null || editor.isBlank()) {
editor = "vi";
}
return editor.trim() + " {file}";
}
private static Map<String, KeyBinding> keybindings(TomlTable table, AppConfig defaults) {
Map<String, KeyBinding> parsed = new LinkedHashMap<>();
for (String key : KEYBINDING_KEYS) {
parsed.put(key, binding(table, "keybindings." + key, defaults.keybindings.get(key)));
}
return Map.copyOf(parsed);
}
private static void writeDefaultConfig(Path path, AppConfig defaults) {
save(path, defaults);
}
private static void save(Path path, AppConfig config) {
try {
Path parent = path.getParent();
if (parent != null) {
Files.createDirectories(parent);
}
Files.writeString(
path,
config.toToml(),
StandardOpenOption.CREATE,
StandardOpenOption.TRUNCATE_EXISTING,
StandardOpenOption.WRITE
);
} catch (IOException ex) {
System.err.println("Could not write " + path + ": " + ex.getMessage());
}
}
private String toToml() {
StringBuilder builder = new StringBuilder();
builder.append("[terminal]\n");
builder.append("columns = ").append(columns).append('\n');
builder.append("rows = ").append(rows).append('\n');
builder.append("max_scrollback = ").append(maxScrollback).append('\n');
builder.append("shell = ").append(quoted(shell)).append('\n');
builder.append("font_family = ").append(quoted(fontFamily)).append('\n');
builder.append("font_size = ").append(trimDouble(fontSize)).append("\n\n");
builder.append("[window]\n");
builder.append("width = ").append(trimDouble(windowWidth)).append('\n');
builder.append("height = ").append(trimDouble(windowHeight)).append("\n\n");
builder.append("[kitty_graphics]\n");
builder.append("enabled = ").append(kittyGraphics).append("\n\n");
builder.append("[scrollback]\n");
builder.append("editor_command = ").append(quoted(scrollbackEditorCommand)).append("\n\n");
builder.append("[env.override]\n");
for (Map.Entry<String, String> entry : envOverride.entrySet()) {
builder.append(entry.getKey()).append(" = ").append(quoted(entry.getValue())).append('\n');
}
builder.append('\n');
builder.append("[keybindings]\n");
for (String key : KEYBINDING_KEYS) {
KeyBinding binding = keybindings.get(key);
if (binding != null) {
builder.append(key).append(" = ").append(quoted(binding.toString())).append('\n');
}
}
return builder.toString();
}
private static String quoted(String value) {
return "\"" + value
.replace("\\", "\\\\")
.replace("\"", "\\\"")
.replace("\n", "\\n")
.replace("\r", "\\r")
.replace("\t", "\\t")
+ "\"";
}
private static String trimDouble(double value) {
if (value == Math.rint(value)) {
return Long.toString((long) value);
}
return Double.toString(value);
} }
private static KeyBinding binding(TomlTable table, String key, KeyBinding fallback) { private static KeyBinding binding(TomlTable table, String key, KeyBinding fallback) {
@@ -99,6 +236,31 @@ public record AppConfig(
} }
} }
private static Map<String, String> envOverride(TomlTable table, Map<String, String> fallback) {
TomlValue value = table.get("env.override");
if (value == null || !value.isTable()) {
return fallback;
}
Map<String, String> result = new LinkedHashMap<>();
TomlTable overrides = value.asTable();
for (TomlKey key : overrides.keys(false)) {
if (key.size() != 1) {
continue;
}
TomlValue override = overrides.get(key);
if (override != null && override.isPrimitive()) {
try {
result.put(key.get(0), override.asPrimitive().asString());
} catch (RuntimeException ignored) {
// Ignore non-string values; environment values are strings.
}
}
}
return Map.copyOf(result);
}
private static String stringValue(TomlTable table, String key, String fallback) { private static String stringValue(TomlTable table, String key, String fallback) {
TomlPrimitive primitive = primitive(table, key); TomlPrimitive primitive = primitive(table, key);
return primitive == null ? fallback : primitive.asString(); return primitive == null ? fallback : primitive.asString();
@@ -116,6 +278,18 @@ public record AppConfig(
} }
} }
private static long longValue(TomlTable table, String key, long fallback) {
TomlPrimitive primitive = primitive(table, key);
if (primitive == null) {
return fallback;
}
try {
return primitive.asInteger();
} catch (RuntimeException ex) {
return fallback;
}
}
private static double doubleValue(TomlTable table, String key, double fallback) { private static double doubleValue(TomlTable table, String key, double fallback) {
TomlPrimitive primitive = primitive(table, key); TomlPrimitive primitive = primitive(table, key);
if (primitive == null) { if (primitive == null) {

View File

@@ -0,0 +1,442 @@
package com.gregor.jprototerm;
import dev.jlibghostty.KeyModifiers;
import dev.jlibghostty.MouseButton;
import dev.jlibghostty.MouseEncoderSize;
import dev.jlibghostty.MouseInput;
import javafx.geometry.Pos;
import javafx.scene.Parent;
import javafx.scene.control.Label;
import javafx.scene.input.InputEvent;
import javafx.scene.input.MouseEvent;
import javafx.scene.input.ScrollEvent;
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 java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* Owns the window's tabs and exposes the terminal surface as a JavaFX scene graph. Each
* terminal pane is mounted as its own node, so JavaFX child order handles stacking and clipping
* between panes. The pane model still owns terminals, ptys, cell geometry, and snapshots; this
* class handles tab/pane lifecycle, layout, focus, mouse routing, and frame scheduling.
*/
public final class Compositor {
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_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 final Pane root = new Pane();
private final Pane paneLayer = new Pane();
private final HBox tabBar = new HBox(1.0);
private final AppConfig config;
private final TerminalMetrics metrics;
private final List<Tab> tabs = new ArrayList<>();
private final Map<TerminalPane, TerminalPaneNode> nodes = new HashMap<>();
private int currentTabIndex;
private boolean sceneDirty = true;
private double lastWidth = -1.0;
private double lastHeight = -1.0;
private long lastContentVersion = Long.MIN_VALUE;
private boolean mouseButtonPressed;
private MouseButton pressedButton = MouseButton.UNKNOWN;
public Compositor(AppConfig config, TerminalMetrics metrics) {
this.config = config;
this.metrics = metrics;
tabs.add(new Tab(config, metrics));
root.setFocusTraversable(true);
root.setBackground(new Background(new BackgroundFill(GAP_BACKGROUND, CornerRadii.EMPTY, null)));
root.getChildren().setAll(paneLayer, tabBar);
root.setOnMousePressed(event -> root.requestFocus());
}
public Parent node() {
return root;
}
public void requestFocus() {
root.requestFocus();
}
public void setFont(String family, double size) {
metrics.setFont(family, size);
nodes.values().forEach(TerminalPaneNode::discard);
markSceneDirty();
}
// ---- Tabs and panes -------------------------------------------------------------
public boolean isEmpty() {
return tabs.isEmpty();
}
public TerminalPane activePane() {
return currentTab().activePane();
}
public void navigate(Direction direction) {
if (!isEmpty() && currentTab().navigate(direction)) {
markSceneDirty();
}
}
public void toggleFloating() {
if (isEmpty()) {
return;
}
currentTab().toggleFloating();
markSceneDirty();
}
public void createPane() {
if (isEmpty()) {
return;
}
currentTab().createPane();
markSceneDirty();
}
public void closeActivePane() {
if (isEmpty()) {
return;
}
currentTab().closeActivePane();
if (currentTab().isEmpty()) {
tabs.remove(currentTabIndex);
if (currentTabIndex >= tabs.size()) {
currentTabIndex = Math.max(0, tabs.size() - 1);
}
}
markSceneDirty();
}
public void newTab() {
tabs.add(new Tab(config, metrics));
currentTabIndex = tabs.size() - 1;
markSceneDirty();
}
public void nextTab() {
if (tabs.size() > 1) {
currentTabIndex = (currentTabIndex + 1) % tabs.size();
markSceneDirty();
}
}
public void previousTab() {
if (tabs.size() > 1) {
currentTabIndex = (currentTabIndex - 1 + tabs.size()) % tabs.size();
markSceneDirty();
}
}
public void close() {
for (Tab tab : tabs) {
tab.close();
}
tabs.clear();
nodes.clear();
paneLayer.getChildren().clear();
}
private Tab currentTab() {
return tabs.get(currentTabIndex);
}
private List<TerminalPane> currentPanes() {
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) {
return !tabs.isEmpty() && currentTab().isActive(pane);
}
private void focus(TerminalPane pane) {
if (!tabs.isEmpty() && currentTab().focus(pane)) {
markSceneDirty();
}
}
// ---- Rendering ------------------------------------------------------------------
public void render() {
double width = root.getWidth();
double height = root.getHeight();
long contentVersion = tabs.isEmpty() ? 0 : currentTab().contentVersion();
boolean geometryChanged = width != lastWidth || height != lastHeight;
boolean contentChanged = contentVersion != lastContentVersion;
boolean syncScene = sceneDirty || geometryChanged;
if (!syncScene && !contentChanged) {
return;
}
lastWidth = width;
lastHeight = height;
lastContentVersion = contentVersion;
sceneDirty = false;
if (syncScene) {
syncSceneGraph(width, height);
}
renderVisiblePanes();
}
private void markSceneDirty() {
sceneDirty = true;
}
private void syncSceneGraph(double width, double height) {
double topInset = tabs.size() > 1 ? TAB_BAR_HEIGHT : 0.0;
paneLayer.resizeRelocate(0.0, 0.0, width, height);
updateTabBar(width, topInset);
if (!tabs.isEmpty()) {
currentTab().layout(width, height, topInset);
}
List<TerminalPane> panes = currentPanes();
retainNodes(allOpenPanes());
List<TerminalPaneNode> orderedNodes = new ArrayList<>(panes.size());
for (TerminalPane pane : panes) {
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);
}
private void renderVisiblePanes() {
for (TerminalPane pane : currentPanes()) {
TerminalPaneNode node = nodes.get(pane);
if (node != null) {
node.renderIncremental(isActive(pane));
}
}
}
private TerminalPaneNode nodeFor(TerminalPane pane) {
return nodes.computeIfAbsent(pane, this::createNode);
}
private TerminalPaneNode createNode(TerminalPane pane) {
TerminalPaneNode node = new TerminalPaneNode(pane, metrics);
node.setOnMousePressed(event -> handleMousePressed(pane, event));
node.setOnMouseReleased(event -> handleMouseReleased(pane, event));
node.setOnMouseDragged(event -> handleMouseDragged(pane, event));
node.setOnMouseMoved(event -> handleMouseMoved(pane, event));
node.setOnScroll(event -> handleScroll(pane, event));
return node;
}
private void retainNodes(List<TerminalPane> openPanes) {
Set<TerminalPane> open = new HashSet<>(openPanes);
nodes.keySet().removeIf(pane -> !open.contains(pane));
}
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;
label.setAlignment(Pos.CENTER);
label.setTextFill(current ? TAB_TEXT : TAB_INACTIVE_TEXT);
label.setBackground(new Background(new BackgroundFill(
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);
}
}
// ---- Input ----------------------------------------------------------------------
private void handleMousePressed(TerminalPane pane, MouseEvent event) {
root.requestFocus();
focus(pane);
pressedButton = mouseButton(event);
mouseButtonPressed = true;
MouseTarget target = mouseTarget(pane);
if (target == null) {
return;
}
send(pane, target, MouseInput.press(pressedButton, localX(event.getX(), target), localY(event.getY(), target), modifiers(event)), true, event);
}
private void handleMouseReleased(TerminalPane pane, MouseEvent event) {
MouseButton button = pressedButton == MouseButton.UNKNOWN ? mouseButton(event) : pressedButton;
MouseTarget target = mouseTarget(pane);
if (target != null) {
send(pane, target, MouseInput.release(button, localX(event.getX(), target), localY(event.getY(), target), modifiers(event)), false, event);
}
mouseButtonPressed = false;
pressedButton = MouseButton.UNKNOWN;
}
private void handleMouseDragged(TerminalPane pane, MouseEvent event) {
MouseButton button = pressedButton == MouseButton.UNKNOWN ? mouseButton(event) : pressedButton;
MouseTarget target = mouseTarget(pane);
if (target == null) {
return;
}
send(pane, target, MouseInput.drag(button, localX(event.getX(), target), localY(event.getY(), target), modifiers(event)), true, event);
}
private void handleMouseMoved(TerminalPane pane, MouseEvent event) {
MouseTarget target = mouseTarget(pane);
if (target == null) {
return;
}
send(pane, target, MouseInput.motion(localX(event.getX(), target), localY(event.getY(), target), modifiers(event)), mouseButtonPressed, event);
}
private void handleScroll(TerminalPane pane, ScrollEvent event) {
root.requestFocus();
focus(pane);
int direction = scrollDirection(event);
if (direction == 0) {
return;
}
MouseButton wheelButton = direction > 0 ? MouseButton.FOUR : MouseButton.FIVE;
int rows = scrollRows(event);
MouseTarget target = mouseTarget(pane);
boolean sent = false;
if (target != null) {
double ex = localX(event.getX(), target);
double ey = localY(event.getY(), target);
KeyModifiers modifiers = modifiers(event);
for (int i = 0; i < rows; i++) {
if (!send(pane, target, MouseInput.press(wheelButton, ex, ey, modifiers), mouseButtonPressed, event)) {
break;
}
sent = true;
}
}
if (!sent) {
pane.scrollViewport(direction > 0 ? -rows : rows);
event.consume();
}
}
private boolean send(TerminalPane pane, MouseTarget target, MouseInput input, boolean anyButtonPressed, InputEvent event) {
boolean sent = pane.sendMouse(input, target.size(), anyButtonPressed);
if (sent) {
event.consume();
}
return sent;
}
private MouseTarget mouseTarget(TerminalPane pane) {
if (pane.width() <= 2 * TerminalMetrics.PADDING || pane.height() <= 2 * TerminalMetrics.PADDING) {
return null;
}
int columns = metrics.columnsFor(pane.width());
int rows = metrics.rowsFor(pane.height());
long cellWidth = Math.max(1L, Math.round(metrics.cellWidth()));
long cellHeight = Math.max(1L, Math.round(metrics.lineHeight()));
long screenWidth = Math.max(1L, Math.round(columns * metrics.cellWidth()));
long screenHeight = Math.max(1L, Math.round(rows * metrics.lineHeight()));
return new MouseTarget(MouseEncoderSize.of(screenWidth, screenHeight, cellWidth, cellHeight), screenWidth, screenHeight);
}
private static double localX(double nodeX, MouseTarget target) {
return clamp(nodeX - TerminalMetrics.PADDING, 0.0, target.screenWidth() - 1.0);
}
private static double localY(double nodeY, MouseTarget target) {
return clamp(nodeY - TerminalMetrics.PADDING, 0.0, target.screenHeight() - 1.0);
}
private static double clamp(double value, double min, double max) {
return Math.max(min, Math.min(max, value));
}
private static KeyModifiers modifiers(MouseEvent event) {
return KeyModifiers.of(event.isShiftDown(), event.isControlDown(), event.isAltDown(), event.isMetaDown());
}
private static KeyModifiers modifiers(ScrollEvent event) {
return KeyModifiers.of(event.isShiftDown(), event.isControlDown(), event.isAltDown(), event.isMetaDown());
}
private static int scrollRows(ScrollEvent event) {
double rows;
if (event.getTextDeltaYUnits() == VerticalTextScrollUnits.LINES && event.getTextDeltaY() != 0.0) {
rows = Math.abs(event.getTextDeltaY());
} else if (event.getTextDeltaYUnits() == VerticalTextScrollUnits.PAGES && event.getTextDeltaY() != 0.0) {
rows = Math.abs(event.getTextDeltaY()) * 24.0;
} else if (event.getMultiplierY() > 0.0) {
rows = Math.abs(event.getDeltaY()) / event.getMultiplierY();
} else {
rows = Math.abs(event.getDeltaY()) / 40.0;
}
return Math.max(1, Math.min(64, (int) Math.ceil(rows)));
}
private static int scrollDirection(ScrollEvent event) {
if (event.getDeltaY() != 0.0) {
return event.getDeltaY() > 0.0 ? 1 : -1;
}
if (event.getTextDeltaYUnits() != VerticalTextScrollUnits.NONE && event.getTextDeltaY() != 0.0) {
return event.getTextDeltaY() > 0.0 ? 1 : -1;
}
return 0;
}
private static MouseButton mouseButton(MouseEvent event) {
return switch (event.getButton()) {
case PRIMARY -> MouseButton.LEFT;
case SECONDARY -> MouseButton.RIGHT;
case MIDDLE -> MouseButton.MIDDLE;
default -> MouseButton.UNKNOWN;
};
}
private record MouseTarget(MouseEncoderSize size, long screenWidth, long screenHeight) {
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,401 @@
package com.gregor.jprototerm;
import java.lang.foreign.AddressLayout;
import java.lang.foreign.Arena;
import java.lang.foreign.FunctionDescriptor;
import java.lang.foreign.Linker;
import java.lang.foreign.MemoryLayout;
import java.lang.foreign.MemorySegment;
import java.lang.foreign.SymbolLookup;
import java.lang.foreign.ValueLayout;
import java.lang.invoke.MethodHandle;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
* A Linux PTY backed by libc via the Foreign Function & Memory API.
*
* <p>This replaces pty4j (which loads a JNA JNI shim). It uses
* {@code posix_openpt}/{@code posix_spawnp} rather than {@code fork}/{@code forkpty}:
* doing work between {@code fork} and {@code exec} inside a multithreaded JVM is unsafe
* (only async-signal-safe calls are permitted), whereas {@code posix_spawn} performs the
* dangerous part in libc with no Java on the stack.
*
* <p>The child gets a fresh session via {@code POSIX_SPAWN_SETSID}; it then opens the slave
* PTY itself (as fd 0, without {@code O_NOCTTY}) so the slave becomes its controlling
* terminal. glibc applies attribute flags (the setsid) before file actions, so the open
* happens in the new session.
*/
public final class LinuxPty implements AutoCloseable {
static final Linker LINKER = Linker.nativeLinker();
private static final SymbolLookup LIBC = LINKER.defaultLookup();
static final AddressLayout C_POINTER = (AddressLayout) LINKER.canonicalLayouts().get("void*");
static final ValueLayout.OfShort C_SHORT = (ValueLayout.OfShort) LINKER.canonicalLayouts().get("short");
static final ValueLayout.OfInt C_INT = (ValueLayout.OfInt) LINKER.canonicalLayouts().get("int");
static final ValueLayout.OfLong C_LONG = (ValueLayout.OfLong) LINKER.canonicalLayouts().get("long");
static final ValueLayout.OfLong C_SIZE_T = (ValueLayout.OfLong) LINKER.canonicalLayouts().get("size_t");
// Function descriptors.
static final FunctionDescriptor FD_INT_INT = FunctionDescriptor.of(C_INT, C_INT);
static final FunctionDescriptor FD_PTSNAME_R = FunctionDescriptor.of(C_INT, C_INT, C_POINTER, C_SIZE_T);
static final FunctionDescriptor FD_RW = FunctionDescriptor.of(C_LONG, C_INT, C_POINTER, C_SIZE_T);
static final FunctionDescriptor FD_IOCTL = FunctionDescriptor.of(C_INT, C_INT, C_LONG, C_POINTER);
static final FunctionDescriptor FD_KILL = FunctionDescriptor.of(C_INT, C_INT, C_INT);
static final FunctionDescriptor FD_WAITPID = FunctionDescriptor.of(C_INT, C_INT, C_POINTER, C_INT);
static final FunctionDescriptor FD_SPAWN = FunctionDescriptor.of(
C_INT, C_POINTER, C_POINTER, C_POINTER, C_POINTER, C_POINTER, C_POINTER);
static final FunctionDescriptor FD_FA_INIT = FunctionDescriptor.of(C_INT, C_POINTER);
static final FunctionDescriptor FD_FA_ADDCLOSE = FunctionDescriptor.of(C_INT, C_POINTER, C_INT);
static final FunctionDescriptor FD_FA_ADDDUP2 = FunctionDescriptor.of(C_INT, C_POINTER, C_INT, C_INT);
static final FunctionDescriptor FD_FA_ADDOPEN =
FunctionDescriptor.of(C_INT, C_POINTER, C_INT, C_POINTER, C_INT, C_INT);
static final FunctionDescriptor FD_FA_ADDCHDIR = FunctionDescriptor.of(C_INT, C_POINTER, C_POINTER);
static final FunctionDescriptor FD_ATTR_SETFLAGS = FunctionDescriptor.of(C_INT, C_POINTER, C_SHORT);
// Linux constants (x86-64 / arm64).
private static final int O_RDWR = 0x0002;
private static final int O_NOCTTY = 0x0100;
private static final long TIOCSWINSZ = 0x5414L;
private static final short POSIX_SPAWN_SETSID = 0x80;
private static final int SIGHUP = 1;
private static final int SIGKILL = 9;
private static final int WNOHANG = 1;
// struct winsize { unsigned short ws_row, ws_col, ws_xpixel, ws_ypixel; }
private static final MemoryLayout WINSIZE = MemoryLayout.structLayout(
C_SHORT.withName("ws_row"),
C_SHORT.withName("ws_col"),
C_SHORT.withName("ws_xpixel"),
C_SHORT.withName("ws_ypixel"));
// posix_spawn_file_actions_t / posix_spawnattr_t are opaque; over-allocate generously.
private static final long SPAWN_ACTIONS_SIZE = 256;
private static final long SPAWN_ATTR_SIZE = 512;
private static final MethodHandle POSIX_OPENPT = handle("posix_openpt", FD_INT_INT);
private static final MethodHandle GRANTPT = handle("grantpt", FD_INT_INT);
private static final MethodHandle UNLOCKPT = handle("unlockpt", FD_INT_INT);
private static final MethodHandle PTSNAME_R = handle("ptsname_r", FD_PTSNAME_R);
private static final MethodHandle CLOSE = handle("close", FD_INT_INT);
private static final MethodHandle READ = handle("read", FD_RW);
private static final MethodHandle WRITE = handle("write", FD_RW);
private static final MethodHandle IOCTL = handle("ioctl", FD_IOCTL, Linker.Option.firstVariadicArg(2));
private static final MethodHandle KILL = handle("kill", FD_KILL);
private static final MethodHandle WAITPID = handle("waitpid", FD_WAITPID);
private static final MethodHandle POSIX_SPAWNP = handle("posix_spawnp", FD_SPAWN);
private static final MethodHandle FA_INIT = handle("posix_spawn_file_actions_init", FD_FA_INIT);
private static final MethodHandle FA_DESTROY = handle("posix_spawn_file_actions_destroy", FD_FA_INIT);
private static final MethodHandle FA_ADDCLOSE = handle("posix_spawn_file_actions_addclose", FD_FA_ADDCLOSE);
private static final MethodHandle FA_ADDDUP2 = handle("posix_spawn_file_actions_adddup2", FD_FA_ADDDUP2);
private static final MethodHandle FA_ADDOPEN = handle("posix_spawn_file_actions_addopen", FD_FA_ADDOPEN);
private static final MethodHandle FA_ADDCHDIR = handle("posix_spawn_file_actions_addchdir_np", FD_FA_ADDCHDIR);
private static final MethodHandle ATTR_INIT = handle("posix_spawnattr_init", FD_FA_INIT);
private static final MethodHandle ATTR_DESTROY = handle("posix_spawnattr_destroy", FD_FA_INIT);
private static final MethodHandle ATTR_SETFLAGS = handle("posix_spawnattr_setflags", FD_ATTR_SETFLAGS);
private final Arena arena = Arena.ofShared();
private final MemorySegment readBuffer = arena.allocate(65536);
private final Object writeLock = new Object();
private final int masterFd;
private final int pid;
private volatile boolean closed;
private LinuxPty(int masterFd, int pid) {
this.masterFd = masterFd;
this.pid = pid;
}
/**
* Opens a PTY and spawns {@code argv} attached to its slave end.
*
* @param argv command and arguments (e.g. {@code {"/bin/zsh", "-i"}})
* @param environment environment for the child, as KEY=VALUE pairs
* @param workingDirectory directory the child starts in, or {@code null} to inherit
*/
public static LinuxPty spawn(String[] argv, Map<String, String> environment, String workingDirectory) {
Arena setup = Arena.ofConfined();
try {
int master = check(callInt(POSIX_OPENPT, O_RDWR | O_NOCTTY), "posix_openpt");
try {
check(callInt(GRANTPT, master), "grantpt");
check(callInt(UNLOCKPT, master), "unlockpt");
MemorySegment nameBuf = setup.allocate(256);
check(callPtsnameR(master, nameBuf), "ptsname_r");
String slavePath = nameBuf.getString(0);
MemorySegment actions = setup.allocate(SPAWN_ACTIONS_SIZE);
MemorySegment attr = setup.allocate(SPAWN_ATTR_SIZE);
check(callInt(FA_INIT, actions), "posix_spawn_file_actions_init");
check(callInt(ATTR_INIT, attr), "posix_spawnattr_init");
try {
check(callInt(ATTR_SETFLAGS, attr, POSIX_SPAWN_SETSID), "posix_spawnattr_setflags");
if (workingDirectory != null) {
MemorySegment dir = setup.allocateFrom(workingDirectory);
check(callAddChdir(actions, dir), "posix_spawn_file_actions_addchdir_np");
}
// Open the slave as fd 0 in the new session -> controlling terminal, then fan out.
MemorySegment slave = setup.allocateFrom(slavePath);
check(callAddOpen(actions, 0, slave, O_RDWR, 0), "posix_spawn_file_actions_addopen");
check(callAddDup2(actions, 0, 1), "posix_spawn_file_actions_adddup2");
check(callAddDup2(actions, 0, 2), "posix_spawn_file_actions_adddup2");
check(callAddClose(actions, master), "posix_spawn_file_actions_addclose");
MemorySegment argvSeg = cStringArray(setup, List.of(argv));
MemorySegment envpSeg = cStringArray(setup, toEnvList(environment));
MemorySegment path = setup.allocateFrom(argv[0]);
MemorySegment pidOut = setup.allocate(C_INT);
int rc = callSpawn(pidOut, path, actions, attr, argvSeg, envpSeg);
if (rc != 0) {
throw new IllegalStateException("posix_spawnp failed for " + argv[0] + " (rc=" + rc + ")");
}
return new LinuxPty(master, pidOut.get(C_INT, 0));
} finally {
callInt(ATTR_DESTROY, attr);
callInt(FA_DESTROY, actions);
}
} catch (RuntimeException ex) {
callInt(CLOSE, master);
throw ex;
}
} finally {
setup.close();
}
}
/** Reads available output into {@code dst}; returns bytes read, or -1 at EOF. */
public int read(byte[] dst) {
if (closed) {
return -1;
}
long n = callLong(READ, masterFd, readBuffer, Math.min(dst.length, readBuffer.byteSize()));
if (n <= 0) {
return -1;
}
MemorySegment.copy(readBuffer, ValueLayout.JAVA_BYTE, 0, dst, 0, (int) n);
return (int) n;
}
/** Writes all of {@code data} to the master end. */
public void write(byte[] data) {
if (closed || data.length == 0) {
return;
}
synchronized (writeLock) {
try (Arena a = Arena.ofConfined()) {
MemorySegment buf = a.allocate(data.length);
MemorySegment.copy(data, 0, buf, ValueLayout.JAVA_BYTE, 0, data.length);
long offset = 0;
while (offset < data.length) {
long n = callLong(WRITE, masterFd, buf.asSlice(offset), data.length - offset);
if (n < 0) {
throw new IllegalStateException("write to pty failed");
}
offset += n;
}
}
}
}
/** Resizes the terminal window. */
public void setWinSize(int columns, int rows) {
if (closed) {
return;
}
try (Arena a = Arena.ofConfined()) {
MemorySegment ws = a.allocate(WINSIZE);
ws.set(C_SHORT, 0, (short) rows);
ws.set(C_SHORT, 2, (short) columns);
ws.set(C_SHORT, 4, (short) 0);
ws.set(C_SHORT, 6, (short) 0);
callIoctl(masterFd, TIOCSWINSZ, ws);
}
}
@Override
public void close() {
if (closed) {
return;
}
closed = true;
callKill(pid, SIGHUP);
callInt(CLOSE, masterFd);
reap();
arena.close();
}
private void reap() {
try (Arena a = Arena.ofConfined()) {
MemorySegment status = a.allocate(C_INT);
// Closing the master sends EOF/SIGHUP; an interactive shell exits promptly.
for (int attempt = 0; attempt < 50; attempt++) {
int r = callWaitpid(pid, status, WNOHANG);
if (r != 0) {
return; // reaped, or no such child
}
if (attempt == 25) {
callKill(pid, SIGKILL);
}
try {
Thread.sleep(2);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
return;
}
}
}
}
// --- typed invokeExact wrappers ---------------------------------------------------------
private static int callInt(MethodHandle handle, int arg) {
try {
return (int) handle.invokeExact(arg);
} catch (Throwable t) {
throw sneaky(t);
}
}
private static int callInt(MethodHandle handle, MemorySegment arg) {
try {
return (int) handle.invokeExact(arg);
} catch (Throwable t) {
throw sneaky(t);
}
}
private static int callInt(MethodHandle handle, MemorySegment a, short b) {
try {
return (int) handle.invokeExact(a, b);
} catch (Throwable t) {
throw sneaky(t);
}
}
private static long callLong(MethodHandle handle, int fd, MemorySegment buf, long len) {
try {
return (long) handle.invokeExact(fd, buf, len);
} catch (Throwable t) {
throw sneaky(t);
}
}
private static int callPtsnameR(int fd, MemorySegment buf) {
try {
return (int) PTSNAME_R.invokeExact(fd, buf, buf.byteSize());
} catch (Throwable t) {
throw sneaky(t);
}
}
private static int callAddChdir(MemorySegment actions, MemorySegment path) {
try {
return (int) FA_ADDCHDIR.invokeExact(actions, path);
} catch (Throwable t) {
throw sneaky(t);
}
}
private static int callAddOpen(MemorySegment actions, int fd, MemorySegment path, int oflag, int mode) {
try {
return (int) FA_ADDOPEN.invokeExact(actions, fd, path, oflag, mode);
} catch (Throwable t) {
throw sneaky(t);
}
}
private static int callAddDup2(MemorySegment actions, int fd, int newFd) {
try {
return (int) FA_ADDDUP2.invokeExact(actions, fd, newFd);
} catch (Throwable t) {
throw sneaky(t);
}
}
private static int callAddClose(MemorySegment actions, int fd) {
try {
return (int) FA_ADDCLOSE.invokeExact(actions, fd);
} catch (Throwable t) {
throw sneaky(t);
}
}
private static int callSpawn(MemorySegment pid, MemorySegment path, MemorySegment actions,
MemorySegment attr, MemorySegment argv, MemorySegment envp) {
try {
return (int) POSIX_SPAWNP.invokeExact(pid, path, actions, attr, argv, envp);
} catch (Throwable t) {
throw sneaky(t);
}
}
private static void callIoctl(int fd, long request, MemorySegment arg) {
try {
int unused = (int) IOCTL.invokeExact(fd, request, arg);
} catch (Throwable t) {
throw sneaky(t);
}
}
private static void callKill(int pid, int signal) {
try {
int unused = (int) KILL.invokeExact(pid, signal);
} catch (Throwable t) {
throw sneaky(t);
}
}
private static int callWaitpid(int pid, MemorySegment status, int options) {
try {
return (int) WAITPID.invokeExact(pid, status, options);
} catch (Throwable t) {
throw sneaky(t);
}
}
// --- helpers ----------------------------------------------------------------------------
private static MethodHandle handle(String symbol, FunctionDescriptor descriptor, Linker.Option... options) {
MemorySegment address = LIBC.find(symbol)
.orElseThrow(() -> new IllegalStateException("libc symbol not found: " + symbol));
return LINKER.downcallHandle(address, descriptor, options);
}
private static MemorySegment cStringArray(Arena arena, List<String> values) {
MemorySegment array = arena.allocate(C_POINTER, values.size() + 1L);
for (int i = 0; i < values.size(); i++) {
array.setAtIndex(C_POINTER, i, arena.allocateFrom(values.get(i)));
}
array.setAtIndex(C_POINTER, values.size(), MemorySegment.NULL);
return array;
}
private static List<String> toEnvList(Map<String, String> environment) {
List<String> out = new ArrayList<>(environment.size());
for (Map.Entry<String, String> entry : environment.entrySet()) {
out.add(entry.getKey() + "=" + entry.getValue());
}
return out;
}
private static int check(int rc, String what) {
if (rc < 0) {
throw new IllegalStateException(what + " failed (rc=" + rc + ")");
}
return rc;
}
private static RuntimeException sneaky(Throwable t) {
if (t instanceof RuntimeException re) {
return re;
}
if (t instanceof Error e) {
throw e;
}
return new IllegalStateException(t);
}
}

View File

@@ -2,64 +2,101 @@ package com.gregor.jprototerm;
import javafx.animation.AnimationTimer; import javafx.animation.AnimationTimer;
import javafx.application.Application; import javafx.application.Application;
import javafx.application.Platform;
import javafx.scene.Scene; import javafx.scene.Scene;
import javafx.scene.control.ButtonType;
import javafx.scene.control.ComboBox;
import javafx.scene.control.Dialog;
import javafx.scene.control.Label;
import javafx.scene.control.Spinner;
import javafx.scene.control.SpinnerValueFactory;
import javafx.scene.input.KeyEvent; import javafx.scene.input.KeyEvent;
import javafx.scene.layout.StackPane; import javafx.scene.layout.GridPane;
import javafx.scene.text.Font;
import javafx.stage.Stage; import javafx.stage.Stage;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
public final class Main extends Application { public final class Main extends Application {
private TerminalWorkspace workspace; private Compositor compositor;
private TerminalMetrics metrics;
private AppConfig config;
@Override @Override
public void start(Stage stage) { public void start(Stage stage) {
AppConfig config = AppConfig.load(); config = AppConfig.load();
workspace = new TerminalWorkspace(config); metrics = new TerminalMetrics(config.fontFamily(), config.fontSize());
TerminalCanvasView terminalView = new TerminalCanvasView(workspace, config); compositor = new Compositor(config, metrics);
StackPane root = new StackPane(terminalView.canvas()); Scene scene = new Scene(compositor.node(), config.windowWidth(), config.windowHeight());
terminalView.canvas().widthProperty().bind(root.widthProperty()); scene.addEventFilter(KeyEvent.KEY_PRESSED, this::handlePressed);
terminalView.canvas().heightProperty().bind(root.heightProperty());
Scene scene = new Scene(root, config.windowWidth(), config.windowHeight());
scene.addEventFilter(KeyEvent.KEY_PRESSED, event -> handlePressed(config, event));
scene.addEventFilter(KeyEvent.KEY_TYPED, event -> handleTyped(event)); scene.addEventFilter(KeyEvent.KEY_TYPED, event -> handleTyped(event));
new AnimationTimer() { new AnimationTimer() {
@Override @Override
public void handle(long now) { public void handle(long now) {
terminalView.render(); compositor.render();
} }
}.start(); }.start();
stage.setTitle("jprototerm"); stage.setTitle("jprototerm");
stage.setScene(scene); stage.setScene(scene);
stage.setOnCloseRequest(event -> { stage.setOnCloseRequest(event -> {
workspace.close(); compositor.close();
}); });
stage.show(); stage.show();
compositor.requestFocus();
} }
private void handlePressed(AppConfig config, KeyEvent event) { private void handlePressed(KeyEvent event) {
if (config.keybindings().get("navigate_left").matches(event)) { if (config.keybindings().get("navigate_left").matches(event)) {
workspace.navigate(Direction.LEFT); compositor.navigate(Direction.LEFT);
event.consume(); event.consume();
} else if (config.keybindings().get("navigate_down").matches(event)) { } else if (config.keybindings().get("navigate_down").matches(event)) {
workspace.navigate(Direction.DOWN); compositor.navigate(Direction.DOWN);
event.consume(); event.consume();
} else if (config.keybindings().get("navigate_up").matches(event)) { } else if (config.keybindings().get("navigate_up").matches(event)) {
workspace.navigate(Direction.UP); compositor.navigate(Direction.UP);
event.consume(); event.consume();
} else if (config.keybindings().get("navigate_right").matches(event)) { } else if (config.keybindings().get("navigate_right").matches(event)) {
workspace.navigate(Direction.RIGHT); compositor.navigate(Direction.RIGHT);
event.consume(); event.consume();
} else if (config.keybindings().get("toggle_floating").matches(event)) { } else if (config.keybindings().get("toggle_floating").matches(event)) {
workspace.toggleFloating(); compositor.toggleFloating();
event.consume();
} 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(); event.consume();
} else { } else {
String encoded = KeyEncoder.encode(event); String encoded = KeyEncoder.encode(event);
if (encoded != null) { if (encoded != null) {
workspace.activePane().send(encoded); compositor.activePane().send(encoded);
event.consume(); event.consume();
} }
} }
@@ -72,14 +109,84 @@ public final class Main extends Application {
String text = event.getCharacter(); String text = event.getCharacter();
if (text != null && !text.isEmpty() && text.charAt(0) >= 0x20 && text.charAt(0) != 0x7f) { if (text != null && !text.isEmpty() && text.charAt(0) >= 0x20 && text.charAt(0) != 0x7f) {
workspace.activePane().send(text); compositor.activePane().send(text);
event.consume(); event.consume();
} }
} }
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) {
System.setProperty("prism.order", "sw"); System.setProperty("prism.order", System.getProperty("prism.order", "es2,sw"));
System.setProperty("prism.verbose", "true");
launch(Main.class, args); launch(Main.class, args);
} }
} }

View 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;
}
}

View File

@@ -1,78 +1,116 @@
package com.gregor.jprototerm; package com.gregor.jprototerm;
import javafx.application.Platform;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ExecutorService; import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors; import java.util.concurrent.Executors;
public final class ShellSession implements AutoCloseable { public final class ShellSession implements AutoCloseable {
private final Process process; private final LinuxPty pty;
private final OutputStream stdin;
private final ExecutorService reader; private final ExecutorService reader;
private volatile boolean closed; private volatile boolean closed;
private ShellSession(Process process, TerminalPane pane, KittyGraphicsRegistry graphicsRegistry) { private ShellSession(LinuxPty pty) {
this.process = process; this.pty = pty;
this.stdin = process.getOutputStream();
this.reader = Executors.newSingleThreadExecutor(runnable -> { this.reader = Executors.newSingleThreadExecutor(runnable -> {
Thread thread = new Thread(runnable, "shell-output-reader"); Thread thread = new Thread(runnable, "shell-output-reader");
thread.setDaemon(true); thread.setDaemon(true);
return thread; return thread;
}); });
reader.submit(() -> readOutput(pane, graphicsRegistry));
} }
public static ShellSession start(String shell, TerminalPane pane, KittyGraphicsRegistry graphicsRegistry) { public static ShellSession start(String shell, Map<String, String> envOverride, TerminalPane pane, int columns, int rows) {
try { try {
ProcessBuilder processBuilder = new ProcessBuilder( Map<String, String> environment = new HashMap<>(System.getenv());
"script", environment.put("TERM", "xterm-kitty");
"-qfec", environment.put("COLORTERM", "truecolor");
shell + " -i", sanitizeWrapperEnvironment(environment);
"/dev/null" environment.putAll(envOverride);
).redirectErrorStream(true);
processBuilder.environment().put("TERM", "xterm-kitty"); LinuxPty pty = LinuxPty.spawn(
processBuilder.environment().put("COLORTERM", "truecolor"); new String[] {shell, "-i"},
Process process = processBuilder.start(); environment,
return new ShellSession(process, pane, graphicsRegistry); System.getProperty("user.home"));
} catch (IOException ex) { ShellSession session = new ShellSession(pty);
session.resize(columns, rows);
return session;
} catch (RuntimeException ex) {
pane.write("failed to start shell: " + ex.getMessage() + "\r\n"); pane.write("failed to start shell: " + ex.getMessage() + "\r\n");
throw new IllegalStateException("Could not start shell " + shell, ex); throw new IllegalStateException("Could not start shell " + shell, ex);
} }
} }
/**
* Strips the variables injected by the Nix launcher wrapper from the shell's
* environment so they do not leak into terminal subprocesses.
*
* <p>jprototerm is launched from a Nix wrapper that prepends Nix store paths to
* {@code LD_LIBRARY_PATH} (and adds a GL shim) so the bundled JavaFX/ghostty natives
* resolve. If the shell inherited that path, host programs run inside the terminal
* (e.g. {@code flatpak}, {@code pdftoppm}) would load the Nix copies of libraries such
* as freetype/fontconfig/glib, which in turn drag in the Nix glibc through their
* RUNPATHs and clash with the host {@code libc.so.6}. We restore the user's original
* {@code LD_LIBRARY_PATH}, captured by the wrapper before it prepended anything.
*/
private static void sanitizeWrapperEnvironment(Map<String, String> environment) {
String hostLibraryPath = environment.remove("JPROTOTERM_HOST_LD_LIBRARY_PATH");
if (hostLibraryPath == null || hostLibraryPath.isEmpty()) {
environment.remove("LD_LIBRARY_PATH");
} else {
environment.put("LD_LIBRARY_PATH", hostLibraryPath);
}
// These are jprototerm's own runtime settings, not the user's shell environment.
environment.remove("GDK_BACKEND");
environment.remove("JLIBGHOSTTY_LIBRARY");
}
public void startReading(TerminalPane pane) {
reader.submit(() -> readOutput(pane));
}
public void resize(int columns, int rows) {
if (closed) {
return;
}
pty.setWinSize(columns, rows);
}
public void send(String text) { public void send(String text) {
send(text.getBytes(StandardCharsets.UTF_8));
}
public void send(byte[] bytes) {
if (closed) { if (closed) {
return; return;
} }
try { try {
stdin.write(text.getBytes(StandardCharsets.UTF_8)); pty.write(bytes);
stdin.flush(); } catch (RuntimeException ex) {
} catch (IOException ex) {
close(); close();
} }
} }
private void readOutput(TerminalPane pane, KittyGraphicsRegistry graphicsRegistry) { private void readOutput(TerminalPane pane) {
byte[] buffer = new byte[8192]; byte[] buffer = new byte[65536];
try { try {
int read; int read;
while ((read = process.getInputStream().read(buffer)) != -1) { while ((read = pty.read(buffer)) != -1) {
String text = new String(buffer, 0, read, StandardCharsets.UTF_8); if (closed) {
if (!closed) { break;
graphicsRegistry.accept(text);
Platform.runLater(() -> {
if (!closed) {
pane.write(text);
}
});
} }
byte[] bytes = new byte[read];
System.arraycopy(buffer, 0, bytes, 0, read);
// Feed the terminal model straight from the reader thread. terminal access is
// guarded by the per-terminal lock, and the render loop picks the change up on
// the next pulse. Avoiding a Platform.runLater hop per chunk removes a frame of
// latency and stops write tasks from contending with rendering on the FX thread
// when a TUI repaints heavily (the input-lag culprit).
pane.write(bytes);
} }
} catch (IOException ex) { } catch (RuntimeException ex) {
if (!closed) { if (!closed) {
Platform.runLater(() -> pane.write("\r\nshell output stopped: " + ex.getMessage() + "\r\n")); pane.write("\r\nshell output stopped: " + ex.getMessage() + "\r\n");
} }
} }
} }
@@ -81,6 +119,6 @@ public final class ShellSession implements AutoCloseable {
public void close() { public void close() {
closed = true; closed = true;
reader.shutdownNow(); reader.shutdownNow();
process.destroy(); pty.close();
} }
} }

View File

@@ -0,0 +1,288 @@
package com.gregor.jprototerm;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.stream.Stream;
/**
* One tab: a row of tiled panes with a group of floating panes shown over them. Floating panes
* are shown/hidden as a group ({@code floatingVisible}), and there is always at least one tiled
* pane — a floating pane is promoted if the last tiled one closes — so the layout always has a
* base. The {@link Compositor} owns the tabs and renders only the current one; mutating methods
* return whether they actually changed anything so it can bump its layout version.
*/
final class Tab implements AutoCloseable {
private final AppConfig config;
private final TerminalMetrics metrics;
private final List<TerminalPane> tiled = new ArrayList<>();
private final List<TerminalPane> floating = new ArrayList<>();
private boolean floatingVisible;
private TerminalPane active;
// The floating pane to re-focus when the group is shown again, and to prefer when promoting
// after the last tiled pane closes.
private TerminalPane lastFocusedFloating;
// Last laid-out size, so a newly opened pane can be created at roughly its eventual rect
// (and thus grid). Seeded from the configured window size for the first pane, which is
// opened before any layout pass runs.
private double lastWidth;
private double lastHeight;
private double lastTopInset;
// Bumped whenever one of this tab's panes changes content; the compositor reads the current
// tab's value each frame as an O(1) "anything to repaint?" check.
private long contentVersion;
Tab(AppConfig config, TerminalMetrics metrics) {
this.config = config;
this.metrics = metrics;
this.lastWidth = config.windowWidth();
this.lastHeight = config.windowHeight();
TerminalPane first = openPane(false);
tiled.add(first);
active = first;
}
TerminalPane activePane() {
return active;
}
boolean isEmpty() {
return tiled.isEmpty() && floating.isEmpty();
}
long contentVersion() {
return contentVersion;
}
/**
* Panes to composite, bottom-to-top: tiled first, then (when shown) the floating group with
* the active floating pane on top.
*/
List<TerminalPane> panes() {
if (!floatingVisible || floating.isEmpty()) {
return List.copyOf(tiled);
}
List<TerminalPane> ordered = new ArrayList<>(tiled.size() + floating.size());
ordered.addAll(tiled);
for (TerminalPane pane : floating) {
if (pane != active) {
ordered.add(pane);
}
}
if (floating.contains(active)) {
ordered.add(active); // active floating pane on top
}
return List.copyOf(ordered);
}
List<TerminalPane> allPanes() {
List<TerminalPane> all = new ArrayList<>(tiled.size() + floating.size());
all.addAll(tiled);
all.addAll(floating);
return List.copyOf(all);
}
boolean isActive(TerminalPane pane) {
return pane != null && pane == active;
}
boolean focus(TerminalPane pane) {
if (pane == active || !isFocusable(pane)) {
return false;
}
setActive(pane);
return true;
}
void layout(double width, double height, double topInset) {
this.lastWidth = width;
this.lastHeight = height;
this.lastTopInset = topInset;
double availHeight = height - topInset;
double tileWidth = width / Math.max(1, tiled.size());
for (int i = 0; i < tiled.size(); i++) {
tiled.get(i).bounds(i * tileWidth, topInset, tileWidth, availHeight);
}
double floatingWidth = Math.max(420, width * 0.58);
double floatingHeight = Math.max(260, availHeight * 0.58);
for (int i = 0; i < floating.size(); i++) {
double offset = i * 28.0;
floating.get(i).bounds(
Math.min(width - floatingWidth - 12.0, ((width - floatingWidth) / 2.0) + offset),
Math.min(height - floatingHeight - 12.0, topInset + ((availHeight - floatingHeight) / 2.0) + offset),
floatingWidth,
floatingHeight);
}
}
boolean navigate(Direction direction) {
if (floating.contains(active) && navigateFloatingStack(direction)) {
return true;
}
TerminalPane target = focusable()
.filter(pane -> pane != active)
.filter(pane -> directionFilter(direction, active, pane))
.min(Comparator.comparingDouble(pane -> distance(active, pane)))
.orElse(null);
if (target != null) {
setActive(target);
return true;
}
return false;
}
void toggleFloating() {
if (floating.isEmpty()) {
createFloatingPane();
return;
}
if (floatingVisible) {
floatingVisible = false;
if (floating.contains(active)) {
setActive(tiled.get(0));
}
} else {
floatingVisible = true;
setActive(floating.contains(lastFocusedFloating) ? lastFocusedFloating : floating.get(floating.size() - 1));
}
}
/** Adds a floating pane while the floating group is shown, otherwise a tiled pane. */
void createPane() {
if (floatingVisible) {
createFloatingPane();
} else {
TerminalPane pane = openPane(false);
tiled.add(pane);
setActive(pane);
}
}
void closeActivePane() {
TerminalPane closing = active;
boolean wasFloating = floating.remove(closing);
if (!wasFloating) {
tiled.remove(closing);
}
if (closing == lastFocusedFloating) {
lastFocusedFloating = null;
}
closing.close();
if (tiled.isEmpty() && floating.isEmpty()) {
active = null; // tab is now empty; the compositor drops it
return;
}
// Always keep a tiled base: if the last tiled pane just closed, promote a floating one
// (preferring the last focused).
if (tiled.isEmpty()) {
TerminalPane promote = floating.contains(lastFocusedFloating) ? lastFocusedFloating : floating.get(0);
var promoteIndex = floating.indexOf(promote);
var nextFocussed = promoteIndex == 0 ? 0 : promoteIndex - 1;
floating.remove(promote);
tiled.add(promote);
if (promote == lastFocusedFloating) {
lastFocusedFloating = null;
if (!floating.isEmpty()) {
lastFocusedFloating = floating.isEmpty() ? null : floating.get(nextFocussed);
}
}
}
if (floating.isEmpty()) {
floatingVisible = false;
}
setActive(wasFloating && floatingVisible ? floating.get(floating.size() - 1) : tiled.get(0));
}
private void setActive(TerminalPane pane) {
active = pane;
if (floating.contains(pane)) {
lastFocusedFloating = pane;
}
}
private void createFloatingPane() {
TerminalPane pane = openPane(true);
floating.add(pane);
floatingVisible = true;
setActive(pane);
}
private boolean navigateFloatingStack(Direction direction) {
if (floating.size() < 2) {
return false;
}
int current = floating.indexOf(active);
if (current < 0) {
return false;
}
int next = switch (direction) {
case LEFT, UP -> current - 1;
case DOWN, RIGHT -> current + 1;
};
if (next < 0 || next >= floating.size()) {
return false;
}
setActive(floating.get(next));
return true;
}
private boolean isFocusable(TerminalPane pane) {
return tiled.contains(pane) || (floatingVisible && floating.contains(pane));
}
private Stream<TerminalPane> focusable() {
return floatingVisible ? Stream.concat(tiled.stream(), floating.stream()) : tiled.stream();
}
private void markContentChanged() {
contentVersion++;
}
private TerminalPane openPane(boolean asFloating) {
double availHeight = lastHeight - lastTopInset;
double widthPx;
double heightPx;
if (asFloating) {
widthPx = Math.max(420, lastWidth * 0.58);
heightPx = Math.max(260, availHeight * 0.58);
} else {
// A new tiled pane joins the row, so each gets 1/(n+1) of the width.
widthPx = lastWidth / (tiled.size() + 1);
heightPx = availHeight;
}
return TerminalPane.create(config, metrics, this::markContentChanged, widthPx, heightPx);
}
private static boolean directionFilter(Direction direction, TerminalPane current, TerminalPane candidate) {
double currentCenterX = current.x() + current.width() / 2.0;
double currentCenterY = current.y() + current.height() / 2.0;
double candidateCenterX = candidate.x() + candidate.width() / 2.0;
double candidateCenterY = candidate.y() + candidate.height() / 2.0;
return switch (direction) {
case LEFT -> candidateCenterX < currentCenterX;
case DOWN -> candidateCenterY > currentCenterY;
case UP -> candidateCenterY < currentCenterY;
case RIGHT -> candidateCenterX > currentCenterX;
};
}
private static double distance(TerminalPane current, TerminalPane candidate) {
double dx = (current.x() + current.width() / 2.0) - (candidate.x() + candidate.width() / 2.0);
double dy = (current.y() + current.height() / 2.0) - (candidate.y() + candidate.height() / 2.0);
return Math.sqrt(dx * dx + dy * dy);
}
@Override
public void close() {
tiled.forEach(TerminalPane::close);
floating.forEach(TerminalPane::close);
tiled.clear();
floating.clear();
}
}

View File

@@ -1,74 +0,0 @@
package com.gregor.jprototerm;
import javafx.scene.canvas.Canvas;
import javafx.scene.canvas.GraphicsContext;
import javafx.scene.paint.Color;
import javafx.scene.text.Font;
import javafx.scene.text.FontSmoothingType;
public final class TerminalCanvasView {
private final Canvas canvas = new Canvas();
private final TerminalWorkspace workspace;
private final AppConfig config;
public TerminalCanvasView(TerminalWorkspace workspace, AppConfig config) {
this.workspace = workspace;
this.config = config;
canvas.setFocusTraversable(true);
}
public Canvas canvas() {
return canvas;
}
public void render() {
double width = canvas.getWidth();
double height = canvas.getHeight();
workspace.layout(width, height);
GraphicsContext gc = canvas.getGraphicsContext2D();
gc.setFill(Color.rgb(16, 16, 18));
gc.fillRect(0, 0, width, height);
gc.setFontSmoothingType(FontSmoothingType.GRAY);
for (TerminalPane pane : workspace.panes()) {
drawPane(gc, pane);
}
}
private void drawPane(GraphicsContext gc, TerminalPane pane) {
gc.save();
gc.beginPath();
gc.rect(pane.x(), pane.y(), pane.width(), pane.height());
gc.clip();
if (pane.floating()) {
gc.setGlobalAlpha(0.96);
}
gc.setFill(Color.rgb(9, 10, 12));
gc.fillRect(pane.x(), pane.y(), pane.width(), pane.height());
gc.setGlobalAlpha(1.0);
gc.setStroke(workspace.isActive(pane) ? Color.rgb(87, 166, 255) : Color.rgb(52, 57, 65));
gc.setLineWidth(workspace.isActive(pane) ? 2.0 : 1.0);
gc.strokeRect(pane.x() + 0.5, pane.y() + 0.5, pane.width() - 1.0, pane.height() - 1.0);
Font font = Font.font(config.fontFamily(), config.fontSize());
gc.setFont(font);
gc.setFill(Color.rgb(225, 229, 235));
double lineHeight = Math.ceil(config.fontSize() * 1.35);
double left = pane.x() + 12.0;
double baseline = pane.y() + 18.0;
int maxLines = Math.max(1, (int) ((pane.height() - 24.0) / lineHeight));
String[] lines = pane.snapshotText().split("\\R", -1);
int start = Math.max(0, lines.length - maxLines);
for (int i = start; i < lines.length; i++) {
gc.fillText(lines[i], left, baseline + ((i - start) * lineHeight));
}
pane.graphicsRegistry().draw(gc, pane.x() + 12.0, pane.y() + 12.0, config.fontSize() * 0.62, lineHeight);
gc.restore();
}
}

View File

@@ -0,0 +1,86 @@
package com.gregor.jprototerm;
import javafx.scene.text.Font;
import javafx.scene.text.Text;
/**
* Cell geometry shared by the {@link Compositor} and every {@link TerminalPane}.
*
* <p>The nominal cell width/height come from measuring the font, but a grid can't use
* fractional cells, so the measured size is snapped to whole (logical) pixels here — that
* snapping is why the value isn't purely a property of the font. The compositor owns the
* single instance (it holds the canvas, which is the pixel context), hands it to panes so
* they can turn their rect into a column/row count themselves, and re-measures it on a font
* change so every pane observes the new geometry through the shared reference.
*/
public final class TerminalMetrics {
/** Inset, in pixels, between a pane's edge and its content on every side. */
public static final double PADDING = 12.0;
private String fontFamily;
private double fontSize;
private Font font;
private double cellWidth;
private double lineHeight;
private double baselineOffset;
public TerminalMetrics(String fontFamily, double fontSize) {
setFont(fontFamily, fontSize);
}
public void setFont(String fontFamily, double fontSize) {
this.fontFamily = fontFamily;
this.fontSize = fontSize;
this.font = Font.font(fontFamily, fontSize);
measure(font);
}
public String fontFamily() {
return fontFamily;
}
public double fontSize() {
return fontSize;
}
public Font font() {
return font;
}
public double cellWidth() {
return cellWidth;
}
public double lineHeight() {
return lineHeight;
}
public double baselineOffset() {
return baselineOffset;
}
/** Columns that fit in a pane of the given pixel width (after subtracting the padding). */
public int columnsFor(double widthPx) {
return Math.max(1, (int) ((widthPx - 2 * PADDING) / cellWidth));
}
/** Rows that fit in a pane of the given pixel height (after subtracting the padding). */
public int rowsFor(double heightPx) {
return Math.max(1, (int) ((heightPx - 2 * PADDING) / lineHeight));
}
private void measure(Font font) {
Text text = new Text("┃MgÅjy");
text.setFont(font);
// Snap the cell size to whole pixels so cells tile on integer boundaries. Fractional
// cell metrics put every cell edge on a sub-pixel position, leaving anti-aliased
// seams that show up as a faint grid behind the themed cell backgrounds. Rounding
// leaves a few pixels of unused space at the right/bottom edge, which is fine.
this.lineHeight = Math.max(1.0, Math.round(text.getLayoutBounds().getHeight()));
this.baselineOffset = -text.getLayoutBounds().getMinY();
Text cell = new Text("M");
cell.setFont(font);
this.cellWidth = Math.max(1.0, Math.round(cell.getLayoutBounds().getWidth()));
}
}

View File

@@ -1,34 +1,89 @@
package com.gregor.jprototerm; package com.gregor.jprototerm;
import dev.jlibghostty.DeviceAttributes;
import dev.jlibghostty.Ghostty; import dev.jlibghostty.Ghostty;
import dev.jlibghostty.KittyGraphics;
import dev.jlibghostty.MouseAction;
import dev.jlibghostty.MouseEncoder;
import dev.jlibghostty.MouseEncoderSize;
import dev.jlibghostty.MouseInput;
import dev.jlibghostty.RenderState;
import dev.jlibghostty.RenderStateSnapshot;
import dev.jlibghostty.ScrollViewport;
import dev.jlibghostty.Terminal; import dev.jlibghostty.Terminal;
import dev.jlibghostty.TerminalOptions; import dev.jlibghostty.TerminalOptions;
import java.util.concurrent.atomic.AtomicReference; import java.util.Optional;
import java.util.concurrent.atomic.AtomicLong;
/**
* 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; {@link TerminalPaneNode}
* reads snapshots from it and represents the visible rows and kitty graphics as JavaFX nodes.
*/
public final class TerminalPane implements AutoCloseable { public final class TerminalPane implements AutoCloseable {
private final Terminal terminal; private final Terminal terminal;
private final KittyGraphicsRegistry graphicsRegistry; private final TerminalMetrics metrics;
private final AtomicReference<String> snapshotText = new AtomicReference<>(""); private final boolean kittyEnabled;
// Run on every content change so the owning tab can bump its content version — the
// compositor's O(1) "did the current tab change?" gate.
private final Runnable onContentChange;
private final MouseEncoder mouseEncoder = new MouseEncoder();
// A persistent render state (reused across frames) is what makes ghostty's per-row dirty
// tracking meaningful: update() accumulates dirty since the last resetDirty().
private final RenderState renderState = new RenderState();
private RenderStateSnapshot cachedSnapshot;
private ShellSession session; private ShellSession session;
private boolean floating;
private double x; private double x;
private double y; private double y;
private double width; private double width;
private double height; private double height;
private int columns;
private int rows;
private int pixelWidth;
private int pixelHeight;
private final AtomicLong contentVersion = new AtomicLong();
private volatile long snapshotVersion = -1;
private TerminalPane(Terminal terminal, KittyGraphicsRegistry graphicsRegistry) { private TerminalPane(Terminal terminal, TerminalMetrics metrics, boolean kittyEnabled,
Runnable onContentChange, int columns, int rows) {
this.terminal = terminal; this.terminal = terminal;
this.graphicsRegistry = graphicsRegistry; this.metrics = metrics;
this.kittyEnabled = kittyEnabled;
this.onContentChange = onContentChange;
this.columns = columns;
this.rows = rows;
} }
public static TerminalPane create(int columns, int rows, boolean kittyGraphics) { /**
Terminal terminal = Ghostty.open(TerminalOptions.of(columns, rows)); * Opens a pane sized to fit the given pixel rect: the shared cell metrics decide how many
TerminalPane pane = new TerminalPane(terminal, new KittyGraphicsRegistry(kittyGraphics)); * columns and rows fit, and that grid is handed to ghostty and the shell at start-up. A
* non-positive size falls back to the configured default grid (used before the first
* layout, when no rect is known yet). The pane owns the shell session it starts and runs
* {@code onContentChange} on every content change.
*/
public static TerminalPane create(AppConfig config, TerminalMetrics metrics, Runnable onContentChange, double widthPx, double heightPx) {
int columns = widthPx > 0 ? metrics.columnsFor(widthPx) : config.columns();
int rows = heightPx > 0 ? metrics.rowsFor(heightPx) : config.rows();
Terminal terminal = Ghostty.open(new TerminalOptions(columns, rows, config.maxScrollback()));
terminal.setDeviceAttributesProvider(DeviceAttributes::xtermCompatible);
TerminalPane pane = new TerminalPane(terminal, metrics, config.kittyGraphics(), onContentChange, columns, rows);
pane.refresh(); pane.refresh();
pane.attach(ShellSession.start(config.shell(), config.envOverride(), pane, columns, rows));
return pane; return pane;
} }
private void attach(ShellSession session) {
this.session = session;
terminal.setPtyWriter(bytes -> {
ShellSession current = this.session;
if (current != null) {
current.send(bytes);
}
});
session.startReading(this);
}
public void write(String text) { public void write(String text) {
synchronized (terminal) { synchronized (terminal) {
terminal.write(text); terminal.write(text);
@@ -36,30 +91,120 @@ public final class TerminalPane implements AutoCloseable {
} }
} }
public void attach(ShellSession session) { public void write(byte[] bytes) {
this.session = session; synchronized (terminal) {
terminal.write(bytes);
refresh();
}
} }
public void send(String text) { public void send(String text) {
scrollViewportToBottom();
if (session != null) { if (session != null) {
session.send(text); session.send(text);
} }
} }
public String snapshotText() { public boolean sendMouse(MouseInput input, MouseEncoderSize size, boolean anyButtonPressed) {
return snapshotText.get(); synchronized (terminal) {
mouseEncoder.syncFromTerminal(terminal);
mouseEncoder.setSize(size);
mouseEncoder.setAnyButtonPressed(anyButtonPressed);
mouseEncoder.setTrackLastCell(input.action() == MouseAction.MOTION && input.button().isEmpty());
byte[] encoded = mouseEncoder.encode(input);
if (encoded.length == 0) {
return false;
}
if (session != null) {
session.send(encoded);
}
return true;
}
} }
public KittyGraphicsRegistry graphicsRegistry() { public void scrollViewport(long rows) {
return graphicsRegistry; synchronized (terminal) {
terminal.scrollViewport(ScrollViewport.delta(rows));
refresh();
}
} }
public boolean floating() { private void scrollViewportToBottom() {
return floating; synchronized (terminal) {
terminal.scrollViewport(ScrollViewport.bottom());
refresh();
}
} }
public void setFloating(boolean floating) { /**
this.floating = floating; * Incremental snapshot: cells are marshalled only for rows that changed since the last
* frame (global dirty == PARTIAL), reused across calls for the same content version.
* Snapshotting is deferred here rather than done in refresh(), so a burst of writes
* between two frames collapses into a single snapshot.
*/
public RenderStateSnapshot snapshot() {
return takeSnapshot(false);
}
/**
* Full snapshot with every row's cells populated. Used where the whole pane is redrawn
* regardless of dirty state (the kitty-graphics path).
*/
public RenderStateSnapshot snapshotFull() {
return takeSnapshot(true);
}
private RenderStateSnapshot takeSnapshot(boolean full) {
synchronized (terminal) {
long version = contentVersion.get();
if (full) {
long updateStart = RenderProfiler.start();
renderState.update(terminal);
RenderProfiler.stop(RenderProfiler.UPDATE, updateStart);
long marshalStart = RenderProfiler.start();
cachedSnapshot = renderState.snapshot();
RenderProfiler.stop(RenderProfiler.MARSHAL, marshalStart);
renderState.resetDirty();
snapshotVersion = version;
} else if (snapshotVersion != version) {
long updateStart = RenderProfiler.start();
renderState.update(terminal);
RenderProfiler.stop(RenderProfiler.UPDATE, updateStart);
long marshalStart = RenderProfiler.start();
cachedSnapshot = renderState.snapshotIncremental();
RenderProfiler.stop(RenderProfiler.MARSHAL, marshalStart);
renderState.resetDirty();
snapshotVersion = version;
}
return cachedSnapshot;
}
}
public String scrollbackText() {
synchronized (terminal) {
return terminal.text();
}
}
/** This pane's own content revision, bumped on every change (see {@link #refresh()}). */
public long contentVersion() {
return contentVersion.get();
}
long snapshotVersion() {
return snapshotVersion;
}
public boolean kittyEnabled() {
return kittyEnabled;
}
public Optional<KittyGraphics> kittyGraphics() {
synchronized (terminal) {
return terminal.kittyGraphics();
}
} }
public double x() { public double x() {
@@ -85,8 +230,40 @@ public final class TerminalPane implements AutoCloseable {
this.height = height; this.height = height;
} }
/** Recompute the ghostty grid from the current bounds and the shared cell metrics. */
public void fitToBounds() {
int columns = metrics.columnsFor(width);
int rows = metrics.rowsFor(height);
resize(columns, rows, (int) Math.round(metrics.cellWidth()), (int) Math.round(metrics.lineHeight()));
}
private void resize(int columns, int rows, int pixelWidth, int pixelHeight) {
if (columns <= 0 || rows <= 0 || pixelWidth <= 0 || pixelHeight <= 0) {
return;
}
if (this.columns == columns && this.rows == rows && this.pixelWidth == pixelWidth && this.pixelHeight == pixelHeight) {
return;
}
synchronized (terminal) {
terminal.resize(columns, rows, pixelWidth, pixelHeight);
if (session != null) {
session.resize(columns, rows);
}
this.columns = columns;
this.rows = rows;
this.pixelWidth = pixelWidth;
this.pixelHeight = pixelHeight;
refresh();
}
}
private void refresh() { private void refresh() {
snapshotText.set(String.valueOf(terminal.snapshot())); // Mark this pane's content dirty (the snapshot is computed lazily in the paint path,
// so a burst of writes collapses into one snapshot per frame) and tell the owning tab
// one of its panes changed.
contentVersion.incrementAndGet();
onContentChange.run();
} }
@Override @Override
@@ -95,6 +272,8 @@ public final class TerminalPane implements AutoCloseable {
session.close(); session.close();
session = null; session = null;
} }
mouseEncoder.close();
renderState.close();
terminal.close(); terminal.close();
} }
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1,108 +0,0 @@
package com.gregor.jprototerm;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
public final class TerminalWorkspace implements AutoCloseable {
private final AppConfig config;
private final List<TerminalPane> panes = new ArrayList<>();
private int activeIndex;
public TerminalWorkspace(AppConfig config) {
this.config = config;
panes.add(openPane(false));
}
public TerminalPane activePane() {
return panes.get(activeIndex);
}
public List<TerminalPane> panes() {
return List.copyOf(panes);
}
public boolean isActive(TerminalPane pane) {
return activePane() == pane;
}
public void layout(double width, double height) {
List<TerminalPane> tiled = panes.stream().filter(pane -> !pane.floating()).toList();
int tileCount = Math.max(1, tiled.size());
double tileWidth = width / tileCount;
for (int i = 0; i < tiled.size(); i++) {
tiled.get(i).bounds(i * tileWidth, 0, tileWidth, height);
}
for (TerminalPane pane : panes) {
if (pane.floating()) {
double floatingWidth = Math.max(420, width * 0.58);
double floatingHeight = Math.max(260, height * 0.58);
pane.bounds(
(width - floatingWidth) / 2.0,
(height - floatingHeight) / 2.0,
floatingWidth,
floatingHeight
);
}
}
}
public void navigate(Direction direction) {
TerminalPane current = activePane();
panes.stream()
.filter(pane -> pane != current)
.filter(pane -> directionFilter(direction, current, pane))
.min(Comparator.comparingDouble(pane -> distance(current, pane)))
.ifPresent(pane -> activeIndex = panes.indexOf(pane));
}
public void toggleFloating() {
TerminalPane active = activePane();
if (active.floating()) {
panes.remove(activeIndex);
active.close();
activeIndex = Math.max(0, activeIndex - 1);
return;
}
TerminalPane pane = openPane(true);
panes.add(pane);
activeIndex = panes.size() - 1;
}
private TerminalPane openPane(boolean floating) {
TerminalPane pane = TerminalPane.create(config.columns(), config.rows(), config.kittyGraphics());
pane.setFloating(floating);
pane.attach(ShellSession.start(config.shell(), pane, pane.graphicsRegistry()));
return pane;
}
private static boolean directionFilter(Direction direction, TerminalPane current, TerminalPane candidate) {
double currentCenterX = current.x() + current.width() / 2.0;
double currentCenterY = current.y() + current.height() / 2.0;
double candidateCenterX = candidate.x() + candidate.width() / 2.0;
double candidateCenterY = candidate.y() + candidate.height() / 2.0;
return switch (direction) {
case LEFT -> candidateCenterX < currentCenterX;
case DOWN -> candidateCenterY > currentCenterY;
case UP -> candidateCenterY < currentCenterY;
case RIGHT -> candidateCenterX > currentCenterX;
};
}
private static double distance(TerminalPane current, TerminalPane candidate) {
double dx = (current.x() + current.width() / 2.0) - (candidate.x() + candidate.width() / 2.0);
double dy = (current.y() + current.height() / 2.0) - (candidate.y() + candidate.height() / 2.0);
return Math.sqrt(dx * dx + dy * dy);
}
@Override
public void close() {
for (TerminalPane pane : panes) {
pane.close();
}
panes.clear();
}
}

View File

View File

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

View File

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