17 Commits

Author SHA1 Message Date
6bf69e8572 update jlibghostty 2026-05-31 22:23:14 +02:00
07585a314c Upload only changed rows to GPU and hoist glyph bounds checks
paintIncremental's per-row dirty work was negated by present() calling
PixelBuffer.updateBuffer(null), which re-uploads the whole pane texture
every frame. Track the vertical band of buffer rows written since the
last present and hand that to updateBuffer so only changed rows upload.
The border is now drawn without extending the dirty band (its pixels are
unchanged between incremental frames). Also clamp blitGlyph's rectangle
once instead of bounds-checking every glyph pixel in the inner loop.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 22:12:45 +02:00
bdb33450f1 update jlibghostty 2026-05-31 21:51:57 +02:00
Gregor Lohaus
2c020bb6cb fix race condition 2026-05-31 18:12:44 +02:00
Gregor Lohaus
71a533ec34 clear context new fix 2026-05-31 18:05:57 +02:00
Gregor Lohaus
54b08c7eca revert failed fix 2026-05-31 18:00:49 +02:00
Gregor Lohaus
2fcdb286af Fixed the partial-dirty blanking regression 2026-05-31 17:59:26 +02:00
Gregor Lohaus
e6848ec684 revert failed fixed 2026-05-31 17:56:36 +02:00
Gregor Lohaus
38822d66b8 Fixed the partial-dirty blanking regression 2026-05-31 17:51:53 +02:00
Gregor Lohaus
586150de59 Fixed the partial-dirty blanking regression 2026-05-31 17:48:04 +02:00
Gregor Lohaus
494d2c40cf pixel buffer, scroll inference 2026-05-31 17:41:33 +02:00
Gregor Lohaus
a99cbdc61a revert row diffing 2026-05-31 17:20:13 +02:00
Gregor Lohaus
86f7174eee row diffing 2026-05-31 17:14:07 +02:00
Gregor Lohaus
137db24023 refert safe batching 2026-05-31 17:04:17 +02:00
Gregor Lohaus
d8faf8d6df safe batching 2026-05-31 17:02:44 +02:00
Gregor Lohaus
9903e9174f fix cell shifting regression 2026-05-31 16:58:11 +02:00
Gregor Lohaus
9b7247a4e0 small improvements 2026-05-31 16:50:12 +02:00
15 changed files with 1837 additions and 1166 deletions

View File

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

View File

@@ -5,7 +5,7 @@ connection.gradle.distribution=GRADLE_DISTRIBUTION(LOCAL_INSTALLATION(/home/anon
connection.project.dir=
eclipse.preferences.version=1
gradle.user.home=
java.home=/nix/store/c3pl7bqrx3d2rc3dh98z6yaj0mv1p52g-openjdk-21.0.10+7/lib/openjdk
java.home=/home/anon/.local/lib/graalvm-jdk-21.0.9+7.1
jvm.arguments=
offline.mode=false
override.workspace.settings=true

View File

@@ -106,6 +106,7 @@ navigate_up = "ALT+K"
navigate_right = "ALT+L"
toggle_floating = "ALT+F"
new_pane = "ALT+N"
next_floating = "ALT+F12"
close_pane = "ALT+X"
new_tab = "ALT+A"
previous_tab = "ALT+SHIFT+H"
@@ -120,6 +121,7 @@ open_scrollback = "ALT+S"
- `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+F12`: cycle floating panes
- `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

View File

@@ -25,7 +25,8 @@ navigate_down = "ALT+J"
navigate_up = "ALT+K"
navigate_right = "ALT+L"
toggle_floating = "ALT+F"
new_pane = "ALT+N"
new_floating = "ALT+SHIFT+F"
next_floating = "ALT+F12"
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": 1780079529,
"narHash": "sha256-AxlGTL8c5xSLcQHvWlm994IdOqxsN8iKrA02Cpv7vso=",
"lastModified": 1780258814,
"narHash": "sha256-8rxL7xaZ/loYg3zdt0w5+hfNyHFVknDZN360NzrtCsQ=",
"ref": "refs/heads/main",
"rev": "68121d50b52fb56038871c97c97e7a12ffe987c2",
"revCount": 20,
"rev": "6a3d5aa0b0b1f738c958e2a2f0249574c07d9c4d",
"revCount": 23,
"type": "git",
"url": "https://gitea.gregorlohaus.com/gregor/jlibghostty.git"
},

View File

@@ -37,6 +37,7 @@ public record AppConfig(
"navigate_right",
"toggle_floating",
"new_pane",
"next_floating",
"close_pane",
"new_tab",
"previous_tab",
@@ -95,6 +96,7 @@ public record AppConfig(
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("next_floating", KeyBinding.parse("ALT+F12")),
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")),

View File

@@ -4,52 +4,57 @@ 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.geometry.VPos;
import javafx.scene.canvas.Canvas;
import javafx.scene.canvas.GraphicsContext;
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 javafx.scene.text.Font;
import javafx.scene.text.FontSmoothingType;
import javafx.scene.text.TextAlignment;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.Objects;
/**
* 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.
* Owns the window's tabs and drives rendering and input. It composites only the current tab:
* each frame it lays that tab out, paints the panes bottom-to-top (so the active floating pane
* lands on top) and lets each pane paint its own content, clipped to the region the layout gave
* it. The cross-pane concerns live here — the dirty-frame bookkeeping, the tab strip, routing
* mouse/scroll to the pane under the pointer, and the tab/pane lifecycle that {@link Main}'s key
* bindings invoke.
*/
public final class Compositor {
// Canvas background shown wherever no pane covers (gaps). Painted on a full recomposite.
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);
// Thin tab strip shown at the top when more than one tab is open.
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 Canvas canvas = new Canvas();
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;
// Bumped on any structural change (tab switch, pane add/close/focus/move) so render()
// knows to recomposite. Terminal *content* changes are tracked separately through each
// tab's content version.
private long layoutVersion;
// Last content version drawn to the canvas per pane, so a content frame repaints only
// the panes that actually changed.
private final Map<TerminalPane, Long> paneContentVersion = new HashMap<>();
// Cheap per-frame dirty signal: skip the whole render when none of these changed.
private double lastWidth = -1.0;
private double lastHeight = -1.0;
private String lastFontFamily;
private double lastFontSize = -1.0;
private long lastLayoutVersion = Long.MIN_VALUE;
private long lastContentVersion = Long.MIN_VALUE;
private boolean mouseButtonPressed;
private MouseButton pressedButton = MouseButton.UNKNOWN;
@@ -58,25 +63,22 @@ public final class Compositor {
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());
canvas.setFocusTraversable(true);
canvas.setOnMousePressed(this::handleMousePressed);
canvas.setOnMouseReleased(this::handleMouseReleased);
canvas.setOnMouseDragged(this::handleMouseDragged);
canvas.setOnMouseMoved(this::handleMouseMoved);
canvas.setOnScroll(this::handleScroll);
}
public Parent node() {
return root;
}
public void requestFocus() {
root.requestFocus();
public Canvas canvas() {
return canvas;
}
public void setFont(String family, double size) {
metrics.setFont(family, size);
nodes.values().forEach(TerminalPaneNode::discard);
markSceneDirty();
paneContentVersion.clear();
lastWidth = -1.0; // force a redraw on the next frame
}
// ---- Tabs and panes -------------------------------------------------------------
@@ -91,7 +93,7 @@ public final class Compositor {
public void navigate(Direction direction) {
if (!isEmpty() && currentTab().navigate(direction)) {
markSceneDirty();
layoutVersion++;
}
}
@@ -100,7 +102,7 @@ public final class Compositor {
return;
}
currentTab().toggleFloating();
markSceneDirty();
layoutVersion++;
}
public void createPane() {
@@ -108,7 +110,15 @@ public final class Compositor {
return;
}
currentTab().createPane();
markSceneDirty();
layoutVersion++;
}
public void nextFloatingPane() {
if (isEmpty()) {
return;
}
currentTab().nextFloatingPane();
layoutVersion++;
}
public void closeActivePane() {
@@ -117,31 +127,33 @@ public final class Compositor {
}
currentTab().closeActivePane();
if (currentTab().isEmpty()) {
// Closing a tab's last pane closes the tab. When no tabs remain the surface is
// empty and Main quits.
tabs.remove(currentTabIndex);
if (currentTabIndex >= tabs.size()) {
currentTabIndex = Math.max(0, tabs.size() - 1);
}
}
markSceneDirty();
layoutVersion++;
}
public void newTab() {
tabs.add(new Tab(config, metrics));
currentTabIndex = tabs.size() - 1;
markSceneDirty();
layoutVersion++;
}
public void nextTab() {
if (tabs.size() > 1) {
currentTabIndex = (currentTabIndex + 1) % tabs.size();
markSceneDirty();
layoutVersion++;
}
}
public void previousTab() {
if (tabs.size() > 1) {
currentTabIndex = (currentTabIndex - 1 + tabs.size()) % tabs.size();
markSceneDirty();
layoutVersion++;
}
}
@@ -150,8 +162,6 @@ public final class Compositor {
tab.close();
}
tabs.clear();
nodes.clear();
paneLayer.getChildren().clear();
}
private Tab currentTab() {
@@ -162,140 +172,141 @@ public final class Compositor {
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();
layoutVersion++;
}
}
// ---- 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;
switch (nextFrameType()) {
case IDLE -> { }
case LAYOUT -> renderLayoutFrame();
case CONTENT -> renderContentFrame();
}
}
// Classify this frame and commit the change trackers. A layout change (size, font,
// tab/pane set, z-order, active pane) needs a full recomposite; otherwise a change to the
// current tab's content version repaints only the panes that changed; otherwise nothing
// changed and the frame is idle.
private FrameType nextFrameType() {
double width = canvas.getWidth();
double height = canvas.getHeight();
long contentVersion = tabs.isEmpty() ? 0 : currentTab().contentVersion();
boolean layoutChanged = width != lastWidth || height != lastHeight
|| metrics.fontSize() != lastFontSize || !Objects.equals(metrics.fontFamily(), lastFontFamily)
|| layoutVersion != lastLayoutVersion;
boolean contentChanged = contentVersion != lastContentVersion;
lastWidth = width;
lastHeight = height;
lastFontFamily = metrics.fontFamily();
lastFontSize = metrics.fontSize();
lastLayoutVersion = layoutVersion;
lastContentVersion = contentVersion;
sceneDirty = false;
if (syncScene) {
syncSceneGraph(width, height);
if (layoutChanged) {
return FrameType.LAYOUT;
}
renderVisiblePanes();
if (contentChanged) {
return FrameType.CONTENT;
}
return FrameType.IDLE;
}
private void markSceneDirty() {
sceneDirty = true;
}
private void syncSceneGraph(double width, double height) {
// Full recomposite onto the retained canvas: lay the tab out, clear to the gap colour,
// draw the tab strip, then paint every pane bottom-to-top (panes() puts the active
// floating pane last == on top).
private void renderLayoutFrame() {
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);
currentTab().layout(canvas.getWidth(), canvas.getHeight(), topInset);
}
List<TerminalPane> panes = currentPanes();
retainNodes(allOpenPanes());
List<TerminalPaneNode> orderedNodes = new ArrayList<>(panes.size());
// Sync each pane's ghostty grid to its (possibly new) bounds; a no-op when unchanged.
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);
GraphicsContext gc = beginFrame();
paneContentVersion.keySet().retainAll(panes);
gc.setFill(GAP_BACKGROUND);
gc.fillRect(0, 0, canvas.getWidth(), canvas.getHeight());
if (topInset > 0.0) {
drawTabBar(gc, canvas.getWidth(), topInset);
}
for (TerminalPane pane : panes) {
paneContentVersion.put(pane, pane.paintFull(gc, isActive(pane)));
}
}
private void renderVisiblePanes() {
for (TerminalPane pane : currentPanes()) {
TerminalPaneNode node = nodes.get(pane);
if (node != null) {
node.renderIncremental(isActive(pane));
// Repaint just the panes whose content changed, directly on the retained canvas. Each pane
// clips itself to its rect minus the panes above it, so a lower pane's repaint can't bleed
// over one stacked on top — no restore pass needed. Bounds and grids can't have changed
// without a layout frame, so a content frame reuses the existing layout untouched.
private void renderContentFrame() {
List<TerminalPane> panes = currentPanes();
GraphicsContext gc = beginFrame();
for (TerminalPane pane : panes) {
Long drawn = paneContentVersion.get(pane);
if (drawn != null && drawn == pane.contentVersion()) {
continue;
}
paneContentVersion.put(pane, pane.paintIncremental(gc, isActive(pane)));
}
}
private TerminalPaneNode nodeFor(TerminalPane pane) {
return nodes.computeIfAbsent(pane, this::createNode);
private GraphicsContext beginFrame() {
GraphicsContext gc = canvas.getGraphicsContext2D();
gc.setFontSmoothingType(FontSmoothingType.LCD); // the per-cell renderer relies on LCD
return gc;
}
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;
}
// Thin tab strip: one equal-width segment per tab, the current one highlighted, with a
// small 1-based number centred in each segment.
private void drawTabBar(GraphicsContext gc, double width, double barHeight) {
int count = tabs.size();
Font barFont = Font.font(metrics.fontFamily(), Math.max(9.0, Math.min(13.0, barHeight * 0.62)));
gc.setFont(barFont);
gc.setFontSmoothingType(FontSmoothingType.GRAY);
gc.setTextAlign(TextAlignment.CENTER);
gc.setTextBaseline(VPos.CENTER);
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));
double gap = 1.0;
double segmentWidth = width / count;
for (int i = 0; i < count; i++) {
double x = i * segmentWidth;
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);
gc.setFill(current ? Color.rgb(45, 55, 72) : Color.rgb(22, 24, 28));
gc.fillRect(x, 0.0, segmentWidth - gap, barHeight);
gc.setFill(current ? TAB_TEXT : Color.rgb(128, 136, 148));
gc.fillText(Integer.toString(i + 1), x + (segmentWidth - gap) / 2.0, barHeight / 2.0);
}
// Restore the defaults the cell renderer relies on (left-aligned, baseline, LCD).
gc.setTextAlign(TextAlignment.LEFT);
gc.setTextBaseline(VPos.BASELINE);
gc.setFontSmoothingType(FontSmoothingType.LCD);
}
// ---- Input ----------------------------------------------------------------------
private void handleMousePressed(TerminalPane pane, MouseEvent event) {
root.requestFocus();
private void handleMousePressed(MouseEvent event) {
canvas.requestFocus();
TerminalPane pane = paneAt(event.getX(), event.getY());
if (pane == null) {
return;
}
focus(pane);
pressedButton = mouseButton(event);
mouseButtonPressed = true;
@@ -303,38 +314,58 @@ public final class Compositor {
if (target == null) {
return;
}
send(pane, target, MouseInput.press(pressedButton, localX(event.getX(), target), localY(event.getY(), target), modifiers(event)), true, event);
send(pane, target, MouseInput.press(pressedButton, localX(event.getX(), pane, target), localY(event.getY(), pane, target), modifiers(event)), true, event);
}
private void handleMouseReleased(TerminalPane pane, MouseEvent event) {
private void handleMouseReleased(MouseEvent event) {
TerminalPane pane = paneAt(event.getX(), event.getY());
if (pane == null) {
pane = activePane();
}
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);
send(pane, target, MouseInput.release(button, localX(event.getX(), pane, target), localY(event.getY(), pane, target), modifiers(event)), false, event);
}
mouseButtonPressed = false;
pressedButton = MouseButton.UNKNOWN;
}
private void handleMouseDragged(TerminalPane pane, MouseEvent event) {
private void handleMouseDragged(MouseEvent event) {
TerminalPane pane = paneAt(event.getX(), event.getY());
if (pane == null) {
pane = activePane();
}
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);
send(pane, target, MouseInput.drag(button, localX(event.getX(), pane, target), localY(event.getY(), pane, target), modifiers(event)), true, event);
}
private void handleMouseMoved(TerminalPane pane, MouseEvent event) {
private void handleMouseMoved(MouseEvent event) {
TerminalPane pane = paneAt(event.getX(), event.getY());
if (pane == null) {
return;
}
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);
send(pane, target, MouseInput.motion(localX(event.getX(), pane, target), localY(event.getY(), pane, target), modifiers(event)), mouseButtonPressed, event);
}
private void handleScroll(TerminalPane pane, ScrollEvent event) {
root.requestFocus();
private void handleScroll(ScrollEvent event) {
TerminalPane pane = paneAt(event.getX(), event.getY());
if (pane == null) {
return;
}
canvas.requestFocus();
focus(pane);
int direction = scrollDirection(event);
if (direction == 0) {
@@ -346,8 +377,9 @@ public final class Compositor {
MouseTarget target = mouseTarget(pane);
boolean sent = false;
if (target != null) {
double ex = localX(event.getX(), target);
double ey = localY(event.getY(), target);
// The wheel sends one button press per scrolled row; resolve the position once.
double ex = localX(event.getX(), pane, target);
double ey = localY(event.getY(), pane, target);
KeyModifiers modifiers = modifiers(event);
for (int i = 0; i < rows; i++) {
if (!send(pane, target, MouseInput.press(wheelButton, ex, ey, modifiers), mouseButtonPressed, event)) {
@@ -357,11 +389,14 @@ public final class Compositor {
}
}
if (!sent) {
// Not consumed by the app (e.g. mouse reporting off): scroll the local viewport.
pane.scrollViewport(direction > 0 ? -rows : rows);
event.consume();
}
}
// Forward an already-positioned mouse event to the pane, consuming it if the pane (i.e.
// the app running in it) acted on it. Returns whether it was sent.
private boolean send(TerminalPane pane, MouseTarget target, MouseInput input, boolean anyButtonPressed, InputEvent event) {
boolean sent = pane.sendMouse(input, target.size(), anyButtonPressed);
if (sent) {
@@ -370,6 +405,17 @@ public final class Compositor {
return sent;
}
private TerminalPane paneAt(double x, double y) {
List<TerminalPane> panes = currentPanes();
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() <= 2 * TerminalMetrics.PADDING || pane.height() <= 2 * TerminalMetrics.PADDING) {
return null;
@@ -384,12 +430,14 @@ public final class Compositor {
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);
// Resolve a canvas-space pointer position to a pane-local pixel coordinate, clamped to
// the pane's reported screen size (what ghostty's mouse encoder expects).
private static double localX(double canvasX, TerminalPane pane, MouseTarget target) {
return clamp(canvasX - pane.x() - 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 localY(double canvasY, TerminalPane pane, MouseTarget target) {
return clamp(canvasY - pane.y() - TerminalMetrics.PADDING, 0.0, target.screenHeight() - 1.0);
}
private static double clamp(double value, double min, double max) {
@@ -437,6 +485,13 @@ public final class Compositor {
};
}
// What one render() pass should do, decided from the change trackers in nextFrame().
private enum FrameType {
IDLE, // nothing changed since the last frame
LAYOUT, // geometry/font/tab/pane set changed: clear and repaint everything
CONTENT // only terminal content changed: repaint the panes that changed
}
private record MouseTarget(MouseEncoderSize size, long screenWidth, long screenHeight) {
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -97,6 +97,7 @@ public final class LinuxPty implements AutoCloseable {
private final Arena arena = Arena.ofShared();
private final MemorySegment readBuffer = arena.allocate(65536);
private final MemorySegment writeBuffer = arena.allocate(65536);
private final Object writeLock = new Object();
private final int masterFd;
private final int pid;
@@ -186,17 +187,20 @@ public final class LinuxPty implements AutoCloseable {
return;
}
synchronized (writeLock) {
try (Arena a = Arena.ofConfined()) {
MemorySegment buf = a.allocate(data.length);
MemorySegment.copy(data, 0, buf, ValueLayout.JAVA_BYTE, 0, data.length);
long offset = 0;
while (offset < data.length) {
long n = callLong(WRITE, masterFd, buf.asSlice(offset), data.length - offset);
if (n < 0) {
int offset = 0;
while (offset < data.length) {
int chunk = (int) Math.min(writeBuffer.byteSize(), data.length - offset);
MemorySegment.copy(data, offset, writeBuffer, ValueLayout.JAVA_BYTE, 0, chunk);
long written = 0;
while (written < chunk) {
long n = callLong(WRITE, masterFd, writeBuffer.asSlice(written), chunk - written);
if (n <= 0) {
throw new IllegalStateException("write to pty failed");
}
offset += n;
written += n;
}
offset += chunk;
}
}
}

View File

@@ -12,6 +12,7 @@ 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;
@@ -31,7 +32,11 @@ public final class Main extends Application {
metrics = new TerminalMetrics(config.fontFamily(), config.fontSize());
compositor = new Compositor(config, metrics);
Scene scene = new Scene(compositor.node(), config.windowWidth(), config.windowHeight());
StackPane root = new StackPane(compositor.canvas());
compositor.canvas().widthProperty().bind(root.widthProperty());
compositor.canvas().heightProperty().bind(root.heightProperty());
Scene scene = new Scene(root, config.windowWidth(), config.windowHeight());
scene.addEventFilter(KeyEvent.KEY_PRESSED, this::handlePressed);
scene.addEventFilter(KeyEvent.KEY_TYPED, event -> handleTyped(event));
@@ -48,7 +53,7 @@ public final class Main extends Application {
compositor.close();
});
stage.show();
compositor.requestFocus();
compositor.canvas().requestFocus();
}
private void handlePressed(KeyEvent event) {
@@ -70,6 +75,9 @@ public final class Main extends Application {
} else if (config.keybindings().get("new_pane").matches(event)) {
compositor.createPane();
event.consume();
} else if (config.keybindings().get("next_floating").matches(event)) {
compositor.nextFloatingPane();
event.consume();
} else if (config.keybindings().get("close_pane").matches(event)) {
compositor.closeActivePane();
event.consume();
@@ -153,7 +161,7 @@ public final class Main extends Application {
config = config.withFont(selectedFamily.trim(), selectedSize);
config.save();
compositor.setFont(config.fontFamily(), config.fontSize());
compositor.requestFocus();
compositor.canvas().requestFocus();
});
}

View File

@@ -0,0 +1,45 @@
package com.gregor.jprototerm;
import dev.jlibghostty.KittyGraphics;
import dev.jlibghostty.RenderStateSnapshot;
import javafx.scene.shape.Shape;
import java.util.Optional;
/**
* The read-only view of a pane that a {@link TerminalRenderer} draws: its on-screen rect, its
* current render snapshot, and its kitty-graphics state. Decoupling the renderer from
* {@link TerminalPane} through this interface lets the renderer be swapped (e.g. a debug
* renderer that just outlines bounds and clip bands) and unit-tested against a synthetic
* target without a real terminal.
*/
interface RenderTarget {
double x();
double y();
double width();
double height();
/** Whether kitty graphics should be drawn for this target at all. */
boolean kittyEnabled();
Optional<KittyGraphics> kittyGraphics();
/**
* Incremental snapshot: only rows that changed since the last frame are populated. May be
* {@code null} before the first snapshot exists.
*/
RenderStateSnapshot snapshot();
/** Full snapshot with every row populated, regardless of dirty state. */
RenderStateSnapshot snapshotFull();
/**
* The region this target may draw into, or {@code null} to clip to its plain rect. Set at
* layout time (a tiled pane gets its rect minus the floating panes that cover it), so the
* renderer can clip its own output and never paint over a pane on top.
*/
Shape clip();
}

View File

@@ -1,8 +1,12 @@
package com.gregor.jprototerm;
import javafx.scene.shape.Rectangle;
import javafx.scene.shape.Shape;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.concurrent.atomic.AtomicLong;
import java.util.stream.Stream;
/**
@@ -30,7 +34,7 @@ final class Tab implements AutoCloseable {
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;
private final AtomicLong contentVersion = new AtomicLong();
Tab(AppConfig config, TerminalMetrics metrics) {
this.config = config;
@@ -51,7 +55,7 @@ final class Tab implements AutoCloseable {
}
long contentVersion() {
return contentVersion;
return contentVersion.get();
}
/**
@@ -75,13 +79,6 @@ final class Tab implements AutoCloseable {
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;
}
@@ -115,6 +112,51 @@ final class Tab implements AutoCloseable {
floatingWidth,
floatingHeight);
}
assignClips();
}
// Give each pane its clip region for the next paints, so repainting a pane on a content
// frame can never bleed over one stacked on top of it. Each pane is clipped to its rect
// minus the union of the panes above it: floating panes are clipped by the floating panes
// higher in the stack, and tiled panes by the whole floating group. When nothing floats,
// every pane clips to its plain bounds.
private void assignClips() {
if (!floatingVisible || floating.isEmpty()) {
tiled.forEach(pane -> pane.setClip(null));
floating.forEach(pane -> pane.setClip(null));
return;
}
// Floating panes bottom-to-top, matching panes(): insertion order, active pane on top.
List<TerminalPane> order = new ArrayList<>(floating.size());
for (TerminalPane pane : floating) {
if (pane != active) {
order.add(pane);
}
}
if (floating.contains(active)) {
order.add(active);
}
// Walk top-to-bottom, accumulating the union of the panes above each one.
Shape above = null;
for (int i = order.size() - 1; i >= 0; i--) {
Rectangle rect = rectOf(order.get(i));
order.get(i).setClip(above == null ? null : Shape.subtract(rect, above));
above = (above == null) ? rect : Shape.union(above, rect);
}
// `above` is now the union of every floating pane; tiled panes sit under all of them.
for (TerminalPane pane : tiled) {
pane.setClip(Shape.subtract(rectOf(pane), above));
}
}
// Match the renderer's pixel snapping (round the origin, keep width/height) so the clip
// lines up exactly with where the floating panes are drawn.
private static Rectangle rectOf(TerminalPane pane) {
return new Rectangle(Math.round(pane.x()), Math.round(pane.y()), pane.width(), pane.height());
}
boolean navigate(Direction direction) {
@@ -160,6 +202,16 @@ final class Tab implements AutoCloseable {
}
}
void nextFloatingPane() {
if (floating.isEmpty()) {
createFloatingPane();
return;
}
floatingVisible = true;
int current = floating.indexOf(active); // -1 when the active pane is tiled
setActive(floating.get((current + 1 + floating.size()) % floating.size()));
}
void closeActivePane() {
TerminalPane closing = active;
boolean wasFloating = floating.remove(closing);
@@ -240,7 +292,7 @@ final class Tab implements AutoCloseable {
}
private void markContentChanged() {
contentVersion++;
contentVersion.incrementAndGet();
}
private TerminalPane openPane(boolean asFloating) {

View File

@@ -12,28 +12,36 @@ import dev.jlibghostty.RenderStateSnapshot;
import dev.jlibghostty.ScrollViewport;
import dev.jlibghostty.Terminal;
import dev.jlibghostty.TerminalOptions;
import javafx.scene.canvas.GraphicsContext;
import javafx.scene.shape.Shape;
import java.util.Optional;
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.
* and its on-screen geometry and grid. It does not draw itself — it is a {@link RenderTarget}
* that a {@link TerminalRenderer} paints. {@link #paintFull}/{@link #paintIncremental} are the
* only rendering API exposed to the {@link Compositor}, and they just delegate to that
* renderer; the compositor decides z-order and which rect each pane occupies.
*/
public final class TerminalPane implements AutoCloseable {
public final class TerminalPane implements AutoCloseable, RenderTarget {
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 TerminalRenderer renderer;
private final MouseEncoder mouseEncoder = new MouseEncoder();
// A persistent render state (reused across frames) is what makes ghostty's per-row dirty
// tracking meaningful: update() accumulates dirty since the last resetDirty().
private final RenderState renderState = new RenderState();
private RenderStateSnapshot cachedSnapshot;
private ShellSession session;
// Clip region for rendering (rect minus the panes covering this one), set at layout time;
// null means clip to the plain bounds. See RenderTarget#clip().
private Shape clip;
private double x;
private double y;
private double width;
@@ -43,14 +51,15 @@ public final class TerminalPane implements AutoCloseable {
private int pixelWidth;
private int pixelHeight;
private final AtomicLong contentVersion = new AtomicLong();
private volatile long snapshotVersion = -1;
private long snapshotVersion = -1;
private TerminalPane(Terminal terminal, TerminalMetrics metrics, boolean kittyEnabled,
Runnable onContentChange, int columns, int rows) {
Runnable onContentChange, TerminalRenderer renderer, int columns, int rows) {
this.terminal = terminal;
this.metrics = metrics;
this.kittyEnabled = kittyEnabled;
this.onContentChange = onContentChange;
this.renderer = renderer;
this.columns = columns;
this.rows = rows;
}
@@ -67,7 +76,8 @@ public final class TerminalPane implements AutoCloseable {
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, metrics, config.kittyGraphics(), onContentChange, columns, rows);
TerminalPane pane = new TerminalPane(terminal, metrics, config.kittyGraphics(), onContentChange,
new GhosttyTerminalRenderer(metrics), columns, rows);
pane.refresh();
pane.attach(ShellSession.start(config.shell(), config.envOverride(), pane, columns, rows));
return pane;
@@ -144,6 +154,7 @@ public final class TerminalPane implements AutoCloseable {
* Snapshotting is deferred here rather than done in refresh(), so a burst of writes
* between two frames collapses into a single snapshot.
*/
@Override
public RenderStateSnapshot snapshot() {
return takeSnapshot(false);
}
@@ -152,6 +163,7 @@ public final class TerminalPane implements AutoCloseable {
* Full snapshot with every row's cells populated. Used where the whole pane is redrawn
* regardless of dirty state (the kitty-graphics path).
*/
@Override
public RenderStateSnapshot snapshotFull() {
return takeSnapshot(true);
}
@@ -185,32 +197,34 @@ public final class TerminalPane implements AutoCloseable {
return contentVersion.get();
}
long snapshotVersion() {
return snapshotVersion;
}
@Override
public boolean kittyEnabled() {
return kittyEnabled;
}
@Override
public Optional<KittyGraphics> kittyGraphics() {
synchronized (terminal) {
return terminal.kittyGraphics();
}
}
@Override
public double x() {
return x;
}
@Override
public double y() {
return y;
}
@Override
public double width() {
return width;
}
@Override
public double height() {
return height;
}
@@ -222,6 +236,16 @@ public final class TerminalPane implements AutoCloseable {
this.height = height;
}
/** Set the clip region applied on the next paints (see {@link RenderTarget#clip()}). */
public void setClip(Shape clip) {
this.clip = clip;
}
@Override
public Shape clip() {
return clip;
}
/** Recompute the ghostty grid from the current bounds and the shared cell metrics. */
public void fitToBounds() {
int columns = metrics.columnsFor(width);
@@ -258,6 +282,18 @@ public final class TerminalPane implements AutoCloseable {
onContentChange.run();
}
/** Paint the whole pane; see {@link TerminalRenderer#paintFull}. */
public long paintFull(GraphicsContext gc, boolean active) {
renderer.paintFull(gc, this, active);
return snapshotVersion;
}
/** Repaint what changed; see {@link TerminalRenderer#paintIncremental}. */
public long paintIncremental(GraphicsContext gc, boolean active) {
renderer.paintIncremental(gc, this, active);
return snapshotVersion;
}
@Override
public void close() {
if (session != null) {

View File

@@ -1,975 +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.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

@@ -0,0 +1,60 @@
package com.gregor.jprototerm;
import javafx.scene.canvas.GraphicsContext;
import javafx.scene.shape.ClosePath;
import javafx.scene.shape.LineTo;
import javafx.scene.shape.MoveTo;
import javafx.scene.shape.Path;
import javafx.scene.shape.PathElement;
import javafx.scene.shape.Shape;
/**
* Draws a {@link RenderTarget} onto a JavaFX canvas. The {@link Compositor} owns positioning
* and z-order; a renderer only fills the target's rect, clipped to the target's {@link
* RenderTarget#clip() clip region} so a repaint can never bleed over a pane on top.
* Implementations can change the look entirely — {@link GhosttyTerminalRenderer} is the real
* terminal renderer; a debug renderer could outline pane bounds instead.
*
* <p>A renderer may hold per-target state (e.g. a decoded-image cache), so an instance belongs
* to a single {@link TerminalPane}.
*/
abstract class TerminalRenderer {
/** Paint the whole target into its rect, clipped to its clip region. */
abstract void paintFull(GraphicsContext gc, RenderTarget target, boolean active);
/** Repaint only what changed since the last frame, clipped to the target's clip region. */
abstract void paintIncremental(GraphicsContext gc, RenderTarget target, boolean active);
protected static void clipRect(GraphicsContext gc, double x, double y, double width, double height) {
gc.beginPath();
gc.rect(x, y, width, height);
gc.clip();
}
/**
* Clip to {@code region} if given (the pane's rect minus the panes covering it, computed by
* {@code Shape.subtract} at layout), otherwise to the plain rect. The region is a rectilinear
* path, so it replays onto the canvas as move/line/close segments.
*/
protected static void clip(GraphicsContext gc, double x, double y, double width, double height, Shape region) {
if (region == null) {
clipRect(gc, x, y, width, height);
return;
}
var elements = ((Path) region).getElements();
gc.beginPath();
if (elements.isEmpty()) {
gc.rect(x, y, 0.0, 0.0); // fully covered: clip to nothing
}
for (PathElement element : elements) {
if (element instanceof MoveTo moveTo) {
gc.moveTo(moveTo.getX(), moveTo.getY());
} else if (element instanceof LineTo lineTo) {
gc.lineTo(lineTo.getX(), lineTo.getY());
} else if (element instanceof ClosePath) {
gc.closePath();
}
}
gc.clip();
}
}