Compare commits
26 Commits
profiling
...
8f70c4bf45
| Author | SHA1 | Date | |
|---|---|---|---|
| 8f70c4bf45 | |||
| 6738051da1 | |||
| 65f69d5c75 | |||
| 85f2d86c09 | |||
| 5f0edcbe31 | |||
| ebf87c0bff | |||
| a51bee3b43 | |||
| aa5ca0451c | |||
| 8ac07218fe | |||
| 6bf69e8572 | |||
| 07585a314c | |||
| bdb33450f1 | |||
|
|
2c020bb6cb | ||
|
|
71a533ec34 | ||
|
|
54b08c7eca | ||
|
|
2fcdb286af | ||
|
|
e6848ec684 | ||
|
|
38822d66b8 | ||
|
|
586150de59 | ||
|
|
494d2c40cf | ||
|
|
a99cbdc61a | ||
|
|
86f7174eee | ||
|
|
137db24023 | ||
|
|
d8faf8d6df | ||
|
|
9903e9174f | ||
|
|
9b7247a4e0 |
6
.gitignore
vendored
6
.gitignore
vendored
@@ -11,10 +11,10 @@ devenv.local.yaml
|
||||
# pre-commit
|
||||
.pre-commit-config.yaml
|
||||
build
|
||||
build
|
||||
.gradle
|
||||
bin
|
||||
.settings
|
||||
.project
|
||||
.worktrees
|
||||
.classpath
|
||||
.project
|
||||
.settings
|
||||
.codexsession
|
||||
|
||||
21
README.md
21
README.md
@@ -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
|
||||
@@ -132,22 +134,3 @@ open_scrollback = "ALT+S"
|
||||
Each tab has its own stack of tiled and floating panes; only the active tab is rendered. A
|
||||
thin tab bar appears at the top when more than one tab is open. Closing the last tiled pane
|
||||
while floating panes exist promotes the most recently active floating pane to a tiled pane.
|
||||
|
||||
## Diagnostics
|
||||
|
||||
Two render-debugging flags are off by default and add no overhead unless enabled. Pass them
|
||||
as JVM system properties (each also has an environment-variable equivalent). With the
|
||||
packaged binary the JVM picks them up from `JDK_JAVA_OPTIONS`:
|
||||
|
||||
```sh
|
||||
JDK_JAVA_OPTIONS="-Djprototerm.profile=true" ./result/bin/jprototerm
|
||||
```
|
||||
|
||||
- `-Djprototerm.profile=true` (or `JPROTOTERM_PROFILE=1`): print a `[render-profile]` line to
|
||||
stderr every N renders with the per-frame cost of each render stage — `snapshot` (terminal
|
||||
state marshalling), `fingerprint` (change detection), `draw` (canvas painting), and the
|
||||
`frame-total`. Set `-Djprototerm.profile.frames=<N>` to change the dump interval (default
|
||||
120).
|
||||
- `-Djprototerm.debugRepaint=true` (or `JPROTOTERM_DEBUG_REPAINT=1`): paint each per-column
|
||||
repaint run's cleared span red instead of clearing it. Repainted regions flash red, so you
|
||||
can see exactly which cells are being redrawn each frame.
|
||||
|
||||
2
TODOS.md
Normal file
2
TODOS.md
Normal file
@@ -0,0 +1,2 @@
|
||||
jlibghostty - why downcall metadata not propagated ?
|
||||
jlibghostty - how need to change flake so consuming flakes dont have to depend on same ghostty flake ?
|
||||
@@ -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
8
flake.lock
generated
@@ -70,11 +70,11 @@
|
||||
"nixpkgs": "nixpkgs"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1780258814,
|
||||
"narHash": "sha256-8rxL7xaZ/loYg3zdt0w5+hfNyHFVknDZN360NzrtCsQ=",
|
||||
"lastModified": 1780272954,
|
||||
"narHash": "sha256-bVWY60iw8yPIu7I8FuRPf06T0H1TDvQDVUlzeHQs8UA=",
|
||||
"ref": "refs/heads/main",
|
||||
"rev": "6a3d5aa0b0b1f738c958e2a2f0249574c07d9c4d",
|
||||
"revCount": 23,
|
||||
"rev": "06a9d5d3ecf11c58f0e41214d1b59900e672dd3a",
|
||||
"revCount": 24,
|
||||
"type": "git",
|
||||
"url": "https://gitea.gregorlohaus.com/gregor/jlibghostty.git"
|
||||
},
|
||||
|
||||
@@ -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")),
|
||||
|
||||
@@ -4,52 +4,60 @@ 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.Node;
|
||||
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();
|
||||
// Kitty images are drawn as retained nodes layered over the canvas, not composited onto it.
|
||||
private final KittyImageOverlay imageOverlay = new KittyImageOverlay();
|
||||
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 +66,27 @@ 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 Canvas canvas() {
|
||||
return canvas;
|
||||
}
|
||||
|
||||
public void requestFocus() {
|
||||
root.requestFocus();
|
||||
/** The kitty-image overlay, to be stacked directly above {@link #canvas()} in the window. */
|
||||
public Node imageOverlay() {
|
||||
return imageOverlay.node();
|
||||
}
|
||||
|
||||
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 +101,7 @@ public final class Compositor {
|
||||
|
||||
public void navigate(Direction direction) {
|
||||
if (!isEmpty() && currentTab().navigate(direction)) {
|
||||
markSceneDirty();
|
||||
layoutVersion++;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,7 +110,7 @@ public final class Compositor {
|
||||
return;
|
||||
}
|
||||
currentTab().toggleFloating();
|
||||
markSceneDirty();
|
||||
layoutVersion++;
|
||||
}
|
||||
|
||||
public void createPane() {
|
||||
@@ -108,7 +118,25 @@ public final class Compositor {
|
||||
return;
|
||||
}
|
||||
currentTab().createPane();
|
||||
markSceneDirty();
|
||||
layoutVersion++;
|
||||
}
|
||||
|
||||
/** Opens a new floating pane, makes it active, and returns it (null when no tab exists). */
|
||||
public TerminalPane openFloatingPane() {
|
||||
if (isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
TerminalPane pane = currentTab().createFloatingPane();
|
||||
layoutVersion++;
|
||||
return pane;
|
||||
}
|
||||
|
||||
public void nextFloatingPane() {
|
||||
if (isEmpty()) {
|
||||
return;
|
||||
}
|
||||
currentTab().nextFloatingPane();
|
||||
layoutVersion++;
|
||||
}
|
||||
|
||||
public void closeActivePane() {
|
||||
@@ -117,31 +145,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 +180,6 @@ public final class Compositor {
|
||||
tab.close();
|
||||
}
|
||||
tabs.clear();
|
||||
nodes.clear();
|
||||
paneLayer.getChildren().clear();
|
||||
}
|
||||
|
||||
private Tab currentTab() {
|
||||
@@ -162,140 +190,143 @@ 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)));
|
||||
}
|
||||
imageOverlay.sync(panes);
|
||||
}
|
||||
|
||||
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)));
|
||||
imageOverlay.updatePane(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 +334,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 +397,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 +409,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 +425,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 +450,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 +505,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) {
|
||||
}
|
||||
}
|
||||
|
||||
1125
src/main/java/com/gregor/jprototerm/GhosttyTerminalRenderer.java
Normal file
1125
src/main/java/com/gregor/jprototerm/GhosttyTerminalRenderer.java
Normal file
File diff suppressed because it is too large
Load Diff
32
src/main/java/com/gregor/jprototerm/KittyImageNode.java
Normal file
32
src/main/java/com/gregor/jprototerm/KittyImageNode.java
Normal file
@@ -0,0 +1,32 @@
|
||||
package com.gregor.jprototerm;
|
||||
|
||||
import javafx.scene.image.Image;
|
||||
|
||||
/**
|
||||
* A single kitty image to display, produced by the renderer and consumed by {@link
|
||||
* KittyImageOverlay}. Images are not painted onto the canvas; each becomes a retained
|
||||
* {@code ImageView} node positioned over the pane. The {@code source*} fields are the region of
|
||||
* {@link #image()} to show (in image pixels); the {@code x/y/width/height} are where to put it,
|
||||
* in scene coordinates (the same space the pane's clip {@code Shape} lives in).
|
||||
*
|
||||
* <p>{@code imageId}+{@code placementId} identify the placement so the overlay can reuse the
|
||||
* same node across frames instead of recreating it.
|
||||
*/
|
||||
record KittyImageNode(
|
||||
long imageId,
|
||||
long placementId,
|
||||
Image image,
|
||||
double sourceX,
|
||||
double sourceY,
|
||||
double sourceWidth,
|
||||
double sourceHeight,
|
||||
double x,
|
||||
double y,
|
||||
double width,
|
||||
double height
|
||||
) {
|
||||
/** Stable per-pane key for node reuse. Packs the two u32 ids without collision. */
|
||||
long key() {
|
||||
return (imageId << 32) | (placementId & 0xffffffffL);
|
||||
}
|
||||
}
|
||||
151
src/main/java/com/gregor/jprototerm/KittyImageOverlay.java
Normal file
151
src/main/java/com/gregor/jprototerm/KittyImageOverlay.java
Normal file
@@ -0,0 +1,151 @@
|
||||
package com.gregor.jprototerm;
|
||||
|
||||
import javafx.geometry.Rectangle2D;
|
||||
import javafx.scene.Group;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.image.ImageView;
|
||||
import javafx.scene.layout.Pane;
|
||||
import javafx.scene.shape.Rectangle;
|
||||
import javafx.scene.shape.Shape;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.Iterator;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Renders kitty graphics images as retained scene-graph nodes layered over the {@link Compositor}
|
||||
* canvas, instead of compositing them onto the canvas. Each pane gets a {@link Group} clipped to
|
||||
* that pane's region (the same clip {@code Shape} the canvas renderer uses), and each visible
|
||||
* image placement is an {@link ImageView} inside it, reused across frames so an unchanged image
|
||||
* costs nothing to redraw.
|
||||
*
|
||||
* <p>The overlay {@link #node()} is mouse-transparent and sits above the canvas in the window's
|
||||
* {@code StackPane}; its children use scene coordinates, which line up with the canvas because
|
||||
* both fill the same root.
|
||||
*/
|
||||
final class KittyImageOverlay {
|
||||
private final Pane root = new Pane();
|
||||
private final Map<TerminalPane, PaneOverlay> overlays = new HashMap<>();
|
||||
|
||||
KittyImageOverlay() {
|
||||
// Input belongs to the canvas underneath; the overlay only shows pixels.
|
||||
root.setMouseTransparent(true);
|
||||
root.setManaged(false);
|
||||
}
|
||||
|
||||
Node node() {
|
||||
return root;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full reconcile to {@code panes} (bottom-to-top): drop overlays for panes that went away,
|
||||
* refresh each surviving/added pane's images and clip, and order the per-pane groups to match
|
||||
* the pane z-order. Called on layout frames, after the panes have painted.
|
||||
*/
|
||||
void sync(List<TerminalPane> panes) {
|
||||
Iterator<Map.Entry<TerminalPane, PaneOverlay>> it = overlays.entrySet().iterator();
|
||||
while (it.hasNext()) {
|
||||
Map.Entry<TerminalPane, PaneOverlay> entry = it.next();
|
||||
if (!panes.contains(entry.getKey())) {
|
||||
root.getChildren().remove(entry.getValue().group);
|
||||
it.remove();
|
||||
}
|
||||
}
|
||||
for (TerminalPane pane : panes) {
|
||||
updatePane(pane);
|
||||
}
|
||||
// Only panes that actually have images get a group; order those to match pane z-order.
|
||||
List<Node> ordered = new ArrayList<>(panes.size());
|
||||
for (TerminalPane pane : panes) {
|
||||
PaneOverlay overlay = overlays.get(pane);
|
||||
if (overlay != null) {
|
||||
ordered.add(overlay.group);
|
||||
}
|
||||
}
|
||||
if (!root.getChildren().equals(ordered)) {
|
||||
root.getChildren().setAll(ordered);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh one pane's images and clip (called on content frames for each repainted pane).
|
||||
* Creates the pane's group if this is the first time it has shown an image.
|
||||
*/
|
||||
void updatePane(TerminalPane pane) {
|
||||
List<KittyImageNode> images = pane.kittyImages();
|
||||
PaneOverlay overlay = overlays.get(pane);
|
||||
if (overlay == null) {
|
||||
if (images.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
overlay = new PaneOverlay();
|
||||
overlays.put(pane, overlay);
|
||||
root.getChildren().add(overlay.group);
|
||||
}
|
||||
overlay.group.setClip(clipFor(pane));
|
||||
reconcile(overlay, images);
|
||||
}
|
||||
|
||||
private static void reconcile(PaneOverlay overlay, List<KittyImageNode> images) {
|
||||
Set<Long> seen = new HashSet<>();
|
||||
for (KittyImageNode node : images) {
|
||||
long key = node.key();
|
||||
seen.add(key);
|
||||
ImageView view = overlay.views.get(key);
|
||||
if (view == null) {
|
||||
view = new ImageView();
|
||||
view.setManaged(false);
|
||||
view.setSmooth(true);
|
||||
view.setPreserveRatio(false);
|
||||
overlay.views.put(key, view);
|
||||
overlay.group.getChildren().add(view);
|
||||
}
|
||||
apply(view, node);
|
||||
}
|
||||
if (overlay.views.size() == seen.size()) {
|
||||
return;
|
||||
}
|
||||
Iterator<Map.Entry<Long, ImageView>> it = overlay.views.entrySet().iterator();
|
||||
while (it.hasNext()) {
|
||||
Map.Entry<Long, ImageView> entry = it.next();
|
||||
if (!seen.contains(entry.getKey())) {
|
||||
overlay.group.getChildren().remove(entry.getValue());
|
||||
it.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void apply(ImageView view, KittyImageNode node) {
|
||||
if (view.getImage() != node.image()) {
|
||||
view.setImage(node.image());
|
||||
}
|
||||
view.setViewport(new Rectangle2D(node.sourceX(), node.sourceY(), node.sourceWidth(), node.sourceHeight()));
|
||||
view.setFitWidth(node.width());
|
||||
view.setFitHeight(node.height());
|
||||
view.setLayoutX(node.x());
|
||||
view.setLayoutY(node.y());
|
||||
}
|
||||
|
||||
// The pane's occlusion clip when one is set (rect minus covering panes), else the pane's
|
||||
// plain bounds so an image can't spill outside its pane. Matches Tab's pixel snapping.
|
||||
private static Shape clipFor(TerminalPane pane) {
|
||||
Shape clip = pane.clip();
|
||||
if (clip != null) {
|
||||
return clip;
|
||||
}
|
||||
return new Rectangle(Math.round(pane.x()), Math.round(pane.y()), pane.width(), pane.height());
|
||||
}
|
||||
|
||||
private static final class PaneOverlay {
|
||||
private final Group group = new Group();
|
||||
private final Map<Long, ImageView> views = new HashMap<>();
|
||||
|
||||
private PaneOverlay() {
|
||||
group.setManaged(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.gregor.jprototerm;
|
||||
import javafx.animation.AnimationTimer;
|
||||
import javafx.application.Application;
|
||||
import javafx.application.Platform;
|
||||
import javafx.geometry.Rectangle2D;
|
||||
import javafx.scene.Scene;
|
||||
import javafx.scene.control.ButtonType;
|
||||
import javafx.scene.control.ComboBox;
|
||||
@@ -12,12 +13,15 @@ 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.Screen;
|
||||
import javafx.stage.Stage;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
|
||||
public final class Main extends Application {
|
||||
private Compositor compositor;
|
||||
@@ -31,7 +35,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.imageOverlay());
|
||||
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));
|
||||
|
||||
@@ -47,8 +55,37 @@ public final class Main extends Application {
|
||||
stage.setOnCloseRequest(event -> {
|
||||
compositor.close();
|
||||
});
|
||||
// JavaFX centres a new stage on the primary screen; on X11 there's no "focused monitor"
|
||||
// to honour, so place it on the screen under the mouse pointer instead.
|
||||
centreOnActiveScreen(stage, config.windowWidth(), config.windowHeight());
|
||||
stage.show();
|
||||
compositor.requestFocus();
|
||||
compositor.canvas().requestFocus();
|
||||
}
|
||||
|
||||
// Centre the stage within the screen the mouse pointer is on (the best proxy for the
|
||||
// "active" monitor on X11, which exposes no focused-monitor concept to JavaFX).
|
||||
private static void centreOnActiveScreen(Stage stage, double width, double height) {
|
||||
Rectangle2D bounds = activeScreen().getVisualBounds();
|
||||
stage.setX(bounds.getMinX() + ((bounds.getWidth() - width) / 2.0));
|
||||
stage.setY(bounds.getMinY() + ((bounds.getHeight() - height) / 2.0));
|
||||
}
|
||||
|
||||
private static Screen activeScreen() {
|
||||
try {
|
||||
// AWT is the only way to read the pointer location before any window is shown;
|
||||
// its coordinate space matches JavaFX's on the X11 virtual screen.
|
||||
java.awt.PointerInfo pointer = java.awt.MouseInfo.getPointerInfo();
|
||||
if (pointer != null) {
|
||||
java.awt.Point at = pointer.getLocation();
|
||||
List<Screen> screens = Screen.getScreensForRectangle(at.x, at.y, 1.0, 1.0);
|
||||
if (!screens.isEmpty()) {
|
||||
return screens.get(0);
|
||||
}
|
||||
}
|
||||
} catch (Throwable ignored) {
|
||||
// Headless or AWT unavailable — fall back to the primary screen.
|
||||
}
|
||||
return Screen.getPrimary();
|
||||
}
|
||||
|
||||
private void handlePressed(KeyEvent event) {
|
||||
@@ -70,6 +107,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,17 +193,22 @@ 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();
|
||||
});
|
||||
}
|
||||
|
||||
private void openScrollbackInEditor() {
|
||||
try {
|
||||
// Capture the active pane's scrollback before opening the floating pane, since that
|
||||
// makes the new pane active.
|
||||
Path file = Files.createTempFile("jprototerm-scrollback-", ".txt");
|
||||
Files.writeString(file, compositor.activePane().scrollbackText());
|
||||
file.toFile().deleteOnExit();
|
||||
|
||||
compositor.activePane().send(scrollbackEditorCommand(file) + "\r");
|
||||
TerminalPane pane = compositor.openFloatingPane();
|
||||
if (pane != null) {
|
||||
pane.send(scrollbackEditorCommand(file) + "\r");
|
||||
}
|
||||
} catch (IOException ex) {
|
||||
System.err.println("Could not open scrollback in editor: " + ex.getMessage());
|
||||
}
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
package com.gregor.jprototerm;
|
||||
|
||||
/**
|
||||
* Lightweight render profiler, disabled unless {@code -Djprototerm.profile=true} (or the
|
||||
* {@code JPROTOTERM_PROFILE=1} environment variable) is set. It accumulates wall-clock nanos
|
||||
* into a handful of buckets and prints aggregate per-frame stats to stderr every
|
||||
* {@code jprototerm.profile.frames} render invocations (default 120).
|
||||
*
|
||||
* <p>All render work runs on the JavaFX application thread, so the accumulators are plain
|
||||
* fields with no synchronization.
|
||||
*
|
||||
* <p>Caveat: JavaFX canvas drawing is deferred to the QuantumRenderer thread, so the
|
||||
* {@link #DRAW} bucket measures only the cost of <em>recording</em> draw commands, not the
|
||||
* GPU paint. Pair this with {@code -Djavafx.pulseLogger=true} to see the render-thread side.
|
||||
*/
|
||||
final class RenderProfiler {
|
||||
static final int SNAPSHOT = 0;
|
||||
static final int FINGERPRINT = 1;
|
||||
static final int DRAW = 2;
|
||||
static final int FRAME = 3;
|
||||
static final int UPDATE = 4;
|
||||
static final int MARSHAL = 5;
|
||||
private static final int BUCKETS = 6;
|
||||
private static final String[] NAMES =
|
||||
{"snapshot", "fingerprint", "draw", "frame-total", "update", "marshal"};
|
||||
|
||||
private static final boolean ENABLED =
|
||||
Boolean.getBoolean("jprototerm.profile") || "1".equals(System.getenv("JPROTOTERM_PROFILE"));
|
||||
private static final int DUMP_FRAMES = Integer.getInteger("jprototerm.profile.frames", 120);
|
||||
|
||||
private static final long[] totalNanos = new long[BUCKETS];
|
||||
private static final long[] counts = new long[BUCKETS];
|
||||
private static int frames;
|
||||
|
||||
private RenderProfiler() {
|
||||
}
|
||||
|
||||
static boolean enabled() {
|
||||
return ENABLED;
|
||||
}
|
||||
|
||||
/** Returns a start timestamp, or 0 when profiling is disabled. */
|
||||
static long start() {
|
||||
return ENABLED ? System.nanoTime() : 0L;
|
||||
}
|
||||
|
||||
/** Records the time elapsed since {@code startNanos} into {@code bucket}. */
|
||||
static void stop(int bucket, long startNanos) {
|
||||
if (!ENABLED) {
|
||||
return;
|
||||
}
|
||||
totalNanos[bucket] += System.nanoTime() - startNanos;
|
||||
counts[bucket]++;
|
||||
}
|
||||
|
||||
/** Marks the end of one render invocation; dumps and resets every {@code DUMP_FRAMES}. */
|
||||
static void frame() {
|
||||
if (!ENABLED) {
|
||||
return;
|
||||
}
|
||||
if (++frames < DUMP_FRAMES) {
|
||||
return;
|
||||
}
|
||||
dump();
|
||||
}
|
||||
|
||||
private static void dump() {
|
||||
StringBuilder sb = new StringBuilder(192);
|
||||
sb.append("[render-profile] ").append(frames).append(" renders");
|
||||
for (int i = 0; i < BUCKETS; i++) {
|
||||
double totalMs = totalNanos[i] / 1_000_000.0;
|
||||
sb.append(String.format(" | %s %.3fms/f (n=%d)", NAMES[i], totalMs / frames, counts[i]));
|
||||
totalNanos[i] = 0;
|
||||
counts[i] = 0;
|
||||
}
|
||||
System.err.println(sb);
|
||||
frames = 0;
|
||||
}
|
||||
}
|
||||
45
src/main/java/com/gregor/jprototerm/RenderTarget.java
Normal file
45
src/main/java/com/gregor/jprototerm/RenderTarget.java
Normal 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();
|
||||
}
|
||||
@@ -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);
|
||||
@@ -205,11 +257,12 @@ final class Tab implements AutoCloseable {
|
||||
}
|
||||
}
|
||||
|
||||
private void createFloatingPane() {
|
||||
TerminalPane createFloatingPane() {
|
||||
TerminalPane pane = openPane(true);
|
||||
floating.add(pane);
|
||||
floatingVisible = true;
|
||||
setActive(pane);
|
||||
return pane;
|
||||
}
|
||||
|
||||
private boolean navigateFloatingStack(Direction direction) {
|
||||
@@ -240,7 +293,7 @@ final class Tab implements AutoCloseable {
|
||||
}
|
||||
|
||||
private void markContentChanged() {
|
||||
contentVersion++;
|
||||
contentVersion.incrementAndGet();
|
||||
}
|
||||
|
||||
private TerminalPane openPane(boolean asFloating) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -160,21 +172,13 @@ public final class TerminalPane implements AutoCloseable {
|
||||
synchronized (terminal) {
|
||||
long version = contentVersion.get();
|
||||
if (full) {
|
||||
long updateStart = RenderProfiler.start();
|
||||
renderState.update(terminal);
|
||||
RenderProfiler.stop(RenderProfiler.UPDATE, updateStart);
|
||||
long marshalStart = RenderProfiler.start();
|
||||
cachedSnapshot = renderState.snapshot();
|
||||
RenderProfiler.stop(RenderProfiler.MARSHAL, marshalStart);
|
||||
renderState.resetDirty();
|
||||
snapshotVersion = version;
|
||||
} else if (snapshotVersion != version) {
|
||||
long updateStart = RenderProfiler.start();
|
||||
renderState.update(terminal);
|
||||
RenderProfiler.stop(RenderProfiler.UPDATE, updateStart);
|
||||
long marshalStart = RenderProfiler.start();
|
||||
cachedSnapshot = renderState.snapshotIncremental();
|
||||
RenderProfiler.stop(RenderProfiler.MARSHAL, marshalStart);
|
||||
renderState.resetDirty();
|
||||
snapshotVersion = version;
|
||||
}
|
||||
@@ -193,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;
|
||||
}
|
||||
@@ -230,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);
|
||||
@@ -266,6 +282,26 @@ 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kitty image placements from the most recent paint, in scene coordinates. The compositor
|
||||
* renders these as overlay nodes clipped to this pane (see {@link KittyImageOverlay}).
|
||||
*/
|
||||
public java.util.List<KittyImageNode> kittyImages() {
|
||||
return renderer.kittyImages();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
if (session != null) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
68
src/main/java/com/gregor/jprototerm/TerminalRenderer.java
Normal file
68
src/main/java/com/gregor/jprototerm/TerminalRenderer.java
Normal file
@@ -0,0 +1,68 @@
|
||||
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);
|
||||
|
||||
/**
|
||||
* The kitty image placements produced by the most recent paint, for the compositor to render
|
||||
* as overlay nodes above the canvas. Empty unless the last paint found visible images.
|
||||
*/
|
||||
java.util.List<KittyImageNode> kittyImages() {
|
||||
return java.util.List.of();
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user