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
.pre-commit-config.yaml .pre-commit-config.yaml
build build
build

View File

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

View File

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

View File

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

View File

@@ -5,6 +5,10 @@ import dev.jlibghostty.KittyImageFormat;
import dev.jlibghostty.KittyImageSnapshot; import dev.jlibghostty.KittyImageSnapshot;
import dev.jlibghostty.KittyPlacement; import dev.jlibghostty.KittyPlacement;
import dev.jlibghostty.KittyRenderInfo; 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.RenderCell;
import dev.jlibghostty.RenderColor; import dev.jlibghostty.RenderColor;
import dev.jlibghostty.RenderCursorStyle; import dev.jlibghostty.RenderCursorStyle;
@@ -15,6 +19,10 @@ import javafx.scene.canvas.GraphicsContext;
import javafx.scene.image.Image; import javafx.scene.image.Image;
import javafx.scene.image.PixelFormat; import javafx.scene.image.PixelFormat;
import javafx.scene.image.WritableImage; 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.paint.Color;
import javafx.scene.text.Font; import javafx.scene.text.Font;
import javafx.scene.text.FontSmoothingType; import javafx.scene.text.FontSmoothingType;
@@ -34,6 +42,8 @@ public final class TerminalCanvasView {
private final Map<Long, Image> kittyImageCache = new HashMap<>(); private final Map<Long, Image> kittyImageCache = new HashMap<>();
private String fontFamily; private String fontFamily;
private double fontSize; private double fontSize;
private boolean mouseButtonPressed;
private MouseButton pressedButton = MouseButton.UNKNOWN;
public TerminalCanvasView(TerminalWorkspace workspace, AppConfig config) { public TerminalCanvasView(TerminalWorkspace workspace, AppConfig config) {
this.workspace = workspace; this.workspace = workspace;
@@ -41,6 +51,11 @@ public final class TerminalCanvasView {
this.fontFamily = config.fontFamily(); this.fontFamily = config.fontFamily();
this.fontSize = config.fontSize(); this.fontSize = config.fontSize();
canvas.setFocusTraversable(true); 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() { public Canvas canvas() {
@@ -119,12 +134,187 @@ public final class TerminalCanvasView {
double lineHeight = Math.max(1.0, text.getLayoutBounds().getHeight()); double lineHeight = Math.max(1.0, text.getLayoutBounds().getHeight());
double baselineOffset = -text.getLayoutBounds().getMinY(); double baselineOffset = -text.getLayoutBounds().getMinY();
Text cell = new Text("M"); String sample = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
Text cell = new Text(sample);
cell.setFont(font); 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); 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( private static void drawRow(
GraphicsContext gc, GraphicsContext gc,
RenderRow row, RenderRow row,
@@ -139,11 +329,11 @@ public final class TerminalCanvasView {
double cellTop = top + (row.row() * lineHeight); double cellTop = top + (row.row() * lineHeight);
cell.background().ifPresent(background -> { cell.background().ifPresent(background -> {
gc.setFill(toFxColor(background)); gc.setFill(toFxColor(background));
gc.fillRect(x, cellTop, cellWidth, lineHeight); fillCellRect(gc, x, cellTop, cellWidth, lineHeight);
}); });
if (cell.selected()) { if (cell.selected()) {
gc.setFill(SELECTED_BACKGROUND); gc.setFill(SELECTED_BACKGROUND);
gc.fillRect(x, cellTop, cellWidth, lineHeight); fillCellRect(gc, x, cellTop, cellWidth, lineHeight);
} }
if (cell.codepoints().length == 0) { if (cell.codepoints().length == 0) {
continue; 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) { private static Color toFxColor(RenderColor color) {
return Color.rgb(color.red(), color.green(), color.blue()); 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 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.Ghostty;
import dev.jlibghostty.KittyGraphics; 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.RenderStateSnapshot;
import dev.jlibghostty.ScrollViewport;
import dev.jlibghostty.Terminal; import dev.jlibghostty.Terminal;
import dev.jlibghostty.TerminalOptions; import dev.jlibghostty.TerminalOptions;
import dev.jlibghostty.DeviceAttributes; import dev.jlibghostty.DeviceAttributes;
@@ -12,6 +17,7 @@ import java.util.concurrent.atomic.AtomicReference;
public final class TerminalPane implements AutoCloseable { public final class TerminalPane implements AutoCloseable {
private final Terminal terminal; private final Terminal terminal;
private final MouseEncoder mouseEncoder = new MouseEncoder();
private final AtomicReference<RenderStateSnapshot> renderSnapshot = new AtomicReference<>(); private final AtomicReference<RenderStateSnapshot> renderSnapshot = new AtomicReference<>();
private ShellSession session; private ShellSession session;
private boolean floating; private boolean floating;
@@ -31,8 +37,8 @@ public final class TerminalPane implements AutoCloseable {
this.rows = rows; this.rows = rows;
} }
public static TerminalPane create(int columns, int rows) { public static TerminalPane create(int columns, int rows, long maxScrollback) {
Terminal terminal = Ghostty.open(TerminalOptions.of(columns, rows)); Terminal terminal = Ghostty.open(new TerminalOptions(columns, rows, maxScrollback));
terminal.setDeviceAttributesProvider(DeviceAttributes::xtermCompatible); terminal.setDeviceAttributesProvider(DeviceAttributes::xtermCompatible);
TerminalPane pane = new TerminalPane(terminal, columns, rows); TerminalPane pane = new TerminalPane(terminal, columns, rows);
pane.refresh(); pane.refresh();
@@ -65,11 +71,45 @@ public final class TerminalPane implements AutoCloseable {
} }
public void send(String text) { public void send(String text) {
scrollViewportToBottom();
if (session != null) { if (session != null) {
session.send(text); 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() { public RenderStateSnapshot renderSnapshot() {
return renderSnapshot.get(); return renderSnapshot.get();
} }
@@ -150,6 +190,7 @@ public final class TerminalPane implements AutoCloseable {
session.close(); session.close();
session = null; session = null;
} }
mouseEncoder.close();
terminal.close(); terminal.close();
} }
} }

View File

@@ -38,6 +38,13 @@ public final class TerminalWorkspace implements AutoCloseable {
return activePane() == pane; 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) { public void layout(double width, double height) {
List<TerminalPane> tiled = panes.stream() List<TerminalPane> tiled = panes.stream()
.filter(TerminalPane::visible) .filter(TerminalPane::visible)
@@ -235,7 +242,7 @@ public final class TerminalWorkspace implements AutoCloseable {
} }
private TerminalPane openPane(boolean floating) { 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.setFloating(floating);
pane.attach(ShellSession.start(config.shell(), pane, config.columns(), config.rows())); pane.attach(ShellSession.start(config.shell(), pane, config.columns(), config.rows()));
return pane; return pane;