scene graph
This commit is contained in:
@@ -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) {
|
|
||||||
TerminalPane pane = paneAt(event.getX(), event.getY());
|
|
||||||
if (pane == null) {
|
|
||||||
pane = activePane();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void handleMouseReleased(TerminalPane pane, MouseEvent event) {
|
||||||
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) {
|
|
||||||
TerminalPane pane = paneAt(event.getX(), event.getY());
|
|
||||||
if (pane == null) {
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void handleMouseMoved(TerminalPane pane, MouseEvent event) {
|
||||||
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) {
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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));
|
tiled.forEach(pane -> pane.setClip(null));
|
||||||
floating.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) {
|
||||||
|
|||||||
78
src/main/java/com/gregor/jprototerm/TerminalPaneNode.java
Normal file
78
src/main/java/com/gregor/jprototerm/TerminalPaneNode.java
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user