30 Commits

Author SHA1 Message Date
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
Gregor Lohaus
76f539d34a fix build error 2026-05-30 01:27:10 +02:00
Gregor Lohaus
ba884cd0a2 probably wrong fix 2026-05-30 01:23:16 +02:00
Gregor Lohaus
7dbbf89b27 non fix of clearing issue 2026-05-30 01:13:53 +02:00
Gregor Lohaus
e2850f067e readme update 2026-05-29 22:10:36 +02:00
Gregor Lohaus
022cf22463 tabbar, background coloring 2026-05-29 22:08:05 +02:00
Gregor Lohaus
250b182060 tabs 2026-05-29 21:41:25 +02:00
Gregor Lohaus
ebba6cc44f main pane splitting 2026-05-29 21:27:17 +02:00
Gregor Lohaus
4c3449129c no image caching, no transparency for performance 2026-05-29 21:18:16 +02:00
Gregor Lohaus
40d6287867 inverse bg 2026-05-29 20:37:16 +02:00
Gregor Lohaus
ff21bf3544 incremental render 2026-05-29 19:50:09 +02:00
15 changed files with 1998 additions and 1205 deletions

1
.gitignore vendored
View File

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

3
.ignore Normal file
View File

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

View File

@@ -2,7 +2,15 @@
JavaFX canvas terminal prototype using `jlibghostty` for terminal emulation and Nix for
the build environment. It builds a plain JavaFX application (JDK 25, JavaFX 25 via Gradle)
packaged as a Nix derivation — no GraalVM/GluonFX native image.
packaged as a Nix derivation — no GraalVM/GluonFX native image. It supports tiled and
floating panes and tabs.
> [!CAUTION]
> `nix profile add` has only been tested on **Debian with the proprietary NVIDIA driver**.
> The runtime GL shim hardcodes Debian's `/lib/x86_64-linux-gnu` driver paths and selects
> the NVIDIA GLX/EGL vendor, so it likely won't work yet on other distros, Wayland-only
> setups, or Mesa/AMD/Intel GPUs. I'm happy to accept pull requests that broaden host
> support.
## Build
@@ -11,7 +19,7 @@ nix build
./result/bin/jprototerm
```
Install it into a profile (works on NixOS and on a plain Debian box with Nix installed):
Install it into a profile (see the caution above on host support):
```sh
nix profile add .
@@ -97,9 +105,11 @@ navigate_down = "ALT+J"
navigate_up = "ALT+K"
navigate_right = "ALT+L"
toggle_floating = "ALT+F"
new_floating = "ALT+SHIFT+F"
next_floating = "ALT+F12"
new_pane = "ALT+N"
close_pane = "ALT+X"
new_tab = "ALT+A"
previous_tab = "ALT+SHIFT+H"
next_tab = "ALT+SHIFT+L"
open_font_selector = "ALT+T"
open_scrollback = "ALT+S"
```
@@ -107,11 +117,18 @@ open_scrollback = "ALT+S"
## Defaults
- `Alt+h/j/k/l`: navigate panes
- `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)
- `Alt+f`: show or hide all floating panes
- `Alt+Shift+f`: create a new floating pane
- `Alt+F12`: cycle floating panes
- `Alt+x`: close the active floating pane
- `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
- `Alt+a`: new tab
- `Alt+Shift+h` / `Alt+Shift+l`: previous / next tab
- `Alt+t`: open the font selector
- `Alt+s`: open the active pane scrollback in `$EDITOR`
- Font default: `JetBrainsMono Nerd Font`
- Kitty graphics protocol parsing is enabled by default
Each tab has its own stack of tiled and floating panes; only the active tab is rendered. A
thin tab bar appears at the top when more than one tab is open. Closing the last tiled pane
while floating panes exist promotes the most recently active floating pane to a tiled pane.

View File

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

8
flake.lock generated
View File

@@ -70,11 +70,11 @@
"nixpkgs": "nixpkgs"
},
"locked": {
"lastModified": 1780050576,
"narHash": "sha256-u06xuO3QnLDpajIOZwDdhwI0HGzMuXG7x1pR+4Zb+RA=",
"lastModified": 1780079529,
"narHash": "sha256-AxlGTL8c5xSLcQHvWlm994IdOqxsN8iKrA02Cpv7vso=",
"ref": "refs/heads/main",
"rev": "d558d554b360a76d03c2fc09d327e3ec4aade878",
"revCount": 17,
"rev": "68121d50b52fb56038871c97c97e7a12ffe987c2",
"revCount": 20,
"type": "git",
"url": "https://gitea.gregorlohaus.com/gregor/jlibghostty.git"
},

View File

@@ -36,9 +36,11 @@ public record AppConfig(
"navigate_up",
"navigate_right",
"toggle_floating",
"new_floating",
"next_floating",
"new_pane",
"close_pane",
"new_tab",
"previous_tab",
"next_tab",
"open_font_selector",
"open_scrollback"
);
@@ -86,17 +88,19 @@ public record AppConfig(
true,
defaultScrollbackEditorCommand(),
Map.of(),
Map.of(
"navigate_left", KeyBinding.parse("ALT+H"),
"navigate_down", KeyBinding.parse("ALT+J"),
"navigate_up", KeyBinding.parse("ALT+K"),
"navigate_right", KeyBinding.parse("ALT+L"),
"toggle_floating", KeyBinding.parse("ALT+F"),
"new_floating", KeyBinding.parse("ALT+SHIFT+F"),
"next_floating", KeyBinding.parse("ALT+F12"),
"close_pane", KeyBinding.parse("ALT+X"),
"open_font_selector", KeyBinding.parse("ALT+T"),
"open_scrollback", KeyBinding.parse("ALT+S")
Map.ofEntries(
Map.entry("navigate_left", KeyBinding.parse("ALT+H")),
Map.entry("navigate_down", KeyBinding.parse("ALT+J")),
Map.entry("navigate_up", KeyBinding.parse("ALT+K")),
Map.entry("navigate_right", KeyBinding.parse("ALT+L")),
Map.entry("toggle_floating", KeyBinding.parse("ALT+F")),
Map.entry("new_pane", KeyBinding.parse("ALT+N")),
Map.entry("close_pane", KeyBinding.parse("ALT+X")),
Map.entry("new_tab", KeyBinding.parse("ALT+A")),
Map.entry("previous_tab", KeyBinding.parse("ALT+SHIFT+H")),
Map.entry("next_tab", KeyBinding.parse("ALT+SHIFT+L")),
Map.entry("open_font_selector", KeyBinding.parse("ALT+T")),
Map.entry("open_scrollback", KeyBinding.parse("ALT+S"))
)
);
}

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

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

View File

@@ -1,7 +1,5 @@
package com.gregor.jprototerm;
import javafx.application.Platform;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;
@@ -94,23 +92,25 @@ public final class ShellSession implements AutoCloseable {
}
private void readOutput(TerminalPane pane) {
byte[] buffer = new byte[8192];
byte[] buffer = new byte[65536];
try {
int read;
while ((read = pty.read(buffer)) != -1) {
if (!closed) {
if (closed) {
break;
}
byte[] bytes = new byte[read];
System.arraycopy(buffer, 0, bytes, 0, read);
Platform.runLater(() -> {
if (!closed) {
// Feed the terminal model straight from the reader thread. terminal access is
// guarded by the per-terminal lock, and the render loop picks the change up on
// the next pulse. Avoiding a Platform.runLater hop per chunk removes a frame of
// latency and stops write tasks from contending with rendering on the FX thread
// when a TUI repaints heavily (the input-lag culprit).
pane.write(bytes);
}
});
}
}
} catch (RuntimeException ex) {
if (!closed) {
Platform.runLater(() -> pane.write("\r\nshell output stopped: " + ex.getMessage() + "\r\n"));
pane.write("\r\nshell output stopped: " + ex.getMessage() + "\r\n");
}
}
}

View File

@@ -0,0 +1,288 @@
package com.gregor.jprototerm;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.stream.Stream;
/**
* One tab: a row of tiled panes with a group of floating panes shown over them. Floating panes
* are shown/hidden as a group ({@code floatingVisible}), and there is always at least one tiled
* pane — a floating pane is promoted if the last tiled one closes — so the layout always has a
* 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 {
private final AppConfig config;
private final TerminalMetrics metrics;
private final List<TerminalPane> tiled = new ArrayList<>();
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;
// 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, TerminalMetrics metrics) {
this.config = config;
this.metrics = metrics;
this.lastWidth = config.windowWidth();
this.lastHeight = config.windowHeight();
TerminalPane first = openPane(false);
tiled.add(first);
active = first;
}
TerminalPane activePane() {
return active;
}
boolean 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() {
if (!floatingVisible || floating.isEmpty()) {
return List.copyOf(tiled);
}
List<TerminalPane> ordered = new ArrayList<>(tiled.size() + floating.size());
ordered.addAll(tiled);
for (TerminalPane pane : floating) {
if (pane != active) {
ordered.add(pane);
}
}
if (floating.contains(active)) {
ordered.add(active); // active floating pane on top
}
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) {
return pane != null && pane == active;
}
boolean focus(TerminalPane pane) {
if (pane == active || !isFocusable(pane)) {
return false;
}
setActive(pane);
return true;
}
void layout(double width, double height, double topInset) {
this.lastWidth = width;
this.lastHeight = height;
this.lastTopInset = topInset;
double availHeight = height - topInset;
double tileWidth = width / Math.max(1, tiled.size());
for (int i = 0; i < tiled.size(); i++) {
tiled.get(i).bounds(i * tileWidth, topInset, tileWidth, availHeight);
}
double floatingWidth = Math.max(420, width * 0.58);
double floatingHeight = Math.max(260, availHeight * 0.58);
for (int i = 0; i < floating.size(); i++) {
double offset = i * 28.0;
floating.get(i).bounds(
Math.min(width - floatingWidth - 12.0, ((width - floatingWidth) / 2.0) + offset),
Math.min(height - floatingHeight - 12.0, topInset + ((availHeight - floatingHeight) / 2.0) + offset),
floatingWidth,
floatingHeight);
}
}
boolean navigate(Direction direction) {
if (floating.contains(active) && navigateFloatingStack(direction)) {
return true;
}
TerminalPane target = focusable()
.filter(pane -> pane != active)
.filter(pane -> directionFilter(direction, active, pane))
.min(Comparator.comparingDouble(pane -> distance(active, pane)))
.orElse(null);
if (target != null) {
setActive(target);
return true;
}
return false;
}
void toggleFloating() {
if (floating.isEmpty()) {
createFloatingPane();
return;
}
if (floatingVisible) {
floatingVisible = false;
if (floating.contains(active)) {
setActive(tiled.get(0));
}
} else {
floatingVisible = true;
setActive(floating.contains(lastFocusedFloating) ? lastFocusedFloating : floating.get(floating.size() - 1));
}
}
/** Adds a floating pane while the floating group is shown, otherwise a tiled pane. */
void createPane() {
if (floatingVisible) {
createFloatingPane();
} else {
TerminalPane pane = openPane(false);
tiled.add(pane);
setActive(pane);
}
}
void closeActivePane() {
TerminalPane closing = active;
boolean wasFloating = floating.remove(closing);
if (!wasFloating) {
tiled.remove(closing);
}
if (closing == lastFocusedFloating) {
lastFocusedFloating = null;
}
closing.close();
if (tiled.isEmpty() && floating.isEmpty()) {
active = null; // tab is now empty; the compositor drops it
return;
}
// Always keep a tiled base: if the last tiled pane just closed, promote a floating one
// (preferring the last focused).
if (tiled.isEmpty()) {
TerminalPane promote = floating.contains(lastFocusedFloating) ? lastFocusedFloating : floating.get(0);
var promoteIndex = floating.indexOf(promote);
var nextFocussed = promoteIndex == 0 ? 0 : promoteIndex - 1;
floating.remove(promote);
tiled.add(promote);
if (promote == lastFocusedFloating) {
lastFocusedFloating = null;
if (!floating.isEmpty()) {
lastFocusedFloating = floating.isEmpty() ? null : floating.get(nextFocussed);
}
}
}
if (floating.isEmpty()) {
floatingVisible = false;
}
setActive(wasFloating && floatingVisible ? floating.get(floating.size() - 1) : tiled.get(0));
}
private void setActive(TerminalPane pane) {
active = pane;
if (floating.contains(pane)) {
lastFocusedFloating = pane;
}
}
private void createFloatingPane() {
TerminalPane pane = openPane(true);
floating.add(pane);
floatingVisible = true;
setActive(pane);
}
private boolean navigateFloatingStack(Direction direction) {
if (floating.size() < 2) {
return false;
}
int current = floating.indexOf(active);
if (current < 0) {
return false;
}
int next = switch (direction) {
case LEFT, UP -> current - 1;
case DOWN, RIGHT -> current + 1;
};
if (next < 0 || next >= floating.size()) {
return false;
}
setActive(floating.get(next));
return true;
}
private boolean isFocusable(TerminalPane pane) {
return tiled.contains(pane) || (floatingVisible && floating.contains(pane));
}
private Stream<TerminalPane> focusable() {
return floatingVisible ? Stream.concat(tiled.stream(), floating.stream()) : tiled.stream();
}
private void markContentChanged() {
contentVersion++;
}
private TerminalPane openPane(boolean asFloating) {
double availHeight = lastHeight - lastTopInset;
double widthPx;
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;
}
return TerminalPane.create(config, metrics, this::markContentChanged, widthPx, heightPx);
}
private static boolean directionFilter(Direction direction, TerminalPane current, TerminalPane candidate) {
double currentCenterX = current.x() + current.width() / 2.0;
double currentCenterY = current.y() + current.height() / 2.0;
double candidateCenterX = candidate.x() + candidate.width() / 2.0;
double candidateCenterY = candidate.y() + candidate.height() / 2.0;
return switch (direction) {
case LEFT -> candidateCenterX < currentCenterX;
case DOWN -> candidateCenterY > currentCenterY;
case UP -> candidateCenterY < currentCenterY;
case RIGHT -> candidateCenterX > currentCenterX;
};
}
private static double distance(TerminalPane current, TerminalPane candidate) {
double dx = (current.x() + current.width() / 2.0) - (candidate.x() + candidate.width() / 2.0);
double dy = (current.y() + current.height() / 2.0) - (candidate.y() + candidate.height() / 2.0);
return Math.sqrt(dx * dx + dy * dy);
}
@Override
public void close() {
tiled.forEach(TerminalPane::close);
floating.forEach(TerminalPane::close);
tiled.clear();
floating.clear();
}
}

View File

@@ -1,799 +0,0 @@
package com.gregor.jprototerm;
import dev.jlibghostty.KittyImageCompression;
import dev.jlibghostty.KittyImageFormat;
import dev.jlibghostty.KittyImageSnapshot;
import dev.jlibghostty.KittyPlacement;
import dev.jlibghostty.KittyPlacementLayer;
import dev.jlibghostty.KittyPlaceholder;
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;
import dev.jlibghostty.RenderRow;
import dev.jlibghostty.RenderStateSnapshot;
import javafx.scene.canvas.Canvas;
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;
import javafx.scene.text.Text;
import java.io.ByteArrayInputStream;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
public final class TerminalCanvasView {
private static final Color DEFAULT_FOREGROUND = Color.rgb(225, 229, 235);
private static final Color SELECTED_BACKGROUND = Color.rgb(52, 92, 140);
private final Canvas canvas = new Canvas();
private final TerminalWorkspace workspace;
private final AppConfig config;
private final Map<KittyImageKey, Image> kittyImageCache = new HashMap<>();
private final Map<TerminalPane, PaneRenderCache> paneRenderCache = new HashMap<>();
private String fontFamily;
private double fontSize;
private Font cachedFont;
private FontMetrics cachedMetrics;
private String cachedFontFamily;
private double cachedFontSize;
private String lastRenderKey;
private boolean mouseButtonPressed;
private MouseButton pressedButton = MouseButton.UNKNOWN;
public TerminalCanvasView(TerminalWorkspace workspace, AppConfig config) {
this.workspace = workspace;
this.config = config;
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() {
return canvas;
}
public void setFont(String family, double size) {
this.fontFamily = family;
this.fontSize = size;
cachedFont = null;
cachedMetrics = null;
paneRenderCache.clear();
lastRenderKey = null;
}
public void render() {
double width = canvas.getWidth();
double height = canvas.getHeight();
workspace.layout(width, height);
Font font = currentFont();
FontMetrics metrics = currentFontMetrics();
List<TerminalPane> panes = workspace.panes();
String renderKey = renderKey(width, height, metrics, panes);
if (renderKey.equals(lastRenderKey)) {
return;
}
lastRenderKey = renderKey;
GraphicsContext gc = canvas.getGraphicsContext2D();
gc.setFill(Color.rgb(16, 16, 18));
gc.fillRect(0, 0, width, height);
gc.setFontSmoothingType(FontSmoothingType.LCD);
paneRenderCache.keySet().removeIf(pane -> !panes.contains(pane));
for (TerminalPane pane : panes) {
drawPane(gc, pane, font, metrics);
}
}
private void drawPane(GraphicsContext gc, TerminalPane pane, Font font, FontMetrics metrics) {
if (config.kittyGraphics() && paneHasKittyGraphics(pane)) {
paneRenderCache.remove(pane);
gc.save();
gc.beginPath();
gc.rect(pane.x(), pane.y(), pane.width(), pane.height());
gc.clip();
drawPaneContent(gc, pane, font, metrics, pane.x(), pane.y(), pane.width(), pane.height(), false);
gc.restore();
return;
}
PaneRenderCache cache = paneRenderCache.computeIfAbsent(pane, ignored -> new PaneRenderCache());
String cacheKey = paneCacheKey(pane, metrics);
int imageWidth = Math.max(1, (int) Math.ceil(pane.width()));
int imageHeight = Math.max(1, (int) Math.ceil(pane.height()));
// Allocate the offscreen buffers only when the pane size changes. Reallocating a
// full-pane Canvas + WritableImage on every content change churns ~20 MB per frame,
// which the native image's serial GC turns into Full-GC frame drops.
if (cache.canvas == null || cache.image == null || cache.imageWidth != imageWidth || cache.imageHeight != imageHeight) {
cache.canvas = new Canvas(imageWidth, imageHeight);
cache.image = new WritableImage(imageWidth, imageHeight);
cache.imageWidth = imageWidth;
cache.imageHeight = imageHeight;
cache.key = null;
}
// Redraw and re-snapshot into the existing buffers only when content changed.
if (!cacheKey.equals(cache.key)) {
GraphicsContext cacheGc = cache.canvas.getGraphicsContext2D();
cacheGc.clearRect(0, 0, imageWidth, imageHeight);
drawPaneContent(cacheGc, pane, font, metrics, 0.0, 0.0, imageWidth, imageHeight, true);
cache.canvas.snapshot(null, cache.image);
cache.key = cacheKey;
}
gc.drawImage(cache.image, pane.x(), pane.y());
}
private void drawPaneContent(
GraphicsContext gc,
TerminalPane pane,
Font font,
FontMetrics metrics,
double x,
double y,
double width,
double height,
boolean clear
) {
if (clear) {
gc.clearRect(x, y, width, height);
}
gc.setFontSmoothingType(FontSmoothingType.LCD);
if (pane.floating()) {
gc.setGlobalAlpha(0.96);
}
gc.setFill(Color.rgb(9, 10, 12));
gc.fillRect(x, y, width, height);
gc.setGlobalAlpha(1.0);
gc.setStroke(workspace.isActive(pane) ? Color.rgb(87, 166, 255) : Color.rgb(52, 57, 65));
gc.setLineWidth(workspace.isActive(pane) ? 2.0 : 1.0);
gc.strokeRect(x + 0.5, y + 0.5, width - 1.0, height - 1.0);
gc.setFont(font);
int columns = Math.max(1, (int) ((width - 24.0) / metrics.cellWidth));
int rows = Math.max(1, (int) ((height - 24.0) / metrics.lineHeight));
pane.resize(columns, rows, (int) Math.round(metrics.cellWidth), (int) Math.round(metrics.lineHeight));
double left = x + 12.0;
double top = y + 12.0;
double baseline = top + metrics.baselineOffset;
RenderStateSnapshot snapshot = pane.renderSnapshot();
Map<KittyPlaceholderKey, KittyPlaceholderBounds> placeholderBounds = config.kittyGraphics()
? kittyPlaceholderBounds(snapshot)
: Map.of();
if (config.kittyGraphics()) {
drawKittyGraphics(gc, pane, KittyPlacementLayer.BELOW_TEXT, placeholderBounds, left, top, metrics.cellWidth, metrics.lineHeight);
}
if (snapshot != null) {
for (RenderRow row : snapshot.renderRows()) {
drawRow(gc, row, left, top, baseline, metrics.cellWidth, metrics.lineHeight);
}
}
if (snapshot != null) {
drawCursor(gc, snapshot, left, top, metrics.cellWidth, metrics.lineHeight);
}
if (config.kittyGraphics()) {
drawKittyGraphics(gc, pane, KittyPlacementLayer.ABOVE_TEXT, placeholderBounds, left, top, metrics.cellWidth, metrics.lineHeight);
}
}
private static FontMetrics measureFontMetrics(Font font) {
Text text = new Text("┃MgÅjy");
text.setFont(font);
double lineHeight = Math.max(1.0, text.getLayoutBounds().getHeight());
double baselineOffset = -text.getLayoutBounds().getMinY();
Text cell = new Text("M");
cell.setFont(font);
double cellWidth = Math.max(1.0, cell.getLayoutBounds().getWidth());
return new FontMetrics(cellWidth, lineHeight, baselineOffset);
}
private Font currentFont() {
if (cachedFont == null || !fontFamily.equals(cachedFontFamily) || fontSize != cachedFontSize) {
cachedFont = Font.font(fontFamily, fontSize);
cachedMetrics = null;
cachedFontFamily = fontFamily;
cachedFontSize = fontSize;
}
return cachedFont;
}
private FontMetrics currentFontMetrics() {
if (cachedMetrics == null) {
cachedMetrics = measureFontMetrics(currentFont());
}
return cachedMetrics;
}
private String renderKey(double width, double height, FontMetrics metrics, List<TerminalPane> panes) {
StringBuilder builder = new StringBuilder();
builder.append(width).append(':')
.append(height).append(':')
.append(workspace.version()).append(':')
.append(fontFamily).append(':')
.append(fontSize).append(':')
.append(metrics.cellWidth).append(':')
.append(metrics.lineHeight);
for (TerminalPane pane : panes) {
builder.append('|')
.append(System.identityHashCode(pane)).append(',')
.append(pane.renderVersion()).append(',')
.append(workspace.isActive(pane)).append(',')
.append(pane.x()).append(',')
.append(pane.y()).append(',')
.append(pane.width()).append(',')
.append(pane.height());
}
return builder.toString();
}
private String paneCacheKey(TerminalPane pane, FontMetrics metrics) {
return pane.renderVersion()
+ ":" + workspace.isActive(pane)
+ ":" + pane.width()
+ ":" + pane.height()
+ ":" + fontFamily
+ ":" + fontSize
+ ":" + metrics.cellWidth
+ ":" + metrics.lineHeight
+ ":" + config.kittyGraphics();
}
private static Map<KittyPlaceholderKey, KittyPlaceholderBounds> kittyPlaceholderBounds(RenderStateSnapshot snapshot) {
if (snapshot == null) {
return Map.of();
}
Map<KittyPlaceholderKey, KittyPlaceholderBounds> result = new HashMap<>();
for (RenderRow row : snapshot.renderRows()) {
for (RenderCell cell : row.cells()) {
cell.kittyPlaceholder().ifPresent(placeholder -> {
KittyPlaceholderKey key = new KittyPlaceholderKey(placeholder.imageId(), placeholder.placementId());
result.computeIfAbsent(key, ignored -> new KittyPlaceholderBounds()).include(row.row(), cell.column(), placeholder);
});
}
}
return result;
}
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 = currentFontMetrics();
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,
double left,
double top,
double baseline,
double cellWidth,
double lineHeight
) {
for (RenderCell cell : row.cells()) {
if (cell.kittyPlaceholder().isPresent()) {
continue;
}
double x = left + (cell.column() * cellWidth);
double cellTop = top + (row.row() * lineHeight);
cell.background().ifPresent(background -> {
gc.setFill(toFxColor(background));
gc.fillRect(x, cellTop, cellWidth, lineHeight);
});
if (cell.selected()) {
gc.setFill(SELECTED_BACKGROUND);
gc.fillRect(x, cellTop, cellWidth, lineHeight);
}
if (cell.codepoints().length == 0) {
continue;
}
double y = baseline + (row.row() * lineHeight);
Color foreground = cell.foreground().map(TerminalCanvasView::toFxColor).orElse(DEFAULT_FOREGROUND);
gc.setFill(foreground);
gc.fillText(cell.text(), x, y);
}
}
private static Color toFxColor(RenderColor color) {
return Color.rgb(color.red(), color.green(), color.blue());
}
private static void drawCursor(GraphicsContext gc, RenderStateSnapshot snapshot, double left, double top, double cellWidth, double lineHeight) {
if (!snapshot.cursorVisible() || !snapshot.cursorViewportHasValue()) {
return;
}
double x = left + (snapshot.cursorViewportX() * cellWidth);
double y = top + (snapshot.cursorViewportY() * lineHeight);
gc.setStroke(Color.rgb(225, 229, 235));
gc.setFill(Color.rgb(225, 229, 235, 0.28));
gc.setLineWidth(1.5);
RenderCursorStyle style = snapshot.cursorStyle();
if (style == RenderCursorStyle.BAR) {
gc.strokeLine(x + 0.5, y + 2.0, x + 0.5, y + lineHeight - 2.0);
} else if (style == RenderCursorStyle.UNDERLINE) {
gc.strokeLine(x + 1.0, y + lineHeight - 2.0, x + cellWidth - 1.0, y + lineHeight - 2.0);
} else if (style == RenderCursorStyle.BLOCK) {
gc.fillRect(x + 0.5, y + 1.0, Math.max(1.0, cellWidth - 1.0), Math.max(1.0, lineHeight - 2.0));
} else {
gc.strokeRect(x + 0.5, y + 1.0, Math.max(1.0, cellWidth - 1.0), Math.max(1.0, lineHeight - 2.0));
}
}
private void drawKittyGraphics(
GraphicsContext gc,
TerminalPane pane,
KittyPlacementLayer layer,
Map<KittyPlaceholderKey, KittyPlaceholderBounds> placeholderBounds,
double originX,
double originY,
double cellWidth,
double lineHeight
) {
pane.kittyGraphics().ifPresent(graphics -> {
for (KittyPlacement placement : graphics.placements(layer)) {
Image image = imageFor(placement);
if (image == null) {
continue;
}
if (placement.virtual()) {
drawVirtualKittyPlacement(gc, placement, image, placeholderBounds, originX, originY, cellWidth, lineHeight);
} else {
drawPinnedKittyPlacement(gc, placement, image, originX, originY, cellWidth, lineHeight);
}
}
});
}
private static void drawPinnedKittyPlacement(
GraphicsContext gc,
KittyPlacement placement,
Image image,
double originX,
double originY,
double cellWidth,
double lineHeight
) {
KittyRenderInfo renderInfo = placement.renderInfo().orElse(null);
if (renderInfo == null || !renderInfo.viewportVisible()) {
return;
}
double sourceX = renderInfo.sourceX();
double sourceY = renderInfo.sourceY();
double sourceWidth = renderInfo.sourceWidth();
double sourceHeight = renderInfo.sourceHeight();
if (sourceWidth <= 0.0 || sourceHeight <= 0.0) {
return;
}
double x = originX + (renderInfo.viewportColumn() * cellWidth) + placement.xOffset();
double y = originY + (renderInfo.viewportRow() * lineHeight) + placement.yOffset();
double width = renderInfo.pixelWidth() > 0 ? renderInfo.pixelWidth() : renderInfo.gridColumns() * cellWidth;
double height = renderInfo.pixelHeight() > 0 ? renderInfo.pixelHeight() : renderInfo.gridRows() * lineHeight;
if (width <= 0.0 || height <= 0.0) {
return;
}
gc.drawImage(image, sourceX, sourceY, sourceWidth, sourceHeight, x, y, width, height);
}
private static void drawVirtualKittyPlacement(
GraphicsContext gc,
KittyPlacement placement,
Image image,
Map<KittyPlaceholderKey, KittyPlaceholderBounds> placeholderBounds,
double originX,
double originY,
double cellWidth,
double lineHeight
) {
KittyPlaceholderBounds bounds = placeholderBounds.get(new KittyPlaceholderKey(placement.imageId(), placement.placementId()));
if (bounds == null) {
bounds = placeholderBounds.get(new KittyPlaceholderKey(placement.imageId(), 0));
}
if (bounds == null && placement.placementId() == 0) {
bounds = placeholderBounds.entrySet().stream()
.filter(entry -> entry.getKey().imageId() == placement.imageId())
.map(Map.Entry::getValue)
.findFirst()
.orElse(null);
}
if (bounds == null || bounds.isEmpty()) {
return;
}
SourceRect source = sourceRect(placement, image);
if (source.width() <= 0.0 || source.height() <= 0.0) {
return;
}
long gridColumns = gridColumns(placement, bounds);
long gridRows = gridRows(placement, bounds);
double sourceCellWidth = source.width() / Math.max(1L, gridColumns);
double sourceCellHeight = source.height() / Math.max(1L, gridRows);
double sourceX = source.x() + (bounds.minSourceColumn * sourceCellWidth);
double sourceY = source.y() + (bounds.minSourceRow * sourceCellHeight);
double sourceWidth = bounds.sourceColumns() * sourceCellWidth;
double sourceHeight = bounds.sourceRows() * sourceCellHeight;
double x = originX + (bounds.minColumn * cellWidth);
double y = originY + (bounds.minRow * lineHeight);
double availableWidth = bounds.columns() * cellWidth;
double availableHeight = bounds.rows() * lineHeight;
if (sourceWidth <= 0.0 || sourceHeight <= 0.0 || availableWidth <= 0.0 || availableHeight <= 0.0) {
return;
}
double scale = Math.min(availableWidth / sourceWidth, availableHeight / sourceHeight);
double width = sourceWidth * scale;
double height = sourceHeight * scale;
gc.drawImage(image, sourceX, sourceY, sourceWidth, sourceHeight, x, y, width, height);
}
private static long gridColumns(KittyPlacement placement, KittyPlaceholderBounds bounds) {
if (placement.columns() > 0) {
return placement.columns();
}
return Math.max(bounds.maxSourceColumn + 1, bounds.sourceColumns());
}
private static long gridRows(KittyPlacement placement, KittyPlaceholderBounds bounds) {
if (placement.rows() > 0) {
return placement.rows();
}
return Math.max(bounds.maxSourceRow + 1, bounds.sourceRows());
}
private static SourceRect sourceRect(KittyPlacement placement, Image image) {
double sourceX = placement.sourceX();
double sourceY = placement.sourceY();
double sourceWidth = placement.sourceWidth() > 0 ? placement.sourceWidth() : image.getWidth() - sourceX;
double sourceHeight = placement.sourceHeight() > 0 ? placement.sourceHeight() : image.getHeight() - sourceY;
return new SourceRect(sourceX, sourceY, Math.min(sourceWidth, image.getWidth() - sourceX), Math.min(sourceHeight, image.getHeight() - sourceY));
}
private Image imageFor(KittyPlacement placement) {
return placement.image().map(snapshot -> {
byte[] data = snapshot.data();
KittyImageKey key = KittyImageKey.of(snapshot, data);
Image cached = kittyImageCache.get(key);
if (cached != null) {
return cached;
}
kittyImageCache.keySet().removeIf(existing -> existing.id() == snapshot.id());
Image decoded = decodeImage(snapshot, data);
if (decoded != null) {
kittyImageCache.put(key, decoded);
}
return decoded;
}).orElse(null);
}
private boolean paneHasKittyGraphics(TerminalPane pane) {
return pane.kittyGraphics()
.map(graphics -> !graphics.placements().isEmpty())
.orElse(false);
}
private Image decodeImage(KittyImageSnapshot snapshot, byte[] data) {
if (snapshot.compression() != KittyImageCompression.NONE) {
return null;
}
if (snapshot.format() == KittyImageFormat.PNG) {
return new Image(new ByteArrayInputStream(data));
}
int width = Math.toIntExact(snapshot.width());
int height = Math.toIntExact(snapshot.height());
WritableImage image = new WritableImage(width, height);
if (snapshot.format() == KittyImageFormat.RGBA) {
image.getPixelWriter().setPixels(0, 0, width, height, PixelFormat.getByteBgraInstance(), rgbaToBgra(data), 0, width * 4);
} else if (snapshot.format() == KittyImageFormat.RGB) {
image.getPixelWriter().setPixels(0, 0, width, height, PixelFormat.getByteRgbInstance(), data, 0, width * 3);
}
return image;
}
private static byte[] rgbaToBgra(byte[] rgba) {
byte[] bgra = new byte[rgba.length];
for (int i = 0; i + 3 < rgba.length; i += 4) {
bgra[i] = rgba[i + 2];
bgra[i + 1] = rgba[i + 1];
bgra[i + 2] = rgba[i];
bgra[i + 3] = rgba[i + 3];
}
return bgra;
}
private record FontMetrics(double cellWidth, double lineHeight, double baselineOffset) {
}
private record MouseTarget(MouseEncoderSize size, long screenWidth, long screenHeight) {
}
private record KittyImageKey(long id, long number, long width, long height, KittyImageFormat format, int dataLength, long fingerprint) {
private static KittyImageKey of(KittyImageSnapshot snapshot, byte[] data) {
return new KittyImageKey(
snapshot.id(),
snapshot.number(),
snapshot.width(),
snapshot.height(),
snapshot.format(),
data.length,
fingerprint(data)
);
}
private static long fingerprint(byte[] data) {
long hash = 0xcbf29ce484222325L;
for (byte value : data) {
hash ^= Byte.toUnsignedInt(value);
hash *= 0x100000001b3L;
}
return hash;
}
}
private record KittyPlaceholderKey(long imageId, long placementId) {
}
private record SourceRect(double x, double y, double width, double height) {
}
private static final class KittyPlaceholderBounds {
private int minRow = Integer.MAX_VALUE;
private int maxRow = Integer.MIN_VALUE;
private int minColumn = Integer.MAX_VALUE;
private int maxColumn = Integer.MIN_VALUE;
private long minSourceRow = Long.MAX_VALUE;
private long maxSourceRow = Long.MIN_VALUE;
private long minSourceColumn = Long.MAX_VALUE;
private long maxSourceColumn = Long.MIN_VALUE;
private void include(int row, int column, KittyPlaceholder placeholder) {
minRow = Math.min(minRow, row);
maxRow = Math.max(maxRow, row);
minColumn = Math.min(minColumn, column);
maxColumn = Math.max(maxColumn, column);
minSourceRow = Math.min(minSourceRow, placeholder.sourceRow());
maxSourceRow = Math.max(maxSourceRow, placeholder.sourceRow());
minSourceColumn = Math.min(minSourceColumn, placeholder.sourceColumn());
maxSourceColumn = Math.max(maxSourceColumn, placeholder.sourceColumn());
}
private boolean isEmpty() {
return minRow == Integer.MAX_VALUE;
}
private int rows() {
return maxRow - minRow + 1;
}
private int columns() {
return maxColumn - minColumn + 1;
}
private long sourceRows() {
return maxSourceRow - minSourceRow + 1;
}
private long sourceColumns() {
return maxSourceColumn - minSourceColumn + 1;
}
}
private static final class PaneRenderCache {
private Canvas canvas;
private WritableImage image;
private int imageWidth;
private int imageHeight;
private String key;
}
}

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,27 +1,39 @@
package com.gregor.jprototerm;
import dev.jlibghostty.DeviceAttributes;
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.RenderState;
import dev.jlibghostty.RenderStateSnapshot;
import dev.jlibghostty.ScrollViewport;
import dev.jlibghostty.Terminal;
import dev.jlibghostty.TerminalOptions;
import dev.jlibghostty.DeviceAttributes;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicReference;
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 {
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 AtomicReference<RenderStateSnapshot> renderSnapshot = new AtomicReference<>();
// 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 ShellSession session;
private boolean floating;
private boolean visible = true;
private double x;
private double y;
private double width;
@@ -30,22 +42,48 @@ public final class TerminalPane implements AutoCloseable {
private int rows;
private int pixelWidth;
private int pixelHeight;
private long renderVersion;
private final AtomicLong contentVersion = new AtomicLong();
private volatile long snapshotVersion = -1;
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.metrics = metrics;
this.kittyEnabled = kittyEnabled;
this.onContentChange = onContentChange;
this.columns = columns;
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);
TerminalPane pane = new TerminalPane(terminal, columns, rows);
TerminalPane pane = new TerminalPane(terminal, metrics, config.kittyGraphics(), onContentChange, columns, rows);
pane.refresh();
pane.attach(ShellSession.start(config.shell(), config.envOverride(), pane, columns, rows));
return pane;
}
private void attach(ShellSession session) {
this.session = session;
terminal.setPtyWriter(bytes -> {
ShellSession current = this.session;
if (current != null) {
current.send(bytes);
}
});
session.startReading(this);
}
public void write(String text) {
synchronized (terminal) {
terminal.write(text);
@@ -60,17 +98,6 @@ public final class TerminalPane implements AutoCloseable {
}
}
public void attach(ShellSession session) {
this.session = session;
terminal.setPtyWriter(bytes -> {
ShellSession current = this.session;
if (current != null) {
current.send(bytes);
}
});
session.startReading(this);
}
public void send(String text) {
scrollViewportToBottom();
if (session != null) {
@@ -104,15 +131,47 @@ public final class TerminalPane implements AutoCloseable {
}
}
public void scrollViewportToBottom() {
private void scrollViewportToBottom() {
synchronized (terminal) {
terminal.scrollViewport(ScrollViewport.bottom());
refresh();
}
}
public RenderStateSnapshot renderSnapshot() {
return renderSnapshot.get();
/**
* Incremental snapshot: cells are marshalled only for rows that changed since the last
* frame (global dirty == PARTIAL), reused across calls for the same content version.
* Snapshotting is deferred here rather than done in refresh(), so a burst of writes
* between two frames collapses into a single snapshot.
*/
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) {
long version = contentVersion.get();
if (full) {
renderState.update(terminal);
cachedSnapshot = renderState.snapshot();
renderState.resetDirty();
snapshotVersion = version;
} else if (snapshotVersion != version) {
renderState.update(terminal);
cachedSnapshot = renderState.snapshotIncremental();
renderState.resetDirty();
snapshotVersion = version;
}
return cachedSnapshot;
}
}
public String scrollbackText() {
@@ -121,8 +180,17 @@ public final class TerminalPane implements AutoCloseable {
}
}
public long renderVersion() {
return renderVersion;
/** This pane's own content revision, bumped on every change (see {@link #refresh()}). */
public long contentVersion() {
return contentVersion.get();
}
long snapshotVersion() {
return snapshotVersion;
}
public boolean kittyEnabled() {
return kittyEnabled;
}
public Optional<KittyGraphics> kittyGraphics() {
@@ -131,22 +199,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() {
return x;
}
@@ -170,7 +222,14 @@ public final class TerminalPane implements AutoCloseable {
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) {
return;
}
@@ -192,8 +251,11 @@ public final class TerminalPane implements AutoCloseable {
}
private void refresh() {
renderSnapshot.set(terminal.renderSnapshot());
renderVersion++;
// Mark this pane's content dirty (the snapshot is computed lazily in the paint path,
// so a burst of writes collapses into one snapshot per frame) and tell the owning tab
// one of its panes changed.
contentVersion.incrementAndGet();
onContentChange.run();
}
@Override
@@ -203,6 +265,7 @@ public final class TerminalPane implements AutoCloseable {
session = null;
}
mouseEncoder.close();
renderState.close();
terminal.close();
}
}

View File

@@ -0,0 +1,975 @@
package com.gregor.jprototerm;
import dev.jlibghostty.KittyImageCompression;
import dev.jlibghostty.KittyImageFormat;
import dev.jlibghostty.KittyImageSnapshot;
import dev.jlibghostty.KittyPlacement;
import dev.jlibghostty.KittyPlacementLayer;
import dev.jlibghostty.KittyPlaceholder;
import dev.jlibghostty.KittyRenderInfo;
import dev.jlibghostty.RenderCell;
import dev.jlibghostty.RenderColor;
import dev.jlibghostty.RenderCursorStyle;
import dev.jlibghostty.RenderRow;
import dev.jlibghostty.RenderStateSnapshot;
import javafx.geometry.Rectangle2D;
import javafx.scene.Node;
import javafx.scene.canvas.Canvas;
import javafx.scene.canvas.GraphicsContext;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.image.PixelFormat;
import javafx.scene.image.WritableImage;
import javafx.scene.layout.Pane;
import javafx.scene.layout.Region;
import javafx.scene.paint.Color;
import javafx.scene.shape.Line;
import javafx.scene.shape.Rectangle;
import javafx.scene.text.FontSmoothingType;
import java.io.ByteArrayInputStream;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* JavaFX node for one terminal pane. The pane is composed from JavaFX primitives: one node per
* terminal row, kitty graphics as ImageView nodes, plus background/cursor/border nodes.
*/
final class TerminalPaneNode extends Region {
private static final int DIRTY_PARTIAL = 1;
private static final int DIRTY_FULL = 2;
private static final Color DEFAULT_FOREGROUND = Color.rgb(225, 229, 235);
private static final Color SELECTED_BACKGROUND = Color.rgb(52, 92, 140);
private static final Color PANE_BACKGROUND = Color.rgb(9, 10, 12);
private static final Map<Integer, Color> COLOR_CACHE = new HashMap<>();
private final TerminalPane pane;
private final TerminalMetrics metrics;
private final Rectangle background = new Rectangle();
private final Pane belowImageLayer = new Pane();
private final Pane rowLayer = new Pane();
private final Pane cursorLayer = new Pane();
private final Pane aboveImageLayer = new Pane();
private final Rectangle topPadding = new Rectangle();
private final Rectangle bottomPadding = new Rectangle();
private final Rectangle border = new Rectangle();
private final Map<Integer, TerminalRowNode> rows = new HashMap<>();
private final Map<Integer, Long> rowFingerprints = new HashMap<>();
private final Map<KittyImageKey, Image> kittyImageCache = new HashMap<>();
private long drawnContentVersion = Long.MIN_VALUE;
private double drawnWidth = -1.0;
private double drawnHeight = -1.0;
TerminalPaneNode(TerminalPane pane, TerminalMetrics metrics) {
this.pane = pane;
this.metrics = metrics;
setPickOnBounds(true);
setClip(new Rectangle());
background.setFill(PANE_BACKGROUND);
border.setFill(Color.TRANSPARENT);
getChildren().setAll(background, belowImageLayer, rowLayer, cursorLayer, aboveImageLayer, border);
rowLayer.getChildren().setAll(topPadding, bottomPadding);
}
void discard() {
drawnContentVersion = Long.MIN_VALUE;
drawnWidth = -1.0;
drawnHeight = -1.0;
rows.clear();
rowFingerprints.clear();
rowLayer.getChildren().setAll(topPadding, bottomPadding);
belowImageLayer.getChildren().clear();
aboveImageLayer.getChildren().clear();
cursorLayer.getChildren().clear();
}
void renderFull(boolean active) {
prepareGeometry();
RenderStateSnapshot snapshot = pane.snapshotFull();
long renderedVersion = pane.snapshotVersion();
boolean withKitty = pane.kittyEnabled() && hasKittyGraphics();
updateRowsFull(snapshot);
updateKittyGraphics(snapshot, withKitty);
updateCursor(snapshot);
updateBorder(active);
markDrawn(renderedVersion);
}
void renderIncremental(boolean active) {
boolean geometryChanged = prepareGeometry();
boolean withKitty = pane.kittyEnabled() && hasKittyGraphics();
if (drawnContentVersion == Long.MIN_VALUE || geometryChanged || withKitty) {
renderFull(active);
return;
}
if (drawnContentVersion == pane.contentVersion()) {
updateBorder(active);
return;
}
RenderStateSnapshot snapshot = pane.snapshot();
long renderedVersion = pane.snapshotVersion();
int dirty = snapshot == null ? DIRTY_FULL : snapshot.dirty();
if (dirty == DIRTY_FULL) {
updateChangedRows(snapshot, snapshot.renderRows());
} else if (dirty == DIRTY_PARTIAL) {
updateDirtyRows(snapshot);
}
updateKittyGraphics(snapshot, false);
updateCursor(snapshot);
updateBorder(active);
markDrawn(renderedVersion);
}
private boolean prepareGeometry() {
double width = Math.max(0.0, pane.width());
double height = Math.max(0.0, pane.height());
boolean changed = drawnWidth != width || drawnHeight != height;
resize(width, height);
background.setWidth(width);
background.setHeight(height);
resizeLayer(belowImageLayer, width, height);
resizeLayer(rowLayer, width, height);
resizeLayer(cursorLayer, width, height);
resizeLayer(aboveImageLayer, width, height);
border.setWidth(Math.max(0.0, width - 1.0));
border.setHeight(Math.max(0.0, height - 1.0));
border.relocate(0.5, 0.5);
Node clip = getClip();
if (clip instanceof Rectangle rectangle) {
rectangle.setWidth(width);
rectangle.setHeight(height);
}
return changed;
}
private static void resizeLayer(Pane layer, double width, double height) {
layer.resizeRelocate(0.0, 0.0, width, height);
}
private void updateRowsFull(RenderStateSnapshot snapshot) {
if (snapshot == null) {
rows.clear();
rowFingerprints.clear();
rowLayer.getChildren().setAll(topPadding, bottomPadding);
return;
}
List<Node> ordered = new ArrayList<>(snapshot.renderRows().size() + 2);
ordered.add(topPadding);
ordered.add(bottomPadding);
Set<Integer> liveRows = new HashSet<>();
for (RenderRow row : snapshot.renderRows()) {
TerminalRowNode node = rowNode(row.row());
long fingerprint = rowFingerprint(row);
node.render(row);
rowFingerprints.put(row.row(), fingerprint);
liveRows.add(row.row());
ordered.add(node);
}
rows.keySet().retainAll(liveRows);
rowFingerprints.keySet().retainAll(liveRows);
rowLayer.getChildren().setAll(ordered);
updateVerticalPadding(snapshot);
}
private void updateDirtyRows(RenderStateSnapshot snapshot) {
List<RenderRow> dirtyRows = snapshot.renderRows().stream()
.filter(RenderRow::dirty)
.toList();
updateChangedRows(snapshot, dirtyRows);
}
private void updateChangedRows(RenderStateSnapshot snapshot, List<RenderRow> changedRows) {
if (snapshot == null || changedRows.isEmpty()) {
return;
}
Set<Integer> movedRows = moveShiftedRows(snapshot, changedRows);
for (RenderRow row : snapshot.renderRows()) {
if (!changedRows.contains(row) || movedRows.contains(row.row())) {
continue;
}
TerminalRowNode node = rowNode(row.row());
long fingerprint = rowFingerprint(row);
node.renderChanged(row);
rowFingerprints.put(row.row(), fingerprint);
}
for (RenderRow row : changedRows) {
updateDirtyVerticalPadding(snapshot, row);
}
syncRowChildren();
}
private Set<Integer> moveShiftedRows(RenderStateSnapshot snapshot, List<RenderRow> changedRows) {
if (rowFingerprints.isEmpty() || changedRows.size() < Math.max(4, snapshot.rows() / 3)) {
return Set.of();
}
ShiftPlan plan = detectShift(snapshot, changedRows);
if (plan == null) {
return Set.of();
}
Map<Integer, TerminalRowNode> oldRows = new HashMap<>(rows);
Map<Integer, Long> oldFingerprints = new HashMap<>(rowFingerprints);
for (RowMove move : plan.moves()) {
rows.remove(move.sourceRow());
rowFingerprints.remove(move.sourceRow());
}
for (RowMove move : plan.moves()) {
TerminalRowNode node = oldRows.get(move.sourceRow());
if (node == null) {
continue;
}
node.moveToRow(move.targetRow());
rows.put(move.targetRow(), node);
rowFingerprints.put(move.targetRow(), oldFingerprints.get(move.sourceRow()));
}
return plan.targetRows();
}
private ShiftPlan detectShift(RenderStateSnapshot snapshot, List<RenderRow> changedRows) {
int bestDelta = 0;
int bestScore = 0;
int rowCount = snapshot.rows();
for (int delta = -rowCount + 1; delta < rowCount; delta++) {
if (delta == 0) {
continue;
}
int score = 0;
for (RenderRow row : changedRows) {
int sourceRow = row.row() + delta;
if (sourceRow < 0 || sourceRow >= rowCount || !rows.containsKey(sourceRow)) {
continue;
}
Long previous = rowFingerprints.get(sourceRow);
if (previous != null && previous == rowFingerprint(row)) {
score++;
}
}
if (score > bestScore) {
bestScore = score;
bestDelta = delta;
}
}
int threshold = Math.max(4, (changedRows.size() * 2 + 2) / 3);
if (bestScore < threshold) {
return null;
}
List<RowMove> moves = new ArrayList<>(bestScore);
Set<Integer> targetRows = new HashSet<>();
for (RenderRow row : changedRows) {
int sourceRow = row.row() + bestDelta;
if (sourceRow < 0 || sourceRow >= rowCount || !rows.containsKey(sourceRow)) {
continue;
}
Long previous = rowFingerprints.get(sourceRow);
if (previous != null && previous == rowFingerprint(row)) {
moves.add(new RowMove(sourceRow, row.row()));
targetRows.add(row.row());
}
}
return new ShiftPlan(moves, targetRows);
}
private void syncRowChildren() {
List<Node> ordered = new ArrayList<>(rows.size() + 2);
ordered.add(topPadding);
ordered.add(bottomPadding);
rows.entrySet().stream()
.sorted(Comparator.comparingInt(Map.Entry::getKey))
.map(Map.Entry::getValue)
.forEach(ordered::add);
rowLayer.getChildren().setAll(ordered);
}
private TerminalRowNode rowNode(int row) {
return rows.computeIfAbsent(row, ignored -> {
TerminalRowNode created = new TerminalRowNode(metrics);
if (!rowLayer.getChildren().contains(created)) {
rowLayer.getChildren().add(created);
}
return created;
});
}
private void updateVerticalPadding(RenderStateSnapshot snapshot) {
List<RenderRow> renderRows = snapshot.renderRows();
if (renderRows.isEmpty()) {
topPadding.setVisible(false);
bottomPadding.setVisible(false);
return;
}
double width = pane.width();
double top = TerminalMetrics.PADDING;
double contentBottom = top + snapshot.rows() * metrics.lineHeight();
topPadding.setVisible(true);
topPadding.setFill(rowEdgeBackground(renderRows.get(0), true));
topPadding.relocate(0.0, 0.0);
topPadding.setWidth(width);
topPadding.setHeight(top);
bottomPadding.setVisible(true);
bottomPadding.setFill(rowEdgeBackground(renderRows.get(renderRows.size() - 1), true));
bottomPadding.relocate(0.0, contentBottom);
bottomPadding.setWidth(width);
bottomPadding.setHeight(Math.max(0.0, pane.height() - contentBottom));
}
private void updateDirtyVerticalPadding(RenderStateSnapshot snapshot, RenderRow row) {
if (row.row() == 0) {
topPadding.setVisible(true);
topPadding.setFill(rowEdgeBackground(row, true));
topPadding.relocate(0.0, 0.0);
topPadding.setWidth(pane.width());
topPadding.setHeight(TerminalMetrics.PADDING);
}
if (row.row() == snapshot.rows() - 1) {
double contentBottom = TerminalMetrics.PADDING + snapshot.rows() * metrics.lineHeight();
bottomPadding.setVisible(true);
bottomPadding.setFill(rowEdgeBackground(row, true));
bottomPadding.relocate(0.0, contentBottom);
bottomPadding.setWidth(pane.width());
bottomPadding.setHeight(Math.max(0.0, pane.height() - contentBottom));
}
}
private void updateCursor(RenderStateSnapshot snapshot) {
cursorLayer.getChildren().clear();
if (snapshot == null || !snapshot.cursorVisible() || !snapshot.cursorViewportHasValue()) {
return;
}
double x = TerminalMetrics.PADDING + (snapshot.cursorViewportX() * metrics.cellWidth());
double y = TerminalMetrics.PADDING + (snapshot.cursorViewportY() * metrics.lineHeight());
double cellWidth = metrics.cellWidth();
double lineHeight = metrics.lineHeight();
RenderCursorStyle style = snapshot.cursorStyle();
if (style == RenderCursorStyle.BAR) {
Line line = new Line(x + 0.5, y + 2.0, x + 0.5, y + lineHeight - 2.0);
line.setStroke(DEFAULT_FOREGROUND);
line.setStrokeWidth(1.5);
cursorLayer.getChildren().add(line);
} else if (style == RenderCursorStyle.UNDERLINE) {
Line line = new Line(x + 1.0, y + lineHeight - 2.0, x + cellWidth - 1.0, y + lineHeight - 2.0);
line.setStroke(DEFAULT_FOREGROUND);
line.setStrokeWidth(1.5);
cursorLayer.getChildren().add(line);
} else if (style == RenderCursorStyle.BLOCK) {
Rectangle rectangle = new Rectangle(x + 0.5, y + 1.0, Math.max(1.0, cellWidth - 1.0), Math.max(1.0, lineHeight - 2.0));
rectangle.setFill(Color.rgb(225, 229, 235, 0.28));
cursorLayer.getChildren().add(rectangle);
} else {
Rectangle rectangle = new Rectangle(x + 0.5, y + 1.0, Math.max(1.0, cellWidth - 1.0), Math.max(1.0, lineHeight - 2.0));
rectangle.setFill(Color.TRANSPARENT);
rectangle.setStroke(DEFAULT_FOREGROUND);
rectangle.setStrokeWidth(1.5);
cursorLayer.getChildren().add(rectangle);
}
}
private void updateBorder(boolean active) {
border.setStroke(active ? Color.rgb(87, 166, 255) : Color.rgb(52, 57, 65));
border.setStrokeWidth(active ? 2.0 : 1.0);
}
private void updateKittyGraphics(RenderStateSnapshot snapshot, boolean withKitty) {
belowImageLayer.getChildren().clear();
aboveImageLayer.getChildren().clear();
if (!withKitty || snapshot == null) {
return;
}
Map<KittyPlaceholderKey, KittyPlaceholderBounds> placeholderBounds = kittyPlaceholderBounds(snapshot);
addKittyGraphics(belowImageLayer, KittyPlacementLayer.BELOW_TEXT, placeholderBounds);
addKittyGraphics(aboveImageLayer, KittyPlacementLayer.ABOVE_TEXT, placeholderBounds);
}
private void addKittyGraphics(Pane layer, KittyPlacementLayer placementLayer, Map<KittyPlaceholderKey, KittyPlaceholderBounds> placeholderBounds) {
pane.kittyGraphics().ifPresent(graphics -> {
for (KittyPlacement placement : graphics.placements(placementLayer)) {
Image image = imageFor(placement);
if (image == null) {
continue;
}
ImageView view = placement.virtual()
? virtualKittyView(placement, image, placeholderBounds)
: pinnedKittyView(placement, image);
if (view != null) {
layer.getChildren().add(view);
}
}
});
}
private ImageView pinnedKittyView(KittyPlacement placement, Image image) {
KittyRenderInfo renderInfo = placement.renderInfo().orElse(null);
if (renderInfo == null || !renderInfo.viewportVisible()) {
return null;
}
double sourceX = renderInfo.sourceX();
double sourceY = renderInfo.sourceY();
double sourceWidth = renderInfo.sourceWidth();
double sourceHeight = renderInfo.sourceHeight();
if (sourceWidth <= 0.0 || sourceHeight <= 0.0) {
return null;
}
double x = TerminalMetrics.PADDING + (renderInfo.viewportColumn() * metrics.cellWidth()) + placement.xOffset();
double y = TerminalMetrics.PADDING + (renderInfo.viewportRow() * metrics.lineHeight()) + placement.yOffset();
double width = renderInfo.pixelWidth() > 0 ? renderInfo.pixelWidth() : renderInfo.gridColumns() * metrics.cellWidth();
double height = renderInfo.pixelHeight() > 0 ? renderInfo.pixelHeight() : renderInfo.gridRows() * metrics.lineHeight();
return imageView(image, sourceX, sourceY, sourceWidth, sourceHeight, x, y, width, height);
}
private ImageView virtualKittyView(KittyPlacement placement, Image image, Map<KittyPlaceholderKey, KittyPlaceholderBounds> placeholderBounds) {
KittyPlaceholderBounds bounds = placeholderBounds.get(new KittyPlaceholderKey(placement.imageId(), placement.placementId()));
if (bounds == null) {
bounds = placeholderBounds.get(new KittyPlaceholderKey(placement.imageId(), 0));
}
if (bounds == null && placement.placementId() == 0) {
bounds = placeholderBounds.entrySet().stream()
.filter(entry -> entry.getKey().imageId() == placement.imageId())
.map(Map.Entry::getValue)
.findFirst()
.orElse(null);
}
if (bounds == null || bounds.isEmpty()) {
return null;
}
SourceRect source = sourceRect(placement, image);
if (source.width() <= 0.0 || source.height() <= 0.0) {
return null;
}
long gridColumns = gridColumns(placement, bounds);
long gridRows = gridRows(placement, bounds);
double sourceCellWidth = source.width() / Math.max(1L, gridColumns);
double sourceCellHeight = source.height() / Math.max(1L, gridRows);
double sourceX = source.x() + (bounds.minSourceColumn * sourceCellWidth);
double sourceY = source.y() + (bounds.minSourceRow * sourceCellHeight);
double sourceWidth = bounds.sourceColumns() * sourceCellWidth;
double sourceHeight = bounds.sourceRows() * sourceCellHeight;
double x = TerminalMetrics.PADDING + (bounds.minColumn * metrics.cellWidth());
double y = TerminalMetrics.PADDING + (bounds.minRow * metrics.lineHeight());
double availableWidth = bounds.columns() * metrics.cellWidth();
double availableHeight = bounds.rows() * metrics.lineHeight();
if (sourceWidth <= 0.0 || sourceHeight <= 0.0 || availableWidth <= 0.0 || availableHeight <= 0.0) {
return null;
}
double scale = Math.min(availableWidth / sourceWidth, availableHeight / sourceHeight);
return imageView(image, sourceX, sourceY, sourceWidth, sourceHeight, x, y, sourceWidth * scale, sourceHeight * scale);
}
private static ImageView imageView(Image image, double sourceX, double sourceY, double sourceWidth, double sourceHeight,
double x, double y, double width, double height) {
if (width <= 0.0 || height <= 0.0) {
return null;
}
ImageView view = new ImageView(image);
view.setViewport(new Rectangle2D(sourceX, sourceY, sourceWidth, sourceHeight));
view.setFitWidth(width);
view.setFitHeight(height);
view.setPreserveRatio(false);
view.relocate(x, y);
return view;
}
private boolean hasKittyGraphics() {
return pane.kittyGraphics()
.map(graphics -> !graphics.placements().isEmpty())
.orElse(false);
}
private Image imageFor(KittyPlacement placement) {
return placement.image().map(snapshot -> {
byte[] data = snapshot.data();
KittyImageKey key = KittyImageKey.of(snapshot, data);
Image cached = kittyImageCache.get(key);
if (cached != null) {
return cached;
}
kittyImageCache.keySet().removeIf(existing -> existing.id() == snapshot.id());
Image decoded = decodeImage(snapshot, data);
if (decoded != null) {
kittyImageCache.put(key, decoded);
}
return decoded;
}).orElse(null);
}
private static Image decodeImage(KittyImageSnapshot snapshot, byte[] data) {
if (snapshot.compression() != KittyImageCompression.NONE) {
return null;
}
if (snapshot.format() == KittyImageFormat.PNG) {
return new Image(new ByteArrayInputStream(data));
}
int width = Math.toIntExact(snapshot.width());
int height = Math.toIntExact(snapshot.height());
WritableImage image = new WritableImage(width, height);
if (snapshot.format() == KittyImageFormat.RGBA) {
image.getPixelWriter().setPixels(0, 0, width, height, PixelFormat.getByteBgraInstance(), rgbaToBgra(data), 0, width * 4);
} else if (snapshot.format() == KittyImageFormat.RGB) {
image.getPixelWriter().setPixels(0, 0, width, height, PixelFormat.getByteRgbInstance(), data, 0, width * 3);
}
return image;
}
private static byte[] rgbaToBgra(byte[] rgba) {
byte[] bgra = new byte[rgba.length];
for (int i = 0; i + 3 < rgba.length; i += 4) {
bgra[i] = rgba[i + 2];
bgra[i + 1] = rgba[i + 1];
bgra[i + 2] = rgba[i];
bgra[i + 3] = rgba[i + 3];
}
return bgra;
}
private static Map<KittyPlaceholderKey, KittyPlaceholderBounds> kittyPlaceholderBounds(RenderStateSnapshot snapshot) {
Map<KittyPlaceholderKey, KittyPlaceholderBounds> result = new HashMap<>();
for (RenderRow row : snapshot.renderRows()) {
for (RenderCell cell : row.cells()) {
cell.kittyPlaceholder().ifPresent(placeholder -> {
KittyPlaceholderKey key = new KittyPlaceholderKey(placeholder.imageId(), placeholder.placementId());
result.computeIfAbsent(key, ignored -> new KittyPlaceholderBounds()).include(row.row(), cell.column(), placeholder);
});
}
}
return result;
}
private static long gridColumns(KittyPlacement placement, KittyPlaceholderBounds bounds) {
if (placement.columns() > 0) {
return placement.columns();
}
return Math.max(bounds.maxSourceColumn + 1, bounds.sourceColumns());
}
private static long gridRows(KittyPlacement placement, KittyPlaceholderBounds bounds) {
if (placement.rows() > 0) {
return placement.rows();
}
return Math.max(bounds.maxSourceRow + 1, bounds.sourceRows());
}
private static SourceRect sourceRect(KittyPlacement placement, Image image) {
double sourceX = placement.sourceX();
double sourceY = placement.sourceY();
double sourceWidth = placement.sourceWidth() > 0 ? placement.sourceWidth() : image.getWidth() - sourceX;
double sourceHeight = placement.sourceHeight() > 0 ? placement.sourceHeight() : image.getHeight() - sourceY;
return new SourceRect(sourceX, sourceY, Math.min(sourceWidth, image.getWidth() - sourceX), Math.min(sourceHeight, image.getHeight() - sourceY));
}
private void markDrawn(long renderedVersion) {
drawnContentVersion = renderedVersion;
drawnWidth = pane.width();
drawnHeight = pane.height();
}
private static Color cellBackgroundColor(RenderCell cell) {
if (cell.inverse()) {
var fg = cell.foreground();
return fg.isPresent() ? toFxColor(fg.get()) : DEFAULT_FOREGROUND;
}
var bg = cell.background();
return bg.isPresent() ? toFxColor(bg.get()) : PANE_BACKGROUND;
}
private static Color rowEdgeBackground(RenderRow row, boolean firstCell) {
List<RenderCell> cells = row.cells();
if (cells.isEmpty()) {
return PANE_BACKGROUND;
}
return cellBackgroundColor(firstCell ? cells.get(0) : cells.get(cells.size() - 1));
}
private static Color cellBackgroundOverride(RenderCell cell) {
if (cell.inverse()) {
var fg = cell.foreground();
return fg.isPresent() ? toFxColor(fg.get()) : DEFAULT_FOREGROUND;
}
var bg = cell.background();
return bg.isPresent() ? toFxColor(bg.get()) : null;
}
private static Color cellForegroundColor(RenderCell cell) {
var fgOpt = cell.foreground();
var bgOpt = cell.background();
Color fg = fgOpt.isPresent() ? toFxColor(fgOpt.get()) : DEFAULT_FOREGROUND;
Color bg = bgOpt.isPresent() ? toFxColor(bgOpt.get()) : null;
if (cell.inverse()) {
return (bg != null) ? bg : PANE_BACKGROUND;
}
return fg;
}
private static Color toFxColor(RenderColor color) {
int key = (color.red() << 16) | (color.green() << 8) | color.blue();
Color cached = COLOR_CACHE.get(key);
if (cached != null) {
return cached;
}
if (COLOR_CACHE.size() >= 4096) {
COLOR_CACHE.clear();
}
Color created = Color.rgb(color.red(), color.green(), color.blue());
COLOR_CACHE.put(key, created);
return created;
}
private static long rowFingerprint(RenderRow row) {
long hash = 0xcbf29ce484222325L;
hash = mix(hash, row.cells().size());
for (RenderCell cell : row.cells()) {
hash = mix(hash, cellFingerprint(cell));
}
return hash;
}
private static long cellFingerprint(RenderCell cell) {
long hash = 0xcbf29ce484222325L;
hash = mix(hash, cell.column());
hash = mix(hash, cell.inverse() ? 1 : 0);
hash = mix(hash, cell.selected() ? 1 : 0);
hash = mix(hash, colorFingerprint(cell.foreground().orElse(null)));
hash = mix(hash, colorFingerprint(cell.background().orElse(null)));
for (int codepoint : cell.codepoints()) {
hash = mix(hash, codepoint);
}
if (cell.kittyPlaceholder().isPresent()) {
KittyPlaceholder placeholder = cell.kittyPlaceholder().get();
hash = mix(hash, placeholder.imageId());
hash = mix(hash, placeholder.placementId());
hash = mix(hash, placeholder.sourceRow());
hash = mix(hash, placeholder.sourceColumn());
}
return hash;
}
private static long colorFingerprint(RenderColor color) {
if (color == null) {
return -1L;
}
return ((long) color.red() << 16) | ((long) color.green() << 8) | color.blue();
}
private static long mix(long hash, long value) {
hash ^= value;
return hash * 0x100000001b3L;
}
private static final class TerminalRowNode extends Region {
private final TerminalMetrics metrics;
private final Canvas canvas = new Canvas();
private long[] cellFingerprints = new long[0];
private TerminalRowNode(TerminalMetrics metrics) {
this.metrics = metrics;
getChildren().add(canvas);
}
private void render(RenderRow row) {
prepareCanvas(row);
GraphicsContext gc = canvas.getGraphicsContext2D();
gc.clearRect(0.0, 0.0, canvas.getWidth(), canvas.getHeight());
gc.setFontSmoothingType(FontSmoothingType.LCD);
gc.setFont(metrics.font());
paintSidePadding(gc, row, canvas.getWidth(), canvas.getHeight());
drawRow(gc, row, rowTop(row), metrics.cellWidth(), metrics.lineHeight());
cellFingerprints = cellFingerprints(row);
}
private void renderChanged(RenderRow row) {
double oldWidth = canvas.getWidth();
double oldHeight = canvas.getHeight();
prepareCanvas(row);
long[] nextFingerprints = cellFingerprints(row);
if (cellFingerprints.length != nextFingerprints.length
|| oldWidth != canvas.getWidth()
|| oldHeight != canvas.getHeight()) {
render(row);
return;
}
GraphicsContext gc = canvas.getGraphicsContext2D();
gc.setFontSmoothingType(FontSmoothingType.LCD);
gc.setFont(metrics.font());
int runStart = -1;
int runEnd = -1;
for (int column = 0; column < nextFingerprints.length; column++) {
if (cellFingerprints[column] == nextFingerprints[column]) {
continue;
}
int start = Math.max(0, column - 1);
int end = Math.min(nextFingerprints.length - 1, column + 1);
if (runStart < 0) {
runStart = start;
runEnd = end;
} else if (start <= runEnd + 1) {
runEnd = Math.max(runEnd, end);
} else {
repaintColumns(gc, row, runStart, runEnd);
runStart = start;
runEnd = end;
}
}
if (runStart >= 0) {
repaintColumns(gc, row, runStart, runEnd);
}
cellFingerprints = nextFingerprints;
}
private void prepareCanvas(RenderRow row) {
double paneWidth = ((Region) getParent()).getWidth();
double rowTop = rowTop(row);
double rowBottom = rowBottom(row);
double rowHeight = Math.max(1.0, rowBottom - rowTop);
resizeRelocate(0.0, rowTop, paneWidth, rowHeight);
canvas.setWidth(Math.max(0.0, paneWidth));
canvas.setHeight(rowHeight);
}
private void moveToRow(int row) {
double paneWidth = ((Region) getParent()).getWidth();
double rowTop = rowTop(row);
double rowBottom = rowBottom(row);
double rowHeight = Math.max(1.0, rowBottom - rowTop);
resizeRelocate(0.0, rowTop, paneWidth, rowHeight);
canvas.setWidth(Math.max(0.0, paneWidth));
canvas.setHeight(rowHeight);
}
private double rowTop(RenderRow row) {
return rowTop(row.row());
}
private double rowTop(int row) {
return Math.floor(TerminalMetrics.PADDING + row * metrics.lineHeight());
}
private double rowBottom(RenderRow row) {
return rowBottom(row.row());
}
private double rowBottom(int row) {
return Math.ceil(TerminalMetrics.PADDING + (row + 1) * metrics.lineHeight());
}
private void repaintColumns(GraphicsContext gc, RenderRow row, int startColumn, int endColumn) {
if (endColumn < startColumn) {
return;
}
double cellWidth = metrics.cellWidth();
double lineHeight = metrics.lineHeight();
double rowTop = rowTop(row);
double contentTop = TerminalMetrics.PADDING + row.row() * lineHeight;
double localCellTop = contentTop - rowTop;
double baseline = TerminalMetrics.PADDING + metrics.baselineOffset() + row.row() * lineHeight - rowTop;
double x = TerminalMetrics.PADDING + startColumn * cellWidth;
double width = (endColumn - startColumn + 1) * cellWidth;
gc.clearRect(x, 0.0, width, canvas.getHeight());
if (startColumn == 0) {
gc.setFill(rowEdgeBackground(row, true));
gc.fillRect(0.0, 0.0, TerminalMetrics.PADDING, canvas.getHeight());
}
if (endColumn >= row.cells().size() - 1) {
double contentRight = TerminalMetrics.PADDING + row.cells().size() * cellWidth;
gc.setFill(rowEdgeBackground(row, false));
gc.fillRect(contentRight, 0.0, canvas.getWidth() - contentRight, canvas.getHeight());
}
drawRowBackgrounds(gc, row, localCellTop, cellWidth, lineHeight, startColumn, endColumn);
drawRowText(gc, row, baseline, cellWidth, startColumn, endColumn);
}
private void paintSidePadding(GraphicsContext gc, RenderRow row, double paneWidth, double bandHeight) {
int columns = row.cells().size();
if (columns == 0) {
return;
}
double contentLeft = TerminalMetrics.PADDING;
double contentRight = contentLeft + columns * metrics.cellWidth();
gc.setFill(rowEdgeBackground(row, true));
gc.fillRect(0.0, 0.0, contentLeft, bandHeight);
gc.setFill(rowEdgeBackground(row, false));
gc.fillRect(contentRight, 0.0, paneWidth - contentRight, bandHeight);
}
private void drawRow(GraphicsContext gc, RenderRow row, double rowTop, double cellWidth, double lineHeight) {
double contentTop = TerminalMetrics.PADDING + row.row() * lineHeight;
double localCellTop = contentTop - rowTop;
double baseline = TerminalMetrics.PADDING + metrics.baselineOffset() + row.row() * lineHeight - rowTop;
drawRowBackgrounds(gc, row, localCellTop, cellWidth, lineHeight, 0, row.cells().size() - 1);
drawRowText(gc, row, baseline, cellWidth, 0, row.cells().size() - 1);
}
private void drawRowBackgrounds(GraphicsContext gc, RenderRow row, double localCellTop,
double cellWidth, double lineHeight, int startColumn, int endColumn) {
Color runBackground = null;
int runStartColumn = 0;
int previousColumn = -1;
for (RenderCell cell : row.cells()) {
if (cell.column() < startColumn || cell.column() > endColumn) {
continue;
}
if (cell.kittyPlaceholder().isPresent()) {
flushBackgroundRun(gc, runBackground, localCellTop, cellWidth, lineHeight, runStartColumn, previousColumn);
runBackground = null;
previousColumn = -1;
continue;
}
Color background = cell.selected() ? SELECTED_BACKGROUND : cellBackgroundOverride(cell);
if (background == null) {
flushBackgroundRun(gc, runBackground, localCellTop, cellWidth, lineHeight, runStartColumn, previousColumn);
runBackground = null;
previousColumn = -1;
continue;
}
if (runBackground == null || background != runBackground || cell.column() != previousColumn + 1) {
flushBackgroundRun(gc, runBackground, localCellTop, cellWidth, lineHeight, runStartColumn, previousColumn);
runBackground = background;
runStartColumn = cell.column();
}
previousColumn = cell.column();
}
flushBackgroundRun(gc, runBackground, localCellTop, cellWidth, lineHeight, runStartColumn, previousColumn);
}
private void flushBackgroundRun(GraphicsContext gc, Color background, double localCellTop,
double cellWidth, double lineHeight, int startColumn, int endColumn) {
if (background == null || endColumn < startColumn) {
return;
}
gc.setFill(background);
gc.fillRect(
TerminalMetrics.PADDING + startColumn * cellWidth,
localCellTop,
(endColumn - startColumn + 1) * cellWidth,
lineHeight);
}
private void drawRowText(GraphicsContext gc, RenderRow row, double baseline,
double cellWidth, int startColumn, int endColumn) {
for (RenderCell cell : row.cells()) {
if (cell.column() < startColumn || cell.column() > endColumn) {
continue;
}
if (cell.kittyPlaceholder().isPresent() || cell.codepoints().length == 0) {
continue;
}
gc.setFill(cellForegroundColor(cell));
gc.fillText(cell.text(), TerminalMetrics.PADDING + cell.column() * cellWidth, baseline);
}
}
private static long[] cellFingerprints(RenderRow row) {
int columns = row.cells().size();
for (RenderCell cell : row.cells()) {
columns = Math.max(columns, cell.column() + 1);
}
long[] fingerprints = new long[columns];
for (RenderCell cell : row.cells()) {
fingerprints[cell.column()] = cellFingerprint(cell);
}
return fingerprints;
}
}
private record KittyImageKey(long id, long number, long width, long height, KittyImageFormat format, int dataLength) {
private static KittyImageKey of(KittyImageSnapshot snapshot, byte[] data) {
return new KittyImageKey(
snapshot.id(),
snapshot.number(),
snapshot.width(),
snapshot.height(),
snapshot.format(),
data.length
);
}
}
private record KittyPlaceholderKey(long imageId, long placementId) {
}
private record SourceRect(double x, double y, double width, double height) {
}
private record RowMove(int sourceRow, int targetRow) {
}
private record ShiftPlan(List<RowMove> moves, Set<Integer> targetRows) {
}
private static final class KittyPlaceholderBounds {
private int minRow = Integer.MAX_VALUE;
private int maxRow = Integer.MIN_VALUE;
private int minColumn = Integer.MAX_VALUE;
private int maxColumn = Integer.MIN_VALUE;
private long minSourceRow = Long.MAX_VALUE;
private long maxSourceRow = Long.MIN_VALUE;
private long minSourceColumn = Long.MAX_VALUE;
private long maxSourceColumn = Long.MIN_VALUE;
private void include(int row, int column, KittyPlaceholder placeholder) {
minRow = Math.min(minRow, row);
maxRow = Math.max(maxRow, row);
minColumn = Math.min(minColumn, column);
maxColumn = Math.max(maxColumn, column);
minSourceRow = Math.min(minSourceRow, placeholder.sourceRow());
maxSourceRow = Math.max(maxSourceRow, placeholder.sourceRow());
minSourceColumn = Math.min(minSourceColumn, placeholder.sourceColumn());
maxSourceColumn = Math.max(maxSourceColumn, placeholder.sourceColumn());
}
private boolean isEmpty() {
return minRow == Integer.MAX_VALUE;
}
private int rows() {
return maxRow - minRow + 1;
}
private int columns() {
return maxColumn - minColumn + 1;
}
private long sourceRows() {
return maxSourceRow - minSourceRow + 1;
}
private long sourceColumns() {
return maxSourceColumn - minSourceColumn + 1;
}
}
}

View File

@@ -1,293 +0,0 @@
package com.gregor.jprototerm;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
public final class TerminalWorkspace implements AutoCloseable {
private final AppConfig config;
private final List<TerminalPane> panes = new ArrayList<>();
private int activeIndex;
private int hiddenFloatingFocusIndex = -1;
private long version;
public TerminalWorkspace(AppConfig config) {
this.config = config;
panes.add(openPane(false));
}
public TerminalPane activePane() {
return panes.get(activeIndex);
}
public List<TerminalPane> panes() {
List<TerminalPane> visible = panes.stream().filter(TerminalPane::visible).toList();
TerminalPane active = activePane();
if (!active.visible() || !active.floating()) {
return visible;
}
List<TerminalPane> ordered = new ArrayList<>(visible.size());
visible.stream()
.filter(pane -> pane != active)
.forEach(ordered::add);
ordered.add(active);
return List.copyOf(ordered);
}
public boolean isActive(TerminalPane pane) {
return activePane() == pane;
}
public long version() {
return version;
}
public void focus(TerminalPane pane) {
int index = panes.indexOf(pane);
if (index >= 0 && pane.visible() && activeIndex != index) {
activeIndex = index;
version++;
}
}
public void layout(double width, double height) {
List<TerminalPane> tiled = panes.stream()
.filter(TerminalPane::visible)
.filter(pane -> !pane.floating())
.toList();
int tileCount = Math.max(1, tiled.size());
double tileWidth = width / tileCount;
for (int i = 0; i < tiled.size(); i++) {
tiled.get(i).bounds(i * tileWidth, 0, tileWidth, height);
}
List<TerminalPane> floating = panes.stream()
.filter(TerminalPane::visible)
.filter(TerminalPane::floating)
.toList();
for (int i = 0; i < floating.size(); i++) {
TerminalPane pane = floating.get(i);
if (pane.visible() && pane.floating()) {
double floatingWidth = Math.max(420, width * 0.58);
double floatingHeight = Math.max(260, height * 0.58);
double offset = i * 28.0;
pane.bounds(
Math.min(width - floatingWidth - 12.0, ((width - floatingWidth) / 2.0) + offset),
Math.min(height - floatingHeight - 12.0, ((height - floatingHeight) / 2.0) + offset),
floatingWidth,
floatingHeight
);
}
}
}
public void navigate(Direction direction) {
TerminalPane current = activePane();
if (current.floating() && navigateFloatingStack(direction)) {
version++;
return;
}
panes.stream()
.filter(TerminalPane::visible)
.filter(pane -> pane != current)
.filter(pane -> directionFilter(direction, current, pane))
.min(Comparator.comparingDouble(pane -> distance(current, pane)))
.ifPresent(pane -> {
activeIndex = panes.indexOf(pane);
version++;
});
}
public void toggleFloating() {
List<TerminalPane> floating = panes.stream()
.filter(TerminalPane::floating)
.toList();
if (floating.isEmpty()) {
createFloatingPane();
return;
}
boolean anyVisible = floating.stream().anyMatch(TerminalPane::visible);
if (anyVisible) {
TerminalPane active = activePane();
hiddenFloatingFocusIndex = active.floating() ? activeIndex : firstVisibleFloatingIndex();
floating.forEach(pane -> pane.setVisible(false));
activeIndex = firstVisibleNonFloatingIndex();
version++;
} else {
floating.forEach(pane -> pane.setVisible(true));
activeIndex = visibleIndexOrFallback(hiddenFloatingFocusIndex, panes.indexOf(floating.get(floating.size() - 1)));
hiddenFloatingFocusIndex = -1;
version++;
}
}
public void createFloatingPane() {
TerminalPane pane = openPane(true);
panes.add(pane);
activeIndex = panes.size() - 1;
version++;
}
public void nextFloatingPane() {
TerminalPane next = nextFloatingAfter(activeIndex);
next.setVisible(true);
activeIndex = panes.indexOf(next);
version++;
}
public void closeActivePane() {
TerminalPane active = activePane();
if (!active.floating() || panes.stream().filter(pane -> !pane.floating()).count() == 0) {
return;
}
int removed = activeIndex;
int previous = previousVisibleIndex(removed);
panes.remove(removed);
active.close();
activeIndex = adjustIndexAfterRemoval(previous, removed);
hiddenFloatingFocusIndex = adjustHiddenFocusAfterRemoval(hiddenFloatingFocusIndex, removed);
version++;
}
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) {
List<TerminalPane> floating = panes.stream()
.filter(TerminalPane::visible)
.filter(TerminalPane::floating)
.toList();
if (floating.size() < 2) {
return false;
}
int current = floating.indexOf(activePane());
if (current < 0) {
return false;
}
int next = switch (direction) {
case LEFT, UP -> current - 1;
case DOWN, RIGHT -> current + 1;
};
if (next < 0 || next >= floating.size()) {
return false;
}
activeIndex = panes.indexOf(floating.get(next));
return true;
}
private int firstVisibleFloatingIndex() {
for (int i = 0; i < panes.size(); i++) {
TerminalPane pane = panes.get(i);
if (pane.visible() && pane.floating()) {
return i;
}
}
return -1;
}
private int firstVisibleNonFloatingIndex() {
for (int i = 0; i < panes.size(); i++) {
TerminalPane pane = panes.get(i);
if (pane.visible() && !pane.floating()) {
return i;
}
}
return 0;
}
private int previousVisibleIndex(int index) {
for (int i = index - 1; i >= 0; i--) {
if (panes.get(i).visible()) {
return i;
}
}
for (int i = index + 1; i < panes.size(); i++) {
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) {
double currentCenterX = current.x() + current.width() / 2.0;
double currentCenterY = current.y() + current.height() / 2.0;
double candidateCenterX = candidate.x() + candidate.width() / 2.0;
double candidateCenterY = candidate.y() + candidate.height() / 2.0;
return switch (direction) {
case LEFT -> candidateCenterX < currentCenterX;
case DOWN -> candidateCenterY > currentCenterY;
case UP -> candidateCenterY < currentCenterY;
case RIGHT -> candidateCenterX > currentCenterX;
};
}
private static double distance(TerminalPane current, TerminalPane candidate) {
double dx = (current.x() + current.width() / 2.0) - (candidate.x() + candidate.width() / 2.0);
double dy = (current.y() + current.height() / 2.0) - (candidate.y() + candidate.height() / 2.0);
return Math.sqrt(dx * dx + dy * dy);
}
@Override
public void close() {
for (TerminalPane pane : panes) {
pane.close();
}
panes.clear();
}
}