From 91e9ce5bb465afae8e341a2b4a52d2bdd6292f2b Mon Sep 17 00:00:00 2001 From: Gregor Lohaus Date: Wed, 27 May 2026 19:38:17 +0200 Subject: [PATCH] expose render state --- src/main/java/dev/jlibghostty/RenderCell.java | 20 ++ src/main/java/dev/jlibghostty/RenderRow.java | 17 ++ .../java/dev/jlibghostty/RenderState.java | 52 ++++ .../dev/jlibghostty/RenderStateSnapshot.java | 9 + src/main/java/dev/jlibghostty/Terminal.java | 16 + .../jlibghostty/internal/GhosttyLibrary.java | 284 ++++++++++++++++++ .../dev/jlibghostty/GhosttySmokeTest.java | 6 + 7 files changed, 404 insertions(+) create mode 100644 src/main/java/dev/jlibghostty/RenderCell.java create mode 100644 src/main/java/dev/jlibghostty/RenderRow.java create mode 100644 src/main/java/dev/jlibghostty/RenderState.java create mode 100644 src/main/java/dev/jlibghostty/RenderStateSnapshot.java diff --git a/src/main/java/dev/jlibghostty/RenderCell.java b/src/main/java/dev/jlibghostty/RenderCell.java new file mode 100644 index 0000000..c16a969 --- /dev/null +++ b/src/main/java/dev/jlibghostty/RenderCell.java @@ -0,0 +1,20 @@ +package dev.jlibghostty; + +public record RenderCell(int column, int[] codepoints, boolean selected) { + public RenderCell { + codepoints = codepoints.clone(); + } + + @Override + public int[] codepoints() { + return codepoints.clone(); + } + + public String text() { + StringBuilder builder = new StringBuilder(); + for (int codepoint : codepoints) { + builder.appendCodePoint(codepoint); + } + return builder.toString(); + } +} diff --git a/src/main/java/dev/jlibghostty/RenderRow.java b/src/main/java/dev/jlibghostty/RenderRow.java new file mode 100644 index 0000000..b61cae5 --- /dev/null +++ b/src/main/java/dev/jlibghostty/RenderRow.java @@ -0,0 +1,17 @@ +package dev.jlibghostty; + +import java.util.List; + +public record RenderRow(int row, boolean dirty, List cells) { + public RenderRow { + cells = List.copyOf(cells); + } + + public String text() { + StringBuilder builder = new StringBuilder(); + for (RenderCell cell : cells) { + builder.append(cell.text()); + } + return builder.toString(); + } +} diff --git a/src/main/java/dev/jlibghostty/RenderState.java b/src/main/java/dev/jlibghostty/RenderState.java new file mode 100644 index 0000000..49e2d86 --- /dev/null +++ b/src/main/java/dev/jlibghostty/RenderState.java @@ -0,0 +1,52 @@ +package dev.jlibghostty; + +import dev.jlibghostty.internal.GhosttyLibrary; + +import java.lang.foreign.MemorySegment; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; + +public final class RenderState implements AutoCloseable { + private final GhosttyLibrary library; + private final MemorySegment handle; + private final AtomicBoolean closed = new AtomicBoolean(); + + public RenderState() { + this.library = GhosttyLibrary.loadDefault(); + this.handle = library.renderStateNew(); + } + + public void update(Terminal terminal) { + ensureOpen(); + terminal.ensureOpenForPackage(); + library.renderStateUpdate(handle, terminal.handleForPackage()); + } + + public RenderStateSnapshot snapshot() { + ensureOpen(); + return new RenderStateSnapshot( + library.renderStateGetU16(handle, GhosttyLibrary.RENDER_STATE_DATA_COLS), + library.renderStateGetU16(handle, GhosttyLibrary.RENDER_STATE_DATA_ROWS), + library.renderStateGetI32(handle, GhosttyLibrary.RENDER_STATE_DATA_DIRTY), + rows() + ); + } + + public List rows() { + ensureOpen(); + return library.renderStateRows(handle); + } + + @Override + public void close() { + if (closed.compareAndSet(false, true)) { + library.renderStateFree(handle); + } + } + + private void ensureOpen() { + if (closed.get()) { + throw new IllegalStateException("RenderState is closed"); + } + } +} diff --git a/src/main/java/dev/jlibghostty/RenderStateSnapshot.java b/src/main/java/dev/jlibghostty/RenderStateSnapshot.java new file mode 100644 index 0000000..69685d1 --- /dev/null +++ b/src/main/java/dev/jlibghostty/RenderStateSnapshot.java @@ -0,0 +1,9 @@ +package dev.jlibghostty; + +import java.util.List; + +public record RenderStateSnapshot(int columns, int rows, int dirty, List renderRows) { + public RenderStateSnapshot { + renderRows = List.copyOf(renderRows); + } +} diff --git a/src/main/java/dev/jlibghostty/Terminal.java b/src/main/java/dev/jlibghostty/Terminal.java index fd23fe8..80ef54c 100644 --- a/src/main/java/dev/jlibghostty/Terminal.java +++ b/src/main/java/dev/jlibghostty/Terminal.java @@ -48,6 +48,14 @@ public final class Terminal implements AutoCloseable { return library.formatTerminal(handle, format.nativeValue()); } + public RenderStateSnapshot renderSnapshot() { + ensureOpen(); + try (RenderState renderState = new RenderState()) { + renderState.update(this); + return renderState.snapshot(); + } + } + public void reset() { ensureOpen(); library.terminalReset(handle); @@ -115,4 +123,12 @@ public final class Terminal implements AutoCloseable { throw new IllegalStateException("Terminal is closed"); } } + + void ensureOpenForPackage() { + ensureOpen(); + } + + MemorySegment handleForPackage() { + return handle; + } } diff --git a/src/main/java/dev/jlibghostty/internal/GhosttyLibrary.java b/src/main/java/dev/jlibghostty/internal/GhosttyLibrary.java index 700dd26..88007c3 100644 --- a/src/main/java/dev/jlibghostty/internal/GhosttyLibrary.java +++ b/src/main/java/dev/jlibghostty/internal/GhosttyLibrary.java @@ -3,6 +3,8 @@ package dev.jlibghostty.internal; import dev.jlibghostty.GhosttyBuildInfo; import dev.jlibghostty.GhosttyException; import dev.jlibghostty.OptimizeMode; +import dev.jlibghostty.RenderCell; +import dev.jlibghostty.RenderRow; import dev.jlibghostty.SizeReportSize; import dev.jlibghostty.TerminalOptions; @@ -18,6 +20,8 @@ import java.lang.foreign.ValueLayout; import java.lang.invoke.MethodHandle; import java.nio.charset.StandardCharsets; import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; import static java.lang.foreign.ValueLayout.JAVA_BYTE; @@ -60,6 +64,18 @@ public final class GhosttyLibrary { public static final int KITTY_IMAGE_DATA_DATA_PTR = 7; public static final int KITTY_IMAGE_DATA_DATA_LEN = 8; + public static final int RENDER_STATE_DATA_COLS = 1; + public static final int RENDER_STATE_DATA_ROWS = 2; + public static final int RENDER_STATE_DATA_DIRTY = 3; + public static final int RENDER_STATE_DATA_ROW_ITERATOR = 4; + + public static final int RENDER_STATE_ROW_DATA_DIRTY = 1; + public static final int RENDER_STATE_ROW_DATA_CELLS = 3; + + public static final int RENDER_STATE_ROW_CELLS_DATA_GRAPHEMES_LEN = 3; + public static final int RENDER_STATE_ROW_CELLS_DATA_GRAPHEMES_BUF = 4; + public static final int RENDER_STATE_ROW_CELLS_DATA_SELECTED = 7; + private static final int GHOSTTY_SUCCESS = 0; private static final int GHOSTTY_OUT_OF_SPACE = -3; private static final int GHOSTTY_NO_VALUE = -4; @@ -169,6 +185,18 @@ public final class GhosttyLibrary { private final MethodHandle formatterTerminalNew; private final MethodHandle formatterFormatBuf; private final MethodHandle formatterFree; + private final MethodHandle renderStateNew; + private final MethodHandle renderStateFree; + private final MethodHandle renderStateUpdate; + private final MethodHandle renderStateGet; + private final MethodHandle renderStateRowIteratorNew; + private final MethodHandle renderStateRowIteratorFree; + private final MethodHandle renderStateRowIteratorNext; + private final MethodHandle renderStateRowGet; + private final MethodHandle renderStateRowCellsNew; + private final MethodHandle renderStateRowCellsFree; + private final MethodHandle renderStateRowCellsNext; + private final MethodHandle renderStateRowCellsGet; private final MethodHandle kittyGraphicsGet; private final MethodHandle kittyGraphicsImage; private final MethodHandle kittyGraphicsImageGet; @@ -217,6 +245,30 @@ public final class GhosttyLibrary { FunctionDescriptor.of(C_INT, C_POINTER, C_POINTER, C_SIZE_T, C_POINTER)); formatterFree = downcall(symbols, "ghostty_formatter_free", FunctionDescriptor.ofVoid(C_POINTER)); + renderStateNew = downcall(symbols, "ghostty_render_state_new", + FunctionDescriptor.of(C_INT, C_POINTER, C_POINTER)); + renderStateFree = downcall(symbols, "ghostty_render_state_free", + FunctionDescriptor.ofVoid(C_POINTER)); + renderStateUpdate = downcall(symbols, "ghostty_render_state_update", + FunctionDescriptor.of(C_INT, C_POINTER, C_POINTER)); + renderStateGet = downcall(symbols, "ghostty_render_state_get", + FunctionDescriptor.of(C_INT, C_POINTER, C_INT, C_POINTER)); + renderStateRowIteratorNew = downcall(symbols, "ghostty_render_state_row_iterator_new", + FunctionDescriptor.of(C_INT, C_POINTER, C_POINTER)); + renderStateRowIteratorFree = downcall(symbols, "ghostty_render_state_row_iterator_free", + FunctionDescriptor.ofVoid(C_POINTER)); + renderStateRowIteratorNext = downcall(symbols, "ghostty_render_state_row_iterator_next", + FunctionDescriptor.of(C_BOOL, C_POINTER)); + renderStateRowGet = downcall(symbols, "ghostty_render_state_row_get", + FunctionDescriptor.of(C_INT, C_POINTER, C_INT, C_POINTER)); + renderStateRowCellsNew = downcall(symbols, "ghostty_render_state_row_cells_new", + FunctionDescriptor.of(C_INT, C_POINTER, C_POINTER)); + renderStateRowCellsFree = downcall(symbols, "ghostty_render_state_row_cells_free", + FunctionDescriptor.ofVoid(C_POINTER)); + renderStateRowCellsNext = downcall(symbols, "ghostty_render_state_row_cells_next", + FunctionDescriptor.of(C_BOOL, C_POINTER)); + renderStateRowCellsGet = downcall(symbols, "ghostty_render_state_row_cells_get", + FunctionDescriptor.of(C_INT, C_POINTER, C_INT, C_POINTER)); kittyGraphicsGet = downcall(symbols, "ghostty_kitty_graphics_get", FunctionDescriptor.of(C_INT, C_POINTER, C_INT, C_POINTER)); kittyGraphicsImage = downcall(symbols, "ghostty_kitty_graphics_image", @@ -515,6 +567,238 @@ public final class GhosttyLibrary { } } + public MemorySegment renderStateNew() { + try (Arena arena = Arena.ofConfined()) { + MemorySegment out = arena.allocate(C_POINTER); + int result = (int) renderStateNew.invoke(MemorySegment.NULL, out); + checkResult("ghostty_render_state_new", result); + + MemorySegment state = out.get(C_POINTER, 0); + if (state.address() == 0) { + throw new IllegalStateException("ghostty_render_state_new returned null"); + } + return state; + } catch (Throwable t) { + return rethrow(t); + } + } + + public void renderStateFree(MemorySegment state) { + try { + renderStateFree.invoke(state); + } catch (Throwable t) { + rethrow(t); + } + } + + public void renderStateUpdate(MemorySegment state, MemorySegment terminal) { + try { + int result = (int) renderStateUpdate.invoke(state, terminal); + checkResult("ghostty_render_state_update", result); + } catch (Throwable t) { + rethrow(t); + } + } + + public int renderStateGetU16(MemorySegment state, int key) { + try (Arena arena = Arena.ofConfined()) { + MemorySegment out = arena.allocate(C_SHORT); + int result = (int) renderStateGet.invoke(state, key, out); + checkResult("ghostty_render_state_get", result); + return Short.toUnsignedInt(out.get(C_SHORT, 0)); + } catch (Throwable t) { + return rethrow(t); + } + } + + public int renderStateGetI32(MemorySegment state, int key) { + try (Arena arena = Arena.ofConfined()) { + MemorySegment out = arena.allocate(C_INT); + int result = (int) renderStateGet.invoke(state, key, out); + checkResult("ghostty_render_state_get", result); + return out.get(C_INT, 0); + } catch (Throwable t) { + return rethrow(t); + } + } + + public List renderStateRows(MemorySegment state) { + MemorySegment iterator = renderStateRowIteratorNew(); + try { + renderStatePopulateRowIterator(state, iterator); + List rows = new ArrayList<>(); + int rowIndex = 0; + while (renderStateRowIteratorNext(iterator)) { + boolean dirty = renderStateRowGetBoolean(iterator, RENDER_STATE_ROW_DATA_DIRTY); + rows.add(new RenderRow(rowIndex, dirty, renderStateRowCells(iterator))); + rowIndex++; + } + return List.copyOf(rows); + } finally { + renderStateRowIteratorFree(iterator); + } + } + + private MemorySegment renderStateRowIteratorNew() { + try (Arena arena = Arena.ofConfined()) { + MemorySegment out = arena.allocate(C_POINTER); + int result = (int) renderStateRowIteratorNew.invoke(MemorySegment.NULL, out); + checkResult("ghostty_render_state_row_iterator_new", result); + + MemorySegment iterator = out.get(C_POINTER, 0); + if (iterator.address() == 0) { + throw new IllegalStateException("ghostty_render_state_row_iterator_new returned null"); + } + return iterator; + } catch (Throwable t) { + return rethrow(t); + } + } + + private void renderStateRowIteratorFree(MemorySegment iterator) { + try { + renderStateRowIteratorFree.invoke(iterator); + } catch (Throwable t) { + rethrow(t); + } + } + + private void renderStatePopulateRowIterator(MemorySegment state, MemorySegment iterator) { + try (Arena arena = Arena.ofConfined()) { + MemorySegment out = arena.allocate(C_POINTER); + out.set(C_POINTER, 0, iterator); + int result = (int) renderStateGet.invoke(state, RENDER_STATE_DATA_ROW_ITERATOR, out); + checkResult("ghostty_render_state_get", result); + } catch (Throwable t) { + rethrow(t); + } + } + + private boolean renderStateRowIteratorNext(MemorySegment iterator) { + try { + return (boolean) renderStateRowIteratorNext.invoke(iterator); + } catch (Throwable t) { + return rethrow(t); + } + } + + private boolean renderStateRowGetBoolean(MemorySegment iterator, int key) { + try (Arena arena = Arena.ofConfined()) { + MemorySegment out = arena.allocate(C_BOOL); + int result = (int) renderStateRowGet.invoke(iterator, key, out); + checkResult("ghostty_render_state_row_get", result); + return out.get(C_BOOL, 0); + } catch (Throwable t) { + return rethrow(t); + } + } + + private List renderStateRowCells(MemorySegment rowIterator) { + MemorySegment cells = renderStateRowCellsNew(); + try { + renderStatePopulateRowCells(rowIterator, cells); + List result = new ArrayList<>(); + int column = 0; + while (renderStateRowCellsNext(cells)) { + result.add(new RenderCell( + column, + renderStateRowCellGraphemes(cells), + renderStateRowCellsGetBoolean(cells, RENDER_STATE_ROW_CELLS_DATA_SELECTED) + )); + column++; + } + return List.copyOf(result); + } finally { + renderStateRowCellsFree(cells); + } + } + + private MemorySegment renderStateRowCellsNew() { + try (Arena arena = Arena.ofConfined()) { + MemorySegment out = arena.allocate(C_POINTER); + int result = (int) renderStateRowCellsNew.invoke(MemorySegment.NULL, out); + checkResult("ghostty_render_state_row_cells_new", result); + + MemorySegment cells = out.get(C_POINTER, 0); + if (cells.address() == 0) { + throw new IllegalStateException("ghostty_render_state_row_cells_new returned null"); + } + return cells; + } catch (Throwable t) { + return rethrow(t); + } + } + + private void renderStateRowCellsFree(MemorySegment cells) { + try { + renderStateRowCellsFree.invoke(cells); + } catch (Throwable t) { + rethrow(t); + } + } + + private void renderStatePopulateRowCells(MemorySegment rowIterator, MemorySegment cells) { + try (Arena arena = Arena.ofConfined()) { + MemorySegment out = arena.allocate(C_POINTER); + out.set(C_POINTER, 0, cells); + int result = (int) renderStateRowGet.invoke(rowIterator, RENDER_STATE_ROW_DATA_CELLS, out); + checkResult("ghostty_render_state_row_get", result); + } catch (Throwable t) { + rethrow(t); + } + } + + private boolean renderStateRowCellsNext(MemorySegment cells) { + try { + return (boolean) renderStateRowCellsNext.invoke(cells); + } catch (Throwable t) { + return rethrow(t); + } + } + + private int[] renderStateRowCellGraphemes(MemorySegment cells) { + int len = renderStateRowCellsGetI32(cells, RENDER_STATE_ROW_CELLS_DATA_GRAPHEMES_LEN); + if (len <= 0) { + return new int[0]; + } + + try (Arena arena = Arena.ofConfined()) { + MemorySegment out = arena.allocate(C_INT, len); + int result = (int) renderStateRowCellsGet.invoke(cells, RENDER_STATE_ROW_CELLS_DATA_GRAPHEMES_BUF, out); + checkResult("ghostty_render_state_row_cells_get", result); + + int[] codepoints = new int[len]; + for (int i = 0; i < len; i++) { + codepoints[i] = out.getAtIndex(C_INT, i); + } + return codepoints; + } catch (Throwable t) { + return rethrow(t); + } + } + + private int renderStateRowCellsGetI32(MemorySegment cells, int key) { + try (Arena arena = Arena.ofConfined()) { + MemorySegment out = arena.allocate(C_INT); + int result = (int) renderStateRowCellsGet.invoke(cells, key, out); + checkResult("ghostty_render_state_row_cells_get", result); + return out.get(C_INT, 0); + } catch (Throwable t) { + return rethrow(t); + } + } + + private boolean renderStateRowCellsGetBoolean(MemorySegment cells, int key) { + try (Arena arena = Arena.ofConfined()) { + MemorySegment out = arena.allocate(C_BOOL); + int result = (int) renderStateRowCellsGet.invoke(cells, key, out); + checkResult("ghostty_render_state_row_cells_get", result); + return out.get(C_BOOL, 0); + } catch (Throwable t) { + return rethrow(t); + } + } + public void kittyGraphicsPopulatePlacementIterator(MemorySegment graphics, MemorySegment iterator) { try (Arena arena = Arena.ofConfined()) { MemorySegment out = arena.allocate(C_POINTER); diff --git a/src/test/java/dev/jlibghostty/GhosttySmokeTest.java b/src/test/java/dev/jlibghostty/GhosttySmokeTest.java index eaeacbb..e379ad5 100644 --- a/src/test/java/dev/jlibghostty/GhosttySmokeTest.java +++ b/src/test/java/dev/jlibghostty/GhosttySmokeTest.java @@ -37,6 +37,12 @@ public final class GhosttySmokeTest { if (!terminal.text().contains("hello")) { throw new AssertionError("formatted terminal text should contain written text: " + terminal.text()); } + RenderStateSnapshot renderSnapshot = terminal.renderSnapshot(); + boolean renderedHello = renderSnapshot.renderRows().stream() + .anyMatch(row -> row.text().contains("hello")); + if (!renderedHello) { + throw new AssertionError("render state should contain written text"); + } TerminalSnapshot snapshot = terminal.snapshot(); if (snapshot.columns() != 80 || snapshot.rows() != 24) { throw new AssertionError("unexpected terminal size: " + snapshot);