scene graph

This commit is contained in:
Gregor Lohaus
2026-05-31 18:27:52 +02:00
parent f5562baf5f
commit beba14c3ea
4 changed files with 206 additions and 214 deletions

View File

@@ -4,52 +4,51 @@ import dev.jlibghostty.KeyModifiers;
import dev.jlibghostty.MouseButton; import dev.jlibghostty.MouseButton;
import dev.jlibghostty.MouseEncoderSize; import dev.jlibghostty.MouseEncoderSize;
import dev.jlibghostty.MouseInput; import dev.jlibghostty.MouseInput;
import javafx.geometry.VPos; import javafx.geometry.Pos;
import javafx.scene.canvas.Canvas; import javafx.scene.Parent;
import javafx.scene.canvas.GraphicsContext; import javafx.scene.control.Label;
import javafx.scene.input.InputEvent; import javafx.scene.input.InputEvent;
import javafx.scene.input.MouseEvent; import javafx.scene.input.MouseEvent;
import javafx.scene.input.ScrollEvent; import javafx.scene.input.ScrollEvent;
import javafx.scene.input.ScrollEvent.VerticalTextScrollUnits; 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.paint.Color;
import javafx.scene.text.Font;
import javafx.scene.text.FontSmoothingType;
import javafx.scene.text.TextAlignment;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashMap; import java.util.HashMap;
import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.Set;
/** /**
* Owns the window's tabs and drives rendering and input. It composites only the current tab: * Owns the window's tabs and exposes the terminal surface as a JavaFX scene graph. Each
* each frame it lays that tab out, paints the panes bottom-to-top (so the active floating pane * terminal pane is mounted as its own node, so JavaFX child order handles stacking and clipping
* lands on top) and lets each pane paint its own content, clipped to the region the layout gave * between panes. The pane model still owns terminals, ptys, cell geometry, and snapshots; this
* it. The cross-pane concerns live here — the dirty-frame bookkeeping, the tab strip, routing * class handles tab/pane lifecycle, layout, focus, mouse routing, and frame scheduling.
* mouse/scroll to the pane under the pointer, and the tab/pane lifecycle that {@link Main}'s key
* bindings invoke.
*/ */
public final class Compositor { 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 GAP_BACKGROUND = Color.rgb(16, 16, 18);
private static final Color TAB_TEXT = Color.rgb(225, 229, 235); private static final Color TAB_TEXT = Color.rgb(225, 229, 235);
// Thin tab strip shown at the top when more than one tab is open. 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 static final double TAB_BAR_HEIGHT = 22.0;
private final Canvas canvas = new Canvas(); 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 AppConfig config;
private final TerminalMetrics metrics; private final TerminalMetrics metrics;
private final List<Tab> tabs = new ArrayList<>(); private final List<Tab> tabs = new ArrayList<>();
private final Map<TerminalPane, TerminalPaneNode> nodes = new HashMap<>();
private int currentTabIndex; private int currentTabIndex;
// 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; 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 lastWidth = -1.0;
private double lastHeight = -1.0; private double lastHeight = -1.0;
private String lastFontFamily; private String lastFontFamily;
@@ -63,22 +62,25 @@ public final class Compositor {
this.config = config; this.config = config;
this.metrics = metrics; this.metrics = metrics;
tabs.add(new Tab(config, metrics)); tabs.add(new Tab(config, metrics));
canvas.setFocusTraversable(true);
canvas.setOnMousePressed(this::handleMousePressed); root.setFocusTraversable(true);
canvas.setOnMouseReleased(this::handleMouseReleased); root.setBackground(new Background(new BackgroundFill(GAP_BACKGROUND, CornerRadii.EMPTY, null)));
canvas.setOnMouseDragged(this::handleMouseDragged); root.getChildren().setAll(paneLayer, tabBar);
canvas.setOnMouseMoved(this::handleMouseMoved); root.setOnMousePressed(event -> root.requestFocus());
canvas.setOnScroll(this::handleScroll);
} }
public Canvas canvas() { public Parent node() {
return canvas; return root;
}
public void requestFocus() {
root.requestFocus();
} }
public void setFont(String family, double size) { public void setFont(String family, double size) {
metrics.setFont(family, size); metrics.setFont(family, size);
paneContentVersion.clear(); nodes.values().forEach(TerminalPaneNode::discard);
lastWidth = -1.0; // force a redraw on the next frame lastWidth = -1.0;
} }
// ---- Tabs and panes ------------------------------------------------------------- // ---- Tabs and panes -------------------------------------------------------------
@@ -127,8 +129,6 @@ public final class Compositor {
} }
currentTab().closeActivePane(); currentTab().closeActivePane();
if (currentTab().isEmpty()) { 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); tabs.remove(currentTabIndex);
if (currentTabIndex >= tabs.size()) { if (currentTabIndex >= tabs.size()) {
currentTabIndex = Math.max(0, tabs.size() - 1); currentTabIndex = Math.max(0, tabs.size() - 1);
@@ -162,6 +162,8 @@ public final class Compositor {
tab.close(); tab.close();
} }
tabs.clear(); tabs.clear();
nodes.clear();
paneLayer.getChildren().clear();
} }
private Tab currentTab() { private Tab currentTab() {
@@ -192,13 +194,9 @@ public final class Compositor {
} }
} }
// 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() { private FrameType nextFrameType() {
double width = canvas.getWidth(); double width = root.getWidth();
double height = canvas.getHeight(); double height = root.getHeight();
long contentVersion = tabs.isEmpty() ? 0 : currentTab().contentVersion(); long contentVersion = tabs.isEmpty() ? 0 : currentTab().contentVersion();
boolean layoutChanged = width != lastWidth || height != lastHeight boolean layoutChanged = width != lastWidth || height != lastHeight
@@ -222,93 +220,97 @@ public final class Compositor {
return FrameType.IDLE; return FrameType.IDLE;
} }
// 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() { private void renderLayoutFrame() {
double width = root.getWidth();
double height = root.getHeight();
double topInset = tabs.size() > 1 ? TAB_BAR_HEIGHT : 0.0; double topInset = tabs.size() > 1 ? TAB_BAR_HEIGHT : 0.0;
paneLayer.resizeRelocate(0.0, 0.0, width, height);
updateTabBar(width, topInset);
if (!tabs.isEmpty()) { if (!tabs.isEmpty()) {
currentTab().layout(canvas.getWidth(), canvas.getHeight(), topInset); currentTab().layout(width, height, topInset);
} }
List<TerminalPane> panes = currentPanes(); List<TerminalPane> panes = currentPanes();
// Sync each pane's ghostty grid to its (possibly new) bounds; a no-op when unchanged. retainNodes(panes);
List<TerminalPaneNode> orderedNodes = new ArrayList<>(panes.size());
for (TerminalPane pane : panes) { for (TerminalPane pane : panes) {
pane.fitToBounds(); pane.fitToBounds();
TerminalPaneNode node = nodeFor(pane);
node.resizeRelocate(Math.round(pane.x()), Math.round(pane.y()), pane.width(), pane.height());
node.renderFull(isActive(pane));
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) {
pane.paintFull(gc, isActive(pane));
paneContentVersion.put(pane, pane.contentVersion());
}
} }
// 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() { private void renderContentFrame() {
List<TerminalPane> panes = currentPanes(); for (TerminalPane pane : currentPanes()) {
GraphicsContext gc = beginFrame(); TerminalPaneNode node = nodes.get(pane);
if (node != null) {
for (TerminalPane pane : panes) { node.renderIncremental(isActive(pane));
Long drawn = paneContentVersion.get(pane);
if (drawn != null && drawn == pane.contentVersion()) {
continue;
} }
pane.paintIncremental(gc, isActive(pane));
paneContentVersion.put(pane, pane.contentVersion());
} }
} }
private GraphicsContext beginFrame() { private TerminalPaneNode nodeFor(TerminalPane pane) {
GraphicsContext gc = canvas.getGraphicsContext2D(); return nodes.computeIfAbsent(pane, this::createNode);
gc.setFontSmoothingType(FontSmoothingType.LCD); // the per-cell renderer relies on LCD
return gc;
} }
// Thin tab strip: one equal-width segment per tab, the current one highlighted, with a private TerminalPaneNode createNode(TerminalPane pane) {
// small 1-based number centred in each segment. TerminalPaneNode node = new TerminalPaneNode(pane);
private void drawTabBar(GraphicsContext gc, double width, double barHeight) { node.setOnMousePressed(event -> handleMousePressed(pane, event));
int count = tabs.size(); node.setOnMouseReleased(event -> handleMouseReleased(pane, event));
Font barFont = Font.font(metrics.fontFamily(), Math.max(9.0, Math.min(13.0, barHeight * 0.62))); node.setOnMouseDragged(event -> handleMouseDragged(pane, event));
gc.setFont(barFont); node.setOnMouseMoved(event -> handleMouseMoved(pane, event));
gc.setFontSmoothingType(FontSmoothingType.GRAY); node.setOnScroll(event -> handleScroll(pane, event));
gc.setTextAlign(TextAlignment.CENTER); return node;
gc.setTextBaseline(VPos.CENTER); }
double gap = 1.0; private void retainNodes(List<TerminalPane> visiblePanes) {
double segmentWidth = width / count; Set<TerminalPane> visible = new HashSet<>(visiblePanes);
for (int i = 0; i < count; i++) { nodes.keySet().removeIf(pane -> !visible.contains(pane));
double x = i * segmentWidth; }
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; boolean current = i == currentTabIndex;
gc.setFill(current ? Color.rgb(45, 55, 72) : Color.rgb(22, 24, 28)); label.setAlignment(Pos.CENTER);
gc.fillRect(x, 0.0, segmentWidth - gap, barHeight); label.setTextFill(current ? TAB_TEXT : TAB_INACTIVE_TEXT);
gc.setFill(current ? TAB_TEXT : Color.rgb(128, 136, 148)); label.setBackground(new Background(new BackgroundFill(
gc.fillText(Integer.toString(i + 1), x + (segmentWidth - gap) / 2.0, barHeight / 2.0); 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;
layoutVersion++;
root.requestFocus();
event.consume();
});
tabBar.getChildren().add(label);
} }
// 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 ---------------------------------------------------------------------- // ---- Input ----------------------------------------------------------------------
private void handleMousePressed(MouseEvent event) { private void handleMousePressed(TerminalPane pane, MouseEvent event) {
canvas.requestFocus(); root.requestFocus();
TerminalPane pane = paneAt(event.getX(), event.getY());
if (pane == null) {
return;
}
focus(pane); focus(pane);
pressedButton = mouseButton(event); pressedButton = mouseButton(event);
mouseButtonPressed = true; mouseButtonPressed = true;
@@ -316,58 +318,38 @@ public final class Compositor {
if (target == null) { if (target == null) {
return; return;
} }
send(pane, target, MouseInput.press(pressedButton, localX(event.getX(), pane, target), localY(event.getY(), pane, target), modifiers(event)), true, event); send(pane, target, MouseInput.press(pressedButton, localX(event.getX(), target), localY(event.getY(), target), modifiers(event)), true, event);
} }
private void handleMouseReleased(MouseEvent event) { private void handleMouseReleased(TerminalPane pane, MouseEvent event) {
TerminalPane pane = paneAt(event.getX(), event.getY());
if (pane == null) {
pane = activePane();
}
MouseButton button = pressedButton == MouseButton.UNKNOWN ? mouseButton(event) : pressedButton; MouseButton button = pressedButton == MouseButton.UNKNOWN ? mouseButton(event) : pressedButton;
MouseTarget target = mouseTarget(pane); MouseTarget target = mouseTarget(pane);
if (target != null) { if (target != null) {
send(pane, target, MouseInput.release(button, localX(event.getX(), pane, target), localY(event.getY(), pane, target), modifiers(event)), false, event); send(pane, target, MouseInput.release(button, localX(event.getX(), target), localY(event.getY(), target), modifiers(event)), false, event);
} }
mouseButtonPressed = false; mouseButtonPressed = false;
pressedButton = MouseButton.UNKNOWN; pressedButton = MouseButton.UNKNOWN;
} }
private void handleMouseDragged(MouseEvent event) { private void handleMouseDragged(TerminalPane pane, MouseEvent event) {
TerminalPane pane = paneAt(event.getX(), event.getY());
if (pane == null) {
pane = activePane();
}
MouseButton button = pressedButton == MouseButton.UNKNOWN ? mouseButton(event) : pressedButton; MouseButton button = pressedButton == MouseButton.UNKNOWN ? mouseButton(event) : pressedButton;
MouseTarget target = mouseTarget(pane); MouseTarget target = mouseTarget(pane);
if (target == null) { if (target == null) {
return; return;
} }
send(pane, target, MouseInput.drag(button, localX(event.getX(), pane, target), localY(event.getY(), pane, target), modifiers(event)), true, event); send(pane, target, MouseInput.drag(button, localX(event.getX(), target), localY(event.getY(), target), modifiers(event)), true, event);
} }
private void handleMouseMoved(MouseEvent event) { private void handleMouseMoved(TerminalPane pane, MouseEvent event) {
TerminalPane pane = paneAt(event.getX(), event.getY());
if (pane == null) {
return;
}
MouseTarget target = mouseTarget(pane); MouseTarget target = mouseTarget(pane);
if (target == null) { if (target == null) {
return; return;
} }
send(pane, target, MouseInput.motion(localX(event.getX(), pane, target), localY(event.getY(), pane, target), modifiers(event)), mouseButtonPressed, event); send(pane, target, MouseInput.motion(localX(event.getX(), target), localY(event.getY(), target), modifiers(event)), mouseButtonPressed, event);
} }
private void handleScroll(ScrollEvent event) { private void handleScroll(TerminalPane pane, ScrollEvent event) {
TerminalPane pane = paneAt(event.getX(), event.getY()); root.requestFocus();
if (pane == null) {
return;
}
canvas.requestFocus();
focus(pane); focus(pane);
int direction = scrollDirection(event); int direction = scrollDirection(event);
if (direction == 0) { if (direction == 0) {
@@ -379,23 +361,19 @@ public final class Compositor {
MouseTarget target = mouseTarget(pane); MouseTarget target = mouseTarget(pane);
boolean sent = false; boolean sent = false;
if (target != null) { if (target != null) {
// The wheel sends one button press per scrolled row; resolve the position once. double ex = localX(event.getX(), target);
double ex = localX(event.getX(), pane, target); double ey = localY(event.getY(), target);
double ey = localY(event.getY(), pane, target);
KeyModifiers modifiers = modifiers(event); KeyModifiers modifiers = modifiers(event);
for (int i = 0; i < rows; i++) { for (int i = 0; i < rows; i++) {
sent |= send(pane, target, MouseInput.press(wheelButton, ex, ey, modifiers), mouseButtonPressed, event); sent |= send(pane, target, MouseInput.press(wheelButton, ex, ey, modifiers), mouseButtonPressed, event);
} }
} }
if (!sent) { if (!sent) {
// Not consumed by the app (e.g. mouse reporting off): scroll the local viewport.
pane.scrollViewport(direction > 0 ? -rows : rows); pane.scrollViewport(direction > 0 ? -rows : rows);
event.consume(); 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) { private boolean send(TerminalPane pane, MouseTarget target, MouseInput input, boolean anyButtonPressed, InputEvent event) {
boolean sent = pane.sendMouse(input, target.size(), anyButtonPressed); boolean sent = pane.sendMouse(input, target.size(), anyButtonPressed);
if (sent) { if (sent) {
@@ -404,17 +382,6 @@ public final class Compositor {
return sent; 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) { private MouseTarget mouseTarget(TerminalPane pane) {
if (pane.width() <= 2 * TerminalMetrics.PADDING || pane.height() <= 2 * TerminalMetrics.PADDING) { if (pane.width() <= 2 * TerminalMetrics.PADDING || pane.height() <= 2 * TerminalMetrics.PADDING) {
return null; return null;
@@ -429,14 +396,12 @@ public final class Compositor {
return new MouseTarget(MouseEncoderSize.of(screenWidth, screenHeight, cellWidth, cellHeight), screenWidth, screenHeight); return new MouseTarget(MouseEncoderSize.of(screenWidth, screenHeight, cellWidth, cellHeight), screenWidth, screenHeight);
} }
// Resolve a canvas-space pointer position to a pane-local pixel coordinate, clamped to private static double localX(double nodeX, MouseTarget target) {
// the pane's reported screen size (what ghostty's mouse encoder expects). return clamp(nodeX - TerminalMetrics.PADDING, 0.0, target.screenWidth() - 1.0);
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 canvasY, TerminalPane pane, MouseTarget target) { private static double localY(double nodeY, MouseTarget target) {
return clamp(canvasY - pane.y() - TerminalMetrics.PADDING, 0.0, target.screenHeight() - 1.0); return clamp(nodeY - TerminalMetrics.PADDING, 0.0, target.screenHeight() - 1.0);
} }
private static double clamp(double value, double min, double max) { private static double clamp(double value, double min, double max) {
@@ -484,11 +449,10 @@ public final class Compositor {
}; };
} }
// What one render() pass should do, decided from the change trackers in nextFrame().
private enum FrameType { private enum FrameType {
IDLE, // nothing changed since the last frame IDLE,
LAYOUT, // geometry/font/tab/pane set changed: clear and repaint everything LAYOUT,
CONTENT // only terminal content changed: repaint the panes that changed CONTENT
} }
private record MouseTarget(MouseEncoderSize size, long screenWidth, long screenHeight) { private record MouseTarget(MouseEncoderSize size, long screenWidth, long screenHeight) {

View File

@@ -12,7 +12,6 @@ import javafx.scene.control.Spinner;
import javafx.scene.control.SpinnerValueFactory; import javafx.scene.control.SpinnerValueFactory;
import javafx.scene.input.KeyEvent; import javafx.scene.input.KeyEvent;
import javafx.scene.layout.GridPane; import javafx.scene.layout.GridPane;
import javafx.scene.layout.StackPane;
import javafx.scene.text.Font; import javafx.scene.text.Font;
import javafx.stage.Stage; import javafx.stage.Stage;
@@ -32,11 +31,7 @@ public final class Main extends Application {
metrics = new TerminalMetrics(config.fontFamily(), config.fontSize()); metrics = new TerminalMetrics(config.fontFamily(), config.fontSize());
compositor = new Compositor(config, metrics); compositor = new Compositor(config, metrics);
StackPane root = new StackPane(compositor.canvas()); Scene scene = new Scene(compositor.node(), config.windowWidth(), config.windowHeight());
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_PRESSED, this::handlePressed);
scene.addEventFilter(KeyEvent.KEY_TYPED, event -> handleTyped(event)); scene.addEventFilter(KeyEvent.KEY_TYPED, event -> handleTyped(event));
@@ -53,7 +48,7 @@ public final class Main extends Application {
compositor.close(); compositor.close();
}); });
stage.show(); stage.show();
compositor.canvas().requestFocus(); compositor.requestFocus();
} }
private void handlePressed(KeyEvent event) { private void handlePressed(KeyEvent event) {
@@ -161,7 +156,7 @@ public final class Main extends Application {
config = config.withFont(selectedFamily.trim(), selectedSize); config = config.withFont(selectedFamily.trim(), selectedSize);
config.save(); config.save();
compositor.setFont(config.fontFamily(), config.fontSize()); compositor.setFont(config.fontFamily(), config.fontSize());
compositor.canvas().requestFocus(); compositor.requestFocus();
}); });
} }

View File

@@ -1,8 +1,5 @@
package com.gregor.jprototerm; package com.gregor.jprototerm;
import javafx.scene.shape.Rectangle;
import javafx.scene.shape.Shape;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Comparator; import java.util.Comparator;
import java.util.List; import java.util.List;
@@ -112,50 +109,8 @@ final class Tab implements AutoCloseable {
floatingHeight); floatingHeight);
} }
assignClips(); tiled.forEach(pane -> pane.setClip(null));
} floating.forEach(pane -> pane.setClip(null));
// 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) { boolean navigate(Direction direction) {

View File

@@ -0,0 +1,78 @@
package com.gregor.jprototerm;
import javafx.scene.canvas.Canvas;
import javafx.scene.canvas.GraphicsContext;
import javafx.scene.layout.Region;
/**
* JavaFX node for one terminal pane. It is intentionally a thin adapter: the terminal model
* still provides snapshots and the existing renderer still draws the cell grid, but drawing is
* now local to this node's own canvas instead of a shared compositor canvas.
*/
final class TerminalPaneNode extends Region {
private final TerminalPane pane;
private final Canvas canvas = new Canvas();
private long drawnContentVersion = Long.MIN_VALUE;
private double drawnWidth = -1.0;
private double drawnHeight = -1.0;
TerminalPaneNode(TerminalPane pane) {
this.pane = pane;
setPickOnBounds(true);
getChildren().add(canvas);
}
void discard() {
drawnContentVersion = Long.MIN_VALUE;
drawnWidth = -1.0;
drawnHeight = -1.0;
}
void renderFull(boolean active) {
prepareCanvas();
paint(active, true);
}
void renderIncremental(boolean active) {
if (drawnContentVersion == Long.MIN_VALUE || prepareCanvas()) {
paint(active, true);
return;
}
if (drawnContentVersion == pane.contentVersion()) {
return;
}
paint(active, false);
}
private boolean prepareCanvas() {
boolean changed = canvas.getWidth() != pane.width() || canvas.getHeight() != pane.height();
if (changed) {
canvas.setWidth(Math.max(0.0, pane.width()));
canvas.setHeight(Math.max(0.0, pane.height()));
drawnWidth = pane.width();
drawnHeight = pane.height();
drawnContentVersion = Long.MIN_VALUE;
}
return changed || drawnWidth != pane.width() || drawnHeight != pane.height();
}
private void paint(boolean active, boolean full) {
GraphicsContext gc = canvas.getGraphicsContext2D();
gc.save();
gc.translate(-pane.x(), -pane.y());
if (full) {
pane.paintFull(gc, active);
} else {
pane.paintIncremental(gc, active);
}
gc.restore();
drawnContentVersion = pane.contentVersion();
drawnWidth = pane.width();
drawnHeight = pane.height();
}
@Override
protected void layoutChildren() {
canvas.relocate(0.0, 0.0);
}
}