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

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