This commit is contained in:
Gregor Lohaus
2026-05-27 14:04:26 +02:00
commit be0c0bb321
18 changed files with 1115 additions and 0 deletions

6
.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
.gradle/
build/
result
result-*
*.class
*.jar

75
README.md Normal file
View File

@@ -0,0 +1,75 @@
# jlibghostty
Java FFM bindings for Ghostty's `libghostty-vt`.
This targets Java 22+ and uses `java.lang.foreign`, not JNI. The public API is intentionally small while Ghostty's C API is still marked unstable upstream.
## Build
```sh
nix build
```
The default Nix package builds:
- `share/java/jlibghostty-0.1.0-SNAPSHOT.jar`
- `maven/dev/jlibghostty/jlibghostty/0.1.0-SNAPSHOT/...`
The jar contains the host platform `libghostty-vt` under `dev/jlibghostty/native/<platform>/`.
## Gradle Consumer
After `nix build`, another Gradle project can consume the generated Maven repository:
```kotlin
repositories {
maven {
url = uri("/home/anon/Dev/jlibghostty/result/maven")
}
}
dependencies {
implementation("dev.jlibghostty:jlibghostty:0.1.0-SNAPSHOT")
}
tasks.withType<JavaExec>().configureEach {
jvmArgs("--enable-native-access=ALL-UNNAMED")
}
```
If the app runs on the module path, use:
```sh
--enable-native-access=dev.jlibghostty
```
## External Native Library
The library normally loads the bundled native `libghostty-vt`. To override it:
```sh
java -Djlibghostty.library.path=/path/to/libghostty-vt.so ...
```
or set:
```sh
export JLIBGHOSTTY_LIBRARY=/path/to/libghostty-vt.so
```
## Example
```java
try (Terminal terminal = Ghostty.open(TerminalOptions.of(80, 24))) {
terminal.write("hello\r\n");
System.out.println(terminal.snapshot());
}
```
## Development Shell
```sh
nix develop
```
The shell provides Java, Gradle, and `JLIBGHOSTTY_LIBRARY` pointing at the Nix-built `libghostty-vt`.

68
build.gradle.kts Normal file
View File

@@ -0,0 +1,68 @@
plugins {
`java-library`
`maven-publish`
}
group = "dev.jlibghostty"
version = "0.1.0-SNAPSHOT"
java {
toolchain {
languageVersion.set(JavaLanguageVersion.of(25))
}
withSourcesJar()
}
tasks.withType<JavaCompile>().configureEach {
options.release.set(22)
}
tasks.test {
jvmArgs("--enable-native-access=ALL-UNNAMED")
}
fun currentPlatform(): String {
val os = System.getProperty("os.name").lowercase()
val arch = System.getProperty("os.arch").lowercase()
val normalizedOs = when {
os.contains("linux") -> "linux"
os.contains("mac") || os.contains("darwin") -> "macos"
else -> error("Unsupported operating system for bundled libghostty-vt: $os")
}
val normalizedArch = when (arch) {
"amd64", "x86_64" -> "x86_64"
"aarch64", "arm64" -> "aarch64"
else -> error("Unsupported architecture for bundled libghostty-vt: $arch")
}
return "$normalizedOs-$normalizedArch"
}
val ghosttyNativeLib = providers
.gradleProperty("ghosttyNativeLib")
.orElse(providers.environmentVariable("JLIBGHOSTTY_LIBRARY"))
tasks.processResources {
ghosttyNativeLib.orNull?.let { nativeLib ->
from(nativeLib) {
into("dev/jlibghostty/native/${currentPlatform()}")
rename { System.mapLibraryName("ghostty-vt") }
}
}
}
publishing {
publications {
create<MavenPublication>("mavenJava") {
from(components["java"])
pom {
name.set("jlibghostty")
description.set("Java FFM bindings for libghostty-vt")
}
}
}
}

View File

@@ -0,0 +1,15 @@
plugins {
application
}
dependencies {
implementation("dev.jlibghostty:jlibghostty:0.1.0-SNAPSHOT")
}
application {
mainClass.set("example.Main")
}
tasks.withType<JavaExec>().configureEach {
jvmArgs("--enable-native-access=ALL-UNNAMED")
}

View File

@@ -0,0 +1,18 @@
pluginManagement {
repositories {
gradlePluginPortal()
mavenCentral()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
mavenCentral()
maven {
url = uri(System.getenv("JLIBGHOSTTY_MAVEN_REPO") ?: "../../result/maven")
}
}
}
rootProject.name = "jlibghostty-consumer"

View File

@@ -0,0 +1,19 @@
package example;
import dev.jlibghostty.Ghostty;
import dev.jlibghostty.Terminal;
import dev.jlibghostty.TerminalOptions;
public final class Main {
private Main() {
}
public static void main(String[] args) {
System.out.println("paste safe: " + Ghostty.pasteIsSafe("hello"));
try (Terminal terminal = Ghostty.open(TerminalOptions.of(80, 24))) {
terminal.write("hello from libghostty-vt\r\n");
System.out.println(terminal.snapshot());
}
}
}

180
flake.lock generated Normal file
View File

@@ -0,0 +1,180 @@
{
"nodes": {
"flake-compat": {
"flake": false,
"locked": {
"lastModified": 1761588595,
"narHash": "sha256-XKUZz9zewJNUj46b4AJdiRZJAvSZ0Dqj2BNfXvFlJC4=",
"owner": "edolstra",
"repo": "flake-compat",
"rev": "f387cd2afec9419c8ee37694406ca490c3f34ee5",
"type": "github"
},
"original": {
"owner": "edolstra",
"repo": "flake-compat",
"type": "github"
}
},
"ghostty": {
"inputs": {
"flake-compat": "flake-compat",
"home-manager": "home-manager",
"nixpkgs": [
"nixpkgs"
],
"systems": "systems",
"zig": "zig",
"zon2nix": "zon2nix"
},
"locked": {
"lastModified": 1779812402,
"narHash": "sha256-gozJEyJHbaAyrbzODKeWJhxpUrGK6m4DIPDogfjz2BU=",
"owner": "ghostty-org",
"repo": "ghostty",
"rev": "2e5ad917eb4e325a3dbb161c3f41208a8cd35e44",
"type": "github"
},
"original": {
"owner": "ghostty-org",
"repo": "ghostty",
"type": "github"
}
},
"home-manager": {
"inputs": {
"nixpkgs": [
"ghostty",
"nixpkgs"
]
},
"locked": {
"lastModified": 1770586272,
"narHash": "sha256-Ucci8mu8QfxwzyfER2DQDbvW9t1BnTUJhBmY7ybralo=",
"owner": "nix-community",
"repo": "home-manager",
"rev": "b1f916ba052341edc1f80d4b2399f1092a4873ca",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "home-manager",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1779560665,
"narHash": "sha256-tpyBcxPpcQb8ukyNF7DoCwfSY3VPsxHoYwj00Cayv5o=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "64c08a7ca051951c8eae34e3e3cb1e202fe36786",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"ghostty": "ghostty",
"nixpkgs": "nixpkgs"
}
},
"systems": {
"flake": false,
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
},
"zig": {
"inputs": {
"flake-compat": [
"ghostty",
"flake-compat"
],
"nixpkgs": [
"ghostty",
"nixpkgs"
],
"systems": [
"ghostty",
"systems"
]
},
"locked": {
"lastModified": 1776789209,
"narHash": "sha256-G6B7Q4TXn7MZ1mB+f9rymjsYF5PLWoSvmbxijb/99bw=",
"owner": "mitchellh",
"repo": "zig-overlay",
"rev": "14fe971844e841297ddd2ce9783d6892b467af39",
"type": "github"
},
"original": {
"owner": "mitchellh",
"repo": "zig-overlay",
"type": "github"
}
},
"zig_2": {
"inputs": {
"nixpkgs": [
"ghostty",
"zon2nix",
"nixpkgs"
]
},
"locked": {
"lastModified": 1777234348,
"narHash": "sha256-fKw44a4qbUuI5eTG8k0gPbqMV5TOrjYF35PBzsYgd2U=",
"ref": "refs/heads/main",
"rev": "2c781c0609ecda600ab98f98cca417bbd981bd53",
"revCount": 1677,
"type": "git",
"url": "https://codeberg.org/jcollie/zig-overlay.git"
},
"original": {
"type": "git",
"url": "https://codeberg.org/jcollie/zig-overlay.git"
}
},
"zon2nix": {
"inputs": {
"nixpkgs": [
"ghostty",
"nixpkgs"
],
"zig": "zig_2"
},
"locked": {
"lastModified": 1777314365,
"narHash": "sha256-eLxQaD0wc96Neqkln8wHS0rNq/chPODifFkhwrwilEU=",
"owner": "jcollie",
"repo": "zon2nix",
"rev": "a5a1d412ad1ab6305511997bbc92b3a9dd6cb784",
"type": "github"
},
"original": {
"owner": "jcollie",
"ref": "main",
"repo": "zon2nix",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

164
flake.nix Normal file
View File

@@ -0,0 +1,164 @@
{
description = "Java FFM bindings for libghostty-vt";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
ghostty = {
url = "github:ghostty-org/ghostty";
inputs.nixpkgs.follows = "nixpkgs";
};
};
outputs = { self, nixpkgs, ghostty }:
let
systems = [
"x86_64-linux"
"aarch64-linux"
"x86_64-darwin"
"aarch64-darwin"
];
forAllSystems = nixpkgs.lib.genAttrs systems;
in
{
packages = forAllSystems (system:
let
pkgs = import nixpkgs { inherit system; };
jdk =
if pkgs ? jdk25_headless then pkgs.jdk25_headless
else if pkgs ? jdk25 then pkgs.jdk25
else if pkgs ? jdk24_headless then pkgs.jdk24_headless
else if pkgs ? jdk24 then pkgs.jdk24
else pkgs.jdk;
version = "0.1.0-SNAPSHOT";
groupPath = "dev/jlibghostty";
artifactId = "jlibghostty";
ghosttyVt = ghostty.packages.${system}.libghostty-vt;
platformName =
if system == "x86_64-linux" then "linux-x86_64"
else if system == "aarch64-linux" then "linux-aarch64"
else if system == "x86_64-darwin" then "macos-x86_64"
else if system == "aarch64-darwin" then "macos-aarch64"
else throw "unsupported system: ${system}";
bundledLibraryName =
if pkgs.stdenv.hostPlatform.isDarwin then "libghostty-vt.dylib"
else "libghostty-vt.so";
sharedLibraryPattern =
if pkgs.stdenv.hostPlatform.isDarwin then "libghostty-vt*.dylib"
else "libghostty-vt.so*";
package = pkgs.stdenvNoCC.mkDerivation {
pname = artifactId;
inherit version;
src = ./.;
nativeBuildInputs = [ jdk ];
dontConfigure = true;
buildPhase = ''
runHook preBuild
mkdir -p build/classes build/test-classes build/resources/${groupPath}/native/${platformName}
ghostty_lib="$(find ${ghosttyVt} -type f -name '${sharedLibraryPattern}' -print -quit)"
if [ -z "$ghostty_lib" ]; then
echo "Could not find ${sharedLibraryPattern} in ${ghosttyVt}" >&2
find ${ghosttyVt} -maxdepth 4 -type f >&2
exit 1
fi
bundled_lib="build/resources/${groupPath}/native/${platformName}/${bundledLibraryName}"
cp "$ghostty_lib" "$bundled_lib"
find src/main/java -name '*.java' | sort > build/sources.txt
javac --release 22 -d build/classes @build/sources.txt
jar --create \
--file build/${artifactId}-${version}.jar \
-C build/classes . \
-C build/resources .
jar --create \
--file build/${artifactId}-${version}-sources.jar \
-C src/main/java .
find src/test/java -name '*.java' | sort > build/test-sources.txt
if [ -s build/test-sources.txt ]; then
javac --release 22 -cp build/classes -d build/test-classes @build/test-sources.txt
java \
--enable-native-access=ALL-UNNAMED \
-Djlibghostty.library.path="$bundled_lib" \
-cp build/classes:build/test-classes \
dev.jlibghostty.GhosttySmokeTest
fi
runHook postBuild
'';
installPhase = ''
runHook preInstall
mkdir -p "$out/share/java" "$out/maven/${groupPath}/${artifactId}/${version}"
cp build/${artifactId}-${version}.jar "$out/share/java/"
cp build/${artifactId}-${version}.jar "$out/maven/${groupPath}/${artifactId}/${version}/"
cp build/${artifactId}-${version}-sources.jar "$out/maven/${groupPath}/${artifactId}/${version}/"
cat > "$out/maven/${groupPath}/${artifactId}/${version}/${artifactId}-${version}.pom" <<'POM'
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>dev.jlibghostty</groupId>
<artifactId>jlibghostty</artifactId>
<version>0.1.0-SNAPSHOT</version>
<name>jlibghostty</name>
<description>Java FFM bindings for libghostty-vt</description>
</project>
POM
runHook postInstall
'';
meta = {
description = "Java FFM bindings for libghostty-vt";
platforms = systems;
};
};
in
{
default = package;
jlibghostty = package;
mavenRepository = package;
});
devShells = forAllSystems (system:
let
pkgs = import nixpkgs { inherit system; };
jdk =
if pkgs ? jdk25_headless then pkgs.jdk25_headless
else if pkgs ? jdk25 then pkgs.jdk25
else if pkgs ? jdk24_headless then pkgs.jdk24_headless
else if pkgs ? jdk24 then pkgs.jdk24
else pkgs.jdk;
ghosttyVt = ghostty.packages.${system}.libghostty-vt;
in
{
default = pkgs.mkShell {
packages =
[ jdk pkgs.gradle ]
++ pkgs.lib.optional (pkgs ? jextract) pkgs.jextract;
JLIBGHOSTTY_LIBRARY =
if pkgs.stdenv.hostPlatform.isDarwin
then "${ghosttyVt}/lib/libghostty-vt.dylib"
else "${ghosttyVt}/lib/libghostty-vt.so";
};
});
};
}

15
settings.gradle.kts Normal file
View File

@@ -0,0 +1,15 @@
pluginManagement {
repositories {
gradlePluginPortal()
mavenCentral()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
mavenCentral()
}
}
rootProject.name = "jlibghostty"

View File

@@ -0,0 +1,31 @@
package dev.jlibghostty;
import dev.jlibghostty.internal.GhosttyLibrary;
import java.nio.charset.StandardCharsets;
public final class Ghostty {
private Ghostty() {
}
public static Terminal open(TerminalOptions options) {
return Terminal.open(options);
}
public static boolean pasteIsSafe(String text) {
return pasteIsSafe(text.getBytes(StandardCharsets.UTF_8));
}
public static boolean pasteIsSafe(byte[] data) {
return GhosttyLibrary.loadDefault().pasteIsSafe(data);
}
public static String encodePaste(String text, boolean bracketed) {
byte[] encoded = encodePaste(text.getBytes(StandardCharsets.UTF_8), bracketed);
return new String(encoded, StandardCharsets.UTF_8);
}
public static byte[] encodePaste(byte[] data, boolean bracketed) {
return GhosttyLibrary.loadDefault().pasteEncode(data, bracketed);
}
}

View File

@@ -0,0 +1,14 @@
package dev.jlibghostty;
public final class GhosttyException extends RuntimeException {
private final int resultCode;
public GhosttyException(String message, int resultCode) {
super(message + " failed with libghostty-vt result " + resultCode);
this.resultCode = resultCode;
}
public int resultCode() {
return resultCode;
}
}

View File

@@ -0,0 +1,68 @@
package dev.jlibghostty;
import dev.jlibghostty.internal.GhosttyLibrary;
import java.lang.foreign.MemorySegment;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.atomic.AtomicBoolean;
public final class Terminal implements AutoCloseable {
private final GhosttyLibrary library;
private final MemorySegment handle;
private final AtomicBoolean closed = new AtomicBoolean();
private Terminal(GhosttyLibrary library, MemorySegment handle) {
this.library = library;
this.handle = handle;
}
public static Terminal open(TerminalOptions options) {
GhosttyLibrary library = GhosttyLibrary.loadDefault();
return new Terminal(library, library.terminalNew(options));
}
public void write(String vtData) {
write(vtData.getBytes(StandardCharsets.UTF_8));
}
public void write(byte[] vtData) {
ensureOpen();
library.terminalWrite(handle, vtData);
}
public void reset() {
ensureOpen();
library.terminalReset(handle);
}
public void resize(int columns, int rows, int cellWidthPx, int cellHeightPx) {
ensureOpen();
library.terminalResize(handle, columns, rows, cellWidthPx, cellHeightPx);
}
public TerminalSnapshot snapshot() {
ensureOpen();
return new TerminalSnapshot(
library.terminalGetU16(handle, GhosttyLibrary.TERMINAL_DATA_COLS),
library.terminalGetU16(handle, GhosttyLibrary.TERMINAL_DATA_ROWS),
library.terminalGetU16(handle, GhosttyLibrary.TERMINAL_DATA_CURSOR_X),
library.terminalGetU16(handle, GhosttyLibrary.TERMINAL_DATA_CURSOR_Y),
library.terminalGetBoolean(handle, GhosttyLibrary.TERMINAL_DATA_CURSOR_VISIBLE),
library.terminalGetString(handle, GhosttyLibrary.TERMINAL_DATA_TITLE),
library.terminalGetString(handle, GhosttyLibrary.TERMINAL_DATA_PWD)
);
}
@Override
public void close() {
if (closed.compareAndSet(false, true)) {
library.terminalFree(handle);
}
}
private void ensureOpen() {
if (closed.get()) {
throw new IllegalStateException("Terminal is closed");
}
}
}

View File

@@ -0,0 +1,19 @@
package dev.jlibghostty;
public record TerminalOptions(int columns, int rows, long maxScrollback) {
public TerminalOptions {
if (columns < 1 || columns > 65535) {
throw new IllegalArgumentException("columns must be between 1 and 65535");
}
if (rows < 1 || rows > 65535) {
throw new IllegalArgumentException("rows must be between 1 and 65535");
}
if (maxScrollback < 0) {
throw new IllegalArgumentException("maxScrollback must be non-negative");
}
}
public static TerminalOptions of(int columns, int rows) {
return new TerminalOptions(columns, rows, 10_000);
}
}

View File

@@ -0,0 +1,12 @@
package dev.jlibghostty;
public record TerminalSnapshot(
int columns,
int rows,
int cursorX,
int cursorY,
boolean cursorVisible,
String title,
String workingDirectory
) {
}

View File

@@ -0,0 +1,297 @@
package dev.jlibghostty.internal;
import dev.jlibghostty.GhosttyException;
import dev.jlibghostty.TerminalOptions;
import java.lang.foreign.Arena;
import java.lang.foreign.FunctionDescriptor;
import java.lang.foreign.GroupLayout;
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.nio.charset.StandardCharsets;
import java.nio.file.Path;
import static java.lang.foreign.ValueLayout.ADDRESS;
import static java.lang.foreign.ValueLayout.JAVA_BOOLEAN;
import static java.lang.foreign.ValueLayout.JAVA_BYTE;
import static java.lang.foreign.ValueLayout.JAVA_INT;
import static java.lang.foreign.ValueLayout.JAVA_SHORT;
public final class GhosttyLibrary {
public static final int TERMINAL_DATA_COLS = 1;
public static final int TERMINAL_DATA_ROWS = 2;
public static final int TERMINAL_DATA_CURSOR_X = 3;
public static final int TERMINAL_DATA_CURSOR_Y = 4;
public static final int TERMINAL_DATA_CURSOR_VISIBLE = 7;
public static final int TERMINAL_DATA_TITLE = 12;
public static final int TERMINAL_DATA_PWD = 13;
private static final int GHOSTTY_SUCCESS = 0;
private static final int GHOSTTY_OUT_OF_SPACE = -3;
private static final int GHOSTTY_NO_VALUE = -4;
private static final Linker LINKER = Linker.nativeLinker();
private static final ValueLayout.OfLong C_SIZE_T = sizeTLayout();
private static final GroupLayout TERMINAL_OPTIONS = MemoryLayout.structLayout(
JAVA_SHORT.withName("cols"),
JAVA_SHORT.withName("rows"),
MemoryLayout.paddingLayout(4),
C_SIZE_T.withName("max_scrollback")
);
private static final GroupLayout GHOSTTY_STRING = MemoryLayout.structLayout(
ADDRESS.withName("ptr"),
C_SIZE_T.withName("len")
);
private final MethodHandle terminalNew;
private final MethodHandle terminalFree;
private final MethodHandle terminalReset;
private final MethodHandle terminalResize;
private final MethodHandle terminalVtWrite;
private final MethodHandle terminalGet;
private final MethodHandle pasteIsSafe;
private final MethodHandle pasteEncode;
private GhosttyLibrary(Path libraryPath) {
try {
SymbolLookup symbols = SymbolLookup.libraryLookup(libraryPath, Arena.global());
terminalNew = downcall(symbols, "ghostty_terminal_new",
FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS, TERMINAL_OPTIONS));
terminalFree = downcall(symbols, "ghostty_terminal_free",
FunctionDescriptor.ofVoid(ADDRESS));
terminalReset = downcall(symbols, "ghostty_terminal_reset",
FunctionDescriptor.ofVoid(ADDRESS));
terminalResize = downcall(symbols, "ghostty_terminal_resize",
FunctionDescriptor.of(JAVA_INT, ADDRESS, JAVA_SHORT, JAVA_SHORT, JAVA_INT, JAVA_INT));
terminalVtWrite = downcall(symbols, "ghostty_terminal_vt_write",
FunctionDescriptor.ofVoid(ADDRESS, ADDRESS, C_SIZE_T));
terminalGet = downcall(symbols, "ghostty_terminal_get",
FunctionDescriptor.of(JAVA_INT, ADDRESS, JAVA_INT, ADDRESS));
pasteIsSafe = downcall(symbols, "ghostty_paste_is_safe",
FunctionDescriptor.of(JAVA_BOOLEAN, ADDRESS, C_SIZE_T));
pasteEncode = downcall(symbols, "ghostty_paste_encode",
FunctionDescriptor.of(JAVA_INT, ADDRESS, C_SIZE_T, JAVA_BOOLEAN, ADDRESS, C_SIZE_T, ADDRESS));
} catch (IllegalCallerException e) {
throw new IllegalStateException(
"FFM native access is disabled. Run with --enable-native-access=dev.jlibghostty "
+ "when using the module path, or --enable-native-access=ALL-UNNAMED on the classpath.",
e
);
}
}
public static GhosttyLibrary loadDefault() {
return Holder.INSTANCE;
}
public MemorySegment terminalNew(TerminalOptions options) {
try (Arena arena = Arena.ofConfined()) {
MemorySegment out = arena.allocate(ADDRESS);
MemorySegment nativeOptions = arena.allocate(TERMINAL_OPTIONS);
nativeOptions.set(JAVA_SHORT, 0, (short) options.columns());
nativeOptions.set(JAVA_SHORT, 2, (short) options.rows());
nativeOptions.set(C_SIZE_T, 8, options.maxScrollback());
int result = (int) terminalNew.invoke(MemorySegment.NULL, out, nativeOptions);
checkResult("ghostty_terminal_new", result);
MemorySegment terminal = out.get(ADDRESS, 0);
if (terminal.address() == 0) {
throw new IllegalStateException("ghostty_terminal_new returned a null terminal handle");
}
return terminal;
} catch (Throwable t) {
return rethrow(t);
}
}
public void terminalFree(MemorySegment terminal) {
try {
terminalFree.invoke(terminal);
} catch (Throwable t) {
rethrow(t);
}
}
public void terminalReset(MemorySegment terminal) {
try {
terminalReset.invoke(terminal);
} catch (Throwable t) {
rethrow(t);
}
}
public void terminalResize(MemorySegment terminal, int columns, int rows, int cellWidthPx, int cellHeightPx) {
try {
int result = (int) terminalResize.invoke(
terminal,
(short) columns,
(short) rows,
cellWidthPx,
cellHeightPx
);
checkResult("ghostty_terminal_resize", result);
} catch (Throwable t) {
rethrow(t);
}
}
public void terminalWrite(MemorySegment terminal, byte[] data) {
if (data.length == 0) {
return;
}
try (Arena arena = Arena.ofConfined()) {
MemorySegment nativeData = arena.allocate(data.length, 1);
MemorySegment.copy(data, 0, nativeData, JAVA_BYTE, 0, data.length);
terminalVtWrite.invoke(terminal, nativeData, (long) data.length);
} catch (Throwable t) {
rethrow(t);
}
}
public int terminalGetU16(MemorySegment terminal, int key) {
try (Arena arena = Arena.ofConfined()) {
MemorySegment out = arena.allocate(JAVA_SHORT);
int result = (int) terminalGet.invoke(terminal, key, out);
checkResult("ghostty_terminal_get", result);
return Short.toUnsignedInt(out.get(JAVA_SHORT, 0));
} catch (Throwable t) {
return rethrow(t);
}
}
public boolean terminalGetBoolean(MemorySegment terminal, int key) {
try (Arena arena = Arena.ofConfined()) {
MemorySegment out = arena.allocate(JAVA_BOOLEAN);
int result = (int) terminalGet.invoke(terminal, key, out);
checkResult("ghostty_terminal_get", result);
return out.get(JAVA_BOOLEAN, 0);
} catch (Throwable t) {
return rethrow(t);
}
}
public String terminalGetString(MemorySegment terminal, int key) {
try (Arena arena = Arena.ofConfined()) {
MemorySegment out = arena.allocate(GHOSTTY_STRING);
int result = (int) terminalGet.invoke(terminal, key, out);
if (result == GHOSTTY_NO_VALUE) {
return "";
}
checkResult("ghostty_terminal_get", result);
MemorySegment ptr = out.get(ADDRESS, 0);
long len = out.get(C_SIZE_T, 8);
if (ptr.address() == 0 || len == 0) {
return "";
}
byte[] bytes = ptr.reinterpret(len).toArray(JAVA_BYTE);
return new String(bytes, StandardCharsets.UTF_8);
} catch (Throwable t) {
return rethrow(t);
}
}
public boolean pasteIsSafe(byte[] data) {
if (data.length == 0) {
return true;
}
try (Arena arena = Arena.ofConfined()) {
MemorySegment nativeData = arena.allocate(data.length, 1);
MemorySegment.copy(data, 0, nativeData, JAVA_BYTE, 0, data.length);
return (boolean) pasteIsSafe.invoke(nativeData, (long) data.length);
} catch (Throwable t) {
return rethrow(t);
}
}
public byte[] pasteEncode(byte[] data, boolean bracketed) {
try (Arena arena = Arena.ofConfined()) {
MemorySegment nativeData = MemorySegment.NULL;
if (data.length > 0) {
nativeData = arena.allocate(data.length, 1);
MemorySegment.copy(data, 0, nativeData, JAVA_BYTE, 0, data.length);
}
MemorySegment outWritten = arena.allocate(C_SIZE_T);
int result = (int) pasteEncode.invoke(
nativeData,
(long) data.length,
bracketed,
MemorySegment.NULL,
0L,
outWritten
);
if (result != GHOSTTY_SUCCESS && result != GHOSTTY_OUT_OF_SPACE) {
checkResult("ghostty_paste_encode", result);
}
long required = outWritten.get(C_SIZE_T, 0);
if (required == 0) {
return new byte[0];
}
MemorySegment out = arena.allocate(required, 1);
result = (int) pasteEncode.invoke(
nativeData,
(long) data.length,
bracketed,
out,
required,
outWritten
);
checkResult("ghostty_paste_encode", result);
long written = outWritten.get(C_SIZE_T, 0);
return out.asSlice(0, written).toArray(JAVA_BYTE);
} catch (Throwable t) {
return rethrow(t);
}
}
private static MethodHandle downcall(SymbolLookup symbols, String name, FunctionDescriptor descriptor) {
MemorySegment symbol = symbols.find(name)
.orElseThrow(() -> new UnsatisfiedLinkError("Missing libghostty-vt symbol: " + name));
return LINKER.downcallHandle(symbol, descriptor);
}
private static void checkResult(String operation, int result) {
if (result != GHOSTTY_SUCCESS) {
throw new GhosttyException(operation, result);
}
}
@SuppressWarnings("unchecked")
private static <T> T rethrow(Throwable t) {
if (t instanceof RuntimeException runtimeException) {
throw runtimeException;
}
if (t instanceof Error error) {
throw error;
}
throw new IllegalStateException("Unexpected libghostty-vt FFM failure", t);
}
private static ValueLayout.OfLong sizeTLayout() {
ValueLayout layout = (ValueLayout) LINKER.canonicalLayouts().get("size_t");
if (layout.byteSize() != Long.BYTES) {
throw new UnsupportedOperationException("jlibghostty currently supports 64-bit platforms only");
}
return (ValueLayout.OfLong) layout;
}
private static final class Holder {
private static final GhosttyLibrary INSTANCE = new GhosttyLibrary(NativeLibraryLoader.resolve());
}
}

View File

@@ -0,0 +1,85 @@
package dev.jlibghostty.internal;
import java.io.IOException;
import java.io.InputStream;
import java.io.UncheckedIOException;
import java.net.URL;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.Locale;
public final class NativeLibraryLoader {
private static final String PROPERTY = "jlibghostty.library.path";
private static final String ENVIRONMENT = "JLIBGHOSTTY_LIBRARY";
private NativeLibraryLoader() {
}
public static Path resolve() {
String propertyOverride = System.getProperty(PROPERTY);
if (propertyOverride != null && !propertyOverride.isBlank()) {
return Path.of(propertyOverride).toAbsolutePath();
}
String environmentOverride = System.getenv(ENVIRONMENT);
if (environmentOverride != null && !environmentOverride.isBlank()) {
return Path.of(environmentOverride).toAbsolutePath();
}
String resource = "/dev/jlibghostty/native/" + platform() + "/" + System.mapLibraryName("ghostty-vt");
URL url = NativeLibraryLoader.class.getResource(resource);
if (url == null) {
throw new UnsatisfiedLinkError(
"Bundled libghostty-vt not found at " + resource
+ ". Set -D" + PROPERTY + "=/path/to/" + System.mapLibraryName("ghostty-vt")
+ " or " + ENVIRONMENT + " to load an external library."
);
}
return extract(resource);
}
private static Path extract(String resource) {
try (InputStream in = NativeLibraryLoader.class.getResourceAsStream(resource)) {
if (in == null) {
throw new UnsatisfiedLinkError("Bundled libghostty-vt resource disappeared: " + resource);
}
Path dir = Files.createTempDirectory("jlibghostty-");
Path library = dir.resolve(System.mapLibraryName("ghostty-vt"));
Files.copy(in, library, StandardCopyOption.REPLACE_EXISTING);
library.toFile().setReadable(true);
library.toFile().setExecutable(true);
library.toFile().deleteOnExit();
dir.toFile().deleteOnExit();
return library;
} catch (IOException e) {
throw new UncheckedIOException("Could not extract bundled libghostty-vt", e);
}
}
private static String platform() {
return normalizeOs() + "-" + normalizeArch();
}
private static String normalizeOs() {
String os = System.getProperty("os.name").toLowerCase(Locale.ROOT);
if (os.contains("linux")) {
return "linux";
}
if (os.contains("mac") || os.contains("darwin")) {
return "macos";
}
throw new UnsupportedOperationException("Unsupported operating system for libghostty-vt: " + os);
}
private static String normalizeArch() {
String arch = System.getProperty("os.arch").toLowerCase(Locale.ROOT);
return switch (arch) {
case "amd64", "x86_64" -> "x86_64";
case "aarch64", "arm64" -> "aarch64";
default -> throw new UnsupportedOperationException("Unsupported architecture for libghostty-vt: " + arch);
};
}
}

View File

@@ -0,0 +1,3 @@
module dev.jlibghostty {
exports dev.jlibghostty;
}

View File

@@ -0,0 +1,26 @@
package dev.jlibghostty;
public final class GhosttySmokeTest {
private GhosttySmokeTest() {
}
public static void main(String[] args) {
if (!Ghostty.pasteIsSafe("hello")) {
throw new AssertionError("simple paste should be safe");
}
if (Ghostty.pasteIsSafe("rm -rf /\n")) {
throw new AssertionError("newline paste should be unsafe");
}
if (!"hello".equals(Ghostty.encodePaste("hello", false))) {
throw new AssertionError("simple paste encoding changed unexpectedly");
}
try (Terminal terminal = Ghostty.open(TerminalOptions.of(80, 24))) {
terminal.write("hello\r\n");
TerminalSnapshot snapshot = terminal.snapshot();
if (snapshot.columns() != 80 || snapshot.rows() != 24) {
throw new AssertionError("unexpected terminal size: " + snapshot);
}
}
}
}