scrollback, mouse

This commit is contained in:
Gregor Lohaus
2026-05-28 02:13:47 +02:00
parent f44df36687
commit acdda73c02
11 changed files with 553 additions and 5 deletions

View File

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

View File

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

View File

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

View File

@@ -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");
}
}
}

View File

@@ -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");
}
}
}

View File

@@ -0,0 +1,32 @@
package dev.jlibghostty;
import java.util.Optional;
public record MouseInput(
MouseAction action,
Optional<MouseButton> 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);
}
}

View File

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

View File

@@ -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);

View File

@@ -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<RenderColor> renderStateRowCellColor(MemorySegment cells, int key) {
try (Arena arena = Arena.ofConfined()) {
MemorySegment out = arena.allocate(3, 1);

View File

@@ -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*"
]
}
]
}

View File

@@ -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();
}
}