diff --git a/src/main/java/dev/jlibghostty/KeyModifiers.java b/src/main/java/dev/jlibghostty/KeyModifiers.java new file mode 100644 index 0000000..f430a58 --- /dev/null +++ b/src/main/java/dev/jlibghostty/KeyModifiers.java @@ -0,0 +1,37 @@ +package dev.jlibghostty; + +public record KeyModifiers(int mask) { + public static final int SHIFT = 1 << 0; + public static final int CTRL = 1 << 1; + public static final int ALT = 1 << 2; + public static final int SUPER = 1 << 3; + public static final int CAPS_LOCK = 1 << 4; + public static final int NUM_LOCK = 1 << 5; + + public KeyModifiers { + if (mask < 0 || mask > 0xffff) { + throw new IllegalArgumentException("modifier mask must fit in uint16_t"); + } + } + + public static KeyModifiers none() { + return new KeyModifiers(0); + } + + public static KeyModifiers of(boolean shift, boolean ctrl, boolean alt, boolean superKey) { + int mask = 0; + if (shift) { + mask |= SHIFT; + } + if (ctrl) { + mask |= CTRL; + } + if (alt) { + mask |= ALT; + } + if (superKey) { + mask |= SUPER; + } + return new KeyModifiers(mask); + } +} diff --git a/src/main/java/dev/jlibghostty/MouseAction.java b/src/main/java/dev/jlibghostty/MouseAction.java new file mode 100644 index 0000000..07bb666 --- /dev/null +++ b/src/main/java/dev/jlibghostty/MouseAction.java @@ -0,0 +1,17 @@ +package dev.jlibghostty; + +public enum MouseAction { + PRESS(0), + RELEASE(1), + MOTION(2); + + private final int nativeValue; + + MouseAction(int nativeValue) { + this.nativeValue = nativeValue; + } + + public int nativeValue() { + return nativeValue; + } +} diff --git a/src/main/java/dev/jlibghostty/MouseButton.java b/src/main/java/dev/jlibghostty/MouseButton.java new file mode 100644 index 0000000..2d163c0 --- /dev/null +++ b/src/main/java/dev/jlibghostty/MouseButton.java @@ -0,0 +1,26 @@ +package dev.jlibghostty; + +public enum MouseButton { + UNKNOWN(0), + LEFT(1), + RIGHT(2), + MIDDLE(3), + FOUR(4), + FIVE(5), + SIX(6), + SEVEN(7), + EIGHT(8), + NINE(9), + TEN(10), + ELEVEN(11); + + private final int nativeValue; + + MouseButton(int nativeValue) { + this.nativeValue = nativeValue; + } + + public int nativeValue() { + return nativeValue; + } +} diff --git a/src/main/java/dev/jlibghostty/MouseEncoder.java b/src/main/java/dev/jlibghostty/MouseEncoder.java new file mode 100644 index 0000000..03b22a6 --- /dev/null +++ b/src/main/java/dev/jlibghostty/MouseEncoder.java @@ -0,0 +1,61 @@ +package dev.jlibghostty; + +import dev.jlibghostty.internal.GhosttyLibrary; + +import java.lang.foreign.MemorySegment; +import java.util.concurrent.atomic.AtomicBoolean; + +public final class MouseEncoder implements AutoCloseable { + private final GhosttyLibrary library; + private final MemorySegment handle; + private final AtomicBoolean closed = new AtomicBoolean(); + + public MouseEncoder() { + library = GhosttyLibrary.loadDefault(); + handle = library.mouseEncoderNew(); + } + + public void syncFromTerminal(Terminal terminal) { + ensureOpen(); + terminal.ensureOpenForPackage(); + library.mouseEncoderSetOptFromTerminal(handle, terminal.handleForPackage()); + } + + public void setSize(MouseEncoderSize size) { + ensureOpen(); + library.mouseEncoderSetSize(handle, size); + } + + public void setAnyButtonPressed(boolean pressed) { + ensureOpen(); + library.mouseEncoderSetBoolean(handle, GhosttyLibrary.MOUSE_ENCODER_OPT_ANY_BUTTON_PRESSED, pressed); + } + + public void setTrackLastCell(boolean enabled) { + ensureOpen(); + library.mouseEncoderSetBoolean(handle, GhosttyLibrary.MOUSE_ENCODER_OPT_TRACK_LAST_CELL, enabled); + } + + public void reset() { + ensureOpen(); + library.mouseEncoderReset(handle); + } + + public byte[] encode(MouseInput input) { + ensureOpen(); + return library.mouseEncoderEncode(handle, input); + } + + @Override + public void close() { + if (closed.compareAndSet(false, true)) { + library.mouseEncoderFree(handle); + } + } + + private void ensureOpen() { + if (closed.get()) { + throw new IllegalStateException("MouseEncoder is closed"); + } + } +} diff --git a/src/main/java/dev/jlibghostty/MouseEncoderSize.java b/src/main/java/dev/jlibghostty/MouseEncoderSize.java new file mode 100644 index 0000000..2dd8497 --- /dev/null +++ b/src/main/java/dev/jlibghostty/MouseEncoderSize.java @@ -0,0 +1,36 @@ +package dev.jlibghostty; + +public record MouseEncoderSize( + long screenWidth, + long screenHeight, + long cellWidth, + long cellHeight, + long paddingTop, + long paddingBottom, + long paddingRight, + long paddingLeft +) { + public MouseEncoderSize { + checkU32("screenWidth", screenWidth); + checkU32("screenHeight", screenHeight); + checkU32("cellWidth", cellWidth); + checkU32("cellHeight", cellHeight); + checkU32("paddingTop", paddingTop); + checkU32("paddingBottom", paddingBottom); + checkU32("paddingRight", paddingRight); + checkU32("paddingLeft", paddingLeft); + if (cellWidth == 0 || cellHeight == 0) { + throw new IllegalArgumentException("cell dimensions must be non-zero"); + } + } + + public static MouseEncoderSize of(long screenWidth, long screenHeight, long cellWidth, long cellHeight) { + return new MouseEncoderSize(screenWidth, screenHeight, cellWidth, cellHeight, 0, 0, 0, 0); + } + + private static void checkU32(String name, long value) { + if (value < 0 || value > 0xffffffffL) { + throw new IllegalArgumentException(name + " must fit in uint32_t"); + } + } +} diff --git a/src/main/java/dev/jlibghostty/MouseInput.java b/src/main/java/dev/jlibghostty/MouseInput.java new file mode 100644 index 0000000..77ceb50 --- /dev/null +++ b/src/main/java/dev/jlibghostty/MouseInput.java @@ -0,0 +1,32 @@ +package dev.jlibghostty; + +import java.util.Optional; + +public record MouseInput( + MouseAction action, + Optional button, + KeyModifiers modifiers, + double x, + double y +) { + public MouseInput { + button = button == null ? Optional.empty() : button; + modifiers = modifiers == null ? KeyModifiers.none() : modifiers; + } + + public static MouseInput press(MouseButton button, double x, double y, KeyModifiers modifiers) { + return new MouseInput(MouseAction.PRESS, Optional.of(button), modifiers, x, y); + } + + public static MouseInput release(MouseButton button, double x, double y, KeyModifiers modifiers) { + return new MouseInput(MouseAction.RELEASE, Optional.of(button), modifiers, x, y); + } + + public static MouseInput motion(double x, double y, KeyModifiers modifiers) { + return new MouseInput(MouseAction.MOTION, Optional.empty(), modifiers, x, y); + } + + public static MouseInput drag(MouseButton button, double x, double y, KeyModifiers modifiers) { + return new MouseInput(MouseAction.MOTION, Optional.of(button), modifiers, x, y); + } +} diff --git a/src/main/java/dev/jlibghostty/ScrollViewport.java b/src/main/java/dev/jlibghostty/ScrollViewport.java new file mode 100644 index 0000000..a46a284 --- /dev/null +++ b/src/main/java/dev/jlibghostty/ScrollViewport.java @@ -0,0 +1,47 @@ +package dev.jlibghostty; + +/** + * Describes how libghostty should move the terminal viewport through scrollback. + */ +public record ScrollViewport(Type type, long delta) { + public ScrollViewport { + if (type == null) { + throw new NullPointerException("type"); + } + if (type != Type.DELTA && delta != 0) { + throw new IllegalArgumentException("delta is only valid for DELTA scroll viewport behavior"); + } + } + + public static ScrollViewport top() { + return new ScrollViewport(Type.TOP, 0); + } + + public static ScrollViewport bottom() { + return new ScrollViewport(Type.BOTTOM, 0); + } + + public static ScrollViewport delta(long rows) { + return new ScrollViewport(Type.DELTA, rows); + } + + public int nativeTag() { + return type.nativeValue(); + } + + public enum Type { + TOP(0), + BOTTOM(1), + DELTA(2); + + private final int nativeValue; + + Type(int nativeValue) { + this.nativeValue = nativeValue; + } + + int nativeValue() { + return nativeValue; + } + } +} diff --git a/src/main/java/dev/jlibghostty/Terminal.java b/src/main/java/dev/jlibghostty/Terminal.java index 280ee02..d19b9cc 100644 --- a/src/main/java/dev/jlibghostty/Terminal.java +++ b/src/main/java/dev/jlibghostty/Terminal.java @@ -102,6 +102,11 @@ public final class Terminal implements AutoCloseable { library.terminalResize(handle, columns, rows, cellWidthPx, cellHeightPx); } + public void scrollViewport(ScrollViewport behavior) { + ensureOpen(); + library.terminalScrollViewport(handle, behavior); + } + public void setKittyImageStorageLimit(long bytes) { ensureOpen(); library.terminalSetU64(handle, GhosttyLibrary.TERMINAL_OPT_KITTY_IMAGE_STORAGE_LIMIT, bytes); diff --git a/src/main/java/dev/jlibghostty/internal/GhosttyLibrary.java b/src/main/java/dev/jlibghostty/internal/GhosttyLibrary.java index bed8d93..96070e6 100644 --- a/src/main/java/dev/jlibghostty/internal/GhosttyLibrary.java +++ b/src/main/java/dev/jlibghostty/internal/GhosttyLibrary.java @@ -4,11 +4,14 @@ import dev.jlibghostty.GhosttyBuildInfo; import dev.jlibghostty.GhosttyException; import dev.jlibghostty.DeviceAttributes; import dev.jlibghostty.DeviceAttributesProvider; +import dev.jlibghostty.MouseEncoderSize; +import dev.jlibghostty.MouseInput; import dev.jlibghostty.OptimizeMode; import dev.jlibghostty.PtyWriter; import dev.jlibghostty.RenderCell; import dev.jlibghostty.RenderColor; import dev.jlibghostty.RenderRow; +import dev.jlibghostty.ScrollViewport; import dev.jlibghostty.SizeReportSize; import dev.jlibghostty.TerminalOptions; @@ -95,6 +98,10 @@ public final class GhosttyLibrary { public static final int RENDER_STATE_ROW_CELLS_DATA_FG_COLOR = 6; public static final int RENDER_STATE_ROW_CELLS_DATA_SELECTED = 7; + public static final int MOUSE_ENCODER_OPT_SIZE = 2; + public static final int MOUSE_ENCODER_OPT_ANY_BUTTON_PRESSED = 3; + public static final int MOUSE_ENCODER_OPT_TRACK_LAST_CELL = 4; + private static final int GHOSTTY_SUCCESS = 0; private static final int GHOSTTY_INVALID_VALUE = -2; private static final int GHOSTTY_OUT_OF_SPACE = -3; @@ -146,6 +153,32 @@ public final class GhosttyLibrary { C_INT.withName("cell_height") ); + private static final GroupLayout SCROLL_VIEWPORT = MemoryLayout.structLayout( + C_INT.withName("tag"), + MemoryLayout.paddingLayout(4), + MemoryLayout.structLayout( + C_LONG_LONG.withName("delta"), + C_LONG_LONG.withName("padding") + ).withName("value") + ); + + private static final GroupLayout MOUSE_POSITION = MemoryLayout.structLayout( + ValueLayout.JAVA_FLOAT.withName("x"), + ValueLayout.JAVA_FLOAT.withName("y") + ); + + private static final GroupLayout MOUSE_ENCODER_SIZE = MemoryLayout.structLayout( + C_SIZE_T.withName("size"), + C_INT.withName("screen_width"), + C_INT.withName("screen_height"), + C_INT.withName("cell_width"), + C_INT.withName("cell_height"), + C_INT.withName("padding_top"), + C_INT.withName("padding_bottom"), + C_INT.withName("padding_right"), + C_INT.withName("padding_left") + ); + private static final GroupLayout KITTY_RENDER_INFO = MemoryLayout.structLayout( C_SIZE_T.withName("size"), C_INT.withName("pixel_width"), @@ -200,6 +233,7 @@ public final class GhosttyLibrary { private final MethodHandle terminalFree; private final MethodHandle terminalReset; private final MethodHandle terminalResize; + private final MethodHandle terminalScrollViewport; private final MethodHandle terminalVtWrite; private final MethodHandle terminalSet; private final MethodHandle terminalGet; @@ -225,6 +259,19 @@ public final class GhosttyLibrary { private final MethodHandle renderStateRowCellsFree; private final MethodHandle renderStateRowCellsNext; private final MethodHandle renderStateRowCellsGet; + private final MethodHandle mouseEventNew; + private final MethodHandle mouseEventFree; + private final MethodHandle mouseEventSetAction; + private final MethodHandle mouseEventSetButton; + private final MethodHandle mouseEventClearButton; + private final MethodHandle mouseEventSetMods; + private final MethodHandle mouseEventSetPosition; + private final MethodHandle mouseEncoderNew; + private final MethodHandle mouseEncoderFree; + private final MethodHandle mouseEncoderSetOpt; + private final MethodHandle mouseEncoderSetOptFromTerminal; + private final MethodHandle mouseEncoderReset; + private final MethodHandle mouseEncoderEncode; private final MethodHandle kittyGraphicsGet; private final MethodHandle kittyGraphicsImage; private final MethodHandle kittyGraphicsImageGet; @@ -247,6 +294,8 @@ public final class GhosttyLibrary { FunctionDescriptor.ofVoid(C_POINTER)); terminalResize = downcall(symbols, "ghostty_terminal_resize", FunctionDescriptor.of(C_INT, C_POINTER, C_SHORT, C_SHORT, C_INT, C_INT)); + terminalScrollViewport = downcall(symbols, "ghostty_terminal_scroll_viewport", + FunctionDescriptor.ofVoid(C_POINTER, SCROLL_VIEWPORT)); terminalVtWrite = downcall(symbols, "ghostty_terminal_vt_write", FunctionDescriptor.ofVoid(C_POINTER, C_POINTER, C_SIZE_T)); terminalSet = downcall(symbols, "ghostty_terminal_set", @@ -297,6 +346,32 @@ public final class GhosttyLibrary { 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)); + mouseEventNew = downcall(symbols, "ghostty_mouse_event_new", + FunctionDescriptor.of(C_INT, C_POINTER, C_POINTER)); + mouseEventFree = downcall(symbols, "ghostty_mouse_event_free", + FunctionDescriptor.ofVoid(C_POINTER)); + mouseEventSetAction = downcall(symbols, "ghostty_mouse_event_set_action", + FunctionDescriptor.ofVoid(C_POINTER, C_INT)); + mouseEventSetButton = downcall(symbols, "ghostty_mouse_event_set_button", + FunctionDescriptor.ofVoid(C_POINTER, C_INT)); + mouseEventClearButton = downcall(symbols, "ghostty_mouse_event_clear_button", + FunctionDescriptor.ofVoid(C_POINTER)); + mouseEventSetMods = downcall(symbols, "ghostty_mouse_event_set_mods", + FunctionDescriptor.ofVoid(C_POINTER, C_SHORT)); + mouseEventSetPosition = downcall(symbols, "ghostty_mouse_event_set_position", + FunctionDescriptor.ofVoid(C_POINTER, MOUSE_POSITION)); + mouseEncoderNew = downcall(symbols, "ghostty_mouse_encoder_new", + FunctionDescriptor.of(C_INT, C_POINTER, C_POINTER)); + mouseEncoderFree = downcall(symbols, "ghostty_mouse_encoder_free", + FunctionDescriptor.ofVoid(C_POINTER)); + mouseEncoderSetOpt = downcall(symbols, "ghostty_mouse_encoder_setopt", + FunctionDescriptor.ofVoid(C_POINTER, C_INT, C_POINTER)); + mouseEncoderSetOptFromTerminal = downcall(symbols, "ghostty_mouse_encoder_setopt_from_terminal", + FunctionDescriptor.ofVoid(C_POINTER, C_POINTER)); + mouseEncoderReset = downcall(symbols, "ghostty_mouse_encoder_reset", + FunctionDescriptor.ofVoid(C_POINTER)); + mouseEncoderEncode = downcall(symbols, "ghostty_mouse_encoder_encode", + FunctionDescriptor.of(C_INT, C_POINTER, C_POINTER, C_POINTER, C_SIZE_T, 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", @@ -381,6 +456,18 @@ public final class GhosttyLibrary { } } + public void terminalScrollViewport(MemorySegment terminal, ScrollViewport behavior) { + try (Arena arena = Arena.ofConfined()) { + MemorySegment nativeBehavior = arena.allocate(SCROLL_VIEWPORT); + nativeBehavior.set(C_INT, 0, behavior.nativeTag()); + nativeBehavior.set(C_LONG_LONG, 8, behavior.delta()); + nativeBehavior.set(C_LONG_LONG, 16, 0); + terminalScrollViewport.invoke(terminal, nativeBehavior); + } catch (Throwable t) { + rethrow(t); + } + } + public void terminalWrite(MemorySegment terminal, byte[] data) { if (data.length == 0) { return; @@ -867,6 +954,129 @@ public final class GhosttyLibrary { } } + public MemorySegment mouseEncoderNew() { + try (Arena arena = Arena.ofConfined()) { + MemorySegment out = arena.allocate(C_POINTER); + int result = (int) mouseEncoderNew.invoke(MemorySegment.NULL, out); + checkResult("ghostty_mouse_encoder_new", result); + + MemorySegment encoder = out.get(C_POINTER, 0); + if (encoder.address() == 0) { + throw new IllegalStateException("ghostty_mouse_encoder_new returned null"); + } + return encoder; + } catch (Throwable t) { + return rethrow(t); + } + } + + public void mouseEncoderFree(MemorySegment encoder) { + try { + mouseEncoderFree.invoke(encoder); + } catch (Throwable t) { + rethrow(t); + } + } + + public void mouseEncoderSetOptFromTerminal(MemorySegment encoder, MemorySegment terminal) { + try { + mouseEncoderSetOptFromTerminal.invoke(encoder, terminal); + } catch (Throwable t) { + rethrow(t); + } + } + + public void mouseEncoderSetSize(MemorySegment encoder, MouseEncoderSize size) { + try (Arena arena = Arena.ofConfined()) { + MemorySegment nativeSize = arena.allocate(MOUSE_ENCODER_SIZE); + nativeSize.set(C_SIZE_T, 0, MOUSE_ENCODER_SIZE.byteSize()); + nativeSize.set(C_INT, 8, (int) size.screenWidth()); + nativeSize.set(C_INT, 12, (int) size.screenHeight()); + nativeSize.set(C_INT, 16, (int) size.cellWidth()); + nativeSize.set(C_INT, 20, (int) size.cellHeight()); + nativeSize.set(C_INT, 24, (int) size.paddingTop()); + nativeSize.set(C_INT, 28, (int) size.paddingBottom()); + nativeSize.set(C_INT, 32, (int) size.paddingRight()); + nativeSize.set(C_INT, 36, (int) size.paddingLeft()); + mouseEncoderSetOpt.invoke(encoder, MOUSE_ENCODER_OPT_SIZE, nativeSize); + } catch (Throwable t) { + rethrow(t); + } + } + + public void mouseEncoderSetBoolean(MemorySegment encoder, int option, boolean value) { + try (Arena arena = Arena.ofConfined()) { + MemorySegment nativeValue = arena.allocate(C_BOOL); + nativeValue.set(C_BOOL, 0, value); + mouseEncoderSetOpt.invoke(encoder, option, nativeValue); + } catch (Throwable t) { + rethrow(t); + } + } + + public void mouseEncoderReset(MemorySegment encoder) { + try { + mouseEncoderReset.invoke(encoder); + } catch (Throwable t) { + rethrow(t); + } + } + + public byte[] mouseEncoderEncode(MemorySegment encoder, MouseInput input) { + MemorySegment event = mouseEventNew(); + try { + configureMouseEvent(event, input); + return encodeBuffer("ghostty_mouse_encoder_encode", (arena, out, outLen, outWritten) -> + (int) mouseEncoderEncode.invoke(encoder, event, out, outLen, outWritten) + ); + } finally { + mouseEventFree(event); + } + } + + private MemorySegment mouseEventNew() { + try (Arena arena = Arena.ofConfined()) { + MemorySegment out = arena.allocate(C_POINTER); + int result = (int) mouseEventNew.invoke(MemorySegment.NULL, out); + checkResult("ghostty_mouse_event_new", result); + + MemorySegment event = out.get(C_POINTER, 0); + if (event.address() == 0) { + throw new IllegalStateException("ghostty_mouse_event_new returned null"); + } + return event; + } catch (Throwable t) { + return rethrow(t); + } + } + + private void mouseEventFree(MemorySegment event) { + try { + mouseEventFree.invoke(event); + } catch (Throwable t) { + rethrow(t); + } + } + + private void configureMouseEvent(MemorySegment event, MouseInput input) { + try (Arena arena = Arena.ofConfined()) { + mouseEventSetAction.invoke(event, input.action().nativeValue()); + if (input.button().isPresent()) { + mouseEventSetButton.invoke(event, input.button().orElseThrow().nativeValue()); + } else { + mouseEventClearButton.invoke(event); + } + mouseEventSetMods.invoke(event, (short) input.modifiers().mask()); + + MemorySegment position = arena.allocate(MOUSE_POSITION); + position.set(ValueLayout.JAVA_FLOAT, 0, (float) input.x()); + position.set(ValueLayout.JAVA_FLOAT, 4, (float) input.y()); + mouseEventSetPosition.invoke(event, position); + } catch (Throwable t) { + rethrow(t); + } + } + private Optional renderStateRowCellColor(MemorySegment cells, int key) { try (Arena arena = Arena.ofConfined()) { MemorySegment out = arena.allocate(3, 1); 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 a75f030..5d2f731 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 @@ -57,6 +57,13 @@ "size_t" ] }, + { + "returnType": "void", + "parameterTypes": [ + "void*", + "struct(int, padding(4), struct(long long, long long))" + ] + }, { "returnType": "int", "parameterTypes": [ @@ -177,6 +184,35 @@ "void*", "void*" ] + }, + { + "returnType": "void", + "parameterTypes": [ + "void*", + "int" + ] + }, + { + "returnType": "void", + "parameterTypes": [ + "void*", + "short" + ] + }, + { + "returnType": "void", + "parameterTypes": [ + "void*", + "struct(float, float)" + ] + }, + { + "returnType": "void", + "parameterTypes": [ + "void*", + "int", + "void*" + ] } ] } diff --git a/src/test/java/dev/jlibghostty/GhosttySmokeTest.java b/src/test/java/dev/jlibghostty/GhosttySmokeTest.java index 8679222..e4fe1eb 100644 --- a/src/test/java/dev/jlibghostty/GhosttySmokeTest.java +++ b/src/test/java/dev/jlibghostty/GhosttySmokeTest.java @@ -39,21 +39,46 @@ public final class GhosttySmokeTest { terminal.setDeviceAttributesProvider(DeviceAttributes::xtermCompatible); terminal.setKittyImageStorageLimit(1024 * 1024); terminal.setKittyImageMediumFile(true); - terminal.write("hello\r\n\u001b[5n\u001b[6n\u001b[c"); + terminal.write("hello\r\n\u001b[?1000h\u001b[?1006h\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); } + try (MouseEncoder mouseEncoder = new MouseEncoder()) { + mouseEncoder.syncFromTerminal(terminal); + mouseEncoder.setSize(MouseEncoderSize.of(640, 384, 8, 16)); + byte[] mouseBytes = mouseEncoder.encode(MouseInput.press( + MouseButton.LEFT, + 12, + 20, + KeyModifiers.none() + )); + if (mouseBytes.length == 0) { + throw new AssertionError("mouse encoder should emit bytes when tracking is enabled"); + } + } if (!terminal.text().contains("hello")) { throw new AssertionError("formatted terminal text should contain written text: " + terminal.text()); } + terminal.write(scrollbackFixture()); + terminal.scrollViewport(ScrollViewport.delta(-10)); + String scrolledText = renderText(terminal.renderSnapshot()); + if (scrolledText.contains("scroll-line-39")) { + throw new AssertionError("scrolled viewport should move away from bottom"); + } + terminal.scrollViewport(ScrollViewport.bottom()); + if (!renderText(terminal.renderSnapshot()).contains("scroll-line-39")) { + throw new AssertionError("bottom viewport should show latest text"); + } + terminal.scrollViewport(ScrollViewport.top()); + terminal.scrollViewport(ScrollViewport.bottom()); 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"); + boolean renderedLatestLine = renderSnapshot.renderRows().stream() + .anyMatch(row -> row.text().contains("scroll-line-39")); + if (!renderedLatestLine) { + throw new AssertionError("render state should contain latest written text"); } if (renderSnapshot.cursorViewportHasValue()) { if (renderSnapshot.cursorViewportX() < 0 || renderSnapshot.cursorViewportX() >= renderSnapshot.columns() @@ -84,4 +109,20 @@ public final class GhosttySmokeTest { throw new AssertionError("invalid color: " + color); } } + + private static String renderText(RenderStateSnapshot snapshot) { + StringBuilder text = new StringBuilder(); + for (RenderRow row : snapshot.renderRows()) { + text.append(row.text()).append('\n'); + } + return text.toString(); + } + + private static String scrollbackFixture() { + StringBuilder text = new StringBuilder(); + for (int i = 0; i < 40; i++) { + text.append("scroll-line-").append(i).append("\r\n"); + } + return text.toString(); + } }