Compare commits
90 Commits
194a6556c0
...
test-new-j
| Author | SHA1 | Date | |
|---|---|---|---|
| 6e3e88919e | |||
| 57103bb98b | |||
| cb95a7188d | |||
|
|
5ca192b7be | ||
|
|
e99a6ee33e | ||
|
|
4923ea5527 | ||
|
|
1f7394d75a | ||
|
|
50641d0a6a | ||
|
|
51f64e7ca8 | ||
|
|
528afafcda | ||
|
|
093a09da39 | ||
|
|
59ab33bc01 | ||
|
|
d8447d9e29 | ||
|
|
dba6474491 | ||
|
|
743f312921 | ||
|
|
3054b3ec77 | ||
|
|
2bcaf951df | ||
|
|
beba14c3ea | ||
|
|
f5562baf5f | ||
|
|
3017b99f87 | ||
|
|
0958c93b4f | ||
|
|
9c98d87783 | ||
|
|
76c731578f | ||
|
|
95619f5b4c | ||
|
|
174cfc00d3 | ||
|
|
29e84c9830 | ||
|
|
a7baa08e68 | ||
|
|
76f539d34a | ||
|
|
ba884cd0a2 | ||
|
|
7dbbf89b27 | ||
|
|
e2850f067e | ||
|
|
022cf22463 | ||
|
|
250b182060 | ||
|
|
ebba6cc44f | ||
|
|
4c3449129c | ||
|
|
40d6287867 | ||
|
|
ff21bf3544 | ||
|
|
c03d9245d0 | ||
|
|
0915c649bd | ||
|
|
4a06a89400 | ||
|
|
4de2d31e91 | ||
|
|
b98a18b49f | ||
|
|
08ad025f76 | ||
|
|
c9fb8b5f0a | ||
|
|
3b26a8d12c | ||
|
|
f545375957 | ||
|
|
224e8d0273 | ||
|
|
422b672dec | ||
|
|
2c7f71064c | ||
|
|
829eb66bbc | ||
|
|
8d36fbae35 | ||
|
|
f9454b28db | ||
|
|
f75b8c29e0 | ||
|
|
2816d99ce4 | ||
|
|
a1e0c2b2d4 | ||
|
|
3612de46bd | ||
|
|
d588eb75a6 | ||
|
|
7b8d30a058 | ||
|
|
96a752566b | ||
|
|
6ebf710031 | ||
|
|
3f102a9ede | ||
|
|
57f97e4119 | ||
|
|
163c7b7279 | ||
|
|
8669de2d32 | ||
|
|
a1717438e4 | ||
|
|
d14fa5c1cb | ||
|
|
6e1aff6200 | ||
|
|
6e4ddbf9df | ||
|
|
ee127fd006 | ||
|
|
34ae351431 | ||
|
|
191ec6d0e0 | ||
|
|
19f20a4039 | ||
|
|
0698016a65 | ||
|
|
0a84ec720a | ||
|
|
e3405fee48 | ||
|
|
380996fe50 | ||
|
|
c7f734bf64 | ||
|
|
80cd318c1c | ||
|
|
7dfff664fc | ||
|
|
1665dcfaae | ||
|
|
cf218e2afd | ||
|
|
f07e524fbb | ||
|
|
a3f4878fc7 | ||
|
|
82cc7f4729 | ||
|
|
9f8767bc88 | ||
|
|
1ae1548db0 | ||
|
|
4e9f1487cb | ||
|
|
b60dcd5918 | ||
|
|
addeed6f30 | ||
|
|
a2de5118c1 |
18
.classpath
Normal file
18
.classpath
Normal file
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<classpath>
|
||||
<classpathentry kind="src" output="bin/main" path="src/main/java">
|
||||
<attributes>
|
||||
<attribute name="gradle_scope" value="main"/>
|
||||
<attribute name="gradle_used_by_scope" value="main,test"/>
|
||||
</attributes>
|
||||
</classpathentry>
|
||||
<classpathentry kind="src" output="bin/main" path="src/main/resources">
|
||||
<attributes>
|
||||
<attribute name="gradle_scope" value="main"/>
|
||||
<attribute name="gradle_used_by_scope" value="main,test"/>
|
||||
</attributes>
|
||||
</classpathentry>
|
||||
<classpathentry kind="con" path="org.eclipse.jdt.launching.JRE_CONTAINER/org.eclipse.jdt.internal.debug.ui.launcher.StandardVMType/JavaSE-21/"/>
|
||||
<classpathentry kind="con" path="org.eclipse.buildship.core.gradleclasspathcontainer"/>
|
||||
<classpathentry kind="output" path="bin/default"/>
|
||||
</classpath>
|
||||
1
.codexsession
Normal file
1
.codexsession
Normal file
@@ -0,0 +1 @@
|
||||
019e6999-b7c8-7591-a8aa-ea51b89a7f7e
|
||||
15
.gitignore
vendored
15
.gitignore
vendored
@@ -1 +1,16 @@
|
||||
result
|
||||
|
||||
# Devenv
|
||||
.devenv*
|
||||
devenv.local.nix
|
||||
devenv.local.yaml
|
||||
|
||||
# direnv
|
||||
.direnv
|
||||
|
||||
# pre-commit
|
||||
.pre-commit-config.yaml
|
||||
build
|
||||
build
|
||||
.gradle
|
||||
bin
|
||||
|
||||
34
.project
Normal file
34
.project
Normal file
@@ -0,0 +1,34 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<projectDescription>
|
||||
<name>jprototerm</name>
|
||||
<comment>Project jprototerm created by Buildship.</comment>
|
||||
<projects>
|
||||
</projects>
|
||||
<buildSpec>
|
||||
<buildCommand>
|
||||
<name>org.eclipse.jdt.core.javabuilder</name>
|
||||
<arguments>
|
||||
</arguments>
|
||||
</buildCommand>
|
||||
<buildCommand>
|
||||
<name>org.eclipse.buildship.core.gradleprojectbuilder</name>
|
||||
<arguments>
|
||||
</arguments>
|
||||
</buildCommand>
|
||||
</buildSpec>
|
||||
<natures>
|
||||
<nature>org.eclipse.jdt.core.javanature</nature>
|
||||
<nature>org.eclipse.buildship.core.gradleprojectnature</nature>
|
||||
</natures>
|
||||
<filteredResources>
|
||||
<filter>
|
||||
<id>1779917652126</id>
|
||||
<name></name>
|
||||
<type>30</type>
|
||||
<matcher>
|
||||
<id>org.eclipse.core.resources.regexFilterMatcher</id>
|
||||
<arguments>node_modules|\.git|__CREATED_BY_JAVA_LANGUAGE_SERVER__</arguments>
|
||||
</matcher>
|
||||
</filter>
|
||||
</filteredResources>
|
||||
</projectDescription>
|
||||
13
.settings/org.eclipse.buildship.core.prefs
Normal file
13
.settings/org.eclipse.buildship.core.prefs
Normal file
@@ -0,0 +1,13 @@
|
||||
arguments=--init-script /home/anon/Src/eclipse.jdt.ls/org.eclipse.jdt.ls.product/target/repository/configuration/org.eclipse.osgi/58/0/.cp/gradle/init/init.gradle
|
||||
auto.sync=false
|
||||
build.scans.enabled=false
|
||||
connection.gradle.distribution=GRADLE_DISTRIBUTION(LOCAL_INSTALLATION(/home/anon/.sdkman/candidates/gradle/current))
|
||||
connection.project.dir=
|
||||
eclipse.preferences.version=1
|
||||
gradle.user.home=
|
||||
java.home=/nix/store/c3pl7bqrx3d2rc3dh98z6yaj0mv1p52g-openjdk-21.0.10+7/lib/openjdk
|
||||
jvm.arguments=
|
||||
offline.mode=false
|
||||
override.workspace.settings=true
|
||||
show.console.view=true
|
||||
show.executions.view=true
|
||||
81
README.md
81
README.md
@@ -1,24 +1,64 @@
|
||||
# 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
|
||||
|
||||
```sh
|
||||
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:
|
||||
|
||||
```sh
|
||||
nix develop
|
||||
gradle -PjlibghosttyMavenRepo="$JLIBGHOSTTY_MAVEN_REPO" run
|
||||
gradle -PjlibghosttyMavenRepo="$JLIBGHOSTTY_MAVEN_REPO" nativeCompile
|
||||
gradle run
|
||||
```
|
||||
|
||||
The Gradle project is kept for interactive development and IDE import.
|
||||
The Gradle project is the source of truth for the JavaFX build.
|
||||
|
||||
## Config
|
||||
|
||||
@@ -34,6 +74,8 @@ If `XDG_CONFIG_HOME` is unset, the fallback is:
|
||||
$HOME/.config/jprototerm/config.toml
|
||||
```
|
||||
|
||||
If no config file exists, jprototerm writes the default config on startup.
|
||||
|
||||
Example, also available in `config.example.toml`:
|
||||
|
||||
```toml
|
||||
@@ -51,17 +93,42 @@ height = 760
|
||||
[kitty_graphics]
|
||||
enabled = true
|
||||
|
||||
[scrollback]
|
||||
editor_command = "vi {file}"
|
||||
|
||||
[env.override]
|
||||
ZELLIJ_SESSION_NAME = ""
|
||||
|
||||
[keybindings]
|
||||
navigate_left = "ALT+H"
|
||||
navigate_down = "ALT+J"
|
||||
navigate_up = "ALT+K"
|
||||
navigate_right = "ALT+L"
|
||||
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
|
||||
|
||||
- `Alt+h/j/k/l`: navigate panes
|
||||
- `Alt+f`: open or close a floating pane
|
||||
- Font default: `Symbols Nerd Font Mono`
|
||||
- `Alt+n`: new pane — a floating pane when floating panes are shown, otherwise a new tiled
|
||||
pane (tiled panes are split equally across the width)
|
||||
- `Alt+f`: show or hide all floating panes
|
||||
- `Alt+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
|
||||
|
||||
Each tab has its own stack of tiled and floating panes; only the active tab is rendered. A
|
||||
thin tab bar appears at the top when more than one tab is open. Closing the last tiled pane
|
||||
while floating panes exist promotes the most recently active floating pane to a tiled pane.
|
||||
|
||||
2
TODOS.md
Normal file
2
TODOS.md
Normal file
@@ -0,0 +1,2 @@
|
||||
jlibghostty - why downcall metadata not propagated ?
|
||||
jlibghostty - how need to change flake so consuming flakes dont have to depend on same ghostty flake ?
|
||||
30
build.gradle
Normal file
30
build.gradle
Normal 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']
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
[terminal]
|
||||
columns = 100
|
||||
rows = 30
|
||||
max_scrollback = 100000
|
||||
shell = "/bin/bash"
|
||||
font_family = "JetBrainsMono Nerd Font"
|
||||
font_size = 15
|
||||
@@ -12,9 +13,19 @@ height = 760
|
||||
[kitty_graphics]
|
||||
enabled = true
|
||||
|
||||
[scrollback]
|
||||
editor_command = "vi {file}"
|
||||
|
||||
[env.override]
|
||||
ZELLIJ_SESSION_NAME = ""
|
||||
|
||||
[keybindings]
|
||||
navigate_left = "ALT+H"
|
||||
navigate_down = "ALT+J"
|
||||
navigate_up = "ALT+K"
|
||||
navigate_right = "ALT+L"
|
||||
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
76
deps.json
Normal file
@@ -0,0 +1,76 @@
|
||||
{
|
||||
"!comment": "This is a nixpkgs Gradle dependency lockfile. For more details, refer to the Gradle section in the nixpkgs manual.",
|
||||
"!version": 1,
|
||||
"https://plugins.gradle.org/m2": {
|
||||
"com/google/code/findbugs#jsr305/3.0.2": {
|
||||
"jar": "sha256-dmrSoHg/JoeWLIrXTO7MOKKLn3Ki0IXuQ4t4E+ko0Mc=",
|
||||
"pom": "sha256-GYidvfGyVLJgGl7mRbgUepdGRIgil2hMeYr+XWPXjf4="
|
||||
},
|
||||
"com/google/gradle#osdetector-gradle-plugin/1.7.3": {
|
||||
"jar": "sha256-a0aS+ROiGx+2Axae54uo8+SrKvnXYq+cqIt5EmwcCtE=",
|
||||
"pom": "sha256-hGDJUBJ8o1mHZhYeOLT/jWO01p+4MQoW4As1E1ABDBE="
|
||||
},
|
||||
"kr/motd/maven#os-maven-plugin/1.7.1": {
|
||||
"jar": "sha256-9Hru+Ggh5SsrGHWJeL0EXwPXIikuMudHCCEixiKJUuA=",
|
||||
"pom": "sha256-S3WABEIrljPdMY8p54Tx0YC9ilkgzVCvGTCGH21qVHY="
|
||||
},
|
||||
"org/openjfx#javafx-plugin/0.1.0": {
|
||||
"jar": "sha256-Xq7sB5m0QGRrDKTP2iGaMttr4rpXktAyoNpKOlw4j6s=",
|
||||
"module": "sha256-rf+3RA0kntF8BJOD1nBp+UU7F3gncMAFtoKkNBbYNmE=",
|
||||
"pom": "sha256-NMjfVSfrWjXl8AmjzeH3oInEzkoOclgC8uy+UDu9PLY="
|
||||
},
|
||||
"org/openjfx/javafxplugin#org.openjfx.javafxplugin.gradle.plugin/0.1.0": {
|
||||
"pom": "sha256-1tASf/Q2PQAXPDV6mByec+/wPDCl0Ohq2CtgVPrvqEE="
|
||||
},
|
||||
"org/sonatype/oss#oss-parent/7": {
|
||||
"pom": "sha256-tR+IZ8kranIkmVV/w6H96ne9+e9XRyL+kM5DailVlFQ="
|
||||
},
|
||||
"org/sonatype/oss#oss-parent/9": {
|
||||
"pom": "sha256-+0AmX5glSCEv+C42LllzKyGH7G8NgBgohcFO8fmCgno="
|
||||
}
|
||||
},
|
||||
"https://repo.maven.apache.org/maven2": {
|
||||
"io/github/wasabithumb#jtoml-api/1.5.2": {
|
||||
"jar": "sha256-3MvElpV+cmdqWO+SAHVKCWDYs+KMUXwIYYk7ax85yWo=",
|
||||
"module": "sha256-RUZYGHsxZfuGBi5TogMHhWcbRcCUBTUAPN8FZpPszlE=",
|
||||
"pom": "sha256-VOaIPO8w4z9saTR8smoLTSoih2PUFOBhBio9eqpoqo8="
|
||||
},
|
||||
"io/github/wasabithumb#jtoml-internals/1.5.2": {
|
||||
"jar": "sha256-k4z/Uxzugk2hXUIdeeNLTz//NeynHzPfHCDXzDHP1Ys=",
|
||||
"module": "sha256-OsnYjM8Tylw/MNdw0/HRUWvXgrQHdnT09O5vYlaZENU=",
|
||||
"pom": "sha256-qXtmwHMJBNIWgrewEoUq1FBZjs2eRuRO1wMLndgsndg="
|
||||
},
|
||||
"io/github/wasabithumb#jtoml/1.5.2": {
|
||||
"jar": "sha256-zDtf6VVGSrvC8StneL9fKq9LyaJSfiDb86w52s+bRYs=",
|
||||
"module": "sha256-TRoE8nqf0ULuQ4J1/u2+voUNf421lTOJ1SajE07F8/Y=",
|
||||
"pom": "sha256-sG4IDPD+ItRgyQcfDiLqdd+wCd40JHcSLocA+jWX1p0="
|
||||
},
|
||||
"org/openjfx#javafx-base/25": {
|
||||
"pom": "sha256-XFYpcqK673qkB7J9Wc4XOl6lCht7dRgEO3/I92/v5Tc="
|
||||
},
|
||||
"org/openjfx#javafx-base/25/linux": {
|
||||
"jar": "sha256-MkJZRruLjbBxfPovsuAOIc1InzW5ZitvrKGLYVpKlmk="
|
||||
},
|
||||
"org/openjfx#javafx-controls/25": {
|
||||
"pom": "sha256-74cad6gX7nuDrKWKKe6yv5h2AvRseKHRXEYAgzpq1uM="
|
||||
},
|
||||
"org/openjfx#javafx-controls/25/linux": {
|
||||
"jar": "sha256-NzVeTZHGfoj9mBX2AeasW1Xd3p9em5P8j0qgRXfmkdM="
|
||||
},
|
||||
"org/openjfx#javafx-fxml/25": {
|
||||
"pom": "sha256-RopsFNQeVHnwNK4v4FPwyJEpfqJoo8dtf/047zyrsio="
|
||||
},
|
||||
"org/openjfx#javafx-fxml/25/linux": {
|
||||
"jar": "sha256-OUrjL2TBIsFPvRDvSb3efbsFVpt6uOf58XDIGSS5Wis="
|
||||
},
|
||||
"org/openjfx#javafx-graphics/25": {
|
||||
"pom": "sha256-zB2jY7Id7uvymRWBk9qmIB+USw+Setv13DhL62qDOfQ="
|
||||
},
|
||||
"org/openjfx#javafx-graphics/25/linux": {
|
||||
"jar": "sha256-PlGLwX7lWFWaKsWKX3/UUmFRCNnVCI9lsTvuk5nDcis="
|
||||
},
|
||||
"org/openjfx#javafx/25": {
|
||||
"pom": "sha256-55IzCPyt1/LGiwcgQfR9jnNVIj2EZVnutceA3EuivxM="
|
||||
}
|
||||
}
|
||||
}
|
||||
65
devenv.lock
Normal file
65
devenv.lock
Normal file
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"nodes": {
|
||||
"devenv": {
|
||||
"locked": {
|
||||
"dir": "src/modules",
|
||||
"lastModified": 1779749056,
|
||||
"narHash": "sha256-AtocdrunzuxTvSDn+82RntEhrs6TicM6Z4/zNQS9KKg=",
|
||||
"owner": "cachix",
|
||||
"repo": "devenv",
|
||||
"rev": "099ac65fcef79e88127bdc06adbd1ea94255274a",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"dir": "src/modules",
|
||||
"owner": "cachix",
|
||||
"repo": "devenv",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"inputs": {
|
||||
"nixpkgs-src": "nixpkgs-src"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1778507786,
|
||||
"narHash": "sha256-HzSQCKMsMr8r55LwM1JuzIOB+8bzk0FEv6sItKvsfoY=",
|
||||
"owner": "cachix",
|
||||
"repo": "devenv-nixpkgs",
|
||||
"rev": "8f24a228a782e24576b155d1e39f0d914b380691",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "cachix",
|
||||
"ref": "rolling",
|
||||
"repo": "devenv-nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs-src": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1778274207,
|
||||
"narHash": "sha256-I4puXmX1iovcCHZlRmztO3vW0mAbbRvq4F8wgIMQ1MM=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "b3da656039dc7a6240f27b2ef8cc6a3ef3bccae7",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixpkgs-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"devenv": "devenv",
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
66
devenv.nix
Normal file
66
devenv.nix
Normal file
@@ -0,0 +1,66 @@
|
||||
{ pkgs, lib, config, inputs, ... }:
|
||||
|
||||
let
|
||||
system = pkgs.stdenv.hostPlatform.system;
|
||||
|
||||
jlibghostty = builtins.getFlake
|
||||
"git+https://gitea.gregorlohaus.com/gregor/jlibghostty.git";
|
||||
|
||||
jlib = jlibghostty.packages.${system}.jlibghostty;
|
||||
hostNvidiaLibs = ".devenv/host-nvidia-libs";
|
||||
in
|
||||
{
|
||||
packages = [
|
||||
pkgs.git
|
||||
pkgs.gradle_9
|
||||
pkgs.jdk25
|
||||
pkgs.jdt-language-server
|
||||
pkgs.openjfx
|
||||
|
||||
pkgs.glib
|
||||
pkgs.xorg.libXxf86vm
|
||||
pkgs.xorg.libXrender
|
||||
pkgs.xorg.libXtst
|
||||
pkgs.xorg.libXi
|
||||
pkgs.xorg.libXrandr
|
||||
|
||||
pkgs.libGL
|
||||
pkgs.gtk3
|
||||
pkgs.alsa-lib
|
||||
pkgs.mesa-demos
|
||||
];
|
||||
|
||||
env.LD_LIBRARY_PATH = "${hostNvidiaLibs}:" + lib.makeLibraryPath [
|
||||
pkgs.openjfx
|
||||
|
||||
pkgs.glib
|
||||
pkgs.xorg.libXxf86vm
|
||||
pkgs.xorg.libXrender
|
||||
pkgs.xorg.libXtst
|
||||
pkgs.xorg.libXi
|
||||
pkgs.xorg.libXrandr
|
||||
|
||||
pkgs.libGL
|
||||
pkgs.gtk3
|
||||
pkgs.alsa-lib
|
||||
] + ":/usr/lib/x86_64-linux-gnu/nvidia/current";
|
||||
env.__GLX_VENDOR_LIBRARY_NAME = "nvidia";
|
||||
env.__EGL_VENDOR_LIBRARY_FILENAMES = "/usr/share/glvnd/egl_vendor.d/10_nvidia.json";
|
||||
env.JLIBGHOSTTY_MAVEN_REPO = "${jlib}/maven";
|
||||
|
||||
enterShell = ''
|
||||
mkdir -p ${hostNvidiaLibs}
|
||||
for lib in \
|
||||
/usr/lib/x86_64-linux-gnu/libnvidia*.so* \
|
||||
/usr/lib/x86_64-linux-gnu/libGLX_nvidia.so* \
|
||||
/usr/lib/x86_64-linux-gnu/libEGL_nvidia.so* \
|
||||
/usr/lib/x86_64-linux-gnu/nvidia/current/libnvidia*.so* \
|
||||
/usr/lib/x86_64-linux-gnu/nvidia/current/libGLX_nvidia.so* \
|
||||
/usr/lib/x86_64-linux-gnu/nvidia/current/libEGL_nvidia.so*
|
||||
do
|
||||
if [ -e "$lib" ]; then
|
||||
ln -sfn "$lib" ${hostNvidiaLibs}/"$(basename "$lib")"
|
||||
fi
|
||||
done
|
||||
'';
|
||||
}
|
||||
18
devenv.yaml
Normal file
18
devenv.yaml
Normal file
@@ -0,0 +1,18 @@
|
||||
# yaml-language-server: $schema=https://devenv.sh/devenv.schema.json
|
||||
inputs:
|
||||
nixpkgs:
|
||||
url: github:cachix/devenv-nixpkgs/rolling
|
||||
|
||||
# If you're using non-OSS software, you can set allowUnfree to true.
|
||||
# allowUnfree: true
|
||||
|
||||
# If you're not willing to allow unsupported packages:
|
||||
# allowUnsupportedSystem: false
|
||||
|
||||
# If you're willing to use a package that's vulnerable
|
||||
# permittedInsecurePackages:
|
||||
# - "openssl-1.1.1w"
|
||||
|
||||
# If you have more than one devenv you can merge them
|
||||
#imports:
|
||||
# - ./backend
|
||||
64
flake.lock
generated
64
flake.lock
generated
@@ -64,53 +64,17 @@
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"javafx-base": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"narHash": "sha256-96fttJUts/rFwKB7u5G8NWkK2NjJ3a6eIKbe1RTWkmM=",
|
||||
"type": "file",
|
||||
"url": "https://repo.maven.apache.org/maven2/org/openjfx/javafx-base/25/javafx-base-25-linux.jar"
|
||||
},
|
||||
"original": {
|
||||
"type": "file",
|
||||
"url": "https://repo.maven.apache.org/maven2/org/openjfx/javafx-base/25/javafx-base-25-linux.jar"
|
||||
}
|
||||
},
|
||||
"javafx-controls": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"narHash": "sha256-2Cdc2/hPOjJmQidDjXu9vnlwAuawLn0cg/tLhzFfkUs=",
|
||||
"type": "file",
|
||||
"url": "https://repo.maven.apache.org/maven2/org/openjfx/javafx-controls/25/javafx-controls-25-linux.jar"
|
||||
},
|
||||
"original": {
|
||||
"type": "file",
|
||||
"url": "https://repo.maven.apache.org/maven2/org/openjfx/javafx-controls/25/javafx-controls-25-linux.jar"
|
||||
}
|
||||
},
|
||||
"javafx-graphics": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"narHash": "sha256-w01IhRAQzcfTvwkqIQkjrI8ZPXT0VTEeijfzbqp3G0k=",
|
||||
"type": "file",
|
||||
"url": "https://repo.maven.apache.org/maven2/org/openjfx/javafx-graphics/25/javafx-graphics-25-linux.jar"
|
||||
},
|
||||
"original": {
|
||||
"type": "file",
|
||||
"url": "https://repo.maven.apache.org/maven2/org/openjfx/javafx-graphics/25/javafx-graphics-25-linux.jar"
|
||||
}
|
||||
},
|
||||
"jlibghostty": {
|
||||
"inputs": {
|
||||
"ghostty": "ghostty",
|
||||
"nixpkgs": "nixpkgs"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1779889299,
|
||||
"narHash": "sha256-B82MyhTvlfeszdcuM3F8YDSZYaxUom+m59oQKSoWjmQ=",
|
||||
"lastModified": 1780256181,
|
||||
"narHash": "sha256-/saXdnYMbAMfP7u6USSqtNkBIgqZhU+CPr3F8tUQhHU=",
|
||||
"ref": "refs/heads/main",
|
||||
"rev": "eea43843002f8fae4fa4cb1c46b64339124bf6b2",
|
||||
"revCount": 6,
|
||||
"rev": "db5ee5d20daf8855de3a3b2fa9349eced70946f0",
|
||||
"revCount": 21,
|
||||
"type": "git",
|
||||
"url": "https://gitea.gregorlohaus.com/gregor/jlibghostty.git"
|
||||
},
|
||||
@@ -119,18 +83,6 @@
|
||||
"url": "https://gitea.gregorlohaus.com/gregor/jlibghostty.git"
|
||||
}
|
||||
},
|
||||
"jtoml-all": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"narHash": "sha256-KWrUaDVmnWzdkQxjgPFFNl8DOEvkCqWW3OmXU2sZHKw=",
|
||||
"type": "file",
|
||||
"url": "https://repo.maven.apache.org/maven2/io/github/wasabithumb/jtoml-all/1.5.2/jtoml-all-1.5.2.jar"
|
||||
},
|
||||
"original": {
|
||||
"type": "file",
|
||||
"url": "https://repo.maven.apache.org/maven2/io/github/wasabithumb/jtoml-all/1.5.2/jtoml-all-1.5.2.jar"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1779560665,
|
||||
@@ -165,11 +117,11 @@
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"javafx-base": "javafx-base",
|
||||
"javafx-controls": "javafx-controls",
|
||||
"javafx-graphics": "javafx-graphics",
|
||||
"ghostty": [
|
||||
"jlibghostty",
|
||||
"ghostty"
|
||||
],
|
||||
"jlibghostty": "jlibghostty",
|
||||
"jtoml-all": "jtoml-all",
|
||||
"nixpkgs": "nixpkgs_2"
|
||||
}
|
||||
},
|
||||
|
||||
203
flake.nix
203
flake.nix
@@ -1,131 +1,150 @@
|
||||
{
|
||||
description = "JavaFX terminal using jlibghostty and GraalVM Native Image";
|
||||
description = "jprototerm";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
jlibghostty.url = "git+https://gitea.gregorlohaus.com/gregor/jlibghostty.git";
|
||||
|
||||
jtoml-all = {
|
||||
url = "https://repo.maven.apache.org/maven2/io/github/wasabithumb/jtoml-all/1.5.2/jtoml-all-1.5.2.jar";
|
||||
flake = false;
|
||||
ghostty.follows = "jlibghostty/ghostty";
|
||||
};
|
||||
|
||||
javafx-base = {
|
||||
url = "https://repo.maven.apache.org/maven2/org/openjfx/javafx-base/25/javafx-base-25-linux.jar";
|
||||
flake = false;
|
||||
};
|
||||
|
||||
javafx-controls = {
|
||||
url = "https://repo.maven.apache.org/maven2/org/openjfx/javafx-controls/25/javafx-controls-25-linux.jar";
|
||||
flake = false;
|
||||
};
|
||||
|
||||
javafx-graphics = {
|
||||
url = "https://repo.maven.apache.org/maven2/org/openjfx/javafx-graphics/25/javafx-graphics-25-linux.jar";
|
||||
flake = false;
|
||||
};
|
||||
};
|
||||
|
||||
outputs = {
|
||||
self,
|
||||
nixpkgs,
|
||||
jlibghostty,
|
||||
jtoml-all,
|
||||
javafx-base,
|
||||
javafx-controls,
|
||||
javafx-graphics
|
||||
}:
|
||||
outputs = { self, nixpkgs, jlibghostty, ghostty }:
|
||||
let
|
||||
supportedSystems = [ "x86_64-linux" ];
|
||||
forAllSystems = nixpkgs.lib.genAttrs supportedSystems;
|
||||
|
||||
# Everything the JavaFX natives (and jlibghostty) dlopen at runtime, EXCEPT the
|
||||
# system OpenGL/graphics drivers. libGL is intentionally left out: it is supplied
|
||||
# by the host at runtime via the GL shim in the wrapper below, so the same closure
|
||||
# works on NixOS and on a plain Debian box with vendor GPU drivers installed.
|
||||
runtimeLibsFor = pkgs: ghosttyVt: [
|
||||
pkgs.glib
|
||||
pkgs.gtk3
|
||||
pkgs.pango
|
||||
pkgs.cairo
|
||||
pkgs.gdk-pixbuf
|
||||
pkgs.harfbuzz
|
||||
pkgs.freetype
|
||||
pkgs.fontconfig.lib
|
||||
pkgs.libx11
|
||||
pkgs.libxext
|
||||
pkgs.libxrender
|
||||
pkgs.libxtst
|
||||
pkgs.libxi
|
||||
pkgs.libxcursor
|
||||
pkgs.libxrandr
|
||||
pkgs.libxinerama
|
||||
pkgs.libxcb
|
||||
pkgs.libxxf86vm
|
||||
pkgs.zlib
|
||||
ghosttyVt
|
||||
];
|
||||
in {
|
||||
packages = forAllSystems (system:
|
||||
let
|
||||
system = "x86_64-linux";
|
||||
pkgs = import nixpkgs { inherit system; };
|
||||
|
||||
jlib = jlibghostty.packages.${system}.jlibghostty;
|
||||
graalvm = pkgs.graalvmPackages.graalvm-ce;
|
||||
gradle = if pkgs ? gradle_9 then pkgs.gradle_9 else pkgs.gradle;
|
||||
openjfx = pkgs.javaPackages.openjfx25;
|
||||
in {
|
||||
packages.${system}.default = pkgs.stdenvNoCC.mkDerivation {
|
||||
ghosttyVt = ghostty.packages.${system}.libghostty-vt;
|
||||
|
||||
runtimeLibs = runtimeLibsFor pkgs ghosttyVt;
|
||||
|
||||
jprototerm = pkgs.stdenv.mkDerivation (finalAttrs: {
|
||||
pname = "jprototerm";
|
||||
version = "0.1.0";
|
||||
src = ./.;
|
||||
|
||||
nativeBuildInputs = [
|
||||
graalvm
|
||||
pkgs.jdk25
|
||||
pkgs.gradle_9
|
||||
pkgs.makeWrapper
|
||||
];
|
||||
|
||||
buildPhase = ''
|
||||
runHook preBuild
|
||||
buildInputs = runtimeLibs;
|
||||
|
||||
mkdir -p build/classes build/native-image build/lib build/javafx-modules
|
||||
mitmCache = pkgs.gradle_9.fetchDeps {
|
||||
pkg = finalAttrs.finalPackage;
|
||||
data = ./deps.json;
|
||||
useBwrap = false;
|
||||
};
|
||||
|
||||
find src/main/java -name '*.java' | sort > build/sources.txt
|
||||
cp ${jtoml-all} build/lib/jtoml-all.jar
|
||||
cp ${javafx-base} build/javafx-modules/javafx-base.jar
|
||||
cp ${javafx-controls} build/javafx-modules/javafx-controls.jar
|
||||
cp ${javafx-graphics} build/javafx-modules/javafx-graphics.jar
|
||||
javafx_module_path="build/javafx-modules"
|
||||
# Builds build/install/jprototerm/{bin,lib} with every runtime jar, including
|
||||
# the maven javafx-*-linux jars that carry the platform natives.
|
||||
gradleBuildTask = "installDist";
|
||||
gradleFlags = [
|
||||
"--no-build-cache"
|
||||
"--stacktrace"
|
||||
"-Dorg.gradle.java.home=${pkgs.jdk25}"
|
||||
];
|
||||
|
||||
jlib_classpath="$(
|
||||
find ${jlib}/maven -type f -name '*.jar' \
|
||||
! -name '*-sources.jar' \
|
||||
! -name '*-javadoc.jar' \
|
||||
| sort \
|
||||
| paste -sd: -
|
||||
)"
|
||||
app_classpath="build/classes:build/lib/jtoml-all.jar:$jlib_classpath:build/javafx-modules/javafx-base.jar:build/javafx-modules/javafx-controls.jar:build/javafx-modules/javafx-graphics.jar"
|
||||
JAVA_HOME = "${pkgs.jdk25}";
|
||||
JLIBGHOSTTY_MAVEN_REPO = "${jlib}/maven";
|
||||
|
||||
javac \
|
||||
--release 25 \
|
||||
--module-path "$javafx_module_path" \
|
||||
--add-modules javafx.controls,javafx.graphics \
|
||||
-cp "build/lib/jtoml-all.jar:$jlib_classpath" \
|
||||
-d build/classes \
|
||||
@build/sources.txt
|
||||
preBuild = ''
|
||||
export HOME="$TMPDIR/home"
|
||||
export GRADLE_OPTS="-Duser.home=$HOME ''${GRADLE_OPTS:-}"
|
||||
'';
|
||||
|
||||
if [ -d src/main/resources ]; then
|
||||
cp -R src/main/resources/. build/classes/
|
||||
fi
|
||||
|
||||
native-image \
|
||||
--no-fallback \
|
||||
--enable-native-access=javafx.graphics \
|
||||
--module-path "$javafx_module_path" \
|
||||
--add-modules javafx.controls,javafx.graphics \
|
||||
-cp "$app_classpath" \
|
||||
-H:Class=com.gregor.jprototerm.Main \
|
||||
-o build/native-image/jprototerm
|
||||
|
||||
runHook postBuild
|
||||
preGradleUpdate = ''
|
||||
export HOME="$TMPDIR/home"
|
||||
'';
|
||||
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
|
||||
mkdir -p $out/bin
|
||||
cp build/native-image/jprototerm $out/bin/jprototerm
|
||||
mkdir -p "$out/share/jprototerm"
|
||||
cp -a build/install/jprototerm/lib "$out/share/jprototerm/lib"
|
||||
|
||||
wrapProgram $out/bin/jprototerm \
|
||||
--set GDK_BACKEND x11 \
|
||||
--set JAVA_TOOL_OPTIONS "-Dprism.order=sw" \
|
||||
--prefix LD_LIBRARY_PATH : ${pkgs.lib.makeLibraryPath [ openjfx jlib ]}:${openjfx}/modules_libs/javafx.graphics \
|
||||
--prefix PATH : ${pkgs.lib.makeBinPath [ pkgs.util-linux pkgs.bash ]}
|
||||
# JavaFX is a set of proper modular jars: put them on the module path and
|
||||
# keep the application + plain dependency jars on the classpath, so the two
|
||||
# worlds do not collide.
|
||||
mkdir -p "$out/share/jprototerm/javafx"
|
||||
mv "$out/share/jprototerm/lib"/javafx-*.jar "$out/share/jprototerm/javafx/"
|
||||
|
||||
# Build an explicit colon-separated classpath. A "lib/*" glob would be
|
||||
# expanded by the wrapper's shell before java sees it, breaking -cp.
|
||||
classpath=""
|
||||
for jar in "$out"/share/jprototerm/lib/*.jar; do
|
||||
classpath="$classpath''${classpath:+:}$jar"
|
||||
done
|
||||
|
||||
makeWrapper "${pkgs.jdk25}/bin/java" "$out/bin/jprototerm" \
|
||||
--run 'export JPROTOTERM_HOST_LD_LIBRARY_PATH="''${LD_LIBRARY_PATH:-}"' \
|
||||
--add-flags "--enable-native-access=ALL-UNNAMED,javafx.graphics" \
|
||||
--add-flags "--module-path $out/share/jprototerm/javafx" \
|
||||
--add-flags "--add-modules javafx.controls,javafx.fxml" \
|
||||
--add-flags "-cp $classpath" \
|
||||
--add-flags "com.gregor.jprototerm.Main" \
|
||||
--prefix LD_LIBRARY_PATH : "${pkgs.lib.makeLibraryPath runtimeLibs}" \
|
||||
--run 'glShimDir="''${XDG_RUNTIME_DIR:-/tmp}/jprototerm-gl"; mkdir -p "$glShimDir"; for lib in /lib/x86_64-linux-gnu/libGL.so.1 /lib/x86_64-linux-gnu/libGLX.so.0 /lib/x86_64-linux-gnu/libGLdispatch.so.0 /usr/lib/x86_64-linux-gnu/libGLX_nvidia.so* /usr/lib/x86_64-linux-gnu/libEGL_nvidia.so* /usr/lib/x86_64-linux-gnu/libnvidia*.so* /usr/lib/x86_64-linux-gnu/nvidia/current/lib*.so*; do [ -e "$lib" ] && ln -sfn "$lib" "$glShimDir/$(basename "$lib")"; done; export LD_LIBRARY_PATH="$glShimDir''${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"; export __GLX_VENDOR_LIBRARY_NAME="''${__GLX_VENDOR_LIBRARY_NAME:-nvidia}"; if [ -e /usr/share/glvnd/egl_vendor.d/10_nvidia.json ]; then export __EGL_VENDOR_LIBRARY_FILENAMES="''${__EGL_VENDOR_LIBRARY_FILENAMES:-/usr/share/glvnd/egl_vendor.d/10_nvidia.json}"; fi' \
|
||||
--set JLIBGHOSTTY_LIBRARY "${ghosttyVt}/lib/libghostty-vt.so" \
|
||||
--set GDK_BACKEND x11
|
||||
|
||||
runHook postInstall
|
||||
'';
|
||||
};
|
||||
});
|
||||
in {
|
||||
default = jprototerm;
|
||||
gradleDepsUpdateScript = jprototerm.mitmCache.updateScript;
|
||||
});
|
||||
|
||||
devShells.${system}.default = pkgs.mkShell {
|
||||
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 = [
|
||||
graalvm
|
||||
gradle
|
||||
pkgs.util-linux
|
||||
];
|
||||
pkgs.gradle_9
|
||||
pkgs.jdk25
|
||||
pkgs.jdt-language-server
|
||||
] ++ runtimeLibs;
|
||||
|
||||
shellHook = ''
|
||||
export JLIBGHOSTTY_MAVEN_REPO=${jlib}/maven
|
||||
echo "Use: gradle -PjlibghosttyMavenRepo=$JLIBGHOSTTY_MAVEN_REPO run"
|
||||
'';
|
||||
JLIBGHOSTTY_MAVEN_REPO = "${jlib}/maven";
|
||||
JLIBGHOSTTY_LIBRARY = "${ghosttyVt}/lib/libghostty-vt.so";
|
||||
LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath runtimeLibs;
|
||||
};
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
8
settings.gradle
Normal file
8
settings.gradle
Normal file
@@ -0,0 +1,8 @@
|
||||
pluginManagement {
|
||||
repositories {
|
||||
gradlePluginPortal()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.name = 'jprototerm'
|
||||
@@ -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"
|
||||
@@ -3,29 +3,53 @@ package com.gregor.jprototerm;
|
||||
import io.github.wasabithumb.jtoml.JToml;
|
||||
import io.github.wasabithumb.jtoml.document.TomlDocument;
|
||||
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.primitive.TomlPrimitive;
|
||||
import io.github.wasabithumb.jtoml.value.table.TomlTable;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
public record AppConfig(
|
||||
int columns,
|
||||
int rows,
|
||||
long maxScrollback,
|
||||
String shell,
|
||||
String fontFamily,
|
||||
double fontSize,
|
||||
double windowWidth,
|
||||
double windowHeight,
|
||||
boolean kittyGraphics,
|
||||
String scrollbackEditorCommand,
|
||||
Map<String, String> envOverride,
|
||||
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() {
|
||||
AppConfig defaults = defaults();
|
||||
Path path = configPath();
|
||||
if (!Files.isRegularFile(path)) {
|
||||
writeDefaultConfig(path, defaults);
|
||||
return defaults;
|
||||
}
|
||||
|
||||
@@ -34,19 +58,16 @@ public record AppConfig(
|
||||
return new AppConfig(
|
||||
intValue(document, "terminal.columns", defaults.columns),
|
||||
intValue(document, "terminal.rows", defaults.rows),
|
||||
longValue(document, "terminal.max_scrollback", defaults.maxScrollback),
|
||||
stringValue(document, "terminal.shell", defaults.shell),
|
||||
stringValue(document, "terminal.font_family", defaults.fontFamily),
|
||||
doubleValue(document, "terminal.font_size", defaults.fontSize),
|
||||
doubleValue(document, "window.width", defaults.windowWidth),
|
||||
doubleValue(document, "window.height", defaults.windowHeight),
|
||||
booleanValue(document, "kitty_graphics.enabled", defaults.kittyGraphics),
|
||||
Map.of(
|
||||
"navigate_left", binding(document, "keybindings.navigate_left", defaults.keybindings.get("navigate_left")),
|
||||
"navigate_down", binding(document, "keybindings.navigate_down", defaults.keybindings.get("navigate_down")),
|
||||
"navigate_up", binding(document, "keybindings.navigate_up", defaults.keybindings.get("navigate_up")),
|
||||
"navigate_right", binding(document, "keybindings.navigate_right", defaults.keybindings.get("navigate_right")),
|
||||
"toggle_floating", binding(document, "keybindings.toggle_floating", defaults.keybindings.get("toggle_floating"))
|
||||
)
|
||||
stringValue(document, "scrollback.editor_command", defaults.scrollbackEditorCommand),
|
||||
envOverride(document, defaults.envOverride),
|
||||
keybindings(document, defaults)
|
||||
);
|
||||
} catch (TomlException ex) {
|
||||
System.err.println("Could not parse " + path + ": " + ex.getMessage());
|
||||
@@ -58,22 +79,53 @@ public record AppConfig(
|
||||
return new AppConfig(
|
||||
100,
|
||||
30,
|
||||
100_000,
|
||||
defaultShell(),
|
||||
"Symbols Nerd Font Mono",
|
||||
"JetBrainsMono Nerd Font",
|
||||
15.0,
|
||||
1200.0,
|
||||
760.0,
|
||||
true,
|
||||
Map.of(
|
||||
"navigate_left", KeyBinding.parse("ALT+H"),
|
||||
"navigate_down", KeyBinding.parse("ALT+J"),
|
||||
"navigate_up", KeyBinding.parse("ALT+K"),
|
||||
"navigate_right", KeyBinding.parse("ALT+L"),
|
||||
"toggle_floating", KeyBinding.parse("ALT+F")
|
||||
defaultScrollbackEditorCommand(),
|
||||
Map.of(),
|
||||
Map.ofEntries(
|
||||
Map.entry("navigate_left", KeyBinding.parse("ALT+H")),
|
||||
Map.entry("navigate_down", KeyBinding.parse("ALT+J")),
|
||||
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() {
|
||||
String configHome = System.getenv("XDG_CONFIG_HOME");
|
||||
if (configHome != null && !configHome.isBlank()) {
|
||||
@@ -83,8 +135,93 @@ public record AppConfig(
|
||||
}
|
||||
|
||||
private static String defaultShell() {
|
||||
String shell = System.getenv("SHELL");
|
||||
return shell == null || shell.isBlank() ? "/bin/sh" : shell;
|
||||
return "/bin/bash";
|
||||
}
|
||||
|
||||
private static 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) {
|
||||
@@ -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) {
|
||||
TomlPrimitive primitive = primitive(table, key);
|
||||
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) {
|
||||
TomlPrimitive primitive = primitive(table, key);
|
||||
if (primitive == null) {
|
||||
|
||||
442
src/main/java/com/gregor/jprototerm/Compositor.java
Normal file
442
src/main/java/com/gregor/jprototerm/Compositor.java
Normal 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) {
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ public record KeyBinding(boolean alt, boolean control, boolean shift, KeyCode co
|
||||
case "ALT", "META" -> alt = true;
|
||||
case "CTRL", "CONTROL" -> control = true;
|
||||
case "SHIFT" -> shift = true;
|
||||
default -> code = KeyCode.getKeyCode(token);
|
||||
default -> code = keyCode(token);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,4 +34,35 @@ public record KeyBinding(boolean alt, boolean control, boolean shift, KeyCode co
|
||||
&& event.isShiftDown() == shift
|
||||
&& event.getCode() == code;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
StringBuilder builder = new StringBuilder();
|
||||
if (control) {
|
||||
builder.append("CTRL+");
|
||||
}
|
||||
if (alt) {
|
||||
builder.append("ALT+");
|
||||
}
|
||||
if (shift) {
|
||||
builder.append("SHIFT+");
|
||||
}
|
||||
builder.append(code.getName().toUpperCase(Locale.ROOT).replace(' ', '_'));
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
private static KeyCode keyCode(String token) {
|
||||
KeyCode alias = switch (token) {
|
||||
case "GRAVE", "BACKTICK", "BACK_QUOTE", "`" -> KeyCode.BACK_QUOTE;
|
||||
default -> null;
|
||||
};
|
||||
if (alias != null) {
|
||||
return alias;
|
||||
}
|
||||
try {
|
||||
return KeyCode.valueOf(token);
|
||||
} catch (IllegalArgumentException ex) {
|
||||
return KeyCode.getKeyCode(token);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,20 @@ final class KeyEncoder {
|
||||
}
|
||||
|
||||
static String encode(KeyEvent event) {
|
||||
if (event.isControlDown() && !event.isAltDown() && !event.isMetaDown()) {
|
||||
String control = controlSequence(event);
|
||||
if (control != null) {
|
||||
return control;
|
||||
}
|
||||
}
|
||||
|
||||
if (event.isAltDown() && !event.isControlDown() && !event.isMetaDown()) {
|
||||
String alt = altSequence(event);
|
||||
if (alt != null) {
|
||||
return alt;
|
||||
}
|
||||
}
|
||||
|
||||
KeyCode code = event.getCode();
|
||||
return switch (code) {
|
||||
case ENTER -> "\r";
|
||||
@@ -23,7 +37,43 @@ final class KeyEncoder {
|
||||
case DELETE -> "\u001b[3~";
|
||||
case PAGE_UP -> "\u001b[5~";
|
||||
case PAGE_DOWN -> "\u001b[6~";
|
||||
case F1 -> "\u001bOP";
|
||||
case F2 -> "\u001bOQ";
|
||||
case F3 -> "\u001bOR";
|
||||
case F4 -> "\u001bOS";
|
||||
case F5 -> "\u001b[15~";
|
||||
case F6 -> "\u001b[17~";
|
||||
case F7 -> "\u001b[18~";
|
||||
case F8 -> "\u001b[19~";
|
||||
case F9 -> "\u001b[20~";
|
||||
case F10 -> "\u001b[21~";
|
||||
case F11 -> "\u001b[23~";
|
||||
case F12 -> "\u001b[24~";
|
||||
default -> null;
|
||||
};
|
||||
}
|
||||
|
||||
private static String controlSequence(KeyEvent event) {
|
||||
KeyCode code = event.getCode();
|
||||
if (code.isLetterKey()) {
|
||||
return String.valueOf((char) (Character.toUpperCase(code.getName().charAt(0)) - '@'));
|
||||
}
|
||||
return switch (code) {
|
||||
case SPACE -> "\u0000";
|
||||
case OPEN_BRACKET -> "\u001b";
|
||||
case BACK_SLASH -> "\u001c";
|
||||
case CLOSE_BRACKET -> "\u001d";
|
||||
case DIGIT6 -> "\u001e";
|
||||
case MINUS -> "\u001f";
|
||||
default -> null;
|
||||
};
|
||||
}
|
||||
|
||||
private static String altSequence(KeyEvent event) {
|
||||
KeyCode code = event.getCode();
|
||||
if (code.isLetterKey() || code.isDigitKey()) {
|
||||
return "\u001b" + code.getName().toLowerCase();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
401
src/main/java/com/gregor/jprototerm/LinuxPty.java
Normal file
401
src/main/java/com/gregor/jprototerm/LinuxPty.java
Normal file
@@ -0,0 +1,401 @@
|
||||
package com.gregor.jprototerm;
|
||||
|
||||
import java.lang.foreign.AddressLayout;
|
||||
import java.lang.foreign.Arena;
|
||||
import java.lang.foreign.FunctionDescriptor;
|
||||
import java.lang.foreign.Linker;
|
||||
import java.lang.foreign.MemoryLayout;
|
||||
import java.lang.foreign.MemorySegment;
|
||||
import java.lang.foreign.SymbolLookup;
|
||||
import java.lang.foreign.ValueLayout;
|
||||
import java.lang.invoke.MethodHandle;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* A Linux PTY backed by libc via the Foreign Function & Memory API.
|
||||
*
|
||||
* <p>This replaces pty4j (which loads a JNA JNI shim). It uses
|
||||
* {@code posix_openpt}/{@code posix_spawnp} rather than {@code fork}/{@code forkpty}:
|
||||
* doing work between {@code fork} and {@code exec} inside a multithreaded JVM is unsafe
|
||||
* (only async-signal-safe calls are permitted), whereas {@code posix_spawn} performs the
|
||||
* dangerous part in libc with no Java on the stack.
|
||||
*
|
||||
* <p>The child gets a fresh session via {@code POSIX_SPAWN_SETSID}; it then opens the slave
|
||||
* PTY itself (as fd 0, without {@code O_NOCTTY}) so the slave becomes its controlling
|
||||
* terminal. glibc applies attribute flags (the setsid) before file actions, so the open
|
||||
* happens in the new session.
|
||||
*/
|
||||
public final class LinuxPty implements AutoCloseable {
|
||||
static final Linker LINKER = Linker.nativeLinker();
|
||||
private static final SymbolLookup LIBC = LINKER.defaultLookup();
|
||||
|
||||
static final AddressLayout C_POINTER = (AddressLayout) LINKER.canonicalLayouts().get("void*");
|
||||
static final ValueLayout.OfShort C_SHORT = (ValueLayout.OfShort) LINKER.canonicalLayouts().get("short");
|
||||
static final ValueLayout.OfInt C_INT = (ValueLayout.OfInt) LINKER.canonicalLayouts().get("int");
|
||||
static final ValueLayout.OfLong C_LONG = (ValueLayout.OfLong) LINKER.canonicalLayouts().get("long");
|
||||
static final ValueLayout.OfLong C_SIZE_T = (ValueLayout.OfLong) LINKER.canonicalLayouts().get("size_t");
|
||||
|
||||
// Function descriptors.
|
||||
static final FunctionDescriptor FD_INT_INT = FunctionDescriptor.of(C_INT, C_INT);
|
||||
static final FunctionDescriptor FD_PTSNAME_R = FunctionDescriptor.of(C_INT, C_INT, C_POINTER, C_SIZE_T);
|
||||
static final FunctionDescriptor FD_RW = FunctionDescriptor.of(C_LONG, C_INT, C_POINTER, C_SIZE_T);
|
||||
static final FunctionDescriptor FD_IOCTL = FunctionDescriptor.of(C_INT, C_INT, C_LONG, C_POINTER);
|
||||
static final FunctionDescriptor FD_KILL = FunctionDescriptor.of(C_INT, C_INT, C_INT);
|
||||
static final FunctionDescriptor FD_WAITPID = FunctionDescriptor.of(C_INT, C_INT, C_POINTER, C_INT);
|
||||
static final FunctionDescriptor FD_SPAWN = FunctionDescriptor.of(
|
||||
C_INT, C_POINTER, C_POINTER, C_POINTER, C_POINTER, C_POINTER, C_POINTER);
|
||||
static final FunctionDescriptor FD_FA_INIT = FunctionDescriptor.of(C_INT, C_POINTER);
|
||||
static final FunctionDescriptor FD_FA_ADDCLOSE = FunctionDescriptor.of(C_INT, C_POINTER, C_INT);
|
||||
static final FunctionDescriptor FD_FA_ADDDUP2 = FunctionDescriptor.of(C_INT, C_POINTER, C_INT, C_INT);
|
||||
static final FunctionDescriptor FD_FA_ADDOPEN =
|
||||
FunctionDescriptor.of(C_INT, C_POINTER, C_INT, C_POINTER, C_INT, C_INT);
|
||||
static final FunctionDescriptor FD_FA_ADDCHDIR = FunctionDescriptor.of(C_INT, C_POINTER, C_POINTER);
|
||||
static final FunctionDescriptor FD_ATTR_SETFLAGS = FunctionDescriptor.of(C_INT, C_POINTER, C_SHORT);
|
||||
|
||||
// Linux constants (x86-64 / arm64).
|
||||
private static final int O_RDWR = 0x0002;
|
||||
private static final int O_NOCTTY = 0x0100;
|
||||
private static final long TIOCSWINSZ = 0x5414L;
|
||||
private static final short POSIX_SPAWN_SETSID = 0x80;
|
||||
private static final int SIGHUP = 1;
|
||||
private static final int SIGKILL = 9;
|
||||
private static final int WNOHANG = 1;
|
||||
|
||||
// struct winsize { unsigned short ws_row, ws_col, ws_xpixel, ws_ypixel; }
|
||||
private static final MemoryLayout WINSIZE = MemoryLayout.structLayout(
|
||||
C_SHORT.withName("ws_row"),
|
||||
C_SHORT.withName("ws_col"),
|
||||
C_SHORT.withName("ws_xpixel"),
|
||||
C_SHORT.withName("ws_ypixel"));
|
||||
|
||||
// posix_spawn_file_actions_t / posix_spawnattr_t are opaque; over-allocate generously.
|
||||
private static final long SPAWN_ACTIONS_SIZE = 256;
|
||||
private static final long SPAWN_ATTR_SIZE = 512;
|
||||
|
||||
private static final MethodHandle POSIX_OPENPT = handle("posix_openpt", FD_INT_INT);
|
||||
private static final MethodHandle GRANTPT = handle("grantpt", FD_INT_INT);
|
||||
private static final MethodHandle UNLOCKPT = handle("unlockpt", FD_INT_INT);
|
||||
private static final MethodHandle PTSNAME_R = handle("ptsname_r", FD_PTSNAME_R);
|
||||
private static final MethodHandle CLOSE = handle("close", FD_INT_INT);
|
||||
private static final MethodHandle READ = handle("read", FD_RW);
|
||||
private static final MethodHandle WRITE = handle("write", FD_RW);
|
||||
private static final MethodHandle IOCTL = handle("ioctl", FD_IOCTL, Linker.Option.firstVariadicArg(2));
|
||||
private static final MethodHandle KILL = handle("kill", FD_KILL);
|
||||
private static final MethodHandle WAITPID = handle("waitpid", FD_WAITPID);
|
||||
private static final MethodHandle POSIX_SPAWNP = handle("posix_spawnp", FD_SPAWN);
|
||||
private static final MethodHandle FA_INIT = handle("posix_spawn_file_actions_init", FD_FA_INIT);
|
||||
private static final MethodHandle FA_DESTROY = handle("posix_spawn_file_actions_destroy", FD_FA_INIT);
|
||||
private static final MethodHandle FA_ADDCLOSE = handle("posix_spawn_file_actions_addclose", FD_FA_ADDCLOSE);
|
||||
private static final MethodHandle FA_ADDDUP2 = handle("posix_spawn_file_actions_adddup2", FD_FA_ADDDUP2);
|
||||
private static final MethodHandle FA_ADDOPEN = handle("posix_spawn_file_actions_addopen", FD_FA_ADDOPEN);
|
||||
private static final MethodHandle FA_ADDCHDIR = handle("posix_spawn_file_actions_addchdir_np", FD_FA_ADDCHDIR);
|
||||
private static final MethodHandle ATTR_INIT = handle("posix_spawnattr_init", FD_FA_INIT);
|
||||
private static final MethodHandle ATTR_DESTROY = handle("posix_spawnattr_destroy", FD_FA_INIT);
|
||||
private static final MethodHandle ATTR_SETFLAGS = handle("posix_spawnattr_setflags", FD_ATTR_SETFLAGS);
|
||||
|
||||
private final Arena arena = Arena.ofShared();
|
||||
private final MemorySegment readBuffer = arena.allocate(65536);
|
||||
private final Object writeLock = new Object();
|
||||
private final int masterFd;
|
||||
private final int pid;
|
||||
private volatile boolean closed;
|
||||
|
||||
private LinuxPty(int masterFd, int pid) {
|
||||
this.masterFd = masterFd;
|
||||
this.pid = pid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a PTY and spawns {@code argv} attached to its slave end.
|
||||
*
|
||||
* @param argv command and arguments (e.g. {@code {"/bin/zsh", "-i"}})
|
||||
* @param environment environment for the child, as KEY=VALUE pairs
|
||||
* @param workingDirectory directory the child starts in, or {@code null} to inherit
|
||||
*/
|
||||
public static LinuxPty spawn(String[] argv, Map<String, String> environment, String workingDirectory) {
|
||||
Arena setup = Arena.ofConfined();
|
||||
try {
|
||||
int master = check(callInt(POSIX_OPENPT, O_RDWR | O_NOCTTY), "posix_openpt");
|
||||
try {
|
||||
check(callInt(GRANTPT, master), "grantpt");
|
||||
check(callInt(UNLOCKPT, master), "unlockpt");
|
||||
|
||||
MemorySegment nameBuf = setup.allocate(256);
|
||||
check(callPtsnameR(master, nameBuf), "ptsname_r");
|
||||
String slavePath = nameBuf.getString(0);
|
||||
|
||||
MemorySegment actions = setup.allocate(SPAWN_ACTIONS_SIZE);
|
||||
MemorySegment attr = setup.allocate(SPAWN_ATTR_SIZE);
|
||||
check(callInt(FA_INIT, actions), "posix_spawn_file_actions_init");
|
||||
check(callInt(ATTR_INIT, attr), "posix_spawnattr_init");
|
||||
try {
|
||||
check(callInt(ATTR_SETFLAGS, attr, POSIX_SPAWN_SETSID), "posix_spawnattr_setflags");
|
||||
|
||||
if (workingDirectory != null) {
|
||||
MemorySegment dir = setup.allocateFrom(workingDirectory);
|
||||
check(callAddChdir(actions, dir), "posix_spawn_file_actions_addchdir_np");
|
||||
}
|
||||
// Open the slave as fd 0 in the new session -> controlling terminal, then fan out.
|
||||
MemorySegment slave = setup.allocateFrom(slavePath);
|
||||
check(callAddOpen(actions, 0, slave, O_RDWR, 0), "posix_spawn_file_actions_addopen");
|
||||
check(callAddDup2(actions, 0, 1), "posix_spawn_file_actions_adddup2");
|
||||
check(callAddDup2(actions, 0, 2), "posix_spawn_file_actions_adddup2");
|
||||
check(callAddClose(actions, master), "posix_spawn_file_actions_addclose");
|
||||
|
||||
MemorySegment argvSeg = cStringArray(setup, List.of(argv));
|
||||
MemorySegment envpSeg = cStringArray(setup, toEnvList(environment));
|
||||
MemorySegment path = setup.allocateFrom(argv[0]);
|
||||
MemorySegment pidOut = setup.allocate(C_INT);
|
||||
|
||||
int rc = callSpawn(pidOut, path, actions, attr, argvSeg, envpSeg);
|
||||
if (rc != 0) {
|
||||
throw new IllegalStateException("posix_spawnp failed for " + argv[0] + " (rc=" + rc + ")");
|
||||
}
|
||||
return new LinuxPty(master, pidOut.get(C_INT, 0));
|
||||
} finally {
|
||||
callInt(ATTR_DESTROY, attr);
|
||||
callInt(FA_DESTROY, actions);
|
||||
}
|
||||
} catch (RuntimeException ex) {
|
||||
callInt(CLOSE, master);
|
||||
throw ex;
|
||||
}
|
||||
} finally {
|
||||
setup.close();
|
||||
}
|
||||
}
|
||||
|
||||
/** Reads available output into {@code dst}; returns bytes read, or -1 at EOF. */
|
||||
public int read(byte[] dst) {
|
||||
if (closed) {
|
||||
return -1;
|
||||
}
|
||||
long n = callLong(READ, masterFd, readBuffer, Math.min(dst.length, readBuffer.byteSize()));
|
||||
if (n <= 0) {
|
||||
return -1;
|
||||
}
|
||||
MemorySegment.copy(readBuffer, ValueLayout.JAVA_BYTE, 0, dst, 0, (int) n);
|
||||
return (int) n;
|
||||
}
|
||||
|
||||
/** Writes all of {@code data} to the master end. */
|
||||
public void write(byte[] data) {
|
||||
if (closed || data.length == 0) {
|
||||
return;
|
||||
}
|
||||
synchronized (writeLock) {
|
||||
try (Arena a = Arena.ofConfined()) {
|
||||
MemorySegment buf = a.allocate(data.length);
|
||||
MemorySegment.copy(data, 0, buf, ValueLayout.JAVA_BYTE, 0, data.length);
|
||||
long offset = 0;
|
||||
while (offset < data.length) {
|
||||
long n = callLong(WRITE, masterFd, buf.asSlice(offset), data.length - offset);
|
||||
if (n < 0) {
|
||||
throw new IllegalStateException("write to pty failed");
|
||||
}
|
||||
offset += n;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Resizes the terminal window. */
|
||||
public void setWinSize(int columns, int rows) {
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
try (Arena a = Arena.ofConfined()) {
|
||||
MemorySegment ws = a.allocate(WINSIZE);
|
||||
ws.set(C_SHORT, 0, (short) rows);
|
||||
ws.set(C_SHORT, 2, (short) columns);
|
||||
ws.set(C_SHORT, 4, (short) 0);
|
||||
ws.set(C_SHORT, 6, (short) 0);
|
||||
callIoctl(masterFd, TIOCSWINSZ, ws);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
closed = true;
|
||||
callKill(pid, SIGHUP);
|
||||
callInt(CLOSE, masterFd);
|
||||
reap();
|
||||
arena.close();
|
||||
}
|
||||
|
||||
private void reap() {
|
||||
try (Arena a = Arena.ofConfined()) {
|
||||
MemorySegment status = a.allocate(C_INT);
|
||||
// Closing the master sends EOF/SIGHUP; an interactive shell exits promptly.
|
||||
for (int attempt = 0; attempt < 50; attempt++) {
|
||||
int r = callWaitpid(pid, status, WNOHANG);
|
||||
if (r != 0) {
|
||||
return; // reaped, or no such child
|
||||
}
|
||||
if (attempt == 25) {
|
||||
callKill(pid, SIGKILL);
|
||||
}
|
||||
try {
|
||||
Thread.sleep(2);
|
||||
} catch (InterruptedException ie) {
|
||||
Thread.currentThread().interrupt();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- typed invokeExact wrappers ---------------------------------------------------------
|
||||
|
||||
private static int callInt(MethodHandle handle, int arg) {
|
||||
try {
|
||||
return (int) handle.invokeExact(arg);
|
||||
} catch (Throwable t) {
|
||||
throw sneaky(t);
|
||||
}
|
||||
}
|
||||
|
||||
private static int callInt(MethodHandle handle, MemorySegment arg) {
|
||||
try {
|
||||
return (int) handle.invokeExact(arg);
|
||||
} catch (Throwable t) {
|
||||
throw sneaky(t);
|
||||
}
|
||||
}
|
||||
|
||||
private static int callInt(MethodHandle handle, MemorySegment a, short b) {
|
||||
try {
|
||||
return (int) handle.invokeExact(a, b);
|
||||
} catch (Throwable t) {
|
||||
throw sneaky(t);
|
||||
}
|
||||
}
|
||||
|
||||
private static long callLong(MethodHandle handle, int fd, MemorySegment buf, long len) {
|
||||
try {
|
||||
return (long) handle.invokeExact(fd, buf, len);
|
||||
} catch (Throwable t) {
|
||||
throw sneaky(t);
|
||||
}
|
||||
}
|
||||
|
||||
private static int callPtsnameR(int fd, MemorySegment buf) {
|
||||
try {
|
||||
return (int) PTSNAME_R.invokeExact(fd, buf, buf.byteSize());
|
||||
} catch (Throwable t) {
|
||||
throw sneaky(t);
|
||||
}
|
||||
}
|
||||
|
||||
private static int callAddChdir(MemorySegment actions, MemorySegment path) {
|
||||
try {
|
||||
return (int) FA_ADDCHDIR.invokeExact(actions, path);
|
||||
} catch (Throwable t) {
|
||||
throw sneaky(t);
|
||||
}
|
||||
}
|
||||
|
||||
private static int callAddOpen(MemorySegment actions, int fd, MemorySegment path, int oflag, int mode) {
|
||||
try {
|
||||
return (int) FA_ADDOPEN.invokeExact(actions, fd, path, oflag, mode);
|
||||
} catch (Throwable t) {
|
||||
throw sneaky(t);
|
||||
}
|
||||
}
|
||||
|
||||
private static int callAddDup2(MemorySegment actions, int fd, int newFd) {
|
||||
try {
|
||||
return (int) FA_ADDDUP2.invokeExact(actions, fd, newFd);
|
||||
} catch (Throwable t) {
|
||||
throw sneaky(t);
|
||||
}
|
||||
}
|
||||
|
||||
private static int callAddClose(MemorySegment actions, int fd) {
|
||||
try {
|
||||
return (int) FA_ADDCLOSE.invokeExact(actions, fd);
|
||||
} catch (Throwable t) {
|
||||
throw sneaky(t);
|
||||
}
|
||||
}
|
||||
|
||||
private static int callSpawn(MemorySegment pid, MemorySegment path, MemorySegment actions,
|
||||
MemorySegment attr, MemorySegment argv, MemorySegment envp) {
|
||||
try {
|
||||
return (int) POSIX_SPAWNP.invokeExact(pid, path, actions, attr, argv, envp);
|
||||
} catch (Throwable t) {
|
||||
throw sneaky(t);
|
||||
}
|
||||
}
|
||||
|
||||
private static void callIoctl(int fd, long request, MemorySegment arg) {
|
||||
try {
|
||||
int unused = (int) IOCTL.invokeExact(fd, request, arg);
|
||||
} catch (Throwable t) {
|
||||
throw sneaky(t);
|
||||
}
|
||||
}
|
||||
|
||||
private static void callKill(int pid, int signal) {
|
||||
try {
|
||||
int unused = (int) KILL.invokeExact(pid, signal);
|
||||
} catch (Throwable t) {
|
||||
throw sneaky(t);
|
||||
}
|
||||
}
|
||||
|
||||
private static int callWaitpid(int pid, MemorySegment status, int options) {
|
||||
try {
|
||||
return (int) WAITPID.invokeExact(pid, status, options);
|
||||
} catch (Throwable t) {
|
||||
throw sneaky(t);
|
||||
}
|
||||
}
|
||||
|
||||
// --- helpers ----------------------------------------------------------------------------
|
||||
|
||||
private static MethodHandle handle(String symbol, FunctionDescriptor descriptor, Linker.Option... options) {
|
||||
MemorySegment address = LIBC.find(symbol)
|
||||
.orElseThrow(() -> new IllegalStateException("libc symbol not found: " + symbol));
|
||||
return LINKER.downcallHandle(address, descriptor, options);
|
||||
}
|
||||
|
||||
private static MemorySegment cStringArray(Arena arena, List<String> values) {
|
||||
MemorySegment array = arena.allocate(C_POINTER, values.size() + 1L);
|
||||
for (int i = 0; i < values.size(); i++) {
|
||||
array.setAtIndex(C_POINTER, i, arena.allocateFrom(values.get(i)));
|
||||
}
|
||||
array.setAtIndex(C_POINTER, values.size(), MemorySegment.NULL);
|
||||
return array;
|
||||
}
|
||||
|
||||
private static List<String> toEnvList(Map<String, String> environment) {
|
||||
List<String> out = new ArrayList<>(environment.size());
|
||||
for (Map.Entry<String, String> entry : environment.entrySet()) {
|
||||
out.add(entry.getKey() + "=" + entry.getValue());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static int check(int rc, String what) {
|
||||
if (rc < 0) {
|
||||
throw new IllegalStateException(what + " failed (rc=" + rc + ")");
|
||||
}
|
||||
return rc;
|
||||
}
|
||||
|
||||
private static RuntimeException sneaky(Throwable t) {
|
||||
if (t instanceof RuntimeException re) {
|
||||
return re;
|
||||
}
|
||||
if (t instanceof Error e) {
|
||||
throw e;
|
||||
}
|
||||
return new IllegalStateException(t);
|
||||
}
|
||||
}
|
||||
@@ -2,64 +2,101 @@ package com.gregor.jprototerm;
|
||||
|
||||
import javafx.animation.AnimationTimer;
|
||||
import javafx.application.Application;
|
||||
import javafx.application.Platform;
|
||||
import javafx.scene.Scene;
|
||||
import javafx.scene.control.ButtonType;
|
||||
import javafx.scene.control.ComboBox;
|
||||
import javafx.scene.control.Dialog;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.Spinner;
|
||||
import javafx.scene.control.SpinnerValueFactory;
|
||||
import javafx.scene.input.KeyEvent;
|
||||
import javafx.scene.layout.StackPane;
|
||||
import javafx.scene.layout.GridPane;
|
||||
import javafx.scene.text.Font;
|
||||
import javafx.stage.Stage;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
public final class Main extends Application {
|
||||
private TerminalWorkspace workspace;
|
||||
private Compositor compositor;
|
||||
private TerminalMetrics metrics;
|
||||
private AppConfig config;
|
||||
|
||||
@Override
|
||||
public void start(Stage stage) {
|
||||
AppConfig config = AppConfig.load();
|
||||
config = AppConfig.load();
|
||||
|
||||
workspace = new TerminalWorkspace(config);
|
||||
TerminalCanvasView terminalView = new TerminalCanvasView(workspace, config);
|
||||
metrics = new TerminalMetrics(config.fontFamily(), config.fontSize());
|
||||
compositor = new Compositor(config, metrics);
|
||||
|
||||
StackPane root = new StackPane(terminalView.canvas());
|
||||
terminalView.canvas().widthProperty().bind(root.widthProperty());
|
||||
terminalView.canvas().heightProperty().bind(root.heightProperty());
|
||||
|
||||
Scene scene = new Scene(root, config.windowWidth(), config.windowHeight());
|
||||
scene.addEventFilter(KeyEvent.KEY_PRESSED, event -> handlePressed(config, event));
|
||||
Scene scene = new Scene(compositor.node(), config.windowWidth(), config.windowHeight());
|
||||
scene.addEventFilter(KeyEvent.KEY_PRESSED, this::handlePressed);
|
||||
scene.addEventFilter(KeyEvent.KEY_TYPED, event -> handleTyped(event));
|
||||
|
||||
new AnimationTimer() {
|
||||
@Override
|
||||
public void handle(long now) {
|
||||
terminalView.render();
|
||||
compositor.render();
|
||||
}
|
||||
}.start();
|
||||
|
||||
stage.setTitle("jprototerm");
|
||||
stage.setScene(scene);
|
||||
stage.setOnCloseRequest(event -> {
|
||||
workspace.close();
|
||||
compositor.close();
|
||||
});
|
||||
stage.show();
|
||||
compositor.requestFocus();
|
||||
}
|
||||
|
||||
private void handlePressed(AppConfig config, KeyEvent event) {
|
||||
private void handlePressed(KeyEvent event) {
|
||||
if (config.keybindings().get("navigate_left").matches(event)) {
|
||||
workspace.navigate(Direction.LEFT);
|
||||
compositor.navigate(Direction.LEFT);
|
||||
event.consume();
|
||||
} else if (config.keybindings().get("navigate_down").matches(event)) {
|
||||
workspace.navigate(Direction.DOWN);
|
||||
compositor.navigate(Direction.DOWN);
|
||||
event.consume();
|
||||
} else if (config.keybindings().get("navigate_up").matches(event)) {
|
||||
workspace.navigate(Direction.UP);
|
||||
compositor.navigate(Direction.UP);
|
||||
event.consume();
|
||||
} else if (config.keybindings().get("navigate_right").matches(event)) {
|
||||
workspace.navigate(Direction.RIGHT);
|
||||
compositor.navigate(Direction.RIGHT);
|
||||
event.consume();
|
||||
} 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();
|
||||
} else {
|
||||
String encoded = KeyEncoder.encode(event);
|
||||
if (encoded != null) {
|
||||
workspace.activePane().send(encoded);
|
||||
compositor.activePane().send(encoded);
|
||||
event.consume();
|
||||
}
|
||||
}
|
||||
@@ -72,14 +109,84 @@ public final class Main extends Application {
|
||||
|
||||
String text = event.getCharacter();
|
||||
if (text != null && !text.isEmpty() && text.charAt(0) >= 0x20 && text.charAt(0) != 0x7f) {
|
||||
workspace.activePane().send(text);
|
||||
compositor.activePane().send(text);
|
||||
event.consume();
|
||||
}
|
||||
}
|
||||
|
||||
private void openFontSelector() {
|
||||
Dialog<ButtonType> dialog = new Dialog<>();
|
||||
dialog.setTitle("Font");
|
||||
dialog.getDialogPane().getButtonTypes().addAll(ButtonType.OK, ButtonType.CANCEL);
|
||||
|
||||
ComboBox<String> family = new ComboBox<>();
|
||||
family.getItems().setAll(Font.getFamilies());
|
||||
family.setEditable(true);
|
||||
family.setMaxWidth(Double.MAX_VALUE);
|
||||
family.setValue(config.fontFamily());
|
||||
|
||||
Spinner<Double> size = new Spinner<>();
|
||||
size.setEditable(true);
|
||||
size.setValueFactory(new SpinnerValueFactory.DoubleSpinnerValueFactory(6.0, 48.0, config.fontSize(), 0.5));
|
||||
|
||||
GridPane content = new GridPane();
|
||||
content.setHgap(10.0);
|
||||
content.setVgap(10.0);
|
||||
content.add(new Label("Family"), 0, 0);
|
||||
content.add(family, 1, 0);
|
||||
content.add(new Label("Size"), 0, 1);
|
||||
content.add(size, 1, 1);
|
||||
dialog.getDialogPane().setContent(content);
|
||||
|
||||
dialog.showAndWait()
|
||||
.filter(button -> button == ButtonType.OK)
|
||||
.ifPresent(ignored -> {
|
||||
String selectedFamily = family.getEditor().getText();
|
||||
if (selectedFamily == null || selectedFamily.isBlank()) {
|
||||
selectedFamily = family.getValue();
|
||||
}
|
||||
if (selectedFamily == null || selectedFamily.isBlank()) {
|
||||
return;
|
||||
}
|
||||
|
||||
double selectedSize = size.getValue();
|
||||
config = config.withFont(selectedFamily.trim(), selectedSize);
|
||||
config.save();
|
||||
compositor.setFont(config.fontFamily(), config.fontSize());
|
||||
compositor.requestFocus();
|
||||
});
|
||||
}
|
||||
|
||||
private void openScrollbackInEditor() {
|
||||
try {
|
||||
Path file = Files.createTempFile("jprototerm-scrollback-", ".txt");
|
||||
Files.writeString(file, compositor.activePane().scrollbackText());
|
||||
file.toFile().deleteOnExit();
|
||||
|
||||
compositor.activePane().send(scrollbackEditorCommand(file) + "\r");
|
||||
} catch (IOException ex) {
|
||||
System.err.println("Could not open scrollback in editor: " + ex.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private String scrollbackEditorCommand(Path file) {
|
||||
String quotedFile = shellQuote(file.toString());
|
||||
String command = config.scrollbackEditorCommand();
|
||||
if (command == null || command.isBlank()) {
|
||||
command = "vi {file}";
|
||||
}
|
||||
if (command.contains("{file}")) {
|
||||
return command.replace("{file}", quotedFile);
|
||||
}
|
||||
return command + " " + quotedFile;
|
||||
}
|
||||
|
||||
private static String shellQuote(String value) {
|
||||
return "'" + value.replace("'", "'\"'\"'") + "'";
|
||||
}
|
||||
|
||||
public static void main(String[] args) {
|
||||
System.setProperty("prism.order", "sw");
|
||||
System.setProperty("prism.verbose", "true");
|
||||
System.setProperty("prism.order", System.getProperty("prism.order", "es2,sw"));
|
||||
launch(Main.class, args);
|
||||
}
|
||||
}
|
||||
|
||||
79
src/main/java/com/gregor/jprototerm/RenderProfiler.java
Normal file
79
src/main/java/com/gregor/jprototerm/RenderProfiler.java
Normal file
@@ -0,0 +1,79 @@
|
||||
package com.gregor.jprototerm;
|
||||
|
||||
/**
|
||||
* Lightweight render profiler, disabled unless {@code -Djprototerm.profile=true} (or the
|
||||
* {@code JPROTOTERM_PROFILE=1} environment variable) is set. It accumulates wall-clock nanos
|
||||
* into a handful of buckets and prints aggregate per-frame stats to stderr every
|
||||
* {@code jprototerm.profile.frames} render invocations (default 120).
|
||||
*
|
||||
* <p>All render work runs on the JavaFX application thread, so the accumulators are plain
|
||||
* fields with no synchronization.
|
||||
*
|
||||
* <p>Caveat: JavaFX canvas drawing is deferred to the QuantumRenderer thread, so the
|
||||
* {@link #DRAW} bucket measures only the cost of <em>recording</em> draw commands, not the
|
||||
* GPU paint. Pair this with {@code -Djavafx.pulseLogger=true} to see the render-thread side.
|
||||
*/
|
||||
final class RenderProfiler {
|
||||
static final int SNAPSHOT = 0;
|
||||
static final int FINGERPRINT = 1;
|
||||
static final int DRAW = 2;
|
||||
static final int FRAME = 3;
|
||||
static final int UPDATE = 4;
|
||||
static final int MARSHAL = 5;
|
||||
private static final int BUCKETS = 6;
|
||||
private static final String[] NAMES =
|
||||
{"snapshot", "fingerprint", "draw", "frame-total", "update", "marshal"};
|
||||
|
||||
private static final boolean ENABLED =
|
||||
Boolean.getBoolean("jprototerm.profile") || "1".equals(System.getenv("JPROTOTERM_PROFILE"));
|
||||
private static final int DUMP_FRAMES = Integer.getInteger("jprototerm.profile.frames", 120);
|
||||
|
||||
private static final long[] totalNanos = new long[BUCKETS];
|
||||
private static final long[] counts = new long[BUCKETS];
|
||||
private static int frames;
|
||||
|
||||
private RenderProfiler() {
|
||||
}
|
||||
|
||||
static boolean enabled() {
|
||||
return ENABLED;
|
||||
}
|
||||
|
||||
/** Returns a start timestamp, or 0 when profiling is disabled. */
|
||||
static long start() {
|
||||
return ENABLED ? System.nanoTime() : 0L;
|
||||
}
|
||||
|
||||
/** Records the time elapsed since {@code startNanos} into {@code bucket}. */
|
||||
static void stop(int bucket, long startNanos) {
|
||||
if (!ENABLED) {
|
||||
return;
|
||||
}
|
||||
totalNanos[bucket] += System.nanoTime() - startNanos;
|
||||
counts[bucket]++;
|
||||
}
|
||||
|
||||
/** Marks the end of one render invocation; dumps and resets every {@code DUMP_FRAMES}. */
|
||||
static void frame() {
|
||||
if (!ENABLED) {
|
||||
return;
|
||||
}
|
||||
if (++frames < DUMP_FRAMES) {
|
||||
return;
|
||||
}
|
||||
dump();
|
||||
}
|
||||
|
||||
private static void dump() {
|
||||
StringBuilder sb = new StringBuilder(192);
|
||||
sb.append("[render-profile] ").append(frames).append(" renders");
|
||||
for (int i = 0; i < BUCKETS; i++) {
|
||||
double totalMs = totalNanos[i] / 1_000_000.0;
|
||||
sb.append(String.format(" | %s %.3fms/f (n=%d)", NAMES[i], totalMs / frames, counts[i]));
|
||||
totalNanos[i] = 0;
|
||||
counts[i] = 0;
|
||||
}
|
||||
System.err.println(sb);
|
||||
frames = 0;
|
||||
}
|
||||
}
|
||||
@@ -1,78 +1,116 @@
|
||||
package com.gregor.jprototerm;
|
||||
|
||||
import javafx.application.Platform;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
|
||||
public final class ShellSession implements AutoCloseable {
|
||||
private final Process process;
|
||||
private final OutputStream stdin;
|
||||
private final LinuxPty pty;
|
||||
private final ExecutorService reader;
|
||||
private volatile boolean closed;
|
||||
|
||||
private ShellSession(Process process, TerminalPane pane, KittyGraphicsRegistry graphicsRegistry) {
|
||||
this.process = process;
|
||||
this.stdin = process.getOutputStream();
|
||||
private ShellSession(LinuxPty pty) {
|
||||
this.pty = pty;
|
||||
this.reader = Executors.newSingleThreadExecutor(runnable -> {
|
||||
Thread thread = new Thread(runnable, "shell-output-reader");
|
||||
thread.setDaemon(true);
|
||||
return thread;
|
||||
});
|
||||
reader.submit(() -> readOutput(pane, graphicsRegistry));
|
||||
}
|
||||
|
||||
public static ShellSession start(String shell, TerminalPane pane, KittyGraphicsRegistry graphicsRegistry) {
|
||||
public static ShellSession start(String shell, Map<String, String> envOverride, TerminalPane pane, int columns, int rows) {
|
||||
try {
|
||||
ProcessBuilder processBuilder = new ProcessBuilder(
|
||||
"script",
|
||||
"-qfec",
|
||||
shell + " -i",
|
||||
"/dev/null"
|
||||
).redirectErrorStream(true);
|
||||
processBuilder.environment().put("TERM", "xterm-kitty");
|
||||
processBuilder.environment().put("COLORTERM", "truecolor");
|
||||
Process process = processBuilder.start();
|
||||
return new ShellSession(process, pane, graphicsRegistry);
|
||||
} catch (IOException ex) {
|
||||
Map<String, String> environment = new HashMap<>(System.getenv());
|
||||
environment.put("TERM", "xterm-kitty");
|
||||
environment.put("COLORTERM", "truecolor");
|
||||
sanitizeWrapperEnvironment(environment);
|
||||
environment.putAll(envOverride);
|
||||
|
||||
LinuxPty pty = LinuxPty.spawn(
|
||||
new String[] {shell, "-i"},
|
||||
environment,
|
||||
System.getProperty("user.home"));
|
||||
ShellSession session = new ShellSession(pty);
|
||||
session.resize(columns, rows);
|
||||
return session;
|
||||
} catch (RuntimeException ex) {
|
||||
pane.write("failed to start shell: " + ex.getMessage() + "\r\n");
|
||||
throw new IllegalStateException("Could not start shell " + shell, ex);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Strips the variables injected by the Nix launcher wrapper from the shell's
|
||||
* environment so they do not leak into terminal subprocesses.
|
||||
*
|
||||
* <p>jprototerm is launched from a Nix wrapper that prepends Nix store paths to
|
||||
* {@code LD_LIBRARY_PATH} (and adds a GL shim) so the bundled JavaFX/ghostty natives
|
||||
* resolve. If the shell inherited that path, host programs run inside the terminal
|
||||
* (e.g. {@code flatpak}, {@code pdftoppm}) would load the Nix copies of libraries such
|
||||
* as freetype/fontconfig/glib, which in turn drag in the Nix glibc through their
|
||||
* RUNPATHs and clash with the host {@code libc.so.6}. We restore the user's original
|
||||
* {@code LD_LIBRARY_PATH}, captured by the wrapper before it prepended anything.
|
||||
*/
|
||||
private static void sanitizeWrapperEnvironment(Map<String, String> environment) {
|
||||
String hostLibraryPath = environment.remove("JPROTOTERM_HOST_LD_LIBRARY_PATH");
|
||||
if (hostLibraryPath == null || hostLibraryPath.isEmpty()) {
|
||||
environment.remove("LD_LIBRARY_PATH");
|
||||
} else {
|
||||
environment.put("LD_LIBRARY_PATH", hostLibraryPath);
|
||||
}
|
||||
// These are jprototerm's own runtime settings, not the user's shell environment.
|
||||
environment.remove("GDK_BACKEND");
|
||||
environment.remove("JLIBGHOSTTY_LIBRARY");
|
||||
}
|
||||
|
||||
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) {
|
||||
send(text.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
public void send(byte[] bytes) {
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
stdin.write(text.getBytes(StandardCharsets.UTF_8));
|
||||
stdin.flush();
|
||||
} catch (IOException ex) {
|
||||
pty.write(bytes);
|
||||
} catch (RuntimeException ex) {
|
||||
close();
|
||||
}
|
||||
}
|
||||
|
||||
private void readOutput(TerminalPane pane, KittyGraphicsRegistry graphicsRegistry) {
|
||||
byte[] buffer = new byte[8192];
|
||||
private void readOutput(TerminalPane pane) {
|
||||
byte[] buffer = new byte[65536];
|
||||
try {
|
||||
int read;
|
||||
while ((read = process.getInputStream().read(buffer)) != -1) {
|
||||
String text = new String(buffer, 0, read, StandardCharsets.UTF_8);
|
||||
if (!closed) {
|
||||
graphicsRegistry.accept(text);
|
||||
Platform.runLater(() -> {
|
||||
if (!closed) {
|
||||
pane.write(text);
|
||||
while ((read = pty.read(buffer)) != -1) {
|
||||
if (closed) {
|
||||
break;
|
||||
}
|
||||
});
|
||||
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) {
|
||||
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() {
|
||||
closed = true;
|
||||
reader.shutdownNow();
|
||||
process.destroy();
|
||||
pty.close();
|
||||
}
|
||||
}
|
||||
|
||||
288
src/main/java/com/gregor/jprototerm/Tab.java
Normal file
288
src/main/java/com/gregor/jprototerm/Tab.java
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
86
src/main/java/com/gregor/jprototerm/TerminalMetrics.java
Normal file
86
src/main/java/com/gregor/jprototerm/TerminalMetrics.java
Normal file
@@ -0,0 +1,86 @@
|
||||
package com.gregor.jprototerm;
|
||||
|
||||
import javafx.scene.text.Font;
|
||||
import javafx.scene.text.Text;
|
||||
|
||||
/**
|
||||
* Cell geometry shared by the {@link Compositor} and every {@link TerminalPane}.
|
||||
*
|
||||
* <p>The nominal cell width/height come from measuring the font, but a grid can't use
|
||||
* fractional cells, so the measured size is snapped to whole (logical) pixels here — that
|
||||
* snapping is why the value isn't purely a property of the font. The compositor owns the
|
||||
* single instance (it holds the canvas, which is the pixel context), hands it to panes so
|
||||
* they can turn their rect into a column/row count themselves, and re-measures it on a font
|
||||
* change so every pane observes the new geometry through the shared reference.
|
||||
*/
|
||||
public final class TerminalMetrics {
|
||||
/** Inset, in pixels, between a pane's edge and its content on every side. */
|
||||
public static final double PADDING = 12.0;
|
||||
|
||||
private String fontFamily;
|
||||
private double fontSize;
|
||||
private Font font;
|
||||
private double cellWidth;
|
||||
private double lineHeight;
|
||||
private double baselineOffset;
|
||||
|
||||
public TerminalMetrics(String fontFamily, double fontSize) {
|
||||
setFont(fontFamily, fontSize);
|
||||
}
|
||||
|
||||
public void setFont(String fontFamily, double fontSize) {
|
||||
this.fontFamily = fontFamily;
|
||||
this.fontSize = fontSize;
|
||||
this.font = Font.font(fontFamily, fontSize);
|
||||
measure(font);
|
||||
}
|
||||
|
||||
public String fontFamily() {
|
||||
return fontFamily;
|
||||
}
|
||||
|
||||
public double fontSize() {
|
||||
return fontSize;
|
||||
}
|
||||
|
||||
public Font font() {
|
||||
return font;
|
||||
}
|
||||
|
||||
public double cellWidth() {
|
||||
return cellWidth;
|
||||
}
|
||||
|
||||
public double lineHeight() {
|
||||
return lineHeight;
|
||||
}
|
||||
|
||||
public double baselineOffset() {
|
||||
return baselineOffset;
|
||||
}
|
||||
|
||||
/** Columns that fit in a pane of the given pixel width (after subtracting the padding). */
|
||||
public int columnsFor(double widthPx) {
|
||||
return Math.max(1, (int) ((widthPx - 2 * PADDING) / cellWidth));
|
||||
}
|
||||
|
||||
/** Rows that fit in a pane of the given pixel height (after subtracting the padding). */
|
||||
public int rowsFor(double heightPx) {
|
||||
return Math.max(1, (int) ((heightPx - 2 * PADDING) / lineHeight));
|
||||
}
|
||||
|
||||
private void measure(Font font) {
|
||||
Text text = new Text("┃MgÅjy");
|
||||
text.setFont(font);
|
||||
// Snap the cell size to whole pixels so cells tile on integer boundaries. Fractional
|
||||
// cell metrics put every cell edge on a sub-pixel position, leaving anti-aliased
|
||||
// seams that show up as a faint grid behind the themed cell backgrounds. Rounding
|
||||
// leaves a few pixels of unused space at the right/bottom edge, which is fine.
|
||||
this.lineHeight = Math.max(1.0, Math.round(text.getLayoutBounds().getHeight()));
|
||||
this.baselineOffset = -text.getLayoutBounds().getMinY();
|
||||
|
||||
Text cell = new Text("M");
|
||||
cell.setFont(font);
|
||||
this.cellWidth = Math.max(1.0, Math.round(cell.getLayoutBounds().getWidth()));
|
||||
}
|
||||
}
|
||||
@@ -1,34 +1,89 @@
|
||||
package com.gregor.jprototerm;
|
||||
|
||||
import dev.jlibghostty.DeviceAttributes;
|
||||
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.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 {
|
||||
private final Terminal terminal;
|
||||
private final KittyGraphicsRegistry graphicsRegistry;
|
||||
private final AtomicReference<String> snapshotText = new AtomicReference<>("");
|
||||
private final TerminalMetrics metrics;
|
||||
private final boolean kittyEnabled;
|
||||
// Run on every content change so the owning tab can bump its content version — the
|
||||
// compositor's O(1) "did the current tab change?" gate.
|
||||
private final Runnable onContentChange;
|
||||
private final 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 boolean floating;
|
||||
private double x;
|
||||
private double y;
|
||||
private double width;
|
||||
private double height;
|
||||
private int columns;
|
||||
private int rows;
|
||||
private int pixelWidth;
|
||||
private int pixelHeight;
|
||||
private 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.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));
|
||||
TerminalPane pane = new TerminalPane(terminal, new KittyGraphicsRegistry(kittyGraphics));
|
||||
/**
|
||||
* Opens a pane sized to fit the given pixel rect: the shared cell metrics decide how many
|
||||
* columns and rows fit, and that grid is handed to ghostty and the shell at start-up. A
|
||||
* non-positive size falls back to the configured default grid (used before the first
|
||||
* layout, when no rect is known yet). The pane owns the shell session it starts and runs
|
||||
* {@code onContentChange} on every content change.
|
||||
*/
|
||||
public static TerminalPane create(AppConfig config, TerminalMetrics metrics, Runnable onContentChange, double widthPx, double heightPx) {
|
||||
int columns = widthPx > 0 ? metrics.columnsFor(widthPx) : config.columns();
|
||||
int rows = heightPx > 0 ? metrics.rowsFor(heightPx) : config.rows();
|
||||
Terminal terminal = Ghostty.open(new TerminalOptions(columns, rows, config.maxScrollback()));
|
||||
terminal.setDeviceAttributesProvider(DeviceAttributes::xtermCompatible);
|
||||
TerminalPane pane = new TerminalPane(terminal, metrics, config.kittyGraphics(), onContentChange, columns, rows);
|
||||
pane.refresh();
|
||||
pane.attach(ShellSession.start(config.shell(), config.envOverride(), pane, columns, rows));
|
||||
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) {
|
||||
synchronized (terminal) {
|
||||
terminal.write(text);
|
||||
@@ -36,30 +91,120 @@ public final class TerminalPane implements AutoCloseable {
|
||||
}
|
||||
}
|
||||
|
||||
public void attach(ShellSession session) {
|
||||
this.session = session;
|
||||
public void write(byte[] bytes) {
|
||||
synchronized (terminal) {
|
||||
terminal.write(bytes);
|
||||
refresh();
|
||||
}
|
||||
}
|
||||
|
||||
public void send(String text) {
|
||||
scrollViewportToBottom();
|
||||
if (session != null) {
|
||||
session.send(text);
|
||||
}
|
||||
}
|
||||
|
||||
public String snapshotText() {
|
||||
return snapshotText.get();
|
||||
public boolean sendMouse(MouseInput input, MouseEncoderSize size, boolean anyButtonPressed) {
|
||||
synchronized (terminal) {
|
||||
mouseEncoder.syncFromTerminal(terminal);
|
||||
mouseEncoder.setSize(size);
|
||||
mouseEncoder.setAnyButtonPressed(anyButtonPressed);
|
||||
mouseEncoder.setTrackLastCell(input.action() == MouseAction.MOTION && input.button().isEmpty());
|
||||
|
||||
byte[] encoded = mouseEncoder.encode(input);
|
||||
if (encoded.length == 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
public KittyGraphicsRegistry graphicsRegistry() {
|
||||
return graphicsRegistry;
|
||||
if (session != null) {
|
||||
session.send(encoded);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public boolean floating() {
|
||||
return floating;
|
||||
public void scrollViewport(long rows) {
|
||||
synchronized (terminal) {
|
||||
terminal.scrollViewport(ScrollViewport.delta(rows));
|
||||
refresh();
|
||||
}
|
||||
}
|
||||
|
||||
public void setFloating(boolean floating) {
|
||||
this.floating = floating;
|
||||
private void scrollViewportToBottom() {
|
||||
synchronized (terminal) {
|
||||
terminal.scrollViewport(ScrollViewport.bottom());
|
||||
refresh();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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() {
|
||||
@@ -85,8 +230,40 @@ public final class TerminalPane implements AutoCloseable {
|
||||
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() {
|
||||
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
|
||||
@@ -95,6 +272,8 @@ public final class TerminalPane implements AutoCloseable {
|
||||
session.close();
|
||||
session = null;
|
||||
}
|
||||
mouseEncoder.close();
|
||||
renderState.close();
|
||||
terminal.close();
|
||||
}
|
||||
}
|
||||
|
||||
1020
src/main/java/com/gregor/jprototerm/TerminalPaneNode.java
Normal file
1020
src/main/java/com/gregor/jprototerm/TerminalPaneNode.java
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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();
|
||||
}
|
||||
}
|
||||
0
src/main/resources/.gitkeep
Normal file
0
src/main/resources/.gitkeep
Normal 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
|
||||
}
|
||||
]
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"resources": [
|
||||
{ "pattern": ".*\\.css$" },
|
||||
{ "pattern": ".*\\.toml$" }
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user