Compare commits
27 Commits
76f539d34a
...
codex-perf
| Author | SHA1 | Date | |
|---|---|---|---|
| 8ac07218fe | |||
| 6bf69e8572 | |||
| 07585a314c | |||
| bdb33450f1 | |||
|
|
2c020bb6cb | ||
|
|
71a533ec34 | ||
|
|
54b08c7eca | ||
|
|
2fcdb286af | ||
|
|
e6848ec684 | ||
|
|
38822d66b8 | ||
|
|
586150de59 | ||
|
|
494d2c40cf | ||
|
|
a99cbdc61a | ||
|
|
86f7174eee | ||
|
|
137db24023 | ||
|
|
d8faf8d6df | ||
|
|
9903e9174f | ||
|
|
9b7247a4e0 | ||
|
|
f5562baf5f | ||
|
|
3017b99f87 | ||
|
|
0958c93b4f | ||
|
|
9c98d87783 | ||
|
|
76c731578f | ||
|
|
95619f5b4c | ||
|
|
174cfc00d3 | ||
|
|
29e84c9830 | ||
|
|
a7baa08e68 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -13,3 +13,4 @@ devenv.local.yaml
|
|||||||
build
|
build
|
||||||
build
|
build
|
||||||
.gradle
|
.gradle
|
||||||
|
bin
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ connection.gradle.distribution=GRADLE_DISTRIBUTION(LOCAL_INSTALLATION(/home/anon
|
|||||||
connection.project.dir=
|
connection.project.dir=
|
||||||
eclipse.preferences.version=1
|
eclipse.preferences.version=1
|
||||||
gradle.user.home=
|
gradle.user.home=
|
||||||
java.home=/nix/store/c3pl7bqrx3d2rc3dh98z6yaj0mv1p52g-openjdk-21.0.10+7/lib/openjdk
|
java.home=/home/anon/.local/lib/graalvm-jdk-21.0.9+7.1
|
||||||
jvm.arguments=
|
jvm.arguments=
|
||||||
offline.mode=false
|
offline.mode=false
|
||||||
override.workspace.settings=true
|
override.workspace.settings=true
|
||||||
|
|||||||
8
flake.lock
generated
8
flake.lock
generated
@@ -70,11 +70,11 @@
|
|||||||
"nixpkgs": "nixpkgs"
|
"nixpkgs": "nixpkgs"
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1780079529,
|
"lastModified": 1780258814,
|
||||||
"narHash": "sha256-AxlGTL8c5xSLcQHvWlm994IdOqxsN8iKrA02Cpv7vso=",
|
"narHash": "sha256-8rxL7xaZ/loYg3zdt0w5+hfNyHFVknDZN360NzrtCsQ=",
|
||||||
"ref": "refs/heads/main",
|
"ref": "refs/heads/main",
|
||||||
"rev": "68121d50b52fb56038871c97c97e7a12ffe987c2",
|
"rev": "6a3d5aa0b0b1f738c958e2a2f0249574c07d9c4d",
|
||||||
"revCount": 20,
|
"revCount": 23,
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://gitea.gregorlohaus.com/gregor/jlibghostty.git"
|
"url": "https://gitea.gregorlohaus.com/gregor/jlibghostty.git"
|
||||||
},
|
},
|
||||||
|
|||||||
497
src/main/java/com/gregor/jprototerm/Compositor.java
Normal file
497
src/main/java/com/gregor/jprototerm/Compositor.java
Normal file
@@ -0,0 +1,497 @@
|
|||||||
|
package com.gregor.jprototerm;
|
||||||
|
|
||||||
|
import dev.jlibghostty.KeyModifiers;
|
||||||
|
import dev.jlibghostty.MouseButton;
|
||||||
|
import dev.jlibghostty.MouseEncoderSize;
|
||||||
|
import dev.jlibghostty.MouseInput;
|
||||||
|
import javafx.geometry.VPos;
|
||||||
|
import javafx.scene.canvas.Canvas;
|
||||||
|
import javafx.scene.canvas.GraphicsContext;
|
||||||
|
import javafx.scene.input.InputEvent;
|
||||||
|
import javafx.scene.input.MouseEvent;
|
||||||
|
import javafx.scene.input.ScrollEvent;
|
||||||
|
import javafx.scene.input.ScrollEvent.VerticalTextScrollUnits;
|
||||||
|
import javafx.scene.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.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
// 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 Canvas canvas = new Canvas();
|
||||||
|
private final AppConfig config;
|
||||||
|
private final TerminalMetrics metrics;
|
||||||
|
private final List<Tab> tabs = new ArrayList<>();
|
||||||
|
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;
|
||||||
|
// 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;
|
||||||
|
|
||||||
|
public Compositor(AppConfig config, TerminalMetrics metrics) {
|
||||||
|
this.config = config;
|
||||||
|
this.metrics = metrics;
|
||||||
|
tabs.add(new Tab(config, metrics));
|
||||||
|
canvas.setFocusTraversable(true);
|
||||||
|
canvas.setOnMousePressed(this::handleMousePressed);
|
||||||
|
canvas.setOnMouseReleased(this::handleMouseReleased);
|
||||||
|
canvas.setOnMouseDragged(this::handleMouseDragged);
|
||||||
|
canvas.setOnMouseMoved(this::handleMouseMoved);
|
||||||
|
canvas.setOnScroll(this::handleScroll);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Canvas canvas() {
|
||||||
|
return canvas;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setFont(String family, double size) {
|
||||||
|
metrics.setFont(family, size);
|
||||||
|
paneContentVersion.clear();
|
||||||
|
lastWidth = -1.0; // force a redraw on the next frame
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Tabs and panes -------------------------------------------------------------
|
||||||
|
|
||||||
|
public boolean isEmpty() {
|
||||||
|
return tabs.isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
public TerminalPane activePane() {
|
||||||
|
return currentTab().activePane();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void navigate(Direction direction) {
|
||||||
|
if (!isEmpty() && currentTab().navigate(direction)) {
|
||||||
|
layoutVersion++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void toggleFloating() {
|
||||||
|
if (isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
currentTab().toggleFloating();
|
||||||
|
layoutVersion++;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void createPane() {
|
||||||
|
if (isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
currentTab().createPane();
|
||||||
|
layoutVersion++;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void nextFloatingPane() {
|
||||||
|
if (isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
currentTab().nextFloatingPane();
|
||||||
|
layoutVersion++;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void closeActivePane() {
|
||||||
|
if (isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
layoutVersion++;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void newTab() {
|
||||||
|
tabs.add(new Tab(config, metrics));
|
||||||
|
currentTabIndex = tabs.size() - 1;
|
||||||
|
layoutVersion++;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void nextTab() {
|
||||||
|
if (tabs.size() > 1) {
|
||||||
|
currentTabIndex = (currentTabIndex + 1) % tabs.size();
|
||||||
|
layoutVersion++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void previousTab() {
|
||||||
|
if (tabs.size() > 1) {
|
||||||
|
currentTabIndex = (currentTabIndex - 1 + tabs.size()) % tabs.size();
|
||||||
|
layoutVersion++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void close() {
|
||||||
|
for (Tab tab : tabs) {
|
||||||
|
tab.close();
|
||||||
|
}
|
||||||
|
tabs.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Tab currentTab() {
|
||||||
|
return tabs.get(currentTabIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<TerminalPane> currentPanes() {
|
||||||
|
return tabs.isEmpty() ? List.of() : currentTab().panes();
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isActive(TerminalPane pane) {
|
||||||
|
return !tabs.isEmpty() && currentTab().isActive(pane);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void focus(TerminalPane pane) {
|
||||||
|
if (!tabs.isEmpty() && currentTab().focus(pane)) {
|
||||||
|
layoutVersion++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Rendering ------------------------------------------------------------------
|
||||||
|
|
||||||
|
public void render() {
|
||||||
|
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;
|
||||||
|
|
||||||
|
if (layoutChanged) {
|
||||||
|
return FrameType.LAYOUT;
|
||||||
|
}
|
||||||
|
if (contentChanged) {
|
||||||
|
return FrameType.CONTENT;
|
||||||
|
}
|
||||||
|
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() {
|
||||||
|
double topInset = tabs.size() > 1 ? TAB_BAR_HEIGHT : 0.0;
|
||||||
|
if (!tabs.isEmpty()) {
|
||||||
|
currentTab().layout(canvas.getWidth(), canvas.getHeight(), topInset);
|
||||||
|
}
|
||||||
|
List<TerminalPane> panes = currentPanes();
|
||||||
|
// Sync each pane's ghostty grid to its (possibly new) bounds; a no-op when unchanged.
|
||||||
|
for (TerminalPane pane : panes) {
|
||||||
|
pane.fitToBounds();
|
||||||
|
}
|
||||||
|
|
||||||
|
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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Repaint just the panes whose content changed, directly on the retained canvas. Each pane
|
||||||
|
// clips itself to its rect minus the panes above it, so a lower pane's repaint can't bleed
|
||||||
|
// over one stacked on top — no restore pass needed. Bounds and grids can't have changed
|
||||||
|
// without a layout frame, so a content frame reuses the existing layout untouched.
|
||||||
|
private void renderContentFrame() {
|
||||||
|
List<TerminalPane> panes = currentPanes();
|
||||||
|
GraphicsContext gc = beginFrame();
|
||||||
|
|
||||||
|
for (TerminalPane pane : panes) {
|
||||||
|
Long drawn = paneContentVersion.get(pane);
|
||||||
|
if (drawn != null && drawn == pane.contentVersion()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
paneContentVersion.put(pane, pane.paintIncremental(gc, isActive(pane)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private GraphicsContext beginFrame() {
|
||||||
|
GraphicsContext gc = canvas.getGraphicsContext2D();
|
||||||
|
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
|
||||||
|
// 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);
|
||||||
|
|
||||||
|
double gap = 1.0;
|
||||||
|
double segmentWidth = width / count;
|
||||||
|
for (int i = 0; i < count; i++) {
|
||||||
|
double x = i * segmentWidth;
|
||||||
|
boolean current = i == currentTabIndex;
|
||||||
|
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(MouseEvent event) {
|
||||||
|
canvas.requestFocus();
|
||||||
|
TerminalPane pane = paneAt(event.getX(), event.getY());
|
||||||
|
if (pane == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
focus(pane);
|
||||||
|
pressedButton = mouseButton(event);
|
||||||
|
mouseButtonPressed = true;
|
||||||
|
MouseTarget target = mouseTarget(pane);
|
||||||
|
if (target == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
send(pane, target, MouseInput.press(pressedButton, localX(event.getX(), pane, target), localY(event.getY(), pane, target), modifiers(event)), true, 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(), pane, target), localY(event.getY(), pane, target), modifiers(event)), false, event);
|
||||||
|
}
|
||||||
|
mouseButtonPressed = false;
|
||||||
|
pressedButton = MouseButton.UNKNOWN;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleMouseDragged(MouseEvent event) {
|
||||||
|
TerminalPane pane = paneAt(event.getX(), event.getY());
|
||||||
|
if (pane == null) {
|
||||||
|
pane = 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(), pane, target), localY(event.getY(), pane, target), modifiers(event)), true, 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(), pane, target), localY(event.getY(), pane, target), modifiers(event)), mouseButtonPressed, event);
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseButton wheelButton = direction > 0 ? MouseButton.FOUR : MouseButton.FIVE;
|
||||||
|
int rows = scrollRows(event);
|
||||||
|
MouseTarget target = mouseTarget(pane);
|
||||||
|
boolean sent = false;
|
||||||
|
if (target != null) {
|
||||||
|
// 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)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
sent = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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) {
|
||||||
|
event.consume();
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
int columns = metrics.columnsFor(pane.width());
|
||||||
|
int rows = metrics.rowsFor(pane.height());
|
||||||
|
long cellWidth = Math.max(1L, Math.round(metrics.cellWidth()));
|
||||||
|
long cellHeight = Math.max(1L, Math.round(metrics.lineHeight()));
|
||||||
|
long screenWidth = Math.max(1L, Math.round(columns * metrics.cellWidth()));
|
||||||
|
long screenHeight = Math.max(1L, Math.round(rows * metrics.lineHeight()));
|
||||||
|
return new MouseTarget(MouseEncoderSize.of(screenWidth, screenHeight, cellWidth, cellHeight), screenWidth, screenHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 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) {
|
||||||
|
return Math.max(min, Math.min(max, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static KeyModifiers modifiers(MouseEvent event) {
|
||||||
|
return KeyModifiers.of(event.isShiftDown(), event.isControlDown(), event.isAltDown(), event.isMetaDown());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static KeyModifiers modifiers(ScrollEvent event) {
|
||||||
|
return KeyModifiers.of(event.isShiftDown(), event.isControlDown(), event.isAltDown(), event.isMetaDown());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int scrollRows(ScrollEvent event) {
|
||||||
|
double rows;
|
||||||
|
if (event.getTextDeltaYUnits() == VerticalTextScrollUnits.LINES && event.getTextDeltaY() != 0.0) {
|
||||||
|
rows = Math.abs(event.getTextDeltaY());
|
||||||
|
} else if (event.getTextDeltaYUnits() == VerticalTextScrollUnits.PAGES && event.getTextDeltaY() != 0.0) {
|
||||||
|
rows = Math.abs(event.getTextDeltaY()) * 24.0;
|
||||||
|
} else if (event.getMultiplierY() > 0.0) {
|
||||||
|
rows = Math.abs(event.getDeltaY()) / event.getMultiplierY();
|
||||||
|
} else {
|
||||||
|
rows = Math.abs(event.getDeltaY()) / 40.0;
|
||||||
|
}
|
||||||
|
return Math.max(1, Math.min(64, (int) Math.ceil(rows)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int scrollDirection(ScrollEvent event) {
|
||||||
|
if (event.getDeltaY() != 0.0) {
|
||||||
|
return event.getDeltaY() > 0.0 ? 1 : -1;
|
||||||
|
}
|
||||||
|
if (event.getTextDeltaYUnits() != VerticalTextScrollUnits.NONE && event.getTextDeltaY() != 0.0) {
|
||||||
|
return event.getTextDeltaY() > 0.0 ? 1 : -1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MouseButton mouseButton(MouseEvent event) {
|
||||||
|
return switch (event.getButton()) {
|
||||||
|
case PRIMARY -> MouseButton.LEFT;
|
||||||
|
case SECONDARY -> MouseButton.RIGHT;
|
||||||
|
case MIDDLE -> MouseButton.MIDDLE;
|
||||||
|
default -> MouseButton.UNKNOWN;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
}
|
||||||
|
}
|
||||||
1384
src/main/java/com/gregor/jprototerm/GhosttyTerminalRenderer.java
Normal file
1384
src/main/java/com/gregor/jprototerm/GhosttyTerminalRenderer.java
Normal file
File diff suppressed because it is too large
Load Diff
@@ -26,7 +26,7 @@ final class KeyEncoder {
|
|||||||
return switch (code) {
|
return switch (code) {
|
||||||
case ENTER -> "\r";
|
case ENTER -> "\r";
|
||||||
case BACK_SPACE -> "\u007f";
|
case BACK_SPACE -> "\u007f";
|
||||||
case TAB -> "\t";
|
case TAB -> event.isShiftDown() ? "\u001b[Z" : "\t";
|
||||||
case ESCAPE -> "\u001b";
|
case ESCAPE -> "\u001b";
|
||||||
case UP -> "\u001b[A";
|
case UP -> "\u001b[A";
|
||||||
case DOWN -> "\u001b[B";
|
case DOWN -> "\u001b[B";
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ public final class LinuxPty implements AutoCloseable {
|
|||||||
|
|
||||||
private final Arena arena = Arena.ofShared();
|
private final Arena arena = Arena.ofShared();
|
||||||
private final MemorySegment readBuffer = arena.allocate(65536);
|
private final MemorySegment readBuffer = arena.allocate(65536);
|
||||||
|
private final MemorySegment writeBuffer = arena.allocate(65536);
|
||||||
private final Object writeLock = new Object();
|
private final Object writeLock = new Object();
|
||||||
private final int masterFd;
|
private final int masterFd;
|
||||||
private final int pid;
|
private final int pid;
|
||||||
@@ -186,17 +187,20 @@ public final class LinuxPty implements AutoCloseable {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
synchronized (writeLock) {
|
synchronized (writeLock) {
|
||||||
try (Arena a = Arena.ofConfined()) {
|
int offset = 0;
|
||||||
MemorySegment buf = a.allocate(data.length);
|
while (offset < data.length) {
|
||||||
MemorySegment.copy(data, 0, buf, ValueLayout.JAVA_BYTE, 0, data.length);
|
int chunk = (int) Math.min(writeBuffer.byteSize(), data.length - offset);
|
||||||
long offset = 0;
|
MemorySegment.copy(data, offset, writeBuffer, ValueLayout.JAVA_BYTE, 0, chunk);
|
||||||
while (offset < data.length) {
|
|
||||||
long n = callLong(WRITE, masterFd, buf.asSlice(offset), data.length - offset);
|
long written = 0;
|
||||||
if (n < 0) {
|
while (written < chunk) {
|
||||||
|
long n = callLong(WRITE, masterFd, writeBuffer.asSlice(written), chunk - written);
|
||||||
|
if (n <= 0) {
|
||||||
throw new IllegalStateException("write to pty failed");
|
throw new IllegalStateException("write to pty failed");
|
||||||
}
|
}
|
||||||
offset += n;
|
written += n;
|
||||||
}
|
}
|
||||||
|
offset += chunk;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,20 +21,20 @@ import java.nio.file.Files;
|
|||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
|
|
||||||
public final class Main extends Application {
|
public final class Main extends Application {
|
||||||
private TerminalWorkspace workspace;
|
private Compositor compositor;
|
||||||
private TerminalCanvasView terminalView;
|
private TerminalMetrics metrics;
|
||||||
private AppConfig config;
|
private AppConfig config;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void start(Stage stage) {
|
public void start(Stage stage) {
|
||||||
config = AppConfig.load();
|
config = AppConfig.load();
|
||||||
|
|
||||||
workspace = new TerminalWorkspace(config);
|
metrics = new TerminalMetrics(config.fontFamily(), config.fontSize());
|
||||||
terminalView = new TerminalCanvasView(workspace, config);
|
compositor = new Compositor(config, metrics);
|
||||||
|
|
||||||
StackPane root = new StackPane(terminalView.canvas());
|
StackPane root = new StackPane(compositor.canvas());
|
||||||
terminalView.canvas().widthProperty().bind(root.widthProperty());
|
compositor.canvas().widthProperty().bind(root.widthProperty());
|
||||||
terminalView.canvas().heightProperty().bind(root.heightProperty());
|
compositor.canvas().heightProperty().bind(root.heightProperty());
|
||||||
|
|
||||||
Scene scene = new Scene(root, config.windowWidth(), config.windowHeight());
|
Scene scene = new Scene(root, config.windowWidth(), config.windowHeight());
|
||||||
scene.addEventFilter(KeyEvent.KEY_PRESSED, this::handlePressed);
|
scene.addEventFilter(KeyEvent.KEY_PRESSED, this::handlePressed);
|
||||||
@@ -43,57 +43,57 @@ public final class Main extends Application {
|
|||||||
new AnimationTimer() {
|
new AnimationTimer() {
|
||||||
@Override
|
@Override
|
||||||
public void handle(long now) {
|
public void handle(long now) {
|
||||||
terminalView.render();
|
compositor.render();
|
||||||
}
|
}
|
||||||
}.start();
|
}.start();
|
||||||
|
|
||||||
stage.setTitle("jprototerm");
|
stage.setTitle("jprototerm");
|
||||||
stage.setScene(scene);
|
stage.setScene(scene);
|
||||||
stage.setOnCloseRequest(event -> {
|
stage.setOnCloseRequest(event -> {
|
||||||
workspace.close();
|
compositor.close();
|
||||||
});
|
});
|
||||||
stage.show();
|
stage.show();
|
||||||
terminalView.canvas().requestFocus();
|
compositor.canvas().requestFocus();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void handlePressed(KeyEvent event) {
|
private void handlePressed(KeyEvent event) {
|
||||||
if (config.keybindings().get("navigate_left").matches(event)) {
|
if (config.keybindings().get("navigate_left").matches(event)) {
|
||||||
workspace.navigate(Direction.LEFT);
|
compositor.navigate(Direction.LEFT);
|
||||||
event.consume();
|
event.consume();
|
||||||
} else if (config.keybindings().get("navigate_down").matches(event)) {
|
} else if (config.keybindings().get("navigate_down").matches(event)) {
|
||||||
workspace.navigate(Direction.DOWN);
|
compositor.navigate(Direction.DOWN);
|
||||||
event.consume();
|
event.consume();
|
||||||
} else if (config.keybindings().get("navigate_up").matches(event)) {
|
} else if (config.keybindings().get("navigate_up").matches(event)) {
|
||||||
workspace.navigate(Direction.UP);
|
compositor.navigate(Direction.UP);
|
||||||
event.consume();
|
event.consume();
|
||||||
} else if (config.keybindings().get("navigate_right").matches(event)) {
|
} else if (config.keybindings().get("navigate_right").matches(event)) {
|
||||||
workspace.navigate(Direction.RIGHT);
|
compositor.navigate(Direction.RIGHT);
|
||||||
event.consume();
|
event.consume();
|
||||||
} else if (config.keybindings().get("toggle_floating").matches(event)) {
|
} else if (config.keybindings().get("toggle_floating").matches(event)) {
|
||||||
workspace.toggleFloating();
|
compositor.toggleFloating();
|
||||||
event.consume();
|
event.consume();
|
||||||
} else if (config.keybindings().get("new_pane").matches(event)) {
|
} else if (config.keybindings().get("new_pane").matches(event)) {
|
||||||
workspace.createPane();
|
compositor.createPane();
|
||||||
event.consume();
|
event.consume();
|
||||||
} else if (config.keybindings().get("next_floating").matches(event)) {
|
} else if (config.keybindings().get("next_floating").matches(event)) {
|
||||||
workspace.nextFloatingPane();
|
compositor.nextFloatingPane();
|
||||||
event.consume();
|
event.consume();
|
||||||
} else if (config.keybindings().get("close_pane").matches(event)) {
|
} else if (config.keybindings().get("close_pane").matches(event)) {
|
||||||
workspace.closeActivePane();
|
compositor.closeActivePane();
|
||||||
event.consume();
|
event.consume();
|
||||||
if (workspace.isEmpty()) {
|
if (compositor.isEmpty()) {
|
||||||
// Closing the last pane quits the app.
|
// Closing the last pane quits the app.
|
||||||
workspace.close();
|
compositor.close();
|
||||||
Platform.exit();
|
Platform.exit();
|
||||||
}
|
}
|
||||||
} else if (config.keybindings().get("new_tab").matches(event)) {
|
} else if (config.keybindings().get("new_tab").matches(event)) {
|
||||||
workspace.newTab();
|
compositor.newTab();
|
||||||
event.consume();
|
event.consume();
|
||||||
} else if (config.keybindings().get("previous_tab").matches(event)) {
|
} else if (config.keybindings().get("previous_tab").matches(event)) {
|
||||||
workspace.previousTab();
|
compositor.previousTab();
|
||||||
event.consume();
|
event.consume();
|
||||||
} else if (config.keybindings().get("next_tab").matches(event)) {
|
} else if (config.keybindings().get("next_tab").matches(event)) {
|
||||||
workspace.nextTab();
|
compositor.nextTab();
|
||||||
event.consume();
|
event.consume();
|
||||||
} else if (config.keybindings().get("open_font_selector").matches(event)) {
|
} else if (config.keybindings().get("open_font_selector").matches(event)) {
|
||||||
openFontSelector();
|
openFontSelector();
|
||||||
@@ -104,7 +104,7 @@ public final class Main extends Application {
|
|||||||
} else {
|
} else {
|
||||||
String encoded = KeyEncoder.encode(event);
|
String encoded = KeyEncoder.encode(event);
|
||||||
if (encoded != null) {
|
if (encoded != null) {
|
||||||
workspace.activePane().send(encoded);
|
compositor.activePane().send(encoded);
|
||||||
event.consume();
|
event.consume();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -117,7 +117,7 @@ public final class Main extends Application {
|
|||||||
|
|
||||||
String text = event.getCharacter();
|
String text = event.getCharacter();
|
||||||
if (text != null && !text.isEmpty() && text.charAt(0) >= 0x20 && text.charAt(0) != 0x7f) {
|
if (text != null && !text.isEmpty() && text.charAt(0) >= 0x20 && text.charAt(0) != 0x7f) {
|
||||||
workspace.activePane().send(text);
|
compositor.activePane().send(text);
|
||||||
event.consume();
|
event.consume();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -160,18 +160,18 @@ public final class Main extends Application {
|
|||||||
double selectedSize = size.getValue();
|
double selectedSize = size.getValue();
|
||||||
config = config.withFont(selectedFamily.trim(), selectedSize);
|
config = config.withFont(selectedFamily.trim(), selectedSize);
|
||||||
config.save();
|
config.save();
|
||||||
terminalView.setFont(config.fontFamily(), config.fontSize());
|
compositor.setFont(config.fontFamily(), config.fontSize());
|
||||||
terminalView.canvas().requestFocus();
|
compositor.canvas().requestFocus();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private void openScrollbackInEditor() {
|
private void openScrollbackInEditor() {
|
||||||
try {
|
try {
|
||||||
Path file = Files.createTempFile("jprototerm-scrollback-", ".txt");
|
Path file = Files.createTempFile("jprototerm-scrollback-", ".txt");
|
||||||
Files.writeString(file, workspace.activePane().scrollbackText());
|
Files.writeString(file, compositor.activePane().scrollbackText());
|
||||||
file.toFile().deleteOnExit();
|
file.toFile().deleteOnExit();
|
||||||
|
|
||||||
workspace.activePane().send(scrollbackEditorCommand(file) + "\r");
|
compositor.activePane().send(scrollbackEditorCommand(file) + "\r");
|
||||||
} catch (IOException ex) {
|
} catch (IOException ex) {
|
||||||
System.err.println("Could not open scrollback in editor: " + ex.getMessage());
|
System.err.println("Could not open scrollback in editor: " + ex.getMessage());
|
||||||
}
|
}
|
||||||
|
|||||||
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,262 +1,277 @@
|
|||||||
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;
|
||||||
|
import java.util.concurrent.atomic.AtomicLong;
|
||||||
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One tab: an isolated stack of panes (tiled + floating) with its own active pane and
|
* One tab: a row of tiled panes with a group of floating panes shown over them. Floating panes
|
||||||
* stashed-floating state. {@link TerminalWorkspace} owns the list of tabs and renders only
|
* are shown/hidden as a group ({@code floatingVisible}), and there is always at least one tiled
|
||||||
* the current one. Mutating methods return whether they actually changed anything so the
|
* pane — a floating pane is promoted if the last tiled one closes — so the layout always has a
|
||||||
* workspace can bump its render version conditionally.
|
* base. The {@link Compositor} owns the tabs and renders only the current one; mutating methods
|
||||||
|
* return whether they actually changed anything so it can bump its layout version.
|
||||||
*/
|
*/
|
||||||
final class Tab implements AutoCloseable {
|
final class Tab implements AutoCloseable {
|
||||||
private final AppConfig config;
|
private final AppConfig config;
|
||||||
private final List<TerminalPane> panes = new ArrayList<>();
|
private final TerminalMetrics metrics;
|
||||||
private int activeIndex;
|
private final List<TerminalPane> tiled = new ArrayList<>();
|
||||||
private int hiddenFloatingFocusIndex = -1;
|
private final List<TerminalPane> floating = new ArrayList<>();
|
||||||
|
private boolean floatingVisible;
|
||||||
|
private TerminalPane active;
|
||||||
|
// The floating pane to re-focus when the group is shown again, and to prefer when promoting
|
||||||
|
// after the last tiled pane closes.
|
||||||
private TerminalPane lastFocusedFloating;
|
private TerminalPane lastFocusedFloating;
|
||||||
|
// Last laid-out size, so a newly opened pane can be created at roughly its eventual rect
|
||||||
|
// (and thus grid). Seeded from the configured window size for the first pane, which is
|
||||||
|
// opened before any layout pass runs.
|
||||||
|
private double lastWidth;
|
||||||
|
private double lastHeight;
|
||||||
|
private double lastTopInset;
|
||||||
|
// Bumped whenever one of this tab's panes changes content; the compositor reads the current
|
||||||
|
// tab's value each frame as an O(1) "anything to repaint?" check.
|
||||||
|
private final AtomicLong contentVersion = new AtomicLong();
|
||||||
|
|
||||||
Tab(AppConfig config) {
|
Tab(AppConfig config, TerminalMetrics metrics) {
|
||||||
this.config = config;
|
this.config = config;
|
||||||
panes.add(openPane(false));
|
this.metrics = metrics;
|
||||||
|
this.lastWidth = config.windowWidth();
|
||||||
|
this.lastHeight = config.windowHeight();
|
||||||
|
TerminalPane first = openPane(false);
|
||||||
|
tiled.add(first);
|
||||||
|
active = first;
|
||||||
}
|
}
|
||||||
|
|
||||||
TerminalPane activePane() {
|
TerminalPane activePane() {
|
||||||
return panes.get(activeIndex);
|
return active;
|
||||||
}
|
}
|
||||||
|
|
||||||
boolean isEmpty() {
|
boolean isEmpty() {
|
||||||
return panes.isEmpty();
|
return tiled.isEmpty() && floating.isEmpty();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
long contentVersion() {
|
||||||
|
return contentVersion.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Panes to composite, bottom-to-top: tiled first, then (when shown) the floating group with
|
||||||
|
* the active floating pane on top.
|
||||||
|
*/
|
||||||
List<TerminalPane> panes() {
|
List<TerminalPane> panes() {
|
||||||
if (panes.isEmpty()) {
|
if (!floatingVisible || floating.isEmpty()) {
|
||||||
return List.of();
|
return List.copyOf(tiled);
|
||||||
}
|
}
|
||||||
List<TerminalPane> visible = panes.stream().filter(TerminalPane::visible).toList();
|
List<TerminalPane> ordered = new ArrayList<>(tiled.size() + floating.size());
|
||||||
if (visible.isEmpty()) {
|
ordered.addAll(tiled);
|
||||||
return List.of();
|
for (TerminalPane pane : floating) {
|
||||||
}
|
if (pane != active) {
|
||||||
// Draw order = z-order: all tiled panes first (they never overlap), then floating
|
|
||||||
// panes on top, with the active floating pane last (topmost). This holds regardless
|
|
||||||
// of creation order, so a tiled pane created after a floating one still sits behind.
|
|
||||||
TerminalPane active = activePane();
|
|
||||||
List<TerminalPane> ordered = new ArrayList<>(visible.size());
|
|
||||||
for (TerminalPane pane : visible) {
|
|
||||||
if (!pane.floating()) {
|
|
||||||
ordered.add(pane);
|
ordered.add(pane);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (TerminalPane pane : visible) {
|
if (floating.contains(active)) {
|
||||||
if (pane.floating() && pane != active) {
|
ordered.add(active); // active floating pane on top
|
||||||
ordered.add(pane);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (active.visible() && active.floating()) {
|
|
||||||
ordered.add(active);
|
|
||||||
}
|
}
|
||||||
return List.copyOf(ordered);
|
return List.copyOf(ordered);
|
||||||
}
|
}
|
||||||
|
|
||||||
boolean isActive(TerminalPane pane) {
|
boolean isActive(TerminalPane pane) {
|
||||||
return !panes.isEmpty() && activePane() == pane;
|
return pane != null && pane == active;
|
||||||
}
|
}
|
||||||
|
|
||||||
boolean focus(TerminalPane pane) {
|
boolean focus(TerminalPane pane) {
|
||||||
int index = panes.indexOf(pane);
|
if (pane == active || !isFocusable(pane)) {
|
||||||
if (index >= 0 && pane.visible() && activeIndex != index) {
|
return false;
|
||||||
setActive(index);
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
return false;
|
setActive(pane);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
void layout(double width, double height, double topInset) {
|
void layout(double width, double height, double topInset) {
|
||||||
|
this.lastWidth = width;
|
||||||
|
this.lastHeight = height;
|
||||||
|
this.lastTopInset = topInset;
|
||||||
double availHeight = height - topInset;
|
double availHeight = height - topInset;
|
||||||
List<TerminalPane> tiled = panes.stream()
|
|
||||||
.filter(TerminalPane::visible)
|
double tileWidth = width / Math.max(1, tiled.size());
|
||||||
.filter(pane -> !pane.floating())
|
|
||||||
.toList();
|
|
||||||
int tileCount = Math.max(1, tiled.size());
|
|
||||||
double tileWidth = width / tileCount;
|
|
||||||
for (int i = 0; i < tiled.size(); i++) {
|
for (int i = 0; i < tiled.size(); i++) {
|
||||||
tiled.get(i).bounds(i * tileWidth, topInset, tileWidth, availHeight);
|
tiled.get(i).bounds(i * tileWidth, topInset, tileWidth, availHeight);
|
||||||
}
|
}
|
||||||
|
|
||||||
List<TerminalPane> floating = panes.stream()
|
double floatingWidth = Math.max(420, width * 0.58);
|
||||||
.filter(TerminalPane::visible)
|
double floatingHeight = Math.max(260, availHeight * 0.58);
|
||||||
.filter(TerminalPane::floating)
|
|
||||||
.toList();
|
|
||||||
for (int i = 0; i < floating.size(); i++) {
|
for (int i = 0; i < floating.size(); i++) {
|
||||||
TerminalPane pane = floating.get(i);
|
|
||||||
double floatingWidth = Math.max(420, width * 0.58);
|
|
||||||
double floatingHeight = Math.max(260, availHeight * 0.58);
|
|
||||||
double offset = i * 28.0;
|
double offset = i * 28.0;
|
||||||
pane.bounds(
|
floating.get(i).bounds(
|
||||||
Math.min(width - floatingWidth - 12.0, ((width - floatingWidth) / 2.0) + offset),
|
Math.min(width - floatingWidth - 12.0, ((width - floatingWidth) / 2.0) + offset),
|
||||||
Math.min(height - floatingHeight - 12.0, topInset + ((availHeight - floatingHeight) / 2.0) + offset),
|
Math.min(height - floatingHeight - 12.0, topInset + ((availHeight - floatingHeight) / 2.0) + offset),
|
||||||
floatingWidth,
|
floatingWidth,
|
||||||
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));
|
||||||
|
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) {
|
||||||
TerminalPane current = activePane();
|
if (floating.contains(active) && navigateFloatingStack(direction)) {
|
||||||
if (current.floating() && navigateFloatingStack(direction)) {
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
TerminalPane target = focusable()
|
||||||
TerminalPane target = panes.stream()
|
.filter(pane -> pane != active)
|
||||||
.filter(TerminalPane::visible)
|
.filter(pane -> directionFilter(direction, active, pane))
|
||||||
.filter(pane -> pane != current)
|
.min(Comparator.comparingDouble(pane -> distance(active, pane)))
|
||||||
.filter(pane -> directionFilter(direction, current, pane))
|
|
||||||
.min(Comparator.comparingDouble(pane -> distance(current, pane)))
|
|
||||||
.orElse(null);
|
.orElse(null);
|
||||||
if (target != null) {
|
if (target != null) {
|
||||||
setActive(panes.indexOf(target));
|
setActive(target);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
void toggleFloating() {
|
void toggleFloating() {
|
||||||
List<TerminalPane> floating = panes.stream()
|
|
||||||
.filter(TerminalPane::floating)
|
|
||||||
.toList();
|
|
||||||
if (floating.isEmpty()) {
|
if (floating.isEmpty()) {
|
||||||
createFloatingPane();
|
createFloatingPane();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (floatingVisible) {
|
||||||
boolean anyVisible = floating.stream().anyMatch(TerminalPane::visible);
|
floatingVisible = false;
|
||||||
if (anyVisible) {
|
if (floating.contains(active)) {
|
||||||
TerminalPane active = activePane();
|
setActive(tiled.get(0));
|
||||||
hiddenFloatingFocusIndex = active.floating() ? activeIndex : firstVisibleFloatingIndex();
|
}
|
||||||
floating.forEach(pane -> pane.setVisible(false));
|
|
||||||
setActive(firstVisibleNonFloatingIndex());
|
|
||||||
} else {
|
} else {
|
||||||
floating.forEach(pane -> pane.setVisible(true));
|
floatingVisible = true;
|
||||||
setActive(visibleIndexOrFallback(hiddenFloatingFocusIndex, panes.indexOf(floating.get(floating.size() - 1))));
|
setActive(floating.contains(lastFocusedFloating) ? lastFocusedFloating : floating.get(floating.size() - 1));
|
||||||
hiddenFloatingFocusIndex = -1;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Adds a floating pane while the floating group is shown, otherwise a tiled pane. */
|
||||||
* "New pane": adds a floating pane while floating panes are shown, otherwise adds a
|
|
||||||
* tiled pane (the tiled row is redistributed equally by the layout).
|
|
||||||
*/
|
|
||||||
void createPane() {
|
void createPane() {
|
||||||
if (anyFloatingVisible()) {
|
if (floatingVisible) {
|
||||||
createFloatingPane();
|
createFloatingPane();
|
||||||
} else {
|
} else {
|
||||||
TerminalPane pane = openPane(false);
|
TerminalPane pane = openPane(false);
|
||||||
panes.add(pane);
|
tiled.add(pane);
|
||||||
setActive(panes.size() - 1);
|
setActive(pane);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void nextFloatingPane() {
|
void nextFloatingPane() {
|
||||||
TerminalPane next = nextFloatingAfter(activeIndex);
|
if (floating.isEmpty()) {
|
||||||
next.setVisible(true);
|
createFloatingPane();
|
||||||
setActive(panes.indexOf(next));
|
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() {
|
void closeActivePane() {
|
||||||
TerminalPane active = activePane();
|
TerminalPane closing = active;
|
||||||
int removed = activeIndex;
|
boolean wasFloating = floating.remove(closing);
|
||||||
// When closing a floating pane, focus the next visible floating pane if there is one
|
if (!wasFloating) {
|
||||||
// (don't jump to a tiled pane); otherwise fall back to the nearest visible pane.
|
tiled.remove(closing);
|
||||||
int target = active.floating() ? nearestVisibleFloatingIndex(removed) : -1;
|
|
||||||
if (target < 0) {
|
|
||||||
target = previousVisibleIndex(removed);
|
|
||||||
}
|
}
|
||||||
panes.remove(removed);
|
if (closing == lastFocusedFloating) {
|
||||||
if (active == lastFocusedFloating) {
|
|
||||||
lastFocusedFloating = null;
|
lastFocusedFloating = null;
|
||||||
}
|
}
|
||||||
active.close();
|
closing.close();
|
||||||
if (panes.isEmpty()) {
|
|
||||||
activeIndex = 0;
|
if (tiled.isEmpty() && floating.isEmpty()) {
|
||||||
|
active = null; // tab is now empty; the compositor drops it
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
activeIndex = adjustIndexAfterRemoval(target, removed);
|
|
||||||
hiddenFloatingFocusIndex = adjustHiddenFocusAfterRemoval(hiddenFloatingFocusIndex, removed);
|
|
||||||
|
|
||||||
// If the last tiled (main) pane was closed, promote a floating pane to be the new
|
// Always keep a tiled base: if the last tiled pane just closed, promote a floating one
|
||||||
// main pane so the layout has a base and rendering continues normally. Prefer the
|
// (preferring the last focused).
|
||||||
// most recently focused floating pane.
|
if (tiled.isEmpty()) {
|
||||||
if (panes.stream().noneMatch(pane -> !pane.floating())) {
|
TerminalPane promote = floating.contains(lastFocusedFloating) ? lastFocusedFloating : floating.get(0);
|
||||||
TerminalPane promote = (lastFocusedFloating != null && panes.contains(lastFocusedFloating))
|
var promoteIndex = floating.indexOf(promote);
|
||||||
? lastFocusedFloating
|
var nextFocussed = promoteIndex == 0 ? 0 : promoteIndex - 1;
|
||||||
: panes.get(activeIndex);
|
floating.remove(promote);
|
||||||
promote.setFloating(false);
|
tiled.add(promote);
|
||||||
promote.setVisible(true);
|
if (promote == lastFocusedFloating) {
|
||||||
activeIndex = panes.indexOf(promote);
|
lastFocusedFloating = null;
|
||||||
lastFocusedFloating = null;
|
if (!floating.isEmpty()) {
|
||||||
|
lastFocusedFloating = floating.isEmpty() ? null : floating.get(nextFocussed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (floating.isEmpty()) {
|
||||||
|
floatingVisible = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If only hidden panes remained, reveal the one we're focusing so the screen isn't
|
setActive(wasFloating && floatingVisible ? floating.get(floating.size() - 1) : tiled.get(0));
|
||||||
// blank.
|
|
||||||
if (!panes.get(activeIndex).visible()) {
|
|
||||||
panes.get(activeIndex).setVisible(true);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void setActive(int index) {
|
private void setActive(TerminalPane pane) {
|
||||||
activeIndex = index;
|
active = pane;
|
||||||
if (index >= 0 && index < panes.size() && panes.get(index).floating()) {
|
if (floating.contains(pane)) {
|
||||||
lastFocusedFloating = panes.get(index);
|
lastFocusedFloating = pane;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void createFloatingPane() {
|
private void createFloatingPane() {
|
||||||
TerminalPane pane = openPane(true);
|
TerminalPane pane = openPane(true);
|
||||||
panes.add(pane);
|
floating.add(pane);
|
||||||
setActive(panes.size() - 1);
|
floatingVisible = true;
|
||||||
}
|
setActive(pane);
|
||||||
|
|
||||||
private boolean anyFloatingVisible() {
|
|
||||||
return panes.stream().anyMatch(pane -> pane.floating() && pane.visible());
|
|
||||||
}
|
|
||||||
|
|
||||||
private TerminalPane nextFloatingAfter(int index) {
|
|
||||||
for (int i = index + 1; i < panes.size(); i++) {
|
|
||||||
TerminalPane pane = panes.get(i);
|
|
||||||
if (pane.floating()) {
|
|
||||||
return pane;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (int i = 0; i <= index && i < panes.size(); i++) {
|
|
||||||
TerminalPane pane = panes.get(i);
|
|
||||||
if (pane.floating()) {
|
|
||||||
return pane;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return createAndReturnFloatingPane();
|
|
||||||
}
|
|
||||||
|
|
||||||
private TerminalPane createAndReturnFloatingPane() {
|
|
||||||
TerminalPane pane = openPane(true);
|
|
||||||
panes.add(pane);
|
|
||||||
return pane;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean navigateFloatingStack(Direction direction) {
|
private boolean navigateFloatingStack(Direction direction) {
|
||||||
List<TerminalPane> floating = panes.stream()
|
|
||||||
.filter(TerminalPane::visible)
|
|
||||||
.filter(TerminalPane::floating)
|
|
||||||
.toList();
|
|
||||||
if (floating.size() < 2) {
|
if (floating.size() < 2) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
int current = floating.indexOf(active);
|
||||||
int current = floating.indexOf(activePane());
|
|
||||||
if (current < 0) {
|
if (current < 0) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
int next = switch (direction) {
|
int next = switch (direction) {
|
||||||
case LEFT, UP -> current - 1;
|
case LEFT, UP -> current - 1;
|
||||||
case DOWN, RIGHT -> current + 1;
|
case DOWN, RIGHT -> current + 1;
|
||||||
@@ -264,85 +279,35 @@ final class Tab implements AutoCloseable {
|
|||||||
if (next < 0 || next >= floating.size()) {
|
if (next < 0 || next >= floating.size()) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
setActive(floating.get(next));
|
||||||
setActive(panes.indexOf(floating.get(next)));
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private int firstVisibleFloatingIndex() {
|
private boolean isFocusable(TerminalPane pane) {
|
||||||
for (int i = 0; i < panes.size(); i++) {
|
return tiled.contains(pane) || (floatingVisible && floating.contains(pane));
|
||||||
TerminalPane pane = panes.get(i);
|
|
||||||
if (pane.visible() && pane.floating()) {
|
|
||||||
return i;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return -1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private int firstVisibleNonFloatingIndex() {
|
private Stream<TerminalPane> focusable() {
|
||||||
for (int i = 0; i < panes.size(); i++) {
|
return floatingVisible ? Stream.concat(tiled.stream(), floating.stream()) : tiled.stream();
|
||||||
TerminalPane pane = panes.get(i);
|
|
||||||
if (pane.visible() && !pane.floating()) {
|
|
||||||
return i;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private int nearestVisibleFloatingIndex(int index) {
|
private void markContentChanged() {
|
||||||
for (int i = index + 1; i < panes.size(); i++) {
|
contentVersion.incrementAndGet();
|
||||||
if (panes.get(i).visible() && panes.get(i).floating()) {
|
|
||||||
return i;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (int i = index - 1; i >= 0; i--) {
|
|
||||||
if (panes.get(i).visible() && panes.get(i).floating()) {
|
|
||||||
return i;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return -1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private int previousVisibleIndex(int index) {
|
private TerminalPane openPane(boolean asFloating) {
|
||||||
for (int i = index - 1; i >= 0; i--) {
|
double availHeight = lastHeight - lastTopInset;
|
||||||
if (panes.get(i).visible()) {
|
double widthPx;
|
||||||
return i;
|
double heightPx;
|
||||||
}
|
if (asFloating) {
|
||||||
|
widthPx = Math.max(420, lastWidth * 0.58);
|
||||||
|
heightPx = Math.max(260, availHeight * 0.58);
|
||||||
|
} else {
|
||||||
|
// A new tiled pane joins the row, so each gets 1/(n+1) of the width.
|
||||||
|
widthPx = lastWidth / (tiled.size() + 1);
|
||||||
|
heightPx = availHeight;
|
||||||
}
|
}
|
||||||
for (int i = index + 1; i < panes.size(); i++) {
|
return TerminalPane.create(config, metrics, this::markContentChanged, widthPx, heightPx);
|
||||||
if (panes.get(i).visible()) {
|
|
||||||
return i;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return firstVisibleNonFloatingIndex();
|
|
||||||
}
|
|
||||||
|
|
||||||
private int visibleIndexOrFallback(int index, int fallback) {
|
|
||||||
if (index >= 0 && index < panes.size() && panes.get(index).visible()) {
|
|
||||||
return index;
|
|
||||||
}
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static int adjustIndexAfterRemoval(int index, int removedIndex) {
|
|
||||||
if (index < 0) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
return index > removedIndex ? index - 1 : index;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static int adjustHiddenFocusAfterRemoval(int index, int removedIndex) {
|
|
||||||
if (index < 0 || index == removedIndex) {
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
return index > removedIndex ? index - 1 : index;
|
|
||||||
}
|
|
||||||
|
|
||||||
private TerminalPane openPane(boolean floating) {
|
|
||||||
TerminalPane pane = TerminalPane.create(config.columns(), config.rows(), config.maxScrollback());
|
|
||||||
pane.setFloating(floating);
|
|
||||||
pane.attach(ShellSession.start(config.shell(), config.envOverride(), pane, config.columns(), config.rows()));
|
|
||||||
return pane;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static boolean directionFilter(Direction direction, TerminalPane current, TerminalPane candidate) {
|
private static boolean directionFilter(Direction direction, TerminalPane current, TerminalPane candidate) {
|
||||||
@@ -367,9 +332,9 @@ final class Tab implements AutoCloseable {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void close() {
|
public void close() {
|
||||||
for (TerminalPane pane : panes) {
|
tiled.forEach(TerminalPane::close);
|
||||||
pane.close();
|
floating.forEach(TerminalPane::close);
|
||||||
}
|
tiled.clear();
|
||||||
panes.clear();
|
floating.clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
86
src/main/java/com/gregor/jprototerm/TerminalMetrics.java
Normal file
86
src/main/java/com/gregor/jprototerm/TerminalMetrics.java
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
package com.gregor.jprototerm;
|
||||||
|
|
||||||
|
import javafx.scene.text.Font;
|
||||||
|
import javafx.scene.text.Text;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cell geometry shared by the {@link Compositor} and every {@link TerminalPane}.
|
||||||
|
*
|
||||||
|
* <p>The nominal cell width/height come from measuring the font, but a grid can't use
|
||||||
|
* fractional cells, so the measured size is snapped to whole (logical) pixels here — that
|
||||||
|
* snapping is why the value isn't purely a property of the font. The compositor owns the
|
||||||
|
* single instance (it holds the canvas, which is the pixel context), hands it to panes so
|
||||||
|
* they can turn their rect into a column/row count themselves, and re-measures it on a font
|
||||||
|
* change so every pane observes the new geometry through the shared reference.
|
||||||
|
*/
|
||||||
|
public final class TerminalMetrics {
|
||||||
|
/** Inset, in pixels, between a pane's edge and its content on every side. */
|
||||||
|
public static final double PADDING = 12.0;
|
||||||
|
|
||||||
|
private String fontFamily;
|
||||||
|
private double fontSize;
|
||||||
|
private Font font;
|
||||||
|
private double cellWidth;
|
||||||
|
private double lineHeight;
|
||||||
|
private double baselineOffset;
|
||||||
|
|
||||||
|
public TerminalMetrics(String fontFamily, double fontSize) {
|
||||||
|
setFont(fontFamily, fontSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setFont(String fontFamily, double fontSize) {
|
||||||
|
this.fontFamily = fontFamily;
|
||||||
|
this.fontSize = fontSize;
|
||||||
|
this.font = Font.font(fontFamily, fontSize);
|
||||||
|
measure(font);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String fontFamily() {
|
||||||
|
return fontFamily;
|
||||||
|
}
|
||||||
|
|
||||||
|
public double fontSize() {
|
||||||
|
return fontSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Font font() {
|
||||||
|
return font;
|
||||||
|
}
|
||||||
|
|
||||||
|
public double cellWidth() {
|
||||||
|
return cellWidth;
|
||||||
|
}
|
||||||
|
|
||||||
|
public double lineHeight() {
|
||||||
|
return lineHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
public double baselineOffset() {
|
||||||
|
return baselineOffset;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Columns that fit in a pane of the given pixel width (after subtracting the padding). */
|
||||||
|
public int columnsFor(double widthPx) {
|
||||||
|
return Math.max(1, (int) ((widthPx - 2 * PADDING) / cellWidth));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rows that fit in a pane of the given pixel height (after subtracting the padding). */
|
||||||
|
public int rowsFor(double heightPx) {
|
||||||
|
return Math.max(1, (int) ((heightPx - 2 * PADDING) / lineHeight));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void measure(Font font) {
|
||||||
|
Text text = new Text("┃MgÅjy");
|
||||||
|
text.setFont(font);
|
||||||
|
// Snap the cell size to whole pixels so cells tile on integer boundaries. Fractional
|
||||||
|
// cell metrics put every cell edge on a sub-pixel position, leaving anti-aliased
|
||||||
|
// seams that show up as a faint grid behind the themed cell backgrounds. Rounding
|
||||||
|
// leaves a few pixels of unused space at the right/bottom edge, which is fine.
|
||||||
|
this.lineHeight = Math.max(1.0, Math.round(text.getLayoutBounds().getHeight()));
|
||||||
|
this.baselineOffset = -text.getLayoutBounds().getMinY();
|
||||||
|
|
||||||
|
Text cell = new Text("M");
|
||||||
|
cell.setFont(font);
|
||||||
|
this.cellWidth = Math.max(1.0, Math.round(cell.getLayoutBounds().getWidth()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,35 +1,47 @@
|
|||||||
package com.gregor.jprototerm;
|
package com.gregor.jprototerm;
|
||||||
|
|
||||||
|
import dev.jlibghostty.DeviceAttributes;
|
||||||
import dev.jlibghostty.Ghostty;
|
import dev.jlibghostty.Ghostty;
|
||||||
import dev.jlibghostty.KittyGraphics;
|
import dev.jlibghostty.KittyGraphics;
|
||||||
import dev.jlibghostty.MouseAction;
|
import dev.jlibghostty.MouseAction;
|
||||||
import dev.jlibghostty.MouseEncoder;
|
import dev.jlibghostty.MouseEncoder;
|
||||||
import dev.jlibghostty.MouseEncoderSize;
|
import dev.jlibghostty.MouseEncoderSize;
|
||||||
import dev.jlibghostty.MouseInput;
|
import dev.jlibghostty.MouseInput;
|
||||||
|
import dev.jlibghostty.RenderState;
|
||||||
import dev.jlibghostty.RenderStateSnapshot;
|
import dev.jlibghostty.RenderStateSnapshot;
|
||||||
import dev.jlibghostty.ScrollViewport;
|
import dev.jlibghostty.ScrollViewport;
|
||||||
import dev.jlibghostty.Terminal;
|
import dev.jlibghostty.Terminal;
|
||||||
import dev.jlibghostty.TerminalOptions;
|
import dev.jlibghostty.TerminalOptions;
|
||||||
import dev.jlibghostty.DeviceAttributes;
|
import javafx.scene.canvas.GraphicsContext;
|
||||||
|
import javafx.scene.shape.Shape;
|
||||||
|
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.concurrent.atomic.AtomicLong;
|
import java.util.concurrent.atomic.AtomicLong;
|
||||||
|
|
||||||
public final class TerminalPane implements AutoCloseable {
|
/**
|
||||||
// Monotonic across all panes, bumped on every content change. Lets the renderer detect
|
* One terminal: owns its ghostty {@link Terminal}, the {@link ShellSession}/pty driving it,
|
||||||
// "nothing changed" in O(1) without scanning panes or building a render key.
|
* and its on-screen geometry and grid. It does not draw itself — it is a {@link RenderTarget}
|
||||||
private static final AtomicLong RENDER_TICK = new AtomicLong();
|
* 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
|
||||||
public static long renderTick() {
|
* renderer; the compositor decides z-order and which rect each pane occupies.
|
||||||
return RENDER_TICK.get();
|
*/
|
||||||
}
|
public final class TerminalPane implements AutoCloseable, RenderTarget {
|
||||||
|
|
||||||
private final Terminal terminal;
|
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();
|
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 RenderStateSnapshot cachedSnapshot;
|
||||||
private ShellSession session;
|
private ShellSession session;
|
||||||
private boolean floating;
|
// Clip region for rendering (rect minus the panes covering this one), set at layout time;
|
||||||
private boolean visible = true;
|
// null means clip to the plain bounds. See RenderTarget#clip().
|
||||||
|
private Shape clip;
|
||||||
private double x;
|
private double x;
|
||||||
private double y;
|
private double y;
|
||||||
private double width;
|
private double width;
|
||||||
@@ -38,47 +50,40 @@ public final class TerminalPane implements AutoCloseable {
|
|||||||
private int rows;
|
private int rows;
|
||||||
private int pixelWidth;
|
private int pixelWidth;
|
||||||
private int pixelHeight;
|
private int pixelHeight;
|
||||||
// Bumped on the reader thread (terminal writes) and read on the FX thread (render loop),
|
private final AtomicLong contentVersion = new AtomicLong();
|
||||||
// so it must be volatile.
|
|
||||||
private volatile long renderVersion;
|
|
||||||
private long snapshotVersion = -1;
|
private long snapshotVersion = -1;
|
||||||
private volatile boolean closed;
|
|
||||||
|
|
||||||
private TerminalPane(Terminal terminal, int columns, int rows) {
|
private TerminalPane(Terminal terminal, TerminalMetrics metrics, boolean kittyEnabled,
|
||||||
|
Runnable onContentChange, TerminalRenderer renderer, int columns, int rows) {
|
||||||
this.terminal = terminal;
|
this.terminal = terminal;
|
||||||
|
this.metrics = metrics;
|
||||||
|
this.kittyEnabled = kittyEnabled;
|
||||||
|
this.onContentChange = onContentChange;
|
||||||
|
this.renderer = renderer;
|
||||||
this.columns = columns;
|
this.columns = columns;
|
||||||
this.rows = rows;
|
this.rows = rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static TerminalPane create(int columns, int rows, long maxScrollback) {
|
/**
|
||||||
Terminal terminal = Ghostty.open(new TerminalOptions(columns, rows, maxScrollback));
|
* Opens a pane sized to fit the given pixel rect: the shared cell metrics decide how many
|
||||||
|
* columns and rows fit, and that grid is handed to ghostty and the shell at start-up. A
|
||||||
|
* non-positive size falls back to the configured default grid (used before the first
|
||||||
|
* layout, when no rect is known yet). The pane owns the shell session it starts and runs
|
||||||
|
* {@code onContentChange} on every content change.
|
||||||
|
*/
|
||||||
|
public static TerminalPane create(AppConfig config, TerminalMetrics metrics, Runnable onContentChange, double widthPx, double heightPx) {
|
||||||
|
int columns = widthPx > 0 ? metrics.columnsFor(widthPx) : config.columns();
|
||||||
|
int rows = heightPx > 0 ? metrics.rowsFor(heightPx) : config.rows();
|
||||||
|
Terminal terminal = Ghostty.open(new TerminalOptions(columns, rows, config.maxScrollback()));
|
||||||
terminal.setDeviceAttributesProvider(DeviceAttributes::xtermCompatible);
|
terminal.setDeviceAttributesProvider(DeviceAttributes::xtermCompatible);
|
||||||
TerminalPane pane = new TerminalPane(terminal, columns, rows);
|
TerminalPane pane = new TerminalPane(terminal, metrics, config.kittyGraphics(), onContentChange,
|
||||||
|
new GhosttyTerminalRenderer(metrics), columns, rows);
|
||||||
pane.refresh();
|
pane.refresh();
|
||||||
|
pane.attach(ShellSession.start(config.shell(), config.envOverride(), pane, columns, rows));
|
||||||
return pane;
|
return pane;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void write(String text) {
|
private void attach(ShellSession session) {
|
||||||
synchronized (terminal) {
|
|
||||||
if (closed) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
terminal.write(text);
|
|
||||||
refresh();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void write(byte[] bytes) {
|
|
||||||
synchronized (terminal) {
|
|
||||||
if (closed) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
terminal.write(bytes);
|
|
||||||
refresh();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void attach(ShellSession session) {
|
|
||||||
this.session = session;
|
this.session = session;
|
||||||
terminal.setPtyWriter(bytes -> {
|
terminal.setPtyWriter(bytes -> {
|
||||||
ShellSession current = this.session;
|
ShellSession current = this.session;
|
||||||
@@ -89,6 +94,20 @@ public final class TerminalPane implements AutoCloseable {
|
|||||||
session.startReading(this);
|
session.startReading(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void write(String text) {
|
||||||
|
synchronized (terminal) {
|
||||||
|
terminal.write(text);
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void write(byte[] bytes) {
|
||||||
|
synchronized (terminal) {
|
||||||
|
terminal.write(bytes);
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public void send(String text) {
|
public void send(String text) {
|
||||||
scrollViewportToBottom();
|
scrollViewportToBottom();
|
||||||
if (session != null) {
|
if (session != null) {
|
||||||
@@ -122,7 +141,7 @@ public final class TerminalPane implements AutoCloseable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void scrollViewportToBottom() {
|
private void scrollViewportToBottom() {
|
||||||
synchronized (terminal) {
|
synchronized (terminal) {
|
||||||
terminal.scrollViewport(ScrollViewport.bottom());
|
terminal.scrollViewport(ScrollViewport.bottom());
|
||||||
refresh();
|
refresh();
|
||||||
@@ -130,16 +149,38 @@ public final class TerminalPane implements AutoCloseable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Full render snapshot of the current screen, memoised per content version (so a burst
|
* Incremental snapshot: cells are marshalled only for rows that changed since the last
|
||||||
* of writes between two frames yields one snapshot). Uses a throwaway render state per
|
* frame (global dirty == PARTIAL), reused across calls for the same content version.
|
||||||
* snapshot, which always returns the complete, correct screen — a persistent render
|
* Snapshotting is deferred here rather than done in refresh(), so a burst of writes
|
||||||
* state's per-row dirty tracking proved unreliable across resizes and screen clears.
|
* between two frames collapses into a single snapshot.
|
||||||
*/
|
*/
|
||||||
public RenderStateSnapshot renderSnapshot() {
|
@Override
|
||||||
|
public RenderStateSnapshot snapshot() {
|
||||||
|
return takeSnapshot(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full snapshot with every row's cells populated. Used where the whole pane is redrawn
|
||||||
|
* regardless of dirty state (the kitty-graphics path).
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public RenderStateSnapshot snapshotFull() {
|
||||||
|
return takeSnapshot(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private RenderStateSnapshot takeSnapshot(boolean full) {
|
||||||
synchronized (terminal) {
|
synchronized (terminal) {
|
||||||
if (snapshotVersion != renderVersion) {
|
long version = contentVersion.get();
|
||||||
cachedSnapshot = terminal.renderSnapshot();
|
if (full) {
|
||||||
snapshotVersion = renderVersion;
|
renderState.update(terminal);
|
||||||
|
cachedSnapshot = renderState.snapshot();
|
||||||
|
renderState.resetDirty();
|
||||||
|
snapshotVersion = version;
|
||||||
|
} else if (snapshotVersion != version) {
|
||||||
|
renderState.update(terminal);
|
||||||
|
cachedSnapshot = renderState.snapshotIncremental();
|
||||||
|
renderState.resetDirty();
|
||||||
|
snapshotVersion = version;
|
||||||
}
|
}
|
||||||
return cachedSnapshot;
|
return cachedSnapshot;
|
||||||
}
|
}
|
||||||
@@ -151,44 +192,39 @@ public final class TerminalPane implements AutoCloseable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public long renderVersion() {
|
/** This pane's own content revision, bumped on every change (see {@link #refresh()}). */
|
||||||
return renderVersion;
|
public long contentVersion() {
|
||||||
|
return contentVersion.get();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean kittyEnabled() {
|
||||||
|
return kittyEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
public Optional<KittyGraphics> kittyGraphics() {
|
public Optional<KittyGraphics> kittyGraphics() {
|
||||||
synchronized (terminal) {
|
synchronized (terminal) {
|
||||||
return terminal.kittyGraphics();
|
return terminal.kittyGraphics();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public boolean floating() {
|
@Override
|
||||||
return floating;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setFloating(boolean floating) {
|
|
||||||
this.floating = floating;
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean visible() {
|
|
||||||
return visible;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setVisible(boolean visible) {
|
|
||||||
this.visible = visible;
|
|
||||||
}
|
|
||||||
|
|
||||||
public double x() {
|
public double x() {
|
||||||
return x;
|
return x;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
public double y() {
|
public double y() {
|
||||||
return y;
|
return y;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
public double width() {
|
public double width() {
|
||||||
return width;
|
return width;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
public double height() {
|
public double height() {
|
||||||
return height;
|
return height;
|
||||||
}
|
}
|
||||||
@@ -200,7 +236,24 @@ public final class TerminalPane implements AutoCloseable {
|
|||||||
this.height = height;
|
this.height = height;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void resize(int columns, int rows, int pixelWidth, int pixelHeight) {
|
/** 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);
|
||||||
|
int rows = metrics.rowsFor(height);
|
||||||
|
resize(columns, rows, (int) Math.round(metrics.cellWidth()), (int) Math.round(metrics.lineHeight()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void resize(int columns, int rows, int pixelWidth, int pixelHeight) {
|
||||||
if (columns <= 0 || rows <= 0 || pixelWidth <= 0 || pixelHeight <= 0) {
|
if (columns <= 0 || rows <= 0 || pixelWidth <= 0 || pixelHeight <= 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -222,26 +275,33 @@ public final class TerminalPane implements AutoCloseable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void refresh() {
|
private void refresh() {
|
||||||
// Only mark the pane dirty; the snapshot itself is computed lazily in
|
// Mark this pane's content dirty (the snapshot is computed lazily in the paint path,
|
||||||
// renderSnapshot() so a burst of writes collapses into a single snapshot per frame.
|
// so a burst of writes collapses into one snapshot per frame) and tell the owning tab
|
||||||
renderVersion++;
|
// one of its panes changed.
|
||||||
RENDER_TICK.incrementAndGet();
|
contentVersion.incrementAndGet();
|
||||||
|
onContentChange.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Paint the whole pane; see {@link TerminalRenderer#paintFull}. */
|
||||||
|
public long paintFull(GraphicsContext gc, boolean active) {
|
||||||
|
renderer.paintFull(gc, this, active);
|
||||||
|
return snapshotVersion;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Repaint what changed; see {@link TerminalRenderer#paintIncremental}. */
|
||||||
|
public long paintIncremental(GraphicsContext gc, boolean active) {
|
||||||
|
renderer.paintIncremental(gc, this, active);
|
||||||
|
return snapshotVersion;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void close() {
|
public void close() {
|
||||||
// Stop accepting reader-thread writes first, then shut the session (which unblocks
|
|
||||||
// and ends the reader), so terminal.close() can't race a write from that thread.
|
|
||||||
synchronized (terminal) {
|
|
||||||
closed = true;
|
|
||||||
}
|
|
||||||
if (session != null) {
|
if (session != null) {
|
||||||
session.close();
|
session.close();
|
||||||
session = null;
|
session = null;
|
||||||
}
|
}
|
||||||
mouseEncoder.close();
|
mouseEncoder.close();
|
||||||
synchronized (terminal) {
|
renderState.close();
|
||||||
terminal.close();
|
terminal.close();
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
60
src/main/java/com/gregor/jprototerm/TerminalRenderer.java
Normal file
60
src/main/java/com/gregor/jprototerm/TerminalRenderer.java
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
package com.gregor.jprototerm;
|
||||||
|
|
||||||
|
import javafx.scene.canvas.GraphicsContext;
|
||||||
|
import javafx.scene.shape.ClosePath;
|
||||||
|
import javafx.scene.shape.LineTo;
|
||||||
|
import javafx.scene.shape.MoveTo;
|
||||||
|
import javafx.scene.shape.Path;
|
||||||
|
import javafx.scene.shape.PathElement;
|
||||||
|
import javafx.scene.shape.Shape;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draws a {@link RenderTarget} onto a JavaFX canvas. The {@link Compositor} owns positioning
|
||||||
|
* and z-order; a renderer only fills the target's rect, clipped to the target's {@link
|
||||||
|
* RenderTarget#clip() clip region} so a repaint can never bleed over a pane on top.
|
||||||
|
* Implementations can change the look entirely — {@link GhosttyTerminalRenderer} is the real
|
||||||
|
* terminal renderer; a debug renderer could outline pane bounds instead.
|
||||||
|
*
|
||||||
|
* <p>A renderer may hold per-target state (e.g. a decoded-image cache), so an instance belongs
|
||||||
|
* to a single {@link TerminalPane}.
|
||||||
|
*/
|
||||||
|
abstract class TerminalRenderer {
|
||||||
|
/** Paint the whole target into its rect, clipped to its clip region. */
|
||||||
|
abstract void paintFull(GraphicsContext gc, RenderTarget target, boolean active);
|
||||||
|
|
||||||
|
/** Repaint only what changed since the last frame, clipped to the target's clip region. */
|
||||||
|
abstract void paintIncremental(GraphicsContext gc, RenderTarget target, boolean active);
|
||||||
|
|
||||||
|
protected static void clipRect(GraphicsContext gc, double x, double y, double width, double height) {
|
||||||
|
gc.beginPath();
|
||||||
|
gc.rect(x, y, width, height);
|
||||||
|
gc.clip();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clip to {@code region} if given (the pane's rect minus the panes covering it, computed by
|
||||||
|
* {@code Shape.subtract} at layout), otherwise to the plain rect. The region is a rectilinear
|
||||||
|
* path, so it replays onto the canvas as move/line/close segments.
|
||||||
|
*/
|
||||||
|
protected static void clip(GraphicsContext gc, double x, double y, double width, double height, Shape region) {
|
||||||
|
if (region == null) {
|
||||||
|
clipRect(gc, x, y, width, height);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var elements = ((Path) region).getElements();
|
||||||
|
gc.beginPath();
|
||||||
|
if (elements.isEmpty()) {
|
||||||
|
gc.rect(x, y, 0.0, 0.0); // fully covered: clip to nothing
|
||||||
|
}
|
||||||
|
for (PathElement element : elements) {
|
||||||
|
if (element instanceof MoveTo moveTo) {
|
||||||
|
gc.moveTo(moveTo.getX(), moveTo.getY());
|
||||||
|
} else if (element instanceof LineTo lineTo) {
|
||||||
|
gc.lineTo(lineTo.getX(), lineTo.getY());
|
||||||
|
} else if (element instanceof ClosePath) {
|
||||||
|
gc.closePath();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
gc.clip();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
package com.gregor.jprototerm;
|
|
||||||
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Holds the tabs and renders only the current one. Pane operations delegate to the current
|
|
||||||
* tab; tab operations switch which tab is current. A single render version bumps on any
|
|
||||||
* change (intra-tab or tab switch) so the renderer recomposites when needed.
|
|
||||||
*/
|
|
||||||
public final class TerminalWorkspace implements AutoCloseable {
|
|
||||||
private final AppConfig config;
|
|
||||||
private final List<Tab> tabs = new ArrayList<>();
|
|
||||||
private int currentTab;
|
|
||||||
private long version;
|
|
||||||
|
|
||||||
public TerminalWorkspace(AppConfig config) {
|
|
||||||
this.config = config;
|
|
||||||
tabs.add(new Tab(config));
|
|
||||||
}
|
|
||||||
|
|
||||||
private Tab current() {
|
|
||||||
return tabs.get(currentTab);
|
|
||||||
}
|
|
||||||
|
|
||||||
public long version() {
|
|
||||||
return version;
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean isEmpty() {
|
|
||||||
return tabs.isEmpty();
|
|
||||||
}
|
|
||||||
|
|
||||||
public TerminalPane activePane() {
|
|
||||||
return current().activePane();
|
|
||||||
}
|
|
||||||
|
|
||||||
public List<TerminalPane> panes() {
|
|
||||||
return tabs.isEmpty() ? List.of() : current().panes();
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean isActive(TerminalPane pane) {
|
|
||||||
return !tabs.isEmpty() && current().isActive(pane);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void layout(double width, double height, double topInset) {
|
|
||||||
if (!tabs.isEmpty()) {
|
|
||||||
current().layout(width, height, topInset);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public int tabCount() {
|
|
||||||
return tabs.size();
|
|
||||||
}
|
|
||||||
|
|
||||||
public int currentTabIndex() {
|
|
||||||
return currentTab;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void focus(TerminalPane pane) {
|
|
||||||
if (!tabs.isEmpty() && current().focus(pane)) {
|
|
||||||
version++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void navigate(Direction direction) {
|
|
||||||
if (!tabs.isEmpty() && current().navigate(direction)) {
|
|
||||||
version++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void toggleFloating() {
|
|
||||||
if (tabs.isEmpty()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
current().toggleFloating();
|
|
||||||
version++;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void createPane() {
|
|
||||||
if (tabs.isEmpty()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
current().createPane();
|
|
||||||
version++;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void nextFloatingPane() {
|
|
||||||
if (tabs.isEmpty()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
current().nextFloatingPane();
|
|
||||||
version++;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void closeActivePane() {
|
|
||||||
if (tabs.isEmpty()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
current().closeActivePane();
|
|
||||||
if (current().isEmpty()) {
|
|
||||||
// Closing a tab's last pane closes the tab. When no tabs remain the workspace
|
|
||||||
// is empty and Main quits.
|
|
||||||
tabs.remove(currentTab);
|
|
||||||
if (currentTab >= tabs.size()) {
|
|
||||||
currentTab = Math.max(0, tabs.size() - 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
version++;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void newTab() {
|
|
||||||
tabs.add(new Tab(config));
|
|
||||||
currentTab = tabs.size() - 1;
|
|
||||||
version++;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void nextTab() {
|
|
||||||
if (tabs.size() > 1) {
|
|
||||||
currentTab = (currentTab + 1) % tabs.size();
|
|
||||||
version++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void previousTab() {
|
|
||||||
if (tabs.size() > 1) {
|
|
||||||
currentTab = (currentTab - 1 + tabs.size()) % tabs.size();
|
|
||||||
version++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void close() {
|
|
||||||
for (Tab tab : tabs) {
|
|
||||||
tab.close();
|
|
||||||
}
|
|
||||||
tabs.clear();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user