scrollback

This commit is contained in:
Gregor Lohaus
2026-05-28 12:48:28 +02:00
parent f07e524fbb
commit cf218e2afd
30 changed files with 275 additions and 8 deletions

1
.gitignore vendored
View File

@@ -11,3 +11,4 @@ devenv.local.yaml
# pre-commit
.pre-commit-config.yaml
build
build

View File

@@ -1,6 +1,7 @@
[terminal]
columns = 100
rows = 30
max_scrollback = 100000
shell = "/bin/bash"
font_family = "JetBrainsMono Nerd Font"
font_size = 15

View File

@@ -18,6 +18,7 @@ import java.util.Map;
public record AppConfig(
int columns,
int rows,
long maxScrollback,
String shell,
String fontFamily,
double fontSize,
@@ -51,6 +52,7 @@ public record AppConfig(
return new AppConfig(
intValue(document, "terminal.columns", defaults.columns),
intValue(document, "terminal.rows", defaults.rows),
longValue(document, "terminal.max_scrollback", defaults.maxScrollback),
stringValue(document, "terminal.shell", defaults.shell),
stringValue(document, "terminal.font_family", defaults.fontFamily),
doubleValue(document, "terminal.font_size", defaults.fontSize),
@@ -69,6 +71,7 @@ public record AppConfig(
return new AppConfig(
100,
30,
100_000,
defaultShell(),
"JetBrainsMono Nerd Font",
15.0,
@@ -93,6 +96,7 @@ public record AppConfig(
return new AppConfig(
columns,
rows,
maxScrollback,
shell,
family,
size,
@@ -154,6 +158,7 @@ public record AppConfig(
builder.append("[terminal]\n");
builder.append("columns = ").append(columns).append('\n');
builder.append("rows = ").append(rows).append('\n');
builder.append("max_scrollback = ").append(maxScrollback).append('\n');
builder.append("shell = ").append(quoted(shell)).append('\n');
builder.append("font_family = ").append(quoted(fontFamily)).append('\n');
builder.append("font_size = ").append(trimDouble(fontSize)).append("\n\n");
@@ -218,6 +223,18 @@ public record AppConfig(
}
}
private static long longValue(TomlTable table, String key, long fallback) {
TomlPrimitive primitive = primitive(table, key);
if (primitive == null) {
return fallback;
}
try {
return primitive.asInteger();
} catch (RuntimeException ex) {
return fallback;
}
}
private static double doubleValue(TomlTable table, String key, double fallback) {
TomlPrimitive primitive = primitive(table, key);
if (primitive == null) {

View File

@@ -30,7 +30,6 @@ public final class Main extends Application {
StackPane root = new StackPane(terminalView.canvas());
terminalView.canvas().widthProperty().bind(root.widthProperty());
terminalView.canvas().heightProperty().bind(root.heightProperty());
terminalView.canvas().setOnMousePressed(event -> terminalView.canvas().requestFocus());
Scene scene = new Scene(root, config.windowWidth(), config.windowHeight());
scene.addEventFilter(KeyEvent.KEY_PRESSED, this::handlePressed);

View File

@@ -5,6 +5,10 @@ import dev.jlibghostty.KittyImageFormat;
import dev.jlibghostty.KittyImageSnapshot;
import dev.jlibghostty.KittyPlacement;
import dev.jlibghostty.KittyRenderInfo;
import dev.jlibghostty.KeyModifiers;
import dev.jlibghostty.MouseButton;
import dev.jlibghostty.MouseEncoderSize;
import dev.jlibghostty.MouseInput;
import dev.jlibghostty.RenderCell;
import dev.jlibghostty.RenderColor;
import dev.jlibghostty.RenderCursorStyle;
@@ -15,6 +19,10 @@ import javafx.scene.canvas.GraphicsContext;
import javafx.scene.image.Image;
import javafx.scene.image.PixelFormat;
import javafx.scene.image.WritableImage;
import javafx.scene.input.InputEvent;
import javafx.scene.input.MouseEvent;
import javafx.scene.input.ScrollEvent;
import javafx.scene.input.ScrollEvent.VerticalTextScrollUnits;
import javafx.scene.paint.Color;
import javafx.scene.text.Font;
import javafx.scene.text.FontSmoothingType;
@@ -34,6 +42,8 @@ public final class TerminalCanvasView {
private final Map<Long, Image> kittyImageCache = new HashMap<>();
private String fontFamily;
private double fontSize;
private boolean mouseButtonPressed;
private MouseButton pressedButton = MouseButton.UNKNOWN;
public TerminalCanvasView(TerminalWorkspace workspace, AppConfig config) {
this.workspace = workspace;
@@ -41,6 +51,11 @@ public final class TerminalCanvasView {
this.fontFamily = config.fontFamily();
this.fontSize = config.fontSize();
canvas.setFocusTraversable(true);
canvas.setOnMousePressed(this::handleMousePressed);
canvas.setOnMouseReleased(this::handleMouseReleased);
canvas.setOnMouseDragged(this::handleMouseDragged);
canvas.setOnMouseMoved(this::handleMouseMoved);
canvas.setOnScroll(this::handleScroll);
}
public Canvas canvas() {
@@ -119,12 +134,187 @@ public final class TerminalCanvasView {
double lineHeight = Math.max(1.0, text.getLayoutBounds().getHeight());
double baselineOffset = -text.getLayoutBounds().getMinY();
Text cell = new Text("M");
String sample = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
Text cell = new Text(sample);
cell.setFont(font);
double cellWidth = Math.max(1.0, cell.getLayoutBounds().getWidth());
double cellWidth = Math.max(1.0, cell.getLayoutBounds().getWidth() / sample.length());
return new FontMetrics(cellWidth, lineHeight, baselineOffset);
}
private void handleMousePressed(MouseEvent event) {
canvas.requestFocus();
TerminalPane pane = paneAt(event.getX(), event.getY());
if (pane == null) {
return;
}
workspace.focus(pane);
pressedButton = mouseButton(event);
mouseButtonPressed = true;
sendMouse(pane, MouseInput.press(pressedButton, eventX(pane, event.getX()), eventY(pane, event.getY()), modifiers(event)), true, event);
}
private void handleMouseReleased(MouseEvent event) {
TerminalPane pane = paneAt(event.getX(), event.getY());
if (pane == null) {
pane = workspace.activePane();
}
MouseButton button = pressedButton == MouseButton.UNKNOWN ? mouseButton(event) : pressedButton;
sendMouse(pane, MouseInput.release(button, eventX(pane, event.getX()), eventY(pane, event.getY()), modifiers(event)), false, event);
mouseButtonPressed = false;
pressedButton = MouseButton.UNKNOWN;
}
private void handleMouseDragged(MouseEvent event) {
TerminalPane pane = paneAt(event.getX(), event.getY());
if (pane == null) {
pane = workspace.activePane();
}
MouseButton button = pressedButton == MouseButton.UNKNOWN ? mouseButton(event) : pressedButton;
sendMouse(pane, MouseInput.drag(button, eventX(pane, event.getX()), eventY(pane, event.getY()), modifiers(event)), true, event);
}
private void handleMouseMoved(MouseEvent event) {
TerminalPane pane = paneAt(event.getX(), event.getY());
if (pane == null) {
return;
}
sendMouse(pane, MouseInput.motion(eventX(pane, event.getX()), eventY(pane, event.getY()), modifiers(event)), mouseButtonPressed, event);
}
private void handleScroll(ScrollEvent event) {
TerminalPane pane = paneAt(event.getX(), event.getY());
if (pane == null) {
return;
}
canvas.requestFocus();
workspace.focus(pane);
int direction = scrollDirection(event);
if (direction == 0) {
return;
}
MouseButton wheelButton = direction > 0 ? MouseButton.FOUR : MouseButton.FIVE;
int rows = scrollRows(event);
boolean sent = false;
for (int i = 0; i < rows; i++) {
sent |= sendMouse(
pane,
MouseInput.press(wheelButton, eventX(pane, event.getX()), eventY(pane, event.getY()), modifiers(event)),
mouseButtonPressed,
event
);
}
if (!sent) {
pane.scrollViewport(direction > 0 ? -rows : rows);
event.consume();
}
}
private boolean sendMouse(TerminalPane pane, MouseInput input, boolean anyButtonPressed, InputEvent event) {
MouseTarget target = mouseTarget(pane);
if (target == null) {
return false;
}
boolean sent = pane.sendMouse(input, target.size(), anyButtonPressed);
if (sent) {
event.consume();
}
return sent;
}
private TerminalPane paneAt(double x, double y) {
java.util.List<TerminalPane> panes = workspace.panes();
for (int i = panes.size() - 1; i >= 0; i--) {
TerminalPane pane = panes.get(i);
if (x >= pane.x() && x < pane.x() + pane.width() && y >= pane.y() && y < pane.y() + pane.height()) {
return pane;
}
}
return null;
}
private MouseTarget mouseTarget(TerminalPane pane) {
if (pane.width() <= 24.0 || pane.height() <= 24.0) {
return null;
}
FontMetrics metrics = measureFontMetrics(Font.font(fontFamily, fontSize));
int columns = Math.max(1, (int) ((pane.width() - 24.0) / metrics.cellWidth));
int rows = Math.max(1, (int) ((pane.height() - 24.0) / metrics.lineHeight));
long cellWidth = Math.max(1L, Math.round(metrics.cellWidth));
long cellHeight = Math.max(1L, Math.round(metrics.lineHeight));
long screenWidth = Math.max(1L, Math.round(columns * metrics.cellWidth));
long screenHeight = Math.max(1L, Math.round(rows * metrics.lineHeight));
return new MouseTarget(MouseEncoderSize.of(screenWidth, screenHeight, cellWidth, cellHeight), screenWidth, screenHeight);
}
private double eventX(TerminalPane pane, double canvasX) {
MouseTarget target = mouseTarget(pane);
if (target == null) {
return 0.0;
}
return clamp(canvasX - pane.x() - 12.0, 0.0, target.screenWidth() - 1.0);
}
private double eventY(TerminalPane pane, double canvasY) {
MouseTarget target = mouseTarget(pane);
if (target == null) {
return 0.0;
}
return clamp(canvasY - pane.y() - 12.0, 0.0, target.screenHeight() - 1.0);
}
private static double clamp(double value, double min, double max) {
return Math.max(min, Math.min(max, value));
}
private static KeyModifiers modifiers(MouseEvent event) {
return KeyModifiers.of(event.isShiftDown(), event.isControlDown(), event.isAltDown(), event.isMetaDown());
}
private static KeyModifiers modifiers(ScrollEvent event) {
return KeyModifiers.of(event.isShiftDown(), event.isControlDown(), event.isAltDown(), event.isMetaDown());
}
private static int scrollRows(ScrollEvent event) {
double rows;
if (event.getTextDeltaYUnits() == VerticalTextScrollUnits.LINES && event.getTextDeltaY() != 0.0) {
rows = Math.abs(event.getTextDeltaY());
} else if (event.getTextDeltaYUnits() == VerticalTextScrollUnits.PAGES && event.getTextDeltaY() != 0.0) {
rows = Math.abs(event.getTextDeltaY()) * 24.0;
} else if (event.getMultiplierY() > 0.0) {
rows = Math.abs(event.getDeltaY()) / event.getMultiplierY();
} else {
rows = Math.abs(event.getDeltaY()) / 40.0;
}
return Math.max(1, Math.min(64, (int) Math.ceil(rows)));
}
private static int scrollDirection(ScrollEvent event) {
if (event.getDeltaY() != 0.0) {
return event.getDeltaY() > 0.0 ? 1 : -1;
}
if (event.getTextDeltaYUnits() != VerticalTextScrollUnits.NONE && event.getTextDeltaY() != 0.0) {
return event.getTextDeltaY() > 0.0 ? 1 : -1;
}
return 0;
}
private static MouseButton mouseButton(MouseEvent event) {
return switch (event.getButton()) {
case PRIMARY -> MouseButton.LEFT;
case SECONDARY -> MouseButton.RIGHT;
case MIDDLE -> MouseButton.MIDDLE;
default -> MouseButton.UNKNOWN;
};
}
private static void drawRow(
GraphicsContext gc,
RenderRow row,
@@ -139,11 +329,11 @@ public final class TerminalCanvasView {
double cellTop = top + (row.row() * lineHeight);
cell.background().ifPresent(background -> {
gc.setFill(toFxColor(background));
gc.fillRect(x, cellTop, cellWidth, lineHeight);
fillCellRect(gc, x, cellTop, cellWidth, lineHeight);
});
if (cell.selected()) {
gc.setFill(SELECTED_BACKGROUND);
gc.fillRect(x, cellTop, cellWidth, lineHeight);
fillCellRect(gc, x, cellTop, cellWidth, lineHeight);
}
if (cell.codepoints().length == 0) {
continue;
@@ -156,6 +346,14 @@ public final class TerminalCanvasView {
}
}
private static void fillCellRect(GraphicsContext gc, double x, double y, double width, double height) {
double x1 = Math.floor(x);
double y1 = Math.floor(y);
double x2 = Math.ceil(x + width);
double y2 = Math.ceil(y + height);
gc.fillRect(x1, y1, Math.max(1.0, x2 - x1), Math.max(1.0, y2 - y1));
}
private static Color toFxColor(RenderColor color) {
return Color.rgb(color.red(), color.green(), color.blue());
}
@@ -253,4 +451,7 @@ public final class TerminalCanvasView {
private record FontMetrics(double cellWidth, double lineHeight, double baselineOffset) {
}
private record MouseTarget(MouseEncoderSize size, long screenWidth, long screenHeight) {
}
}

View File

@@ -2,7 +2,12 @@ package com.gregor.jprototerm;
import dev.jlibghostty.Ghostty;
import dev.jlibghostty.KittyGraphics;
import dev.jlibghostty.MouseAction;
import dev.jlibghostty.MouseEncoder;
import dev.jlibghostty.MouseEncoderSize;
import dev.jlibghostty.MouseInput;
import dev.jlibghostty.RenderStateSnapshot;
import dev.jlibghostty.ScrollViewport;
import dev.jlibghostty.Terminal;
import dev.jlibghostty.TerminalOptions;
import dev.jlibghostty.DeviceAttributes;
@@ -12,6 +17,7 @@ import java.util.concurrent.atomic.AtomicReference;
public final class TerminalPane implements AutoCloseable {
private final Terminal terminal;
private final MouseEncoder mouseEncoder = new MouseEncoder();
private final AtomicReference<RenderStateSnapshot> renderSnapshot = new AtomicReference<>();
private ShellSession session;
private boolean floating;
@@ -31,8 +37,8 @@ public final class TerminalPane implements AutoCloseable {
this.rows = rows;
}
public static TerminalPane create(int columns, int rows) {
Terminal terminal = Ghostty.open(TerminalOptions.of(columns, rows));
public static TerminalPane create(int columns, int rows, long maxScrollback) {
Terminal terminal = Ghostty.open(new TerminalOptions(columns, rows, maxScrollback));
terminal.setDeviceAttributesProvider(DeviceAttributes::xtermCompatible);
TerminalPane pane = new TerminalPane(terminal, columns, rows);
pane.refresh();
@@ -65,11 +71,45 @@ public final class TerminalPane implements AutoCloseable {
}
public void send(String text) {
scrollViewportToBottom();
if (session != null) {
session.send(text);
}
}
public boolean sendMouse(MouseInput input, MouseEncoderSize size, boolean anyButtonPressed) {
synchronized (terminal) {
mouseEncoder.syncFromTerminal(terminal);
mouseEncoder.setSize(size);
mouseEncoder.setAnyButtonPressed(anyButtonPressed);
mouseEncoder.setTrackLastCell(input.action() == MouseAction.MOTION && input.button().isEmpty());
byte[] encoded = mouseEncoder.encode(input);
if (encoded.length == 0) {
return false;
}
if (session != null) {
session.send(encoded);
}
return true;
}
}
public void scrollViewport(long rows) {
synchronized (terminal) {
terminal.scrollViewport(ScrollViewport.delta(rows));
refresh();
}
}
public void scrollViewportToBottom() {
synchronized (terminal) {
terminal.scrollViewport(ScrollViewport.bottom());
refresh();
}
}
public RenderStateSnapshot renderSnapshot() {
return renderSnapshot.get();
}
@@ -150,6 +190,7 @@ public final class TerminalPane implements AutoCloseable {
session.close();
session = null;
}
mouseEncoder.close();
terminal.close();
}
}

View File

@@ -38,6 +38,13 @@ public final class TerminalWorkspace implements AutoCloseable {
return activePane() == pane;
}
public void focus(TerminalPane pane) {
int index = panes.indexOf(pane);
if (index >= 0 && pane.visible()) {
activeIndex = index;
}
}
public void layout(double width, double height) {
List<TerminalPane> tiled = panes.stream()
.filter(TerminalPane::visible)
@@ -235,7 +242,7 @@ public final class TerminalWorkspace implements AutoCloseable {
}
private TerminalPane openPane(boolean floating) {
TerminalPane pane = TerminalPane.create(config.columns(), config.rows());
TerminalPane pane = TerminalPane.create(config.columns(), config.rows(), config.maxScrollback());
pane.setFloating(floating);
pane.attach(ShellSession.start(config.shell(), pane, config.columns(), config.rows()));
return pane;