diff --git a/src/main/java/dev/jlibghostty/DeviceAttributes.java b/src/main/java/dev/jlibghostty/DeviceAttributes.java new file mode 100644 index 0000000..e0efa74 --- /dev/null +++ b/src/main/java/dev/jlibghostty/DeviceAttributes.java @@ -0,0 +1,68 @@ +package dev.jlibghostty; + +import java.util.Arrays; + +public record DeviceAttributes( + int conformanceLevel, + int[] features, + int deviceType, + int firmwareVersion, + int romCartridge, + long unitId +) { + public static final int DA_CONFORMANCE_VT220 = 62; + public static final int DA_FEATURE_ANSI_COLOR = 22; + public static final int DA_FEATURE_CLIPBOARD = 52; + public static final int DA_FEATURE_SELECTIVE_ERASE = 6; + public static final int DA_DEVICE_TYPE_VT220 = 1; + + public DeviceAttributes { + checkU16("conformanceLevel", conformanceLevel); + features = features.clone(); + if (features.length > 64) { + throw new IllegalArgumentException("features may contain at most 64 entries"); + } + for (int feature : features) { + checkU16("feature", feature); + } + checkU16("deviceType", deviceType); + checkU16("firmwareVersion", firmwareVersion); + checkU16("romCartridge", romCartridge); + if (unitId < 0 || unitId > 0xffffffffL) { + throw new IllegalArgumentException("unitId must fit in uint32_t"); + } + } + + public static DeviceAttributes xtermCompatible() { + return new DeviceAttributes( + DA_CONFORMANCE_VT220, + new int[] { DA_FEATURE_SELECTIVE_ERASE, DA_FEATURE_ANSI_COLOR, DA_FEATURE_CLIPBOARD }, + DA_DEVICE_TYPE_VT220, + 0, + 0, + 0 + ); + } + + @Override + public int[] features() { + return features.clone(); + } + + @Override + public String toString() { + return "DeviceAttributes[conformanceLevel=" + conformanceLevel + + ", features=" + Arrays.toString(features) + + ", deviceType=" + deviceType + + ", firmwareVersion=" + firmwareVersion + + ", romCartridge=" + romCartridge + + ", unitId=" + unitId + + "]"; + } + + private static void checkU16(String name, int value) { + if (value < 0 || value > 65535) { + throw new IllegalArgumentException(name + " must fit in uint16_t"); + } + } +} diff --git a/src/main/java/dev/jlibghostty/DeviceAttributesProvider.java b/src/main/java/dev/jlibghostty/DeviceAttributesProvider.java new file mode 100644 index 0000000..61977a6 --- /dev/null +++ b/src/main/java/dev/jlibghostty/DeviceAttributesProvider.java @@ -0,0 +1,6 @@ +package dev.jlibghostty; + +@FunctionalInterface +public interface DeviceAttributesProvider { + DeviceAttributes deviceAttributes(); +} diff --git a/src/main/java/dev/jlibghostty/PtyWriter.java b/src/main/java/dev/jlibghostty/PtyWriter.java new file mode 100644 index 0000000..a3493f6 --- /dev/null +++ b/src/main/java/dev/jlibghostty/PtyWriter.java @@ -0,0 +1,6 @@ +package dev.jlibghostty; + +@FunctionalInterface +public interface PtyWriter { + void write(byte[] bytes); +} diff --git a/src/main/java/dev/jlibghostty/Terminal.java b/src/main/java/dev/jlibghostty/Terminal.java index 80ef54c..280ee02 100644 --- a/src/main/java/dev/jlibghostty/Terminal.java +++ b/src/main/java/dev/jlibghostty/Terminal.java @@ -2,6 +2,7 @@ package dev.jlibghostty; import dev.jlibghostty.internal.GhosttyLibrary; +import java.lang.foreign.Arena; import java.lang.foreign.MemorySegment; import java.nio.charset.StandardCharsets; import java.util.Optional; @@ -10,7 +11,12 @@ import java.util.concurrent.atomic.AtomicBoolean; public final class Terminal implements AutoCloseable { private final GhosttyLibrary library; private final MemorySegment handle; + private final Arena callbackArena = Arena.ofShared(); private final AtomicBoolean closed = new AtomicBoolean(); + private MemorySegment ptyWriterStub = MemorySegment.NULL; + private MemorySegment deviceAttributesStub = MemorySegment.NULL; + private PtyWriter ptyWriter; + private DeviceAttributesProvider deviceAttributesProvider; private Terminal(GhosttyLibrary library, MemorySegment handle) { this.library = library; @@ -31,6 +37,36 @@ public final class Terminal implements AutoCloseable { library.terminalWrite(handle, vtData); } + public void setPtyWriter(PtyWriter writer) { + ensureOpen(); + ptyWriter = writer; + if (writer == null) { + ptyWriterStub = MemorySegment.NULL; + library.terminalSetPointer(handle, GhosttyLibrary.TERMINAL_OPT_WRITE_PTY, MemorySegment.NULL); + return; + } + + ptyWriterStub = library.upcallPtyWriter(writer, callbackArena); + library.terminalSetPointer(handle, GhosttyLibrary.TERMINAL_OPT_WRITE_PTY, ptyWriterStub); + } + + public void setDeviceAttributesProvider(DeviceAttributesProvider provider) { + ensureOpen(); + deviceAttributesProvider = provider; + if (provider == null) { + deviceAttributesStub = MemorySegment.NULL; + library.terminalSetPointer(handle, GhosttyLibrary.TERMINAL_OPT_DEVICE_ATTRIBUTES, MemorySegment.NULL); + return; + } + + deviceAttributesStub = library.upcallDeviceAttributesProvider(provider, callbackArena); + library.terminalSetPointer( + handle, + GhosttyLibrary.TERMINAL_OPT_DEVICE_ATTRIBUTES, + deviceAttributesStub + ); + } + public String text() { return format(TerminalFormat.PLAIN); } @@ -115,6 +151,7 @@ public final class Terminal implements AutoCloseable { public void close() { if (closed.compareAndSet(false, true)) { library.terminalFree(handle); + callbackArena.close(); } } diff --git a/src/main/java/dev/jlibghostty/internal/GhosttyLibrary.java b/src/main/java/dev/jlibghostty/internal/GhosttyLibrary.java index 0191422..bed8d93 100644 --- a/src/main/java/dev/jlibghostty/internal/GhosttyLibrary.java +++ b/src/main/java/dev/jlibghostty/internal/GhosttyLibrary.java @@ -2,7 +2,10 @@ package dev.jlibghostty.internal; import dev.jlibghostty.GhosttyBuildInfo; import dev.jlibghostty.GhosttyException; +import dev.jlibghostty.DeviceAttributes; +import dev.jlibghostty.DeviceAttributesProvider; import dev.jlibghostty.OptimizeMode; +import dev.jlibghostty.PtyWriter; import dev.jlibghostty.RenderCell; import dev.jlibghostty.RenderColor; import dev.jlibghostty.RenderRow; @@ -19,6 +22,8 @@ import java.lang.foreign.MemorySegment; import java.lang.foreign.SymbolLookup; import java.lang.foreign.ValueLayout; import java.lang.invoke.MethodHandle; +import java.lang.invoke.MethodHandles; +import java.lang.invoke.MethodType; import java.nio.charset.StandardCharsets; import java.nio.file.Path; import java.util.ArrayList; @@ -37,6 +42,8 @@ public final class GhosttyLibrary { public static final int TERMINAL_DATA_PWD = 13; public static final int TERMINAL_DATA_KITTY_GRAPHICS = 30; + public static final int TERMINAL_OPT_WRITE_PTY = 1; + public static final int TERMINAL_OPT_DEVICE_ATTRIBUTES = 8; public static final int TERMINAL_OPT_KITTY_IMAGE_STORAGE_LIMIT = 15; public static final int TERMINAL_OPT_KITTY_IMAGE_MEDIUM_FILE = 16; public static final int TERMINAL_OPT_KITTY_IMAGE_MEDIUM_TEMP_FILE = 17; @@ -111,6 +118,14 @@ public final class GhosttyLibrary { private static final ValueLayout.OfInt C_INT = (ValueLayout.OfInt) LINKER.canonicalLayouts().get("int"); private static final ValueLayout.OfLong C_LONG_LONG = (ValueLayout.OfLong) LINKER.canonicalLayouts().get("long long"); private static final ValueLayout.OfLong C_SIZE_T = sizeTLayout(); + private static final MethodHandle PTY_WRITER_UPCALL = staticMethod( + "ptyWriterUpcall", + MethodType.methodType(void.class, PtyWriter.class, MemorySegment.class, MemorySegment.class, MemorySegment.class, long.class) + ); + private static final MethodHandle DEVICE_ATTRIBUTES_UPCALL = staticMethod( + "deviceAttributesUpcall", + MethodType.methodType(boolean.class, DeviceAttributesProvider.class, MemorySegment.class, MemorySegment.class, MemorySegment.class) + ); private static final GroupLayout TERMINAL_OPTIONS = MemoryLayout.structLayout( C_SHORT.withName("cols"), @@ -402,6 +417,33 @@ public final class GhosttyLibrary { } } + public void terminalSetPointer(MemorySegment terminal, int key, MemorySegment value) { + try { + int result = (int) terminalSet.invoke(terminal, key, value); + checkResult("ghostty_terminal_set", result); + } catch (Throwable t) { + rethrow(t); + } + } + + public MemorySegment upcallPtyWriter(PtyWriter writer, Arena arena) { + MethodHandle handle = MethodHandles.insertArguments(PTY_WRITER_UPCALL, 0, writer); + return LINKER.upcallStub( + handle, + FunctionDescriptor.ofVoid(C_POINTER, C_POINTER, C_POINTER, C_SIZE_T), + arena + ); + } + + public MemorySegment upcallDeviceAttributesProvider(DeviceAttributesProvider provider, Arena arena) { + MethodHandle handle = MethodHandles.insertArguments(DEVICE_ATTRIBUTES_UPCALL, 0, provider); + return LINKER.upcallStub( + handle, + FunctionDescriptor.of(C_BOOL, C_POINTER, C_POINTER, C_POINTER), + arena + ); + } + public int terminalGetU16(MemorySegment terminal, int key) { try (Arena arena = Arena.ofConfined()) { MemorySegment out = arena.allocate(C_SHORT); @@ -1006,6 +1048,71 @@ public final class GhosttyLibrary { return LINKER.downcallHandle(symbol, descriptor); } + private static MethodHandle staticMethod(String name, MethodType type) { + try { + return MethodHandles.lookup().findStatic(GhosttyLibrary.class, name, type); + } catch (NoSuchMethodException | IllegalAccessException e) { + throw new ExceptionInInitializerError(e); + } + } + + private static void ptyWriterUpcall( + PtyWriter writer, + MemorySegment terminal, + MemorySegment userdata, + MemorySegment data, + long len + ) { + try { + if (data.address() == 0 || len == 0) { + writer.write(new byte[0]); + return; + } + writer.write(data.reinterpret(len).toArray(JAVA_BYTE)); + } catch (Throwable ignored) { + } + } + + private static boolean deviceAttributesUpcall( + DeviceAttributesProvider provider, + MemorySegment terminal, + MemorySegment userdata, + MemorySegment outAttrs + ) { + try { + if (outAttrs.address() == 0) { + return false; + } + + DeviceAttributes attrs = provider.deviceAttributes(); + if (attrs == null) { + return false; + } + + writeDeviceAttributes(outAttrs, attrs); + return true; + } catch (Throwable ignored) { + return false; + } + } + + private static void writeDeviceAttributes(MemorySegment out, DeviceAttributes attrs) { + out.reinterpret(160).fill((byte) 0); + + out.set(C_SHORT, 0, (short) attrs.conformanceLevel()); + int[] features = attrs.features(); + for (int i = 0; i < features.length; i++) { + out.set(C_SHORT, 2L + (long) i * Short.BYTES, (short) features[i]); + } + out.set(C_SIZE_T, 136, features.length); + + out.set(C_SHORT, 144, (short) attrs.deviceType()); + out.set(C_SHORT, 146, (short) attrs.firmwareVersion()); + out.set(C_SHORT, 148, (short) attrs.romCartridge()); + + out.set(C_INT, 152, (int) attrs.unitId()); + } + private static void checkResult(String operation, int result) { if (result != GHOSTTY_SUCCESS) { throw new GhosttyException(operation, result); diff --git a/src/main/resources/META-INF/native-image/dev.jlibghostty/jlibghostty/reachability-metadata.json b/src/main/resources/META-INF/native-image/dev.jlibghostty/jlibghostty/reachability-metadata.json index 3a09f8f..a75f030 100644 --- a/src/main/resources/META-INF/native-image/dev.jlibghostty/jlibghostty/reachability-metadata.json +++ b/src/main/resources/META-INF/native-image/dev.jlibghostty/jlibghostty/reachability-metadata.json @@ -5,6 +5,25 @@ } ], "foreign": { + "upcalls": [ + { + "returnType": "void", + "parameterTypes": [ + "void*", + "void*", + "void*", + "size_t" + ] + }, + { + "returnType": "bool", + "parameterTypes": [ + "void*", + "void*", + "void*" + ] + } + ], "downcalls": [ { "returnType": "int", diff --git a/src/test/java/dev/jlibghostty/GhosttySmokeTest.java b/src/test/java/dev/jlibghostty/GhosttySmokeTest.java index b5576f6..8679222 100644 --- a/src/test/java/dev/jlibghostty/GhosttySmokeTest.java +++ b/src/test/java/dev/jlibghostty/GhosttySmokeTest.java @@ -1,5 +1,8 @@ package dev.jlibghostty; +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; + public final class GhosttySmokeTest { private GhosttySmokeTest() { } @@ -31,9 +34,18 @@ public final class GhosttySmokeTest { } try (Terminal terminal = Ghostty.open(TerminalOptions.of(80, 24))) { + ByteArrayOutputStream ptyResponses = new ByteArrayOutputStream(); + terminal.setPtyWriter(ptyResponses::writeBytes); + terminal.setDeviceAttributesProvider(DeviceAttributes::xtermCompatible); terminal.setKittyImageStorageLimit(1024 * 1024); terminal.setKittyImageMediumFile(true); - terminal.write("hello\r\n"); + terminal.write("hello\r\n\u001b[5n\u001b[6n\u001b[c"); + String responses = ptyResponses.toString(StandardCharsets.UTF_8); + if (!responses.contains("\u001b[0n") + || !responses.contains("\u001b[") + || !responses.contains("c")) { + throw new AssertionError("expected PTY query responses, got: " + responses); + } if (!terminal.text().contains("hello")) { throw new AssertionError("formatted terminal text should contain written text: " + terminal.text()); }