27 Commits

Author SHA1 Message Date
6e3e88919e Revert "fix glyph-overhang artifacts in partial row repaint"
This reverts commit 57103bb98b.
2026-05-31 21:50:17 +02:00
57103bb98b fix glyph-overhang artifacts in partial row repaint
repaintColumns cleared and redrew only [start,end], but a neighbouring cell's
glyph can overhang into that span. The clearRect erased the overhang and the
neighbour was never redrawn, leaving black notches through the line 1-2 cells
before the cursor that survived until a full rerender.

Redraw text for a couple of extra cells on each side, clipped to the cleared
span, so overhang from just-outside cells is restored without touching their
own cell areas. Keeps the per-column repaint efficiency (vs the full-row
repaint debug toggle, which fixed the bars but repainted every dirty cell).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 21:48:34 +02:00
cb95a7188d update jlibghostty 2026-05-31 21:39:18 +02:00
Gregor Lohaus
5ca192b7be add full-row-repaint debug toggle
-Djprototerm.fullRowRepaint=true (or JPROTOTERM_FULL_ROW_REPAINT=1) bypasses the
per-column repaint in renderChanged and repaints the whole row, to bisect the
stale black-bar artifact that appears near the cursor and survives until a full
rerender.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 21:25:13 +02:00
Gregor Lohaus
e99a6ee33e split snapshot profiler bucket into update vs marshal
The snapshot bucket lumped ghostty's native dirty-state update together with
the Java-side cell marshaling. Time them separately to see which half of the
~7ms/frame snapshot cost (now the dominant frame cost after the detectShift
hoist) is the real target.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 21:18:07 +02:00
Gregor Lohaus
4923ea5527 hoist row hash out of detectShift delta scan
rowFingerprint(row) is invariant across the delta loop but was recomputed for
every candidate delta, making shift detection O(rows^2 x cols) on large changes
(full-screen scroll). Precompute each changed row's hash once, dropping it to
O(rows x cols). Profiling showed fingerprint hashing at ~74% of frame time under
heavy scroll, dominated by this loop.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 21:09:54 +02:00
Gregor Lohaus
1f7394d75a add opt-in render profiler instrumentation
Gated behind -Djprototerm.profile=true (or JPROTOTERM_PROFILE=1), accumulates
per-frame nanos into snapshot/fingerprint/draw/frame-total buckets and dumps
to stderr every N renders. Splits the three suspected render costs: native
snapshot marshaling, fingerprint hashing, and canvas draw recording.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 21:04:00 +02:00
Gregor Lohaus
50641d0a6a per-row cell-run repaint 2026-05-31 20:12:43 +02:00
Gregor Lohaus
51f64e7ca8 cache hidden panes 2026-05-31 19:56:09 +02:00
Gregor Lohaus
528afafcda no next floating pane keyboard shortcut 2026-05-31 19:52:24 +02:00
Gregor Lohaus
093a09da39 frame classifiaction not needed anymore 2026-05-31 19:46:55 +02:00
Gregor Lohaus
59ab33bc01 dont rerender full on every layout frame 2026-05-31 19:45:38 +02:00
Gregor Lohaus
d8447d9e29 port feasable performance improvements 2026-05-31 19:38:06 +02:00
Gregor Lohaus
dba6474491 apply race condition fix 2026-05-31 19:30:36 +02:00
Gregor Lohaus
743f312921 move unchanged rows 2026-05-31 18:55:53 +02:00
Gregor Lohaus
3054b3ec77 cleanup 2026-05-31 18:51:16 +02:00
Gregor Lohaus
2bcaf951df Render terminal rows as JavaFX nodes 2026-05-31 18:40:33 +02:00
Gregor Lohaus
beba14c3ea scene graph 2026-05-31 18:27:52 +02:00
Gregor Lohaus
f5562baf5f Merge branch 'refactor' 2026-05-31 16:27:21 +02:00
Gregor Lohaus
3017b99f87 recover pane 2026-05-31 16:24:43 +02:00
Gregor Lohaus
0958c93b4f recover tab 2026-05-31 16:23:36 +02:00
Gregor Lohaus
9c98d87783 recover abstract terminal renderer 2026-05-31 16:21:38 +02:00
Gregor Lohaus
76c731578f recover terminal metrics from helix buffer 2026-05-31 16:20:43 +02:00
Gregor Lohaus
95619f5b4c fuck did bad git reset hard to main, recovering from helix buffer 2026-05-31 16:19:37 +02:00
Gregor Lohaus
174cfc00d3 what is happening ?? 2026-05-31 16:15:37 +02:00
Gregor Lohaus
29e84c9830 remove unused old classes 2026-05-31 16:13:40 +02:00
Gregor Lohaus
a7baa08e68 add bin to gitignore 2026-05-31 15:45:55 +02:00
15 changed files with 1918 additions and 1514 deletions

1
.gitignore vendored
View File

@@ -13,3 +13,4 @@ devenv.local.yaml
build build
build build
.gradle .gradle
bin

3
.ignore Normal file
View File

@@ -0,0 +1,3 @@
.gradle
result
bin

View File

@@ -106,7 +106,6 @@ navigate_up = "ALT+K"
navigate_right = "ALT+L" navigate_right = "ALT+L"
toggle_floating = "ALT+F" toggle_floating = "ALT+F"
new_pane = "ALT+N" new_pane = "ALT+N"
next_floating = "ALT+F12"
close_pane = "ALT+X" close_pane = "ALT+X"
new_tab = "ALT+A" new_tab = "ALT+A"
previous_tab = "ALT+SHIFT+H" previous_tab = "ALT+SHIFT+H"
@@ -121,7 +120,6 @@ open_scrollback = "ALT+S"
- `Alt+n`: new pane — a floating pane when floating panes are shown, otherwise a new tiled - `Alt+n`: new pane — a floating pane when floating panes are shown, otherwise a new tiled
pane (tiled panes are split equally across the width) pane (tiled panes are split equally across the width)
- `Alt+f`: show or hide all floating panes - `Alt+f`: show or hide all floating panes
- `Alt+F12`: cycle floating panes
- `Alt+x`: close the active pane; closing a tab's last pane closes the tab, and closing the - `Alt+x`: close the active pane; closing a tab's last pane closes the tab, and closing the
last pane of the last tab quits last pane of the last tab quits
- `Alt+a`: new tab - `Alt+a`: new tab

View File

@@ -25,8 +25,7 @@ navigate_down = "ALT+J"
navigate_up = "ALT+K" navigate_up = "ALT+K"
navigate_right = "ALT+L" navigate_right = "ALT+L"
toggle_floating = "ALT+F" toggle_floating = "ALT+F"
new_floating = "ALT+SHIFT+F" new_pane = "ALT+N"
next_floating = "ALT+F12"
close_pane = "ALT+X" close_pane = "ALT+X"
open_font_selector = "ALT+T" open_font_selector = "ALT+T"
open_scrollback = "ALT+S" open_scrollback = "ALT+S"

8
flake.lock generated
View File

@@ -70,11 +70,11 @@
"nixpkgs": "nixpkgs" "nixpkgs": "nixpkgs"
}, },
"locked": { "locked": {
"lastModified": 1780079529, "lastModified": 1780256181,
"narHash": "sha256-AxlGTL8c5xSLcQHvWlm994IdOqxsN8iKrA02Cpv7vso=", "narHash": "sha256-/saXdnYMbAMfP7u6USSqtNkBIgqZhU+CPr3F8tUQhHU=",
"ref": "refs/heads/main", "ref": "refs/heads/main",
"rev": "68121d50b52fb56038871c97c97e7a12ffe987c2", "rev": "db5ee5d20daf8855de3a3b2fa9349eced70946f0",
"revCount": 20, "revCount": 21,
"type": "git", "type": "git",
"url": "https://gitea.gregorlohaus.com/gregor/jlibghostty.git" "url": "https://gitea.gregorlohaus.com/gregor/jlibghostty.git"
}, },

View File

@@ -37,7 +37,6 @@ public record AppConfig(
"navigate_right", "navigate_right",
"toggle_floating", "toggle_floating",
"new_pane", "new_pane",
"next_floating",
"close_pane", "close_pane",
"new_tab", "new_tab",
"previous_tab", "previous_tab",
@@ -96,7 +95,6 @@ public record AppConfig(
Map.entry("navigate_right", KeyBinding.parse("ALT+L")), Map.entry("navigate_right", KeyBinding.parse("ALT+L")),
Map.entry("toggle_floating", KeyBinding.parse("ALT+F")), Map.entry("toggle_floating", KeyBinding.parse("ALT+F")),
Map.entry("new_pane", KeyBinding.parse("ALT+N")), Map.entry("new_pane", KeyBinding.parse("ALT+N")),
Map.entry("next_floating", KeyBinding.parse("ALT+F12")),
Map.entry("close_pane", KeyBinding.parse("ALT+X")), Map.entry("close_pane", KeyBinding.parse("ALT+X")),
Map.entry("new_tab", KeyBinding.parse("ALT+A")), Map.entry("new_tab", KeyBinding.parse("ALT+A")),
Map.entry("previous_tab", KeyBinding.parse("ALT+SHIFT+H")), Map.entry("previous_tab", KeyBinding.parse("ALT+SHIFT+H")),

View File

@@ -0,0 +1,442 @@
package com.gregor.jprototerm;
import dev.jlibghostty.KeyModifiers;
import dev.jlibghostty.MouseButton;
import dev.jlibghostty.MouseEncoderSize;
import dev.jlibghostty.MouseInput;
import javafx.geometry.Pos;
import javafx.scene.Parent;
import javafx.scene.control.Label;
import javafx.scene.input.InputEvent;
import javafx.scene.input.MouseEvent;
import javafx.scene.input.ScrollEvent;
import javafx.scene.input.ScrollEvent.VerticalTextScrollUnits;
import javafx.scene.layout.Background;
import javafx.scene.layout.BackgroundFill;
import javafx.scene.layout.CornerRadii;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Pane;
import javafx.scene.paint.Color;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* Owns the window's tabs and exposes the terminal surface as a JavaFX scene graph. Each
* terminal pane is mounted as its own node, so JavaFX child order handles stacking and clipping
* between panes. The pane model still owns terminals, ptys, cell geometry, and snapshots; this
* class handles tab/pane lifecycle, layout, focus, mouse routing, and frame scheduling.
*/
public final class Compositor {
private static final Color GAP_BACKGROUND = Color.rgb(16, 16, 18);
private static final Color TAB_TEXT = Color.rgb(225, 229, 235);
private static final Color TAB_INACTIVE_TEXT = Color.rgb(128, 136, 148);
private static final Color TAB_ACTIVE_BACKGROUND = Color.rgb(45, 55, 72);
private static final Color TAB_INACTIVE_BACKGROUND = Color.rgb(22, 24, 28);
private static final double TAB_BAR_HEIGHT = 22.0;
private final Pane root = new Pane();
private final Pane paneLayer = new Pane();
private final HBox tabBar = new HBox(1.0);
private final AppConfig config;
private final TerminalMetrics metrics;
private final List<Tab> tabs = new ArrayList<>();
private final Map<TerminalPane, TerminalPaneNode> nodes = new HashMap<>();
private int currentTabIndex;
private boolean sceneDirty = true;
private double lastWidth = -1.0;
private double lastHeight = -1.0;
private long lastContentVersion = Long.MIN_VALUE;
private boolean mouseButtonPressed;
private MouseButton pressedButton = MouseButton.UNKNOWN;
public Compositor(AppConfig config, TerminalMetrics metrics) {
this.config = config;
this.metrics = metrics;
tabs.add(new Tab(config, metrics));
root.setFocusTraversable(true);
root.setBackground(new Background(new BackgroundFill(GAP_BACKGROUND, CornerRadii.EMPTY, null)));
root.getChildren().setAll(paneLayer, tabBar);
root.setOnMousePressed(event -> root.requestFocus());
}
public Parent node() {
return root;
}
public void requestFocus() {
root.requestFocus();
}
public void setFont(String family, double size) {
metrics.setFont(family, size);
nodes.values().forEach(TerminalPaneNode::discard);
markSceneDirty();
}
// ---- Tabs and panes -------------------------------------------------------------
public boolean isEmpty() {
return tabs.isEmpty();
}
public TerminalPane activePane() {
return currentTab().activePane();
}
public void navigate(Direction direction) {
if (!isEmpty() && currentTab().navigate(direction)) {
markSceneDirty();
}
}
public void toggleFloating() {
if (isEmpty()) {
return;
}
currentTab().toggleFloating();
markSceneDirty();
}
public void createPane() {
if (isEmpty()) {
return;
}
currentTab().createPane();
markSceneDirty();
}
public void closeActivePane() {
if (isEmpty()) {
return;
}
currentTab().closeActivePane();
if (currentTab().isEmpty()) {
tabs.remove(currentTabIndex);
if (currentTabIndex >= tabs.size()) {
currentTabIndex = Math.max(0, tabs.size() - 1);
}
}
markSceneDirty();
}
public void newTab() {
tabs.add(new Tab(config, metrics));
currentTabIndex = tabs.size() - 1;
markSceneDirty();
}
public void nextTab() {
if (tabs.size() > 1) {
currentTabIndex = (currentTabIndex + 1) % tabs.size();
markSceneDirty();
}
}
public void previousTab() {
if (tabs.size() > 1) {
currentTabIndex = (currentTabIndex - 1 + tabs.size()) % tabs.size();
markSceneDirty();
}
}
public void close() {
for (Tab tab : tabs) {
tab.close();
}
tabs.clear();
nodes.clear();
paneLayer.getChildren().clear();
}
private Tab currentTab() {
return tabs.get(currentTabIndex);
}
private List<TerminalPane> currentPanes() {
return tabs.isEmpty() ? List.of() : currentTab().panes();
}
private List<TerminalPane> allOpenPanes() {
List<TerminalPane> panes = new ArrayList<>();
for (Tab tab : tabs) {
panes.addAll(tab.allPanes());
}
return panes;
}
private boolean isActive(TerminalPane pane) {
return !tabs.isEmpty() && currentTab().isActive(pane);
}
private void focus(TerminalPane pane) {
if (!tabs.isEmpty() && currentTab().focus(pane)) {
markSceneDirty();
}
}
// ---- Rendering ------------------------------------------------------------------
public void render() {
double width = root.getWidth();
double height = root.getHeight();
long contentVersion = tabs.isEmpty() ? 0 : currentTab().contentVersion();
boolean geometryChanged = width != lastWidth || height != lastHeight;
boolean contentChanged = contentVersion != lastContentVersion;
boolean syncScene = sceneDirty || geometryChanged;
if (!syncScene && !contentChanged) {
return;
}
lastWidth = width;
lastHeight = height;
lastContentVersion = contentVersion;
sceneDirty = false;
if (syncScene) {
syncSceneGraph(width, height);
}
renderVisiblePanes();
}
private void markSceneDirty() {
sceneDirty = true;
}
private void syncSceneGraph(double width, double height) {
double topInset = tabs.size() > 1 ? TAB_BAR_HEIGHT : 0.0;
paneLayer.resizeRelocate(0.0, 0.0, width, height);
updateTabBar(width, topInset);
if (!tabs.isEmpty()) {
currentTab().layout(width, height, topInset);
}
List<TerminalPane> panes = currentPanes();
retainNodes(allOpenPanes());
List<TerminalPaneNode> orderedNodes = new ArrayList<>(panes.size());
for (TerminalPane pane : panes) {
pane.fitToBounds();
TerminalPaneNode node = nodeFor(pane);
node.resizeRelocate(Math.round(pane.x()), Math.round(pane.y()), pane.width(), pane.height());
orderedNodes.add(node);
}
paneLayer.getChildren().setAll(orderedNodes);
}
private void renderVisiblePanes() {
for (TerminalPane pane : currentPanes()) {
TerminalPaneNode node = nodes.get(pane);
if (node != null) {
node.renderIncremental(isActive(pane));
}
}
}
private TerminalPaneNode nodeFor(TerminalPane pane) {
return nodes.computeIfAbsent(pane, this::createNode);
}
private TerminalPaneNode createNode(TerminalPane pane) {
TerminalPaneNode node = new TerminalPaneNode(pane, metrics);
node.setOnMousePressed(event -> handleMousePressed(pane, event));
node.setOnMouseReleased(event -> handleMouseReleased(pane, event));
node.setOnMouseDragged(event -> handleMouseDragged(pane, event));
node.setOnMouseMoved(event -> handleMouseMoved(pane, event));
node.setOnScroll(event -> handleScroll(pane, event));
return node;
}
private void retainNodes(List<TerminalPane> openPanes) {
Set<TerminalPane> open = new HashSet<>(openPanes);
nodes.keySet().removeIf(pane -> !open.contains(pane));
}
private void updateTabBar(double width, double barHeight) {
tabBar.setVisible(barHeight > 0.0);
tabBar.setManaged(false);
tabBar.resizeRelocate(0.0, 0.0, width, barHeight);
tabBar.getChildren().clear();
if (barHeight <= 0.0) {
return;
}
double segmentWidth = width / tabs.size();
for (int i = 0; i < tabs.size(); i++) {
Label label = new Label(Integer.toString(i + 1));
boolean current = i == currentTabIndex;
label.setAlignment(Pos.CENTER);
label.setTextFill(current ? TAB_TEXT : TAB_INACTIVE_TEXT);
label.setBackground(new Background(new BackgroundFill(
current ? TAB_ACTIVE_BACKGROUND : TAB_INACTIVE_BACKGROUND,
CornerRadii.EMPTY,
null)));
label.setFont(javafx.scene.text.Font.font(metrics.fontFamily(), Math.max(9.0, Math.min(13.0, barHeight * 0.62))));
label.setMinSize(0.0, barHeight);
label.setPrefSize(Math.max(0.0, segmentWidth - 1.0), barHeight);
label.setMaxSize(Double.MAX_VALUE, barHeight);
final int index = i;
label.setOnMousePressed(event -> {
currentTabIndex = index;
markSceneDirty();
root.requestFocus();
event.consume();
});
tabBar.getChildren().add(label);
}
}
// ---- Input ----------------------------------------------------------------------
private void handleMousePressed(TerminalPane pane, MouseEvent event) {
root.requestFocus();
focus(pane);
pressedButton = mouseButton(event);
mouseButtonPressed = true;
MouseTarget target = mouseTarget(pane);
if (target == null) {
return;
}
send(pane, target, MouseInput.press(pressedButton, localX(event.getX(), target), localY(event.getY(), target), modifiers(event)), true, event);
}
private void handleMouseReleased(TerminalPane pane, MouseEvent event) {
MouseButton button = pressedButton == MouseButton.UNKNOWN ? mouseButton(event) : pressedButton;
MouseTarget target = mouseTarget(pane);
if (target != null) {
send(pane, target, MouseInput.release(button, localX(event.getX(), target), localY(event.getY(), target), modifiers(event)), false, event);
}
mouseButtonPressed = false;
pressedButton = MouseButton.UNKNOWN;
}
private void handleMouseDragged(TerminalPane pane, MouseEvent event) {
MouseButton button = pressedButton == MouseButton.UNKNOWN ? mouseButton(event) : pressedButton;
MouseTarget target = mouseTarget(pane);
if (target == null) {
return;
}
send(pane, target, MouseInput.drag(button, localX(event.getX(), target), localY(event.getY(), target), modifiers(event)), true, event);
}
private void handleMouseMoved(TerminalPane pane, MouseEvent event) {
MouseTarget target = mouseTarget(pane);
if (target == null) {
return;
}
send(pane, target, MouseInput.motion(localX(event.getX(), target), localY(event.getY(), target), modifiers(event)), mouseButtonPressed, event);
}
private void handleScroll(TerminalPane pane, ScrollEvent event) {
root.requestFocus();
focus(pane);
int direction = scrollDirection(event);
if (direction == 0) {
return;
}
MouseButton wheelButton = direction > 0 ? MouseButton.FOUR : MouseButton.FIVE;
int rows = scrollRows(event);
MouseTarget target = mouseTarget(pane);
boolean sent = false;
if (target != null) {
double ex = localX(event.getX(), target);
double ey = localY(event.getY(), target);
KeyModifiers modifiers = modifiers(event);
for (int i = 0; i < rows; i++) {
if (!send(pane, target, MouseInput.press(wheelButton, ex, ey, modifiers), mouseButtonPressed, event)) {
break;
}
sent = true;
}
}
if (!sent) {
pane.scrollViewport(direction > 0 ? -rows : rows);
event.consume();
}
}
private boolean send(TerminalPane pane, MouseTarget target, MouseInput input, boolean anyButtonPressed, InputEvent event) {
boolean sent = pane.sendMouse(input, target.size(), anyButtonPressed);
if (sent) {
event.consume();
}
return sent;
}
private MouseTarget mouseTarget(TerminalPane pane) {
if (pane.width() <= 2 * TerminalMetrics.PADDING || pane.height() <= 2 * TerminalMetrics.PADDING) {
return null;
}
int columns = metrics.columnsFor(pane.width());
int rows = metrics.rowsFor(pane.height());
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 static double localX(double nodeX, MouseTarget target) {
return clamp(nodeX - TerminalMetrics.PADDING, 0.0, target.screenWidth() - 1.0);
}
private static double localY(double nodeY, MouseTarget target) {
return clamp(nodeY - TerminalMetrics.PADDING, 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 record MouseTarget(MouseEncoderSize size, long screenWidth, long screenHeight) {
}
}

View File

@@ -12,7 +12,6 @@ import javafx.scene.control.Spinner;
import javafx.scene.control.SpinnerValueFactory; import javafx.scene.control.SpinnerValueFactory;
import javafx.scene.input.KeyEvent; import javafx.scene.input.KeyEvent;
import javafx.scene.layout.GridPane; import javafx.scene.layout.GridPane;
import javafx.scene.layout.StackPane;
import javafx.scene.text.Font; import javafx.scene.text.Font;
import javafx.stage.Stage; import javafx.stage.Stage;
@@ -21,79 +20,72 @@ import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
public final class Main extends Application { public final class Main extends Application {
private TerminalWorkspace workspace; private Compositor compositor;
private TerminalCanvasView terminalView; private TerminalMetrics metrics;
private AppConfig config; private AppConfig config;
@Override @Override
public void start(Stage stage) { public void start(Stage stage) {
config = AppConfig.load(); config = AppConfig.load();
workspace = new TerminalWorkspace(config); metrics = new TerminalMetrics(config.fontFamily(), config.fontSize());
terminalView = new TerminalCanvasView(workspace, config); compositor = new Compositor(config, metrics);
StackPane root = new StackPane(terminalView.canvas()); Scene scene = new Scene(compositor.node(), config.windowWidth(), config.windowHeight());
terminalView.canvas().widthProperty().bind(root.widthProperty());
terminalView.canvas().heightProperty().bind(root.heightProperty());
Scene scene = new Scene(root, config.windowWidth(), config.windowHeight());
scene.addEventFilter(KeyEvent.KEY_PRESSED, this::handlePressed); scene.addEventFilter(KeyEvent.KEY_PRESSED, this::handlePressed);
scene.addEventFilter(KeyEvent.KEY_TYPED, event -> handleTyped(event)); scene.addEventFilter(KeyEvent.KEY_TYPED, event -> handleTyped(event));
new AnimationTimer() { new AnimationTimer() {
@Override @Override
public void handle(long now) { public void handle(long now) {
terminalView.render(); compositor.render();
} }
}.start(); }.start();
stage.setTitle("jprototerm"); stage.setTitle("jprototerm");
stage.setScene(scene); stage.setScene(scene);
stage.setOnCloseRequest(event -> { stage.setOnCloseRequest(event -> {
workspace.close(); compositor.close();
}); });
stage.show(); stage.show();
terminalView.canvas().requestFocus(); compositor.requestFocus();
} }
private void handlePressed(KeyEvent event) { private void handlePressed(KeyEvent event) {
if (config.keybindings().get("navigate_left").matches(event)) { if (config.keybindings().get("navigate_left").matches(event)) {
workspace.navigate(Direction.LEFT); compositor.navigate(Direction.LEFT);
event.consume(); event.consume();
} else if (config.keybindings().get("navigate_down").matches(event)) { } else if (config.keybindings().get("navigate_down").matches(event)) {
workspace.navigate(Direction.DOWN); compositor.navigate(Direction.DOWN);
event.consume(); event.consume();
} else if (config.keybindings().get("navigate_up").matches(event)) { } else if (config.keybindings().get("navigate_up").matches(event)) {
workspace.navigate(Direction.UP); compositor.navigate(Direction.UP);
event.consume(); event.consume();
} else if (config.keybindings().get("navigate_right").matches(event)) { } else if (config.keybindings().get("navigate_right").matches(event)) {
workspace.navigate(Direction.RIGHT); compositor.navigate(Direction.RIGHT);
event.consume(); event.consume();
} else if (config.keybindings().get("toggle_floating").matches(event)) { } else if (config.keybindings().get("toggle_floating").matches(event)) {
workspace.toggleFloating(); compositor.toggleFloating();
event.consume(); event.consume();
} else if (config.keybindings().get("new_pane").matches(event)) { } else if (config.keybindings().get("new_pane").matches(event)) {
workspace.createPane(); compositor.createPane();
event.consume();
} else if (config.keybindings().get("next_floating").matches(event)) {
workspace.nextFloatingPane();
event.consume(); event.consume();
} else if (config.keybindings().get("close_pane").matches(event)) { } else if (config.keybindings().get("close_pane").matches(event)) {
workspace.closeActivePane(); compositor.closeActivePane();
event.consume(); event.consume();
if (workspace.isEmpty()) { if (compositor.isEmpty()) {
// Closing the last pane quits the app. // Closing the last pane quits the app.
workspace.close(); compositor.close();
Platform.exit(); Platform.exit();
} }
} else if (config.keybindings().get("new_tab").matches(event)) { } else if (config.keybindings().get("new_tab").matches(event)) {
workspace.newTab(); compositor.newTab();
event.consume(); event.consume();
} else if (config.keybindings().get("previous_tab").matches(event)) { } else if (config.keybindings().get("previous_tab").matches(event)) {
workspace.previousTab(); compositor.previousTab();
event.consume(); event.consume();
} else if (config.keybindings().get("next_tab").matches(event)) { } else if (config.keybindings().get("next_tab").matches(event)) {
workspace.nextTab(); compositor.nextTab();
event.consume(); event.consume();
} else if (config.keybindings().get("open_font_selector").matches(event)) { } else if (config.keybindings().get("open_font_selector").matches(event)) {
openFontSelector(); openFontSelector();
@@ -104,7 +96,7 @@ public final class Main extends Application {
} else { } else {
String encoded = KeyEncoder.encode(event); String encoded = KeyEncoder.encode(event);
if (encoded != null) { if (encoded != null) {
workspace.activePane().send(encoded); compositor.activePane().send(encoded);
event.consume(); event.consume();
} }
} }
@@ -117,7 +109,7 @@ public final class Main extends Application {
String text = event.getCharacter(); String text = event.getCharacter();
if (text != null && !text.isEmpty() && text.charAt(0) >= 0x20 && text.charAt(0) != 0x7f) { if (text != null && !text.isEmpty() && text.charAt(0) >= 0x20 && text.charAt(0) != 0x7f) {
workspace.activePane().send(text); compositor.activePane().send(text);
event.consume(); event.consume();
} }
} }
@@ -160,18 +152,18 @@ public final class Main extends Application {
double selectedSize = size.getValue(); double selectedSize = size.getValue();
config = config.withFont(selectedFamily.trim(), selectedSize); config = config.withFont(selectedFamily.trim(), selectedSize);
config.save(); config.save();
terminalView.setFont(config.fontFamily(), config.fontSize()); compositor.setFont(config.fontFamily(), config.fontSize());
terminalView.canvas().requestFocus(); compositor.requestFocus();
}); });
} }
private void openScrollbackInEditor() { private void openScrollbackInEditor() {
try { try {
Path file = Files.createTempFile("jprototerm-scrollback-", ".txt"); Path file = Files.createTempFile("jprototerm-scrollback-", ".txt");
Files.writeString(file, workspace.activePane().scrollbackText()); Files.writeString(file, compositor.activePane().scrollbackText());
file.toFile().deleteOnExit(); file.toFile().deleteOnExit();
workspace.activePane().send(scrollbackEditorCommand(file) + "\r"); compositor.activePane().send(scrollbackEditorCommand(file) + "\r");
} catch (IOException ex) { } catch (IOException ex) {
System.err.println("Could not open scrollback in editor: " + ex.getMessage()); System.err.println("Could not open scrollback in editor: " + ex.getMessage());
} }

View File

@@ -0,0 +1,79 @@
package com.gregor.jprototerm;
/**
* Lightweight render profiler, disabled unless {@code -Djprototerm.profile=true} (or the
* {@code JPROTOTERM_PROFILE=1} environment variable) is set. It accumulates wall-clock nanos
* into a handful of buckets and prints aggregate per-frame stats to stderr every
* {@code jprototerm.profile.frames} render invocations (default 120).
*
* <p>All render work runs on the JavaFX application thread, so the accumulators are plain
* fields with no synchronization.
*
* <p>Caveat: JavaFX canvas drawing is deferred to the QuantumRenderer thread, so the
* {@link #DRAW} bucket measures only the cost of <em>recording</em> draw commands, not the
* GPU paint. Pair this with {@code -Djavafx.pulseLogger=true} to see the render-thread side.
*/
final class RenderProfiler {
static final int SNAPSHOT = 0;
static final int FINGERPRINT = 1;
static final int DRAW = 2;
static final int FRAME = 3;
static final int UPDATE = 4;
static final int MARSHAL = 5;
private static final int BUCKETS = 6;
private static final String[] NAMES =
{"snapshot", "fingerprint", "draw", "frame-total", "update", "marshal"};
private static final boolean ENABLED =
Boolean.getBoolean("jprototerm.profile") || "1".equals(System.getenv("JPROTOTERM_PROFILE"));
private static final int DUMP_FRAMES = Integer.getInteger("jprototerm.profile.frames", 120);
private static final long[] totalNanos = new long[BUCKETS];
private static final long[] counts = new long[BUCKETS];
private static int frames;
private RenderProfiler() {
}
static boolean enabled() {
return ENABLED;
}
/** Returns a start timestamp, or 0 when profiling is disabled. */
static long start() {
return ENABLED ? System.nanoTime() : 0L;
}
/** Records the time elapsed since {@code startNanos} into {@code bucket}. */
static void stop(int bucket, long startNanos) {
if (!ENABLED) {
return;
}
totalNanos[bucket] += System.nanoTime() - startNanos;
counts[bucket]++;
}
/** Marks the end of one render invocation; dumps and resets every {@code DUMP_FRAMES}. */
static void frame() {
if (!ENABLED) {
return;
}
if (++frames < DUMP_FRAMES) {
return;
}
dump();
}
private static void dump() {
StringBuilder sb = new StringBuilder(192);
sb.append("[render-profile] ").append(frames).append(" renders");
for (int i = 0; i < BUCKETS; i++) {
double totalMs = totalNanos[i] / 1_000_000.0;
sb.append(String.format(" | %s %.3fms/f (n=%d)", NAMES[i], totalMs / frames, counts[i]));
totalNanos[i] = 0;
counts[i] = 0;
}
System.err.println(sb);
frames = 0;
}
}

View File

@@ -3,260 +3,223 @@ package com.gregor.jprototerm;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Comparator; import java.util.Comparator;
import java.util.List; import java.util.List;
import java.util.stream.Stream;
/** /**
* One tab: an isolated stack of panes (tiled + floating) with its own active pane and * One tab: a row of tiled panes with a group of floating panes shown over them. Floating panes
* stashed-floating state. {@link TerminalWorkspace} owns the list of tabs and renders only * are shown/hidden as a group ({@code floatingVisible}), and there is always at least one tiled
* the current one. Mutating methods return whether they actually changed anything so the * pane — a floating pane is promoted if the last tiled one closes — so the layout always has a
* workspace can bump its render version conditionally. * base. The {@link Compositor} owns the tabs and renders only the current one; mutating methods
* return whether they actually changed anything so it can bump its layout version.
*/ */
final class Tab implements AutoCloseable { final class Tab implements AutoCloseable {
private final AppConfig config; private final AppConfig config;
private final List<TerminalPane> panes = new ArrayList<>(); private final TerminalMetrics metrics;
private int activeIndex; private final List<TerminalPane> tiled = new ArrayList<>();
private int hiddenFloatingFocusIndex = -1; private final List<TerminalPane> floating = new ArrayList<>();
private boolean floatingVisible;
private TerminalPane active;
// The floating pane to re-focus when the group is shown again, and to prefer when promoting
// after the last tiled pane closes.
private TerminalPane lastFocusedFloating; private TerminalPane lastFocusedFloating;
// Last laid-out size, so a newly opened pane can be created at roughly its eventual rect
// (and thus grid). Seeded from the configured window size for the first pane, which is
// opened before any layout pass runs.
private double lastWidth;
private double lastHeight;
private double lastTopInset;
// Bumped whenever one of this tab's panes changes content; the compositor reads the current
// tab's value each frame as an O(1) "anything to repaint?" check.
private long contentVersion;
Tab(AppConfig config) { Tab(AppConfig config, TerminalMetrics metrics) {
this.config = config; this.config = config;
panes.add(openPane(false)); this.metrics = metrics;
this.lastWidth = config.windowWidth();
this.lastHeight = config.windowHeight();
TerminalPane first = openPane(false);
tiled.add(first);
active = first;
} }
TerminalPane activePane() { TerminalPane activePane() {
return panes.get(activeIndex); return active;
} }
boolean isEmpty() { boolean isEmpty() {
return panes.isEmpty(); return tiled.isEmpty() && floating.isEmpty();
} }
long contentVersion() {
return contentVersion;
}
/**
* Panes to composite, bottom-to-top: tiled first, then (when shown) the floating group with
* the active floating pane on top.
*/
List<TerminalPane> panes() { List<TerminalPane> panes() {
if (panes.isEmpty()) { if (!floatingVisible || floating.isEmpty()) {
return List.of(); return List.copyOf(tiled);
} }
List<TerminalPane> visible = panes.stream().filter(TerminalPane::visible).toList(); List<TerminalPane> ordered = new ArrayList<>(tiled.size() + floating.size());
if (visible.isEmpty()) { ordered.addAll(tiled);
return List.of(); for (TerminalPane pane : floating) {
} if (pane != active) {
// Draw order = z-order: all tiled panes first (they never overlap), then floating
// panes on top, with the active floating pane last (topmost). This holds regardless
// of creation order, so a tiled pane created after a floating one still sits behind.
TerminalPane active = activePane();
List<TerminalPane> ordered = new ArrayList<>(visible.size());
for (TerminalPane pane : visible) {
if (!pane.floating()) {
ordered.add(pane); ordered.add(pane);
} }
} }
for (TerminalPane pane : visible) { if (floating.contains(active)) {
if (pane.floating() && pane != active) { ordered.add(active); // active floating pane on top
ordered.add(pane);
}
}
if (active.visible() && active.floating()) {
ordered.add(active);
} }
return List.copyOf(ordered); return List.copyOf(ordered);
} }
List<TerminalPane> allPanes() {
List<TerminalPane> all = new ArrayList<>(tiled.size() + floating.size());
all.addAll(tiled);
all.addAll(floating);
return List.copyOf(all);
}
boolean isActive(TerminalPane pane) { boolean isActive(TerminalPane pane) {
return !panes.isEmpty() && activePane() == pane; return pane != null && pane == active;
} }
boolean focus(TerminalPane pane) { boolean focus(TerminalPane pane) {
int index = panes.indexOf(pane); if (pane == active || !isFocusable(pane)) {
if (index >= 0 && pane.visible() && activeIndex != index) { return false;
setActive(index);
return true;
} }
return false; setActive(pane);
return true;
} }
void layout(double width, double height, double topInset) { void layout(double width, double height, double topInset) {
this.lastWidth = width;
this.lastHeight = height;
this.lastTopInset = topInset;
double availHeight = height - topInset; double availHeight = height - topInset;
List<TerminalPane> tiled = panes.stream()
.filter(TerminalPane::visible) double tileWidth = width / Math.max(1, tiled.size());
.filter(pane -> !pane.floating())
.toList();
int tileCount = Math.max(1, tiled.size());
double tileWidth = width / tileCount;
for (int i = 0; i < tiled.size(); i++) { for (int i = 0; i < tiled.size(); i++) {
tiled.get(i).bounds(i * tileWidth, topInset, tileWidth, availHeight); tiled.get(i).bounds(i * tileWidth, topInset, tileWidth, availHeight);
} }
List<TerminalPane> floating = panes.stream() double floatingWidth = Math.max(420, width * 0.58);
.filter(TerminalPane::visible) double floatingHeight = Math.max(260, availHeight * 0.58);
.filter(TerminalPane::floating)
.toList();
for (int i = 0; i < floating.size(); i++) { for (int i = 0; i < floating.size(); i++) {
TerminalPane pane = floating.get(i);
double floatingWidth = Math.max(420, width * 0.58);
double floatingHeight = Math.max(260, availHeight * 0.58);
double offset = i * 28.0; double offset = i * 28.0;
pane.bounds( floating.get(i).bounds(
Math.min(width - floatingWidth - 12.0, ((width - floatingWidth) / 2.0) + offset), Math.min(width - floatingWidth - 12.0, ((width - floatingWidth) / 2.0) + offset),
Math.min(height - floatingHeight - 12.0, topInset + ((availHeight - floatingHeight) / 2.0) + offset), Math.min(height - floatingHeight - 12.0, topInset + ((availHeight - floatingHeight) / 2.0) + offset),
floatingWidth, floatingWidth,
floatingHeight floatingHeight);
);
} }
} }
boolean navigate(Direction direction) { boolean navigate(Direction direction) {
TerminalPane current = activePane(); if (floating.contains(active) && navigateFloatingStack(direction)) {
if (current.floating() && navigateFloatingStack(direction)) {
return true; return true;
} }
TerminalPane target = focusable()
TerminalPane target = panes.stream() .filter(pane -> pane != active)
.filter(TerminalPane::visible) .filter(pane -> directionFilter(direction, active, pane))
.filter(pane -> pane != current) .min(Comparator.comparingDouble(pane -> distance(active, pane)))
.filter(pane -> directionFilter(direction, current, pane))
.min(Comparator.comparingDouble(pane -> distance(current, pane)))
.orElse(null); .orElse(null);
if (target != null) { if (target != null) {
setActive(panes.indexOf(target)); setActive(target);
return true; return true;
} }
return false; return false;
} }
void toggleFloating() { void toggleFloating() {
List<TerminalPane> floating = panes.stream()
.filter(TerminalPane::floating)
.toList();
if (floating.isEmpty()) { if (floating.isEmpty()) {
createFloatingPane(); createFloatingPane();
return; return;
} }
if (floatingVisible) {
boolean anyVisible = floating.stream().anyMatch(TerminalPane::visible); floatingVisible = false;
if (anyVisible) { if (floating.contains(active)) {
TerminalPane active = activePane(); setActive(tiled.get(0));
hiddenFloatingFocusIndex = active.floating() ? activeIndex : firstVisibleFloatingIndex(); }
floating.forEach(pane -> pane.setVisible(false));
setActive(firstVisibleNonFloatingIndex());
} else { } else {
floating.forEach(pane -> pane.setVisible(true)); floatingVisible = true;
setActive(visibleIndexOrFallback(hiddenFloatingFocusIndex, panes.indexOf(floating.get(floating.size() - 1)))); setActive(floating.contains(lastFocusedFloating) ? lastFocusedFloating : floating.get(floating.size() - 1));
hiddenFloatingFocusIndex = -1;
} }
} }
/** /** Adds a floating pane while the floating group is shown, otherwise a tiled pane. */
* "New pane": adds a floating pane while floating panes are shown, otherwise adds a
* tiled pane (the tiled row is redistributed equally by the layout).
*/
void createPane() { void createPane() {
if (anyFloatingVisible()) { if (floatingVisible) {
createFloatingPane(); createFloatingPane();
} else { } else {
TerminalPane pane = openPane(false); TerminalPane pane = openPane(false);
panes.add(pane); tiled.add(pane);
setActive(panes.size() - 1); setActive(pane);
} }
} }
void nextFloatingPane() {
TerminalPane next = nextFloatingAfter(activeIndex);
next.setVisible(true);
setActive(panes.indexOf(next));
}
void closeActivePane() { void closeActivePane() {
TerminalPane active = activePane(); TerminalPane closing = active;
int removed = activeIndex; boolean wasFloating = floating.remove(closing);
// When closing a floating pane, focus the next visible floating pane if there is one if (!wasFloating) {
// (don't jump to a tiled pane); otherwise fall back to the nearest visible pane. tiled.remove(closing);
int target = active.floating() ? nearestVisibleFloatingIndex(removed) : -1;
if (target < 0) {
target = previousVisibleIndex(removed);
} }
panes.remove(removed); if (closing == lastFocusedFloating) {
if (active == lastFocusedFloating) {
lastFocusedFloating = null; lastFocusedFloating = null;
} }
active.close(); closing.close();
if (panes.isEmpty()) {
activeIndex = 0; if (tiled.isEmpty() && floating.isEmpty()) {
active = null; // tab is now empty; the compositor drops it
return; return;
} }
activeIndex = adjustIndexAfterRemoval(target, removed);
hiddenFloatingFocusIndex = adjustHiddenFocusAfterRemoval(hiddenFloatingFocusIndex, removed);
// If the last tiled (main) pane was closed, promote a floating pane to be the new // Always keep a tiled base: if the last tiled pane just closed, promote a floating one
// main pane so the layout has a base and rendering continues normally. Prefer the // (preferring the last focused).
// most recently focused floating pane. if (tiled.isEmpty()) {
if (panes.stream().noneMatch(pane -> !pane.floating())) { TerminalPane promote = floating.contains(lastFocusedFloating) ? lastFocusedFloating : floating.get(0);
TerminalPane promote = (lastFocusedFloating != null && panes.contains(lastFocusedFloating)) var promoteIndex = floating.indexOf(promote);
? lastFocusedFloating var nextFocussed = promoteIndex == 0 ? 0 : promoteIndex - 1;
: panes.get(activeIndex); floating.remove(promote);
promote.setFloating(false); tiled.add(promote);
promote.setVisible(true); if (promote == lastFocusedFloating) {
activeIndex = panes.indexOf(promote); lastFocusedFloating = null;
lastFocusedFloating = null; if (!floating.isEmpty()) {
lastFocusedFloating = floating.isEmpty() ? null : floating.get(nextFocussed);
}
}
}
if (floating.isEmpty()) {
floatingVisible = false;
} }
// If only hidden panes remained, reveal the one we're focusing so the screen isn't setActive(wasFloating && floatingVisible ? floating.get(floating.size() - 1) : tiled.get(0));
// blank.
if (!panes.get(activeIndex).visible()) {
panes.get(activeIndex).setVisible(true);
}
} }
private void setActive(int index) { private void setActive(TerminalPane pane) {
activeIndex = index; active = pane;
if (index >= 0 && index < panes.size() && panes.get(index).floating()) { if (floating.contains(pane)) {
lastFocusedFloating = panes.get(index); lastFocusedFloating = pane;
} }
} }
private void createFloatingPane() { private void createFloatingPane() {
TerminalPane pane = openPane(true); TerminalPane pane = openPane(true);
panes.add(pane); floating.add(pane);
setActive(panes.size() - 1); floatingVisible = true;
} setActive(pane);
private boolean anyFloatingVisible() {
return panes.stream().anyMatch(pane -> pane.floating() && pane.visible());
}
private TerminalPane nextFloatingAfter(int index) {
for (int i = index + 1; i < panes.size(); i++) {
TerminalPane pane = panes.get(i);
if (pane.floating()) {
return pane;
}
}
for (int i = 0; i <= index && i < panes.size(); i++) {
TerminalPane pane = panes.get(i);
if (pane.floating()) {
return pane;
}
}
return createAndReturnFloatingPane();
}
private TerminalPane createAndReturnFloatingPane() {
TerminalPane pane = openPane(true);
panes.add(pane);
return pane;
} }
private boolean navigateFloatingStack(Direction direction) { private boolean navigateFloatingStack(Direction direction) {
List<TerminalPane> floating = panes.stream()
.filter(TerminalPane::visible)
.filter(TerminalPane::floating)
.toList();
if (floating.size() < 2) { if (floating.size() < 2) {
return false; return false;
} }
int current = floating.indexOf(active);
int current = floating.indexOf(activePane());
if (current < 0) { if (current < 0) {
return false; return false;
} }
int next = switch (direction) { int next = switch (direction) {
case LEFT, UP -> current - 1; case LEFT, UP -> current - 1;
case DOWN, RIGHT -> current + 1; case DOWN, RIGHT -> current + 1;
@@ -264,85 +227,35 @@ final class Tab implements AutoCloseable {
if (next < 0 || next >= floating.size()) { if (next < 0 || next >= floating.size()) {
return false; return false;
} }
setActive(floating.get(next));
setActive(panes.indexOf(floating.get(next)));
return true; return true;
} }
private int firstVisibleFloatingIndex() { private boolean isFocusable(TerminalPane pane) {
for (int i = 0; i < panes.size(); i++) { return tiled.contains(pane) || (floatingVisible && floating.contains(pane));
TerminalPane pane = panes.get(i);
if (pane.visible() && pane.floating()) {
return i;
}
}
return -1;
} }
private int firstVisibleNonFloatingIndex() { private Stream<TerminalPane> focusable() {
for (int i = 0; i < panes.size(); i++) { return floatingVisible ? Stream.concat(tiled.stream(), floating.stream()) : tiled.stream();
TerminalPane pane = panes.get(i);
if (pane.visible() && !pane.floating()) {
return i;
}
}
return 0;
} }
private int nearestVisibleFloatingIndex(int index) { private void markContentChanged() {
for (int i = index + 1; i < panes.size(); i++) { contentVersion++;
if (panes.get(i).visible() && panes.get(i).floating()) {
return i;
}
}
for (int i = index - 1; i >= 0; i--) {
if (panes.get(i).visible() && panes.get(i).floating()) {
return i;
}
}
return -1;
} }
private int previousVisibleIndex(int index) { private TerminalPane openPane(boolean asFloating) {
for (int i = index - 1; i >= 0; i--) { double availHeight = lastHeight - lastTopInset;
if (panes.get(i).visible()) { double widthPx;
return i; double heightPx;
} if (asFloating) {
widthPx = Math.max(420, lastWidth * 0.58);
heightPx = Math.max(260, availHeight * 0.58);
} else {
// A new tiled pane joins the row, so each gets 1/(n+1) of the width.
widthPx = lastWidth / (tiled.size() + 1);
heightPx = availHeight;
} }
for (int i = index + 1; i < panes.size(); i++) { return TerminalPane.create(config, metrics, this::markContentChanged, widthPx, heightPx);
if (panes.get(i).visible()) {
return i;
}
}
return firstVisibleNonFloatingIndex();
}
private int visibleIndexOrFallback(int index, int fallback) {
if (index >= 0 && index < panes.size() && panes.get(index).visible()) {
return index;
}
return fallback;
}
private static int adjustIndexAfterRemoval(int index, int removedIndex) {
if (index < 0) {
return 0;
}
return index > removedIndex ? index - 1 : index;
}
private static int adjustHiddenFocusAfterRemoval(int index, int removedIndex) {
if (index < 0 || index == removedIndex) {
return -1;
}
return index > removedIndex ? index - 1 : index;
}
private TerminalPane openPane(boolean floating) {
TerminalPane pane = TerminalPane.create(config.columns(), config.rows(), config.maxScrollback());
pane.setFloating(floating);
pane.attach(ShellSession.start(config.shell(), config.envOverride(), pane, config.columns(), config.rows()));
return pane;
} }
private static boolean directionFilter(Direction direction, TerminalPane current, TerminalPane candidate) { private static boolean directionFilter(Direction direction, TerminalPane current, TerminalPane candidate) {
@@ -367,9 +280,9 @@ final class Tab implements AutoCloseable {
@Override @Override
public void close() { public void close() {
for (TerminalPane pane : panes) { tiled.forEach(TerminalPane::close);
pane.close(); floating.forEach(TerminalPane::close);
} tiled.clear();
panes.clear(); floating.clear();
} }
} }

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,86 @@
package com.gregor.jprototerm;
import javafx.scene.text.Font;
import javafx.scene.text.Text;
/**
* Cell geometry shared by the {@link Compositor} and every {@link TerminalPane}.
*
* <p>The nominal cell width/height come from measuring the font, but a grid can't use
* fractional cells, so the measured size is snapped to whole (logical) pixels here — that
* snapping is why the value isn't purely a property of the font. The compositor owns the
* single instance (it holds the canvas, which is the pixel context), hands it to panes so
* they can turn their rect into a column/row count themselves, and re-measures it on a font
* change so every pane observes the new geometry through the shared reference.
*/
public final class TerminalMetrics {
/** Inset, in pixels, between a pane's edge and its content on every side. */
public static final double PADDING = 12.0;
private String fontFamily;
private double fontSize;
private Font font;
private double cellWidth;
private double lineHeight;
private double baselineOffset;
public TerminalMetrics(String fontFamily, double fontSize) {
setFont(fontFamily, fontSize);
}
public void setFont(String fontFamily, double fontSize) {
this.fontFamily = fontFamily;
this.fontSize = fontSize;
this.font = Font.font(fontFamily, fontSize);
measure(font);
}
public String fontFamily() {
return fontFamily;
}
public double fontSize() {
return fontSize;
}
public Font font() {
return font;
}
public double cellWidth() {
return cellWidth;
}
public double lineHeight() {
return lineHeight;
}
public double baselineOffset() {
return baselineOffset;
}
/** Columns that fit in a pane of the given pixel width (after subtracting the padding). */
public int columnsFor(double widthPx) {
return Math.max(1, (int) ((widthPx - 2 * PADDING) / cellWidth));
}
/** Rows that fit in a pane of the given pixel height (after subtracting the padding). */
public int rowsFor(double heightPx) {
return Math.max(1, (int) ((heightPx - 2 * PADDING) / lineHeight));
}
private void measure(Font font) {
Text text = new Text("┃MgÅjy");
text.setFont(font);
// Snap the cell size to whole pixels so cells tile on integer boundaries. Fractional
// cell metrics put every cell edge on a sub-pixel position, leaving anti-aliased
// seams that show up as a faint grid behind the themed cell backgrounds. Rounding
// leaves a few pixels of unused space at the right/bottom edge, which is fine.
this.lineHeight = Math.max(1.0, Math.round(text.getLayoutBounds().getHeight()));
this.baselineOffset = -text.getLayoutBounds().getMinY();
Text cell = new Text("M");
cell.setFont(font);
this.cellWidth = Math.max(1.0, Math.round(cell.getLayoutBounds().getWidth()));
}
}

View File

@@ -1,35 +1,39 @@
package com.gregor.jprototerm; package com.gregor.jprototerm;
import dev.jlibghostty.DeviceAttributes;
import dev.jlibghostty.Ghostty; import dev.jlibghostty.Ghostty;
import dev.jlibghostty.KittyGraphics; import dev.jlibghostty.KittyGraphics;
import dev.jlibghostty.MouseAction; import dev.jlibghostty.MouseAction;
import dev.jlibghostty.MouseEncoder; import dev.jlibghostty.MouseEncoder;
import dev.jlibghostty.MouseEncoderSize; import dev.jlibghostty.MouseEncoderSize;
import dev.jlibghostty.MouseInput; import dev.jlibghostty.MouseInput;
import dev.jlibghostty.RenderState;
import dev.jlibghostty.RenderStateSnapshot; import dev.jlibghostty.RenderStateSnapshot;
import dev.jlibghostty.ScrollViewport; 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 java.util.Optional; import java.util.Optional;
import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.AtomicLong;
/**
* One terminal: owns its ghostty {@link Terminal}, the {@link ShellSession}/pty driving it,
* and its on-screen geometry and grid. It does not draw itself; {@link TerminalPaneNode}
* reads snapshots from it and represents the visible rows and kitty graphics as JavaFX nodes.
*/
public final class TerminalPane implements AutoCloseable { public final class TerminalPane implements AutoCloseable {
// Monotonic across all panes, bumped on every content change. Lets the renderer detect
// "nothing changed" in O(1) without scanning panes or building a render key.
private static final AtomicLong RENDER_TICK = new AtomicLong();
public static long renderTick() {
return RENDER_TICK.get();
}
private final Terminal terminal; private final Terminal terminal;
private final TerminalMetrics metrics;
private final boolean kittyEnabled;
// Run on every content change so the owning tab can bump its content version — the
// compositor's O(1) "did the current tab change?" gate.
private final Runnable onContentChange;
private final MouseEncoder mouseEncoder = new MouseEncoder(); private final MouseEncoder mouseEncoder = new MouseEncoder();
// A persistent render state (reused across frames) is what makes ghostty's per-row dirty
// tracking meaningful: update() accumulates dirty since the last resetDirty().
private final RenderState renderState = new RenderState();
private RenderStateSnapshot cachedSnapshot; private RenderStateSnapshot cachedSnapshot;
private ShellSession session; private ShellSession session;
private boolean floating;
private boolean visible = true;
private double x; private double x;
private double y; private double y;
private double width; private double width;
@@ -38,47 +42,38 @@ public final class TerminalPane implements AutoCloseable {
private int rows; private int rows;
private int pixelWidth; private int pixelWidth;
private int pixelHeight; private int pixelHeight;
// Bumped on the reader thread (terminal writes) and read on the FX thread (render loop), private final AtomicLong contentVersion = new AtomicLong();
// so it must be volatile. private volatile long snapshotVersion = -1;
private volatile long renderVersion;
private long snapshotVersion = -1;
private volatile boolean closed;
private TerminalPane(Terminal terminal, int columns, int rows) { private TerminalPane(Terminal terminal, TerminalMetrics metrics, boolean kittyEnabled,
Runnable onContentChange, int columns, int rows) {
this.terminal = terminal; this.terminal = terminal;
this.metrics = metrics;
this.kittyEnabled = kittyEnabled;
this.onContentChange = onContentChange;
this.columns = columns; this.columns = columns;
this.rows = rows; this.rows = rows;
} }
public static TerminalPane create(int columns, int rows, long maxScrollback) { /**
Terminal terminal = Ghostty.open(new TerminalOptions(columns, rows, maxScrollback)); * Opens a pane sized to fit the given pixel rect: the shared cell metrics decide how many
* columns and rows fit, and that grid is handed to ghostty and the shell at start-up. A
* non-positive size falls back to the configured default grid (used before the first
* layout, when no rect is known yet). The pane owns the shell session it starts and runs
* {@code onContentChange} on every content change.
*/
public static TerminalPane create(AppConfig config, TerminalMetrics metrics, Runnable onContentChange, double widthPx, double heightPx) {
int columns = widthPx > 0 ? metrics.columnsFor(widthPx) : config.columns();
int rows = heightPx > 0 ? metrics.rowsFor(heightPx) : config.rows();
Terminal terminal = Ghostty.open(new TerminalOptions(columns, rows, config.maxScrollback()));
terminal.setDeviceAttributesProvider(DeviceAttributes::xtermCompatible); terminal.setDeviceAttributesProvider(DeviceAttributes::xtermCompatible);
TerminalPane pane = new TerminalPane(terminal, columns, rows); TerminalPane pane = new TerminalPane(terminal, metrics, config.kittyGraphics(), onContentChange, columns, rows);
pane.refresh(); pane.refresh();
pane.attach(ShellSession.start(config.shell(), config.envOverride(), pane, columns, rows));
return pane; return pane;
} }
public void write(String text) { private void attach(ShellSession session) {
synchronized (terminal) {
if (closed) {
return;
}
terminal.write(text);
refresh();
}
}
public void write(byte[] bytes) {
synchronized (terminal) {
if (closed) {
return;
}
terminal.write(bytes);
refresh();
}
}
public void attach(ShellSession session) {
this.session = session; this.session = session;
terminal.setPtyWriter(bytes -> { terminal.setPtyWriter(bytes -> {
ShellSession current = this.session; ShellSession current = this.session;
@@ -89,6 +84,20 @@ public final class TerminalPane implements AutoCloseable {
session.startReading(this); session.startReading(this);
} }
public void write(String text) {
synchronized (terminal) {
terminal.write(text);
refresh();
}
}
public void write(byte[] bytes) {
synchronized (terminal) {
terminal.write(bytes);
refresh();
}
}
public void send(String text) { public void send(String text) {
scrollViewportToBottom(); scrollViewportToBottom();
if (session != null) { if (session != null) {
@@ -122,7 +131,7 @@ public final class TerminalPane implements AutoCloseable {
} }
} }
public void scrollViewportToBottom() { private void scrollViewportToBottom() {
synchronized (terminal) { synchronized (terminal) {
terminal.scrollViewport(ScrollViewport.bottom()); terminal.scrollViewport(ScrollViewport.bottom());
refresh(); refresh();
@@ -130,16 +139,44 @@ public final class TerminalPane implements AutoCloseable {
} }
/** /**
* Full render snapshot of the current screen, memoised per content version (so a burst * Incremental snapshot: cells are marshalled only for rows that changed since the last
* of writes between two frames yields one snapshot). Uses a throwaway render state per * frame (global dirty == PARTIAL), reused across calls for the same content version.
* snapshot, which always returns the complete, correct screen — a persistent render * Snapshotting is deferred here rather than done in refresh(), so a burst of writes
* state's per-row dirty tracking proved unreliable across resizes and screen clears. * between two frames collapses into a single snapshot.
*/ */
public RenderStateSnapshot renderSnapshot() { public RenderStateSnapshot snapshot() {
return takeSnapshot(false);
}
/**
* Full snapshot with every row's cells populated. Used where the whole pane is redrawn
* regardless of dirty state (the kitty-graphics path).
*/
public RenderStateSnapshot snapshotFull() {
return takeSnapshot(true);
}
private RenderStateSnapshot takeSnapshot(boolean full) {
synchronized (terminal) { synchronized (terminal) {
if (snapshotVersion != renderVersion) { long version = contentVersion.get();
cachedSnapshot = terminal.renderSnapshot(); if (full) {
snapshotVersion = renderVersion; long updateStart = RenderProfiler.start();
renderState.update(terminal);
RenderProfiler.stop(RenderProfiler.UPDATE, updateStart);
long marshalStart = RenderProfiler.start();
cachedSnapshot = renderState.snapshot();
RenderProfiler.stop(RenderProfiler.MARSHAL, marshalStart);
renderState.resetDirty();
snapshotVersion = version;
} else if (snapshotVersion != version) {
long updateStart = RenderProfiler.start();
renderState.update(terminal);
RenderProfiler.stop(RenderProfiler.UPDATE, updateStart);
long marshalStart = RenderProfiler.start();
cachedSnapshot = renderState.snapshotIncremental();
RenderProfiler.stop(RenderProfiler.MARSHAL, marshalStart);
renderState.resetDirty();
snapshotVersion = version;
} }
return cachedSnapshot; return cachedSnapshot;
} }
@@ -151,8 +188,17 @@ public final class TerminalPane implements AutoCloseable {
} }
} }
public long renderVersion() { /** This pane's own content revision, bumped on every change (see {@link #refresh()}). */
return renderVersion; public long contentVersion() {
return contentVersion.get();
}
long snapshotVersion() {
return snapshotVersion;
}
public boolean kittyEnabled() {
return kittyEnabled;
} }
public Optional<KittyGraphics> kittyGraphics() { public Optional<KittyGraphics> kittyGraphics() {
@@ -161,22 +207,6 @@ public final class TerminalPane implements AutoCloseable {
} }
} }
public boolean floating() {
return floating;
}
public void setFloating(boolean floating) {
this.floating = floating;
}
public boolean visible() {
return visible;
}
public void setVisible(boolean visible) {
this.visible = visible;
}
public double x() { public double x() {
return x; return x;
} }
@@ -200,7 +230,14 @@ public final class TerminalPane implements AutoCloseable {
this.height = height; this.height = height;
} }
public void resize(int columns, int rows, int pixelWidth, int pixelHeight) { /** Recompute the ghostty grid from the current bounds and the shared cell metrics. */
public void fitToBounds() {
int columns = metrics.columnsFor(width);
int rows = metrics.rowsFor(height);
resize(columns, rows, (int) Math.round(metrics.cellWidth()), (int) Math.round(metrics.lineHeight()));
}
private void resize(int columns, int rows, int pixelWidth, int pixelHeight) {
if (columns <= 0 || rows <= 0 || pixelWidth <= 0 || pixelHeight <= 0) { if (columns <= 0 || rows <= 0 || pixelWidth <= 0 || pixelHeight <= 0) {
return; return;
} }
@@ -222,26 +259,21 @@ public final class TerminalPane implements AutoCloseable {
} }
private void refresh() { private void refresh() {
// Only mark the pane dirty; the snapshot itself is computed lazily in // Mark this pane's content dirty (the snapshot is computed lazily in the paint path,
// renderSnapshot() so a burst of writes collapses into a single snapshot per frame. // so a burst of writes collapses into one snapshot per frame) and tell the owning tab
renderVersion++; // one of its panes changed.
RENDER_TICK.incrementAndGet(); contentVersion.incrementAndGet();
onContentChange.run();
} }
@Override @Override
public void close() { public void close() {
// Stop accepting reader-thread writes first, then shut the session (which unblocks
// and ends the reader), so terminal.close() can't race a write from that thread.
synchronized (terminal) {
closed = true;
}
if (session != null) { if (session != null) {
session.close(); session.close();
session = null; session = null;
} }
mouseEncoder.close(); mouseEncoder.close();
synchronized (terminal) { renderState.close();
terminal.close(); terminal.close();
}
} }
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1,139 +0,0 @@
package com.gregor.jprototerm;
import java.util.ArrayList;
import java.util.List;
/**
* Holds the tabs and renders only the current one. Pane operations delegate to the current
* tab; tab operations switch which tab is current. A single render version bumps on any
* change (intra-tab or tab switch) so the renderer recomposites when needed.
*/
public final class TerminalWorkspace implements AutoCloseable {
private final AppConfig config;
private final List<Tab> tabs = new ArrayList<>();
private int currentTab;
private long version;
public TerminalWorkspace(AppConfig config) {
this.config = config;
tabs.add(new Tab(config));
}
private Tab current() {
return tabs.get(currentTab);
}
public long version() {
return version;
}
public boolean isEmpty() {
return tabs.isEmpty();
}
public TerminalPane activePane() {
return current().activePane();
}
public List<TerminalPane> panes() {
return tabs.isEmpty() ? List.of() : current().panes();
}
public boolean isActive(TerminalPane pane) {
return !tabs.isEmpty() && current().isActive(pane);
}
public void layout(double width, double height, double topInset) {
if (!tabs.isEmpty()) {
current().layout(width, height, topInset);
}
}
public int tabCount() {
return tabs.size();
}
public int currentTabIndex() {
return currentTab;
}
public void focus(TerminalPane pane) {
if (!tabs.isEmpty() && current().focus(pane)) {
version++;
}
}
public void navigate(Direction direction) {
if (!tabs.isEmpty() && current().navigate(direction)) {
version++;
}
}
public void toggleFloating() {
if (tabs.isEmpty()) {
return;
}
current().toggleFloating();
version++;
}
public void createPane() {
if (tabs.isEmpty()) {
return;
}
current().createPane();
version++;
}
public void nextFloatingPane() {
if (tabs.isEmpty()) {
return;
}
current().nextFloatingPane();
version++;
}
public void closeActivePane() {
if (tabs.isEmpty()) {
return;
}
current().closeActivePane();
if (current().isEmpty()) {
// Closing a tab's last pane closes the tab. When no tabs remain the workspace
// is empty and Main quits.
tabs.remove(currentTab);
if (currentTab >= tabs.size()) {
currentTab = Math.max(0, tabs.size() - 1);
}
}
version++;
}
public void newTab() {
tabs.add(new Tab(config));
currentTab = tabs.size() - 1;
version++;
}
public void nextTab() {
if (tabs.size() > 1) {
currentTab = (currentTab + 1) % tabs.size();
version++;
}
}
public void previousTab() {
if (tabs.size() > 1) {
currentTab = (currentTab - 1 + tabs.size()) % tabs.size();
version++;
}
}
@Override
public void close() {
for (Tab tab : tabs) {
tab.close();
}
tabs.clear();
}
}