37 Commits

Author SHA1 Message Date
897a76d8cf cleanup repo 2026-06-01 00:26:01 +02:00
5799c800d3 untrack 2026-06-01 00:25:16 +02:00
cc9ac43ffa remove obsolete full-row-repaint debug toggle; document diagnostics
The fullRowRepaint toggle existed only to bisect the black-bar repaint artifact,
which is now fixed, so drop it. Document the two remaining render-debug flags
(profile, debugRepaint) in the README.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 22:31:50 +02:00
93d53fcef6 send backtab (ESC [ Z) for Shift+Tab
KeyEncoder mapped TAB to a plain tab regardless of Shift, so Shift+Tab sent the
same byte as Tab. Apps that use backtab for reverse navigation (fish completion
menu, helix theme picker) never saw it. Emit CSI Z when Shift is held.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 22:27:54 +02:00
b0ec6c7014 update jlibghostty 2026-05-31 22:20:56 +02:00
3c913fefd3 fix black seam bars: opaque base fill instead of clearRect
The persistent black bars in the partial-repaint path were clearRect leaving
the run's fractional edge pixels transparent, which showed the near-black pane
background as a seam against the adjacent un-repainted line. Confirmed with the
debugRepaint toggle: filling the span opaque removed the bars entirely.

Fill the repaint run with PANE_BACKGROUND (the default cell background) instead
of clearing to transparent; per-cell backgrounds paint over it as before. Safe
because the per-column path never runs while kitty graphics are present (those
force a full render), so no below-text image needs a transparent row canvas.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 22:08:57 +02:00
263bcf36b7 add debugRepaint toggle that fills cleared spans red
Diagnostic for the persistent black bars: fills each repaint run's cleared span
red instead of clearing to transparent. If the bars turn red they are spans
repaintColumns clears but never refills; if they stay black those pixels are
never touched by the per-column repaint and the cause is elsewhere.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 22:02:41 +02:00
8e060b27ca Revert "snap repaint clear/fill to integer pixels to kill seam bars"
This reverts commit 6613f1f746.
2026-05-31 21:58:15 +02:00
6613f1f746 snap repaint clear/fill to integer pixels to kill seam bars
cellWidth is fractional, so in repaintColumns the clearRect cleared a run's
edge pixel to full transparency while the background fillRect covered that same
pixel only partially (antialiased), leaving a ~1px part-transparent column that
showed the near-black pane background as a thin bar 1-2 cells before the cursor.
Full-row repaint hid it because the highlighted line is then one contiguous
fill with no internal junction.

Route the clear and the background fills through a shared columnX() that rounds
each column boundary to a whole device pixel, so run edges land on integer
pixels with full single coverage and adjacent runs tile seamlessly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 21:56:01 +02:00
5728733f5f worktrees ignores 2026-05-31 21:52:39 +02:00
6e3e88919e Revert "fix glyph-overhang artifacts in partial row repaint"
This reverts commit 57103bb98b.
2026-05-31 21:50:17 +02:00
57103bb98b fix glyph-overhang artifacts in partial row repaint
repaintColumns cleared and redrew only [start,end], but a neighbouring cell's
glyph can overhang into that span. The clearRect erased the overhang and the
neighbour was never redrawn, leaving black notches through the line 1-2 cells
before the cursor that survived until a full rerender.

Redraw text for a couple of extra cells on each side, clipped to the cleared
span, so overhang from just-outside cells is restored without touching their
own cell areas. Keeps the per-column repaint efficiency (vs the full-row
repaint debug toggle, which fixed the bars but repainted every dirty cell).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 21:48:34 +02:00
cb95a7188d update jlibghostty 2026-05-31 21:39:18 +02:00
Gregor Lohaus
5ca192b7be add full-row-repaint debug toggle
-Djprototerm.fullRowRepaint=true (or JPROTOTERM_FULL_ROW_REPAINT=1) bypasses the
per-column repaint in renderChanged and repaints the whole row, to bisect the
stale black-bar artifact that appears near the cursor and survives until a full
rerender.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 21:25:13 +02:00
Gregor Lohaus
e99a6ee33e split snapshot profiler bucket into update vs marshal
The snapshot bucket lumped ghostty's native dirty-state update together with
the Java-side cell marshaling. Time them separately to see which half of the
~7ms/frame snapshot cost (now the dominant frame cost after the detectShift
hoist) is the real target.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 21:18:07 +02:00
Gregor Lohaus
4923ea5527 hoist row hash out of detectShift delta scan
rowFingerprint(row) is invariant across the delta loop but was recomputed for
every candidate delta, making shift detection O(rows^2 x cols) on large changes
(full-screen scroll). Precompute each changed row's hash once, dropping it to
O(rows x cols). Profiling showed fingerprint hashing at ~74% of frame time under
heavy scroll, dominated by this loop.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 21:09:54 +02:00
Gregor Lohaus
1f7394d75a add opt-in render profiler instrumentation
Gated behind -Djprototerm.profile=true (or JPROTOTERM_PROFILE=1), accumulates
per-frame nanos into snapshot/fingerprint/draw/frame-total buckets and dumps
to stderr every N renders. Splits the three suspected render costs: native
snapshot marshaling, fingerprint hashing, and canvas draw recording.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 21:04:00 +02:00
Gregor Lohaus
50641d0a6a per-row cell-run repaint 2026-05-31 20:12:43 +02:00
Gregor Lohaus
51f64e7ca8 cache hidden panes 2026-05-31 19:56:09 +02:00
Gregor Lohaus
528afafcda no next floating pane keyboard shortcut 2026-05-31 19:52:24 +02:00
Gregor Lohaus
093a09da39 frame classifiaction not needed anymore 2026-05-31 19:46:55 +02:00
Gregor Lohaus
59ab33bc01 dont rerender full on every layout frame 2026-05-31 19:45:38 +02:00
Gregor Lohaus
d8447d9e29 port feasable performance improvements 2026-05-31 19:38:06 +02:00
Gregor Lohaus
dba6474491 apply race condition fix 2026-05-31 19:30:36 +02:00
Gregor Lohaus
743f312921 move unchanged rows 2026-05-31 18:55:53 +02:00
Gregor Lohaus
3054b3ec77 cleanup 2026-05-31 18:51:16 +02:00
Gregor Lohaus
2bcaf951df Render terminal rows as JavaFX nodes 2026-05-31 18:40:33 +02:00
Gregor Lohaus
beba14c3ea scene graph 2026-05-31 18:27:52 +02:00
Gregor Lohaus
f5562baf5f Merge branch 'refactor' 2026-05-31 16:27:21 +02:00
Gregor Lohaus
3017b99f87 recover pane 2026-05-31 16:24:43 +02:00
Gregor Lohaus
0958c93b4f recover tab 2026-05-31 16:23:36 +02:00
Gregor Lohaus
9c98d87783 recover abstract terminal renderer 2026-05-31 16:21:38 +02:00
Gregor Lohaus
76c731578f recover terminal metrics from helix buffer 2026-05-31 16:20:43 +02:00
Gregor Lohaus
95619f5b4c fuck did bad git reset hard to main, recovering from helix buffer 2026-05-31 16:19:37 +02:00
Gregor Lohaus
174cfc00d3 what is happening ?? 2026-05-31 16:15:37 +02:00
Gregor Lohaus
29e84c9830 remove unused old classes 2026-05-31 16:13:40 +02:00
Gregor Lohaus
a7baa08e68 add bin to gitignore 2026-05-31 15:45:55 +02:00
21 changed files with 1947 additions and 1583 deletions

View File

@@ -1,18 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<classpath>
<classpathentry kind="src" output="bin/main" path="src/main/java">
<attributes>
<attribute name="gradle_scope" value="main"/>
<attribute name="gradle_used_by_scope" value="main,test"/>
</attributes>
</classpathentry>
<classpathentry kind="src" output="bin/main" path="src/main/resources">
<attributes>
<attribute name="gradle_scope" value="main"/>
<attribute name="gradle_used_by_scope" value="main,test"/>
</attributes>
</classpathentry>
<classpathentry kind="con" path="org.eclipse.jdt.launching.JRE_CONTAINER/org.eclipse.jdt.internal.debug.ui.launcher.StandardVMType/JavaSE-21/"/>
<classpathentry kind="con" path="org.eclipse.buildship.core.gradleclasspathcontainer"/>
<classpathentry kind="output" path="bin/default"/>
</classpath>

View File

@@ -1 +0,0 @@
019e6999-b7c8-7591-a8aa-ea51b89a7f7e

5
.gitignore vendored
View File

@@ -13,3 +13,8 @@ devenv.local.yaml
build build
build build
.gradle .gradle
bin
.worktrees
.classpath
.project
.settings

3
.ignore Normal file
View File

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

View File

@@ -1,34 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<projectDescription>
<name>jprototerm</name>
<comment>Project jprototerm created by Buildship.</comment>
<projects>
</projects>
<buildSpec>
<buildCommand>
<name>org.eclipse.jdt.core.javabuilder</name>
<arguments>
</arguments>
</buildCommand>
<buildCommand>
<name>org.eclipse.buildship.core.gradleprojectbuilder</name>
<arguments>
</arguments>
</buildCommand>
</buildSpec>
<natures>
<nature>org.eclipse.jdt.core.javanature</nature>
<nature>org.eclipse.buildship.core.gradleprojectnature</nature>
</natures>
<filteredResources>
<filter>
<id>1779917652126</id>
<name></name>
<type>30</type>
<matcher>
<id>org.eclipse.core.resources.regexFilterMatcher</id>
<arguments>node_modules|\.git|__CREATED_BY_JAVA_LANGUAGE_SERVER__</arguments>
</matcher>
</filter>
</filteredResources>
</projectDescription>

View File

@@ -1,13 +0,0 @@
arguments=--init-script /home/anon/Src/eclipse.jdt.ls/org.eclipse.jdt.ls.product/target/repository/configuration/org.eclipse.osgi/58/0/.cp/gradle/init/init.gradle
auto.sync=false
build.scans.enabled=false
connection.gradle.distribution=GRADLE_DISTRIBUTION(LOCAL_INSTALLATION(/home/anon/.sdkman/candidates/gradle/current))
connection.project.dir=
eclipse.preferences.version=1
gradle.user.home=
java.home=/nix/store/c3pl7bqrx3d2rc3dh98z6yaj0mv1p52g-openjdk-21.0.10+7/lib/openjdk
jvm.arguments=
offline.mode=false
override.workspace.settings=true
show.console.view=true
show.executions.view=true

View File

@@ -106,7 +106,6 @@ navigate_up = "ALT+K"
navigate_right = "ALT+L" navigate_right = "ALT+L"
toggle_floating = "ALT+F" toggle_floating = "ALT+F"
new_pane = "ALT+N" new_pane = "ALT+N"
next_floating = "ALT+F12"
close_pane = "ALT+X" close_pane = "ALT+X"
new_tab = "ALT+A" new_tab = "ALT+A"
previous_tab = "ALT+SHIFT+H" previous_tab = "ALT+SHIFT+H"
@@ -121,7 +120,6 @@ open_scrollback = "ALT+S"
- `Alt+n`: new pane — a floating pane when floating panes are shown, otherwise a new tiled - `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) pane (tiled panes are split equally across the width)
- `Alt+f`: show or hide all floating panes - `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 - `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 last pane of the last tab quits
- `Alt+a`: new tab - `Alt+a`: new tab
@@ -134,3 +132,22 @@ open_scrollback = "ALT+S"
Each tab has its own stack of tiled and floating panes; only the active tab is rendered. A 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 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. 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.

View File

@@ -1,2 +0,0 @@
jlibghostty - why downcall metadata not propagated ?
jlibghostty - how need to change flake so consuming flakes dont have to depend on same ghostty flake ?

View File

@@ -25,8 +25,7 @@ navigate_down = "ALT+J"
navigate_up = "ALT+K" navigate_up = "ALT+K"
navigate_right = "ALT+L" navigate_right = "ALT+L"
toggle_floating = "ALT+F" toggle_floating = "ALT+F"
new_floating = "ALT+SHIFT+F" new_pane = "ALT+N"
next_floating = "ALT+F12"
close_pane = "ALT+X" close_pane = "ALT+X"
open_font_selector = "ALT+T" open_font_selector = "ALT+T"
open_scrollback = "ALT+S" open_scrollback = "ALT+S"

8
flake.lock generated
View File

@@ -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"
}, },

View File

@@ -37,7 +37,6 @@ public record AppConfig(
"navigate_right", "navigate_right",
"toggle_floating", "toggle_floating",
"new_pane", "new_pane",
"next_floating",
"close_pane", "close_pane",
"new_tab", "new_tab",
"previous_tab", "previous_tab",
@@ -96,7 +95,6 @@ public record AppConfig(
Map.entry("navigate_right", KeyBinding.parse("ALT+L")), Map.entry("navigate_right", KeyBinding.parse("ALT+L")),
Map.entry("toggle_floating", KeyBinding.parse("ALT+F")), Map.entry("toggle_floating", KeyBinding.parse("ALT+F")),
Map.entry("new_pane", KeyBinding.parse("ALT+N")), 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("close_pane", KeyBinding.parse("ALT+X")),
Map.entry("new_tab", KeyBinding.parse("ALT+A")), Map.entry("new_tab", KeyBinding.parse("ALT+A")),
Map.entry("previous_tab", KeyBinding.parse("ALT+SHIFT+H")), Map.entry("previous_tab", KeyBinding.parse("ALT+SHIFT+H")),

View File

@@ -0,0 +1,442 @@
package com.gregor.jprototerm;
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.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 java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* 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.
*/
public final class Compositor {
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);
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 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;
private double lastWidth = -1.0;
private double lastHeight = -1.0;
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));
root.setFocusTraversable(true);
root.setBackground(new Background(new BackgroundFill(GAP_BACKGROUND, CornerRadii.EMPTY, null)));
root.getChildren().setAll(paneLayer, tabBar);
root.setOnMousePressed(event -> root.requestFocus());
}
public Parent node() {
return root;
}
public void requestFocus() {
root.requestFocus();
}
public void setFont(String family, double size) {
metrics.setFont(family, size);
nodes.values().forEach(TerminalPaneNode::discard);
markSceneDirty();
}
// ---- 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)) {
markSceneDirty();
}
}
public void toggleFloating() {
if (isEmpty()) {
return;
}
currentTab().toggleFloating();
markSceneDirty();
}
public void createPane() {
if (isEmpty()) {
return;
}
currentTab().createPane();
markSceneDirty();
}
public void closeActivePane() {
if (isEmpty()) {
return;
}
currentTab().closeActivePane();
if (currentTab().isEmpty()) {
tabs.remove(currentTabIndex);
if (currentTabIndex >= tabs.size()) {
currentTabIndex = Math.max(0, tabs.size() - 1);
}
}
markSceneDirty();
}
public void newTab() {
tabs.add(new Tab(config, metrics));
currentTabIndex = tabs.size() - 1;
markSceneDirty();
}
public void nextTab() {
if (tabs.size() > 1) {
currentTabIndex = (currentTabIndex + 1) % tabs.size();
markSceneDirty();
}
}
public void previousTab() {
if (tabs.size() > 1) {
currentTabIndex = (currentTabIndex - 1 + tabs.size()) % tabs.size();
markSceneDirty();
}
}
public void close() {
for (Tab tab : tabs) {
tab.close();
}
tabs.clear();
nodes.clear();
paneLayer.getChildren().clear();
}
private Tab currentTab() {
return tabs.get(currentTabIndex);
}
private List<TerminalPane> currentPanes() {
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();
}
}
// ---- 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;
}
lastWidth = width;
lastHeight = height;
lastContentVersion = contentVersion;
sceneDirty = false;
if (syncScene) {
syncSceneGraph(width, height);
}
renderVisiblePanes();
}
private void markSceneDirty() {
sceneDirty = true;
}
private void syncSceneGraph(double width, double height) {
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);
}
List<TerminalPane> panes = currentPanes();
retainNodes(allOpenPanes());
List<TerminalPaneNode> orderedNodes = new ArrayList<>(panes.size());
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);
}
private void renderVisiblePanes() {
for (TerminalPane pane : currentPanes()) {
TerminalPaneNode node = nodes.get(pane);
if (node != null) {
node.renderIncremental(isActive(pane));
}
}
}
private TerminalPaneNode nodeFor(TerminalPane pane) {
return nodes.computeIfAbsent(pane, this::createNode);
}
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;
}
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));
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);
}
}
// ---- Input ----------------------------------------------------------------------
private void handleMousePressed(TerminalPane pane, MouseEvent event) {
root.requestFocus();
focus(pane);
pressedButton = mouseButton(event);
mouseButtonPressed = true;
MouseTarget target = mouseTarget(pane);
if (target == null) {
return;
}
send(pane, target, MouseInput.press(pressedButton, localX(event.getX(), target), localY(event.getY(), target), modifiers(event)), true, event);
}
private void handleMouseReleased(TerminalPane pane, MouseEvent event) {
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);
}
mouseButtonPressed = false;
pressedButton = MouseButton.UNKNOWN;
}
private void handleMouseDragged(TerminalPane pane, MouseEvent event) {
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);
}
private void handleMouseMoved(TerminalPane pane, MouseEvent event) {
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);
}
private void handleScroll(TerminalPane pane, ScrollEvent event) {
root.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) {
double ex = localX(event.getX(), target);
double ey = localY(event.getY(), 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) {
pane.scrollViewport(direction > 0 ? -rows : rows);
event.consume();
}
}
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 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);
}
private static double localX(double nodeX, MouseTarget target) {
return clamp(nodeX - 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 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;
};
}
private record MouseTarget(MouseEncoderSize size, long screenWidth, long screenHeight) {
}
}

View File

@@ -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";

View File

@@ -12,7 +12,6 @@ import javafx.scene.control.Spinner;
import javafx.scene.control.SpinnerValueFactory; import javafx.scene.control.SpinnerValueFactory;
import javafx.scene.input.KeyEvent; import javafx.scene.input.KeyEvent;
import javafx.scene.layout.GridPane; import javafx.scene.layout.GridPane;
import javafx.scene.layout.StackPane;
import javafx.scene.text.Font; import javafx.scene.text.Font;
import javafx.stage.Stage; import javafx.stage.Stage;
@@ -21,79 +20,72 @@ 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()); Scene scene = new Scene(compositor.node(), config.windowWidth(), config.windowHeight());
terminalView.canvas().widthProperty().bind(root.widthProperty());
terminalView.canvas().heightProperty().bind(root.heightProperty());
Scene scene = new Scene(root, config.windowWidth(), config.windowHeight());
scene.addEventFilter(KeyEvent.KEY_PRESSED, this::handlePressed); scene.addEventFilter(KeyEvent.KEY_PRESSED, this::handlePressed);
scene.addEventFilter(KeyEvent.KEY_TYPED, event -> handleTyped(event)); scene.addEventFilter(KeyEvent.KEY_TYPED, event -> handleTyped(event));
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.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();
} else if (config.keybindings().get("next_floating").matches(event)) {
workspace.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 +96,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 +109,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 +152,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.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());
} }

View File

@@ -0,0 +1,79 @@
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;
}
}

View File

@@ -3,260 +3,223 @@ package com.gregor.jprototerm;
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.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 long contentVersion;
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;
}
/**
* 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);
} }
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) { 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);
);
} }
} }
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() {
TerminalPane next = nextFloatingAfter(activeIndex);
next.setVisible(true);
setActive(panes.indexOf(next));
}
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 +227,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++;
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 +280,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

View 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()));
}
}

View File

@@ -1,35 +1,39 @@
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 java.util.Optional; import java.util.Optional;
import java.util.concurrent.atomic.AtomicLong; 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.
*/
public final class TerminalPane implements AutoCloseable { public final class TerminalPane implements AutoCloseable {
// Monotonic across all panes, bumped on every content change. Lets the renderer detect
// "nothing changed" in O(1) without scanning panes or building a render key.
private static final AtomicLong RENDER_TICK = new AtomicLong();
public static long renderTick() {
return RENDER_TICK.get();
}
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 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;
private boolean visible = true;
private double x; private double x;
private double y; private double y;
private double width; private double width;
@@ -38,47 +42,38 @@ 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 snapshotVersion = -1;
private volatile long renderVersion;
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, int columns, int rows) {
this.terminal = terminal; this.terminal = terminal;
this.metrics = metrics;
this.kittyEnabled = kittyEnabled;
this.onContentChange = onContentChange;
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, 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 +84,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 +131,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 +139,44 @@ 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() { 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).
*/
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; 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;
} }
return cachedSnapshot; return cachedSnapshot;
} }
@@ -151,8 +188,17 @@ 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();
}
long snapshotVersion() {
return snapshotVersion;
}
public boolean kittyEnabled() {
return kittyEnabled;
} }
public Optional<KittyGraphics> kittyGraphics() { public Optional<KittyGraphics> kittyGraphics() {
@@ -161,22 +207,6 @@ public final class TerminalPane implements AutoCloseable {
} }
} }
public boolean floating() {
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;
} }
@@ -200,7 +230,14 @@ public final class TerminalPane implements AutoCloseable {
this.height = height; this.height = height;
} }
public void resize(int columns, int rows, int pixelWidth, int pixelHeight) { /** 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 +259,21 @@ 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();
} }
@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();
}
} }
} }

File diff suppressed because it is too large Load Diff

View File

@@ -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();
}
}