Compare commits
7 Commits
c03d9245d0
...
e2850f067e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2850f067e | ||
|
|
022cf22463 | ||
|
|
250b182060 | ||
|
|
ebba6cc44f | ||
|
|
4c3449129c | ||
|
|
40d6287867 | ||
|
|
ff21bf3544 |
29
README.md
29
README.md
@@ -2,7 +2,15 @@
|
||||
|
||||
JavaFX canvas terminal prototype using `jlibghostty` for terminal emulation and Nix for
|
||||
the build environment. It builds a plain JavaFX application (JDK 25, JavaFX 25 via Gradle)
|
||||
packaged as a Nix derivation — no GraalVM/GluonFX native image.
|
||||
packaged as a Nix derivation — no GraalVM/GluonFX native image. It supports tiled and
|
||||
floating panes and tabs.
|
||||
|
||||
> [!CAUTION]
|
||||
> `nix profile add` has only been tested on **Debian with the proprietary NVIDIA driver**.
|
||||
> The runtime GL shim hardcodes Debian's `/lib/x86_64-linux-gnu` driver paths and selects
|
||||
> the NVIDIA GLX/EGL vendor, so it likely won't work yet on other distros, Wayland-only
|
||||
> setups, or Mesa/AMD/Intel GPUs. I'm happy to accept pull requests that broaden host
|
||||
> support.
|
||||
|
||||
## Build
|
||||
|
||||
@@ -11,7 +19,7 @@ nix build
|
||||
./result/bin/jprototerm
|
||||
```
|
||||
|
||||
Install it into a profile (works on NixOS and on a plain Debian box with Nix installed):
|
||||
Install it into a profile (see the caution above on host support):
|
||||
|
||||
```sh
|
||||
nix profile add .
|
||||
@@ -97,9 +105,12 @@ navigate_down = "ALT+J"
|
||||
navigate_up = "ALT+K"
|
||||
navigate_right = "ALT+L"
|
||||
toggle_floating = "ALT+F"
|
||||
new_floating = "ALT+SHIFT+F"
|
||||
new_pane = "ALT+N"
|
||||
next_floating = "ALT+F12"
|
||||
close_pane = "ALT+X"
|
||||
new_tab = "ALT+A"
|
||||
previous_tab = "ALT+SHIFT+H"
|
||||
next_tab = "ALT+SHIFT+L"
|
||||
open_font_selector = "ALT+T"
|
||||
open_scrollback = "ALT+S"
|
||||
```
|
||||
@@ -107,11 +118,19 @@ open_scrollback = "ALT+S"
|
||||
## Defaults
|
||||
|
||||
- `Alt+h/j/k/l`: navigate panes
|
||||
- `Alt+n`: new pane — a floating pane when floating panes are shown, otherwise a new tiled
|
||||
pane (tiled panes are split equally across the width)
|
||||
- `Alt+f`: show or hide all floating panes
|
||||
- `Alt+Shift+f`: create a new floating pane
|
||||
- `Alt+F12`: cycle floating panes
|
||||
- `Alt+x`: close the active floating pane
|
||||
- `Alt+x`: close the active pane; closing a tab's last pane closes the tab, and closing the
|
||||
last pane of the last tab quits
|
||||
- `Alt+a`: new tab
|
||||
- `Alt+Shift+h` / `Alt+Shift+l`: previous / next tab
|
||||
- `Alt+t`: open the font selector
|
||||
- `Alt+s`: open the active pane scrollback in `$EDITOR`
|
||||
- Font default: `JetBrainsMono Nerd Font`
|
||||
- Kitty graphics protocol parsing is enabled by default
|
||||
|
||||
Each tab has its own stack of tiled and floating panes; only the active tab is rendered. A
|
||||
thin tab bar appears at the top when more than one tab is open. Closing the last tiled pane
|
||||
while floating panes exist promotes the most recently active floating pane to a tiled pane.
|
||||
|
||||
8
flake.lock
generated
8
flake.lock
generated
@@ -70,11 +70,11 @@
|
||||
"nixpkgs": "nixpkgs"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1780050576,
|
||||
"narHash": "sha256-u06xuO3QnLDpajIOZwDdhwI0HGzMuXG7x1pR+4Zb+RA=",
|
||||
"lastModified": 1780079529,
|
||||
"narHash": "sha256-AxlGTL8c5xSLcQHvWlm994IdOqxsN8iKrA02Cpv7vso=",
|
||||
"ref": "refs/heads/main",
|
||||
"rev": "d558d554b360a76d03c2fc09d327e3ec4aade878",
|
||||
"revCount": 17,
|
||||
"rev": "68121d50b52fb56038871c97c97e7a12ffe987c2",
|
||||
"revCount": 20,
|
||||
"type": "git",
|
||||
"url": "https://gitea.gregorlohaus.com/gregor/jlibghostty.git"
|
||||
},
|
||||
|
||||
@@ -36,9 +36,12 @@ public record AppConfig(
|
||||
"navigate_up",
|
||||
"navigate_right",
|
||||
"toggle_floating",
|
||||
"new_floating",
|
||||
"new_pane",
|
||||
"next_floating",
|
||||
"close_pane",
|
||||
"new_tab",
|
||||
"previous_tab",
|
||||
"next_tab",
|
||||
"open_font_selector",
|
||||
"open_scrollback"
|
||||
);
|
||||
@@ -86,17 +89,20 @@ public record AppConfig(
|
||||
true,
|
||||
defaultScrollbackEditorCommand(),
|
||||
Map.of(),
|
||||
Map.of(
|
||||
"navigate_left", KeyBinding.parse("ALT+H"),
|
||||
"navigate_down", KeyBinding.parse("ALT+J"),
|
||||
"navigate_up", KeyBinding.parse("ALT+K"),
|
||||
"navigate_right", KeyBinding.parse("ALT+L"),
|
||||
"toggle_floating", KeyBinding.parse("ALT+F"),
|
||||
"new_floating", KeyBinding.parse("ALT+SHIFT+F"),
|
||||
"next_floating", KeyBinding.parse("ALT+F12"),
|
||||
"close_pane", KeyBinding.parse("ALT+X"),
|
||||
"open_font_selector", KeyBinding.parse("ALT+T"),
|
||||
"open_scrollback", KeyBinding.parse("ALT+S")
|
||||
Map.ofEntries(
|
||||
Map.entry("navigate_left", KeyBinding.parse("ALT+H")),
|
||||
Map.entry("navigate_down", KeyBinding.parse("ALT+J")),
|
||||
Map.entry("navigate_up", KeyBinding.parse("ALT+K")),
|
||||
Map.entry("navigate_right", KeyBinding.parse("ALT+L")),
|
||||
Map.entry("toggle_floating", KeyBinding.parse("ALT+F")),
|
||||
Map.entry("new_pane", KeyBinding.parse("ALT+N")),
|
||||
Map.entry("next_floating", KeyBinding.parse("ALT+F12")),
|
||||
Map.entry("close_pane", KeyBinding.parse("ALT+X")),
|
||||
Map.entry("new_tab", KeyBinding.parse("ALT+A")),
|
||||
Map.entry("previous_tab", KeyBinding.parse("ALT+SHIFT+H")),
|
||||
Map.entry("next_tab", KeyBinding.parse("ALT+SHIFT+L")),
|
||||
Map.entry("open_font_selector", KeyBinding.parse("ALT+T")),
|
||||
Map.entry("open_scrollback", KeyBinding.parse("ALT+S"))
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.gregor.jprototerm;
|
||||
|
||||
import javafx.animation.AnimationTimer;
|
||||
import javafx.application.Application;
|
||||
import javafx.application.Platform;
|
||||
import javafx.scene.Scene;
|
||||
import javafx.scene.control.ButtonType;
|
||||
import javafx.scene.control.ComboBox;
|
||||
@@ -71,8 +72,8 @@ public final class Main extends Application {
|
||||
} else if (config.keybindings().get("toggle_floating").matches(event)) {
|
||||
workspace.toggleFloating();
|
||||
event.consume();
|
||||
} else if (config.keybindings().get("new_floating").matches(event)) {
|
||||
workspace.createFloatingPane();
|
||||
} else if (config.keybindings().get("new_pane").matches(event)) {
|
||||
workspace.createPane();
|
||||
event.consume();
|
||||
} else if (config.keybindings().get("next_floating").matches(event)) {
|
||||
workspace.nextFloatingPane();
|
||||
@@ -80,6 +81,20 @@ public final class Main extends Application {
|
||||
} else if (config.keybindings().get("close_pane").matches(event)) {
|
||||
workspace.closeActivePane();
|
||||
event.consume();
|
||||
if (workspace.isEmpty()) {
|
||||
// Closing the last pane quits the app.
|
||||
workspace.close();
|
||||
Platform.exit();
|
||||
}
|
||||
} else if (config.keybindings().get("new_tab").matches(event)) {
|
||||
workspace.newTab();
|
||||
event.consume();
|
||||
} else if (config.keybindings().get("previous_tab").matches(event)) {
|
||||
workspace.previousTab();
|
||||
event.consume();
|
||||
} else if (config.keybindings().get("next_tab").matches(event)) {
|
||||
workspace.nextTab();
|
||||
event.consume();
|
||||
} else if (config.keybindings().get("open_font_selector").matches(event)) {
|
||||
openFontSelector();
|
||||
event.consume();
|
||||
|
||||
345
src/main/java/com/gregor/jprototerm/Tab.java
Normal file
345
src/main/java/com/gregor/jprototerm/Tab.java
Normal file
@@ -0,0 +1,345 @@
|
||||
package com.gregor.jprototerm;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* One tab: an isolated stack of panes (tiled + floating) with its own active pane and
|
||||
* stashed-floating state. {@link TerminalWorkspace} owns the list of tabs and renders only
|
||||
* the current one. Mutating methods return whether they actually changed anything so the
|
||||
* workspace can bump its render version conditionally.
|
||||
*/
|
||||
final class Tab implements AutoCloseable {
|
||||
private final AppConfig config;
|
||||
private final List<TerminalPane> panes = new ArrayList<>();
|
||||
private int activeIndex;
|
||||
private int hiddenFloatingFocusIndex = -1;
|
||||
private TerminalPane lastFocusedFloating;
|
||||
|
||||
Tab(AppConfig config) {
|
||||
this.config = config;
|
||||
panes.add(openPane(false));
|
||||
}
|
||||
|
||||
TerminalPane activePane() {
|
||||
return panes.get(activeIndex);
|
||||
}
|
||||
|
||||
boolean isEmpty() {
|
||||
return panes.isEmpty();
|
||||
}
|
||||
|
||||
List<TerminalPane> panes() {
|
||||
if (panes.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
List<TerminalPane> visible = panes.stream().filter(TerminalPane::visible).toList();
|
||||
TerminalPane active = activePane();
|
||||
if (!active.visible() || !active.floating()) {
|
||||
return visible;
|
||||
}
|
||||
|
||||
List<TerminalPane> ordered = new ArrayList<>(visible.size());
|
||||
visible.stream()
|
||||
.filter(pane -> pane != active)
|
||||
.forEach(ordered::add);
|
||||
ordered.add(active);
|
||||
return List.copyOf(ordered);
|
||||
}
|
||||
|
||||
boolean isActive(TerminalPane pane) {
|
||||
return !panes.isEmpty() && activePane() == pane;
|
||||
}
|
||||
|
||||
boolean focus(TerminalPane pane) {
|
||||
int index = panes.indexOf(pane);
|
||||
if (index >= 0 && pane.visible() && activeIndex != index) {
|
||||
setActive(index);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void layout(double width, double height, double topInset) {
|
||||
double availHeight = height - topInset;
|
||||
List<TerminalPane> tiled = panes.stream()
|
||||
.filter(TerminalPane::visible)
|
||||
.filter(pane -> !pane.floating())
|
||||
.toList();
|
||||
int tileCount = Math.max(1, tiled.size());
|
||||
double tileWidth = width / tileCount;
|
||||
for (int i = 0; i < tiled.size(); i++) {
|
||||
tiled.get(i).bounds(i * tileWidth, topInset, tileWidth, availHeight);
|
||||
}
|
||||
|
||||
List<TerminalPane> floating = panes.stream()
|
||||
.filter(TerminalPane::visible)
|
||||
.filter(TerminalPane::floating)
|
||||
.toList();
|
||||
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;
|
||||
pane.bounds(
|
||||
Math.min(width - floatingWidth - 12.0, ((width - floatingWidth) / 2.0) + offset),
|
||||
Math.min(height - floatingHeight - 12.0, topInset + ((availHeight - floatingHeight) / 2.0) + offset),
|
||||
floatingWidth,
|
||||
floatingHeight
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
boolean navigate(Direction direction) {
|
||||
TerminalPane current = activePane();
|
||||
if (current.floating() && navigateFloatingStack(direction)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
TerminalPane target = panes.stream()
|
||||
.filter(TerminalPane::visible)
|
||||
.filter(pane -> pane != current)
|
||||
.filter(pane -> directionFilter(direction, current, pane))
|
||||
.min(Comparator.comparingDouble(pane -> distance(current, pane)))
|
||||
.orElse(null);
|
||||
if (target != null) {
|
||||
setActive(panes.indexOf(target));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void toggleFloating() {
|
||||
List<TerminalPane> floating = panes.stream()
|
||||
.filter(TerminalPane::floating)
|
||||
.toList();
|
||||
if (floating.isEmpty()) {
|
||||
createFloatingPane();
|
||||
return;
|
||||
}
|
||||
|
||||
boolean anyVisible = floating.stream().anyMatch(TerminalPane::visible);
|
||||
if (anyVisible) {
|
||||
TerminalPane active = activePane();
|
||||
hiddenFloatingFocusIndex = active.floating() ? activeIndex : firstVisibleFloatingIndex();
|
||||
floating.forEach(pane -> pane.setVisible(false));
|
||||
setActive(firstVisibleNonFloatingIndex());
|
||||
} else {
|
||||
floating.forEach(pane -> pane.setVisible(true));
|
||||
setActive(visibleIndexOrFallback(hiddenFloatingFocusIndex, panes.indexOf(floating.get(floating.size() - 1))));
|
||||
hiddenFloatingFocusIndex = -1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* "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() {
|
||||
if (anyFloatingVisible()) {
|
||||
createFloatingPane();
|
||||
} else {
|
||||
TerminalPane pane = openPane(false);
|
||||
panes.add(pane);
|
||||
setActive(panes.size() - 1);
|
||||
}
|
||||
}
|
||||
|
||||
void nextFloatingPane() {
|
||||
TerminalPane next = nextFloatingAfter(activeIndex);
|
||||
next.setVisible(true);
|
||||
setActive(panes.indexOf(next));
|
||||
}
|
||||
|
||||
void closeActivePane() {
|
||||
TerminalPane active = activePane();
|
||||
int removed = activeIndex;
|
||||
int previous = previousVisibleIndex(removed);
|
||||
panes.remove(removed);
|
||||
if (active == lastFocusedFloating) {
|
||||
lastFocusedFloating = null;
|
||||
}
|
||||
active.close();
|
||||
if (panes.isEmpty()) {
|
||||
activeIndex = 0;
|
||||
return;
|
||||
}
|
||||
activeIndex = adjustIndexAfterRemoval(previous, removed);
|
||||
hiddenFloatingFocusIndex = adjustHiddenFocusAfterRemoval(hiddenFloatingFocusIndex, removed);
|
||||
|
||||
// If the last tiled (main) pane was closed, promote a floating pane to be the new
|
||||
// main pane so the layout has a base and rendering continues normally. Prefer the
|
||||
// most recently focused floating pane.
|
||||
if (panes.stream().noneMatch(pane -> !pane.floating())) {
|
||||
TerminalPane promote = (lastFocusedFloating != null && panes.contains(lastFocusedFloating))
|
||||
? lastFocusedFloating
|
||||
: panes.get(activeIndex);
|
||||
promote.setFloating(false);
|
||||
promote.setVisible(true);
|
||||
activeIndex = panes.indexOf(promote);
|
||||
lastFocusedFloating = null;
|
||||
}
|
||||
|
||||
// If only hidden panes remained, reveal the one we're focusing so the screen isn't
|
||||
// blank.
|
||||
if (!panes.get(activeIndex).visible()) {
|
||||
panes.get(activeIndex).setVisible(true);
|
||||
}
|
||||
}
|
||||
|
||||
private void setActive(int index) {
|
||||
activeIndex = index;
|
||||
if (index >= 0 && index < panes.size() && panes.get(index).floating()) {
|
||||
lastFocusedFloating = panes.get(index);
|
||||
}
|
||||
}
|
||||
|
||||
private void createFloatingPane() {
|
||||
TerminalPane pane = openPane(true);
|
||||
panes.add(pane);
|
||||
setActive(panes.size() - 1);
|
||||
}
|
||||
|
||||
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) {
|
||||
List<TerminalPane> floating = panes.stream()
|
||||
.filter(TerminalPane::visible)
|
||||
.filter(TerminalPane::floating)
|
||||
.toList();
|
||||
if (floating.size() < 2) {
|
||||
return false;
|
||||
}
|
||||
|
||||
int current = floating.indexOf(activePane());
|
||||
if (current < 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
int next = switch (direction) {
|
||||
case LEFT, UP -> current - 1;
|
||||
case DOWN, RIGHT -> current + 1;
|
||||
};
|
||||
if (next < 0 || next >= floating.size()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
setActive(panes.indexOf(floating.get(next)));
|
||||
return true;
|
||||
}
|
||||
|
||||
private int firstVisibleFloatingIndex() {
|
||||
for (int i = 0; i < panes.size(); i++) {
|
||||
TerminalPane pane = panes.get(i);
|
||||
if (pane.visible() && pane.floating()) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
private int firstVisibleNonFloatingIndex() {
|
||||
for (int i = 0; i < panes.size(); i++) {
|
||||
TerminalPane pane = panes.get(i);
|
||||
if (pane.visible() && !pane.floating()) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
private int previousVisibleIndex(int index) {
|
||||
for (int i = index - 1; i >= 0; i--) {
|
||||
if (panes.get(i).visible()) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
for (int i = index + 1; i < panes.size(); i++) {
|
||||
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) {
|
||||
double currentCenterX = current.x() + current.width() / 2.0;
|
||||
double currentCenterY = current.y() + current.height() / 2.0;
|
||||
double candidateCenterX = candidate.x() + candidate.width() / 2.0;
|
||||
double candidateCenterY = candidate.y() + candidate.height() / 2.0;
|
||||
|
||||
return switch (direction) {
|
||||
case LEFT -> candidateCenterX < currentCenterX;
|
||||
case DOWN -> candidateCenterY > currentCenterY;
|
||||
case UP -> candidateCenterY < currentCenterY;
|
||||
case RIGHT -> candidateCenterX > currentCenterX;
|
||||
};
|
||||
}
|
||||
|
||||
private static double distance(TerminalPane current, TerminalPane candidate) {
|
||||
double dx = (current.x() + current.width() / 2.0) - (candidate.x() + candidate.width() / 2.0);
|
||||
double dy = (current.y() + current.height() / 2.0) - (candidate.y() + candidate.height() / 2.0);
|
||||
return Math.sqrt(dx * dx + dy * dy);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
for (TerminalPane pane : panes) {
|
||||
pane.close();
|
||||
}
|
||||
panes.clear();
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import dev.jlibghostty.RenderColor;
|
||||
import dev.jlibghostty.RenderCursorStyle;
|
||||
import dev.jlibghostty.RenderRow;
|
||||
import dev.jlibghostty.RenderStateSnapshot;
|
||||
import javafx.geometry.VPos;
|
||||
import javafx.scene.canvas.Canvas;
|
||||
import javafx.scene.canvas.GraphicsContext;
|
||||
import javafx.scene.image.Image;
|
||||
@@ -29,6 +30,7 @@ import javafx.scene.paint.Color;
|
||||
import javafx.scene.text.Font;
|
||||
import javafx.scene.text.FontSmoothingType;
|
||||
import javafx.scene.text.Text;
|
||||
import javafx.scene.text.TextAlignment;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.util.HashMap;
|
||||
@@ -38,19 +40,33 @@ import java.util.Map;
|
||||
public final class TerminalCanvasView {
|
||||
private static final Color DEFAULT_FOREGROUND = Color.rgb(225, 229, 235);
|
||||
private static final Color SELECTED_BACKGROUND = Color.rgb(52, 92, 140);
|
||||
// The default cell background (used for cells with no explicit bg, and as the foreground
|
||||
// for reverse-video cells whose background is the terminal default).
|
||||
private static final Color PANE_BACKGROUND = Color.rgb(9, 10, 12);
|
||||
// Canvas background shown wherever no pane covers (gaps, behind nothing once tiled panes
|
||||
// fill the canvas). Painted on a full recomposite.
|
||||
private static final Color GAP_BACKGROUND = Color.rgb(16, 16, 18);
|
||||
|
||||
private final Canvas canvas = new Canvas();
|
||||
private final TerminalWorkspace workspace;
|
||||
private final AppConfig config;
|
||||
private final Map<KittyImageKey, Image> kittyImageCache = new HashMap<>();
|
||||
private final Map<TerminalPane, PaneRenderCache> paneRenderCache = new HashMap<>();
|
||||
// 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<>();
|
||||
private String fontFamily;
|
||||
private double fontSize;
|
||||
private Font cachedFont;
|
||||
private FontMetrics cachedMetrics;
|
||||
private String cachedFontFamily;
|
||||
private double cachedFontSize;
|
||||
private String lastRenderKey;
|
||||
// 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 lastWorkspaceVersion = Long.MIN_VALUE;
|
||||
private long lastRenderTick = Long.MIN_VALUE;
|
||||
private boolean mouseButtonPressed;
|
||||
private MouseButton pressedButton = MouseButton.UNKNOWN;
|
||||
|
||||
@@ -76,144 +92,391 @@ public final class TerminalCanvasView {
|
||||
this.fontSize = size;
|
||||
cachedFont = null;
|
||||
cachedMetrics = null;
|
||||
paneRenderCache.clear();
|
||||
lastRenderKey = null;
|
||||
paneContentVersion.clear();
|
||||
lastWidth = -1.0; // force a redraw on the next frame
|
||||
}
|
||||
|
||||
// GhosttyRenderStateDirty values (stable C ABI; see ghostty/vt/render.h).
|
||||
private static final int DIRTY_PARTIAL = 1;
|
||||
private static final int DIRTY_FULL = 2;
|
||||
|
||||
// Thin tab strip shown at the top when more than one tab is open.
|
||||
private static final double TAB_BAR_HEIGHT = 22.0;
|
||||
|
||||
public void render() {
|
||||
double width = canvas.getWidth();
|
||||
double height = canvas.getHeight();
|
||||
workspace.layout(width, height);
|
||||
long workspaceVersion = workspace.version();
|
||||
long renderTick = TerminalPane.renderTick();
|
||||
|
||||
// Two kinds of change: a layout change (size, font, active pane, pane set / z-order)
|
||||
// forces a full recomposite; a content change (renderTick) only repaints the panes
|
||||
// whose terminal content changed. Idle frames — neither — bail out immediately.
|
||||
boolean layoutChanged = width != lastWidth || height != lastHeight
|
||||
|| fontSize != lastFontSize || !java.util.Objects.equals(fontFamily, lastFontFamily)
|
||||
|| workspaceVersion != lastWorkspaceVersion;
|
||||
boolean contentChanged = renderTick != lastRenderTick;
|
||||
if (!layoutChanged && !contentChanged) {
|
||||
return;
|
||||
}
|
||||
lastWidth = width;
|
||||
lastHeight = height;
|
||||
lastFontFamily = fontFamily;
|
||||
lastFontSize = fontSize;
|
||||
lastWorkspaceVersion = workspaceVersion;
|
||||
lastRenderTick = renderTick;
|
||||
|
||||
double topInset = workspace.tabCount() > 1 ? TAB_BAR_HEIGHT : 0.0;
|
||||
workspace.layout(width, height, topInset);
|
||||
Font font = currentFont();
|
||||
FontMetrics metrics = currentFontMetrics();
|
||||
List<TerminalPane> panes = workspace.panes();
|
||||
|
||||
String renderKey = renderKey(width, height, metrics, panes);
|
||||
if (renderKey.equals(lastRenderKey)) {
|
||||
return;
|
||||
// Apply terminal resizes up front so snapshots reflect current geometry (a no-op
|
||||
// when the grid is unchanged).
|
||||
for (TerminalPane pane : panes) {
|
||||
applyResize(pane, metrics);
|
||||
}
|
||||
lastRenderKey = renderKey;
|
||||
|
||||
GraphicsContext gc = canvas.getGraphicsContext2D();
|
||||
gc.setFill(Color.rgb(16, 16, 18));
|
||||
gc.fillRect(0, 0, width, height);
|
||||
gc.setFontSmoothingType(FontSmoothingType.LCD);
|
||||
|
||||
paneRenderCache.keySet().removeIf(pane -> !panes.contains(pane));
|
||||
if (layoutChanged) {
|
||||
// Recomposite everything onto the retained canvas: clear, then paint panes
|
||||
// bottom-to-top (workspace.panes() puts the active floating pane last == on top).
|
||||
paneContentVersion.keySet().retainAll(panes);
|
||||
gc.setFill(GAP_BACKGROUND);
|
||||
gc.fillRect(0, 0, width, height);
|
||||
if (topInset > 0.0) {
|
||||
drawTabBar(gc, width, topInset);
|
||||
}
|
||||
for (TerminalPane pane : panes) {
|
||||
drawPane(gc, pane, font, metrics);
|
||||
paintPane(gc, pane, font, metrics, pane.renderSnapshotFull());
|
||||
paneContentVersion.put(pane, pane.renderVersion());
|
||||
}
|
||||
}
|
||||
|
||||
private void drawPane(GraphicsContext gc, TerminalPane pane, Font font, FontMetrics metrics) {
|
||||
if (config.kittyGraphics() && paneHasKittyGraphics(pane)) {
|
||||
paneRenderCache.remove(pane);
|
||||
gc.save();
|
||||
gc.beginPath();
|
||||
gc.rect(pane.x(), pane.y(), pane.width(), pane.height());
|
||||
gc.clip();
|
||||
drawPaneContent(gc, pane, font, metrics, pane.x(), pane.y(), pane.width(), pane.height(), false);
|
||||
gc.restore();
|
||||
return;
|
||||
}
|
||||
|
||||
PaneRenderCache cache = paneRenderCache.computeIfAbsent(pane, ignored -> new PaneRenderCache());
|
||||
String cacheKey = paneCacheKey(pane, metrics);
|
||||
int imageWidth = Math.max(1, (int) Math.ceil(pane.width()));
|
||||
int imageHeight = Math.max(1, (int) Math.ceil(pane.height()));
|
||||
|
||||
// Allocate the offscreen buffers only when the pane size changes. Reallocating a
|
||||
// full-pane Canvas + WritableImage on every content change churns ~20 MB per frame,
|
||||
// which the native image's serial GC turns into Full-GC frame drops.
|
||||
if (cache.canvas == null || cache.image == null || cache.imageWidth != imageWidth || cache.imageHeight != imageHeight) {
|
||||
cache.canvas = new Canvas(imageWidth, imageHeight);
|
||||
cache.image = new WritableImage(imageWidth, imageHeight);
|
||||
cache.imageWidth = imageWidth;
|
||||
cache.imageHeight = imageHeight;
|
||||
cache.key = null;
|
||||
// Content-only frame: repaint just the panes whose content changed, directly on the
|
||||
// retained canvas, then restore any panes stacked above where they overlap.
|
||||
for (int i = 0; i < panes.size(); i++) {
|
||||
TerminalPane pane = panes.get(i);
|
||||
Long drawn = paneContentVersion.get(pane);
|
||||
if (drawn != null && drawn == pane.renderVersion()) {
|
||||
continue;
|
||||
}
|
||||
repaintPaneContent(gc, panes, i, font, metrics);
|
||||
paneContentVersion.put(pane, pane.renderVersion());
|
||||
}
|
||||
}
|
||||
|
||||
// Redraw and re-snapshot into the existing buffers only when content changed.
|
||||
if (!cacheKey.equals(cache.key)) {
|
||||
GraphicsContext cacheGc = cache.canvas.getGraphicsContext2D();
|
||||
cacheGc.clearRect(0, 0, imageWidth, imageHeight);
|
||||
drawPaneContent(cacheGc, pane, font, metrics, 0.0, 0.0, imageWidth, imageHeight, true);
|
||||
cache.canvas.snapshot(null, cache.image);
|
||||
cache.key = cacheKey;
|
||||
private void applyResize(TerminalPane pane, FontMetrics metrics) {
|
||||
int columns = Math.max(1, (int) ((pane.width() - 24.0) / metrics.cellWidth));
|
||||
int rows = Math.max(1, (int) ((pane.height() - 24.0) / metrics.lineHeight));
|
||||
pane.resize(columns, rows, (int) Math.round(metrics.cellWidth), (int) Math.round(metrics.lineHeight));
|
||||
}
|
||||
|
||||
gc.drawImage(cache.image, pane.x(), pane.y());
|
||||
// Paint a pane's whole body, clipped to its rect. Used for full recomposites.
|
||||
private void paintPane(GraphicsContext gc, TerminalPane pane, Font font, FontMetrics metrics, RenderStateSnapshot snapshot) {
|
||||
double px = Math.round(pane.x());
|
||||
double py = Math.round(pane.y());
|
||||
gc.save();
|
||||
clipRect(gc, px, py, pane.width(), pane.height());
|
||||
drawPaneContent(gc, pane, font, metrics, snapshot, px, py, pane.width(), pane.height(),
|
||||
config.kittyGraphics() && paneHasKittyGraphics(pane));
|
||||
gc.restore();
|
||||
}
|
||||
|
||||
// Repaint one pane whose content changed, then restore the (opaque) panes stacked above
|
||||
// it wherever they overlap the repainted region, so the z-order stays correct.
|
||||
private void repaintPaneContent(GraphicsContext gc, List<TerminalPane> panes, int index, Font font, FontMetrics metrics) {
|
||||
TerminalPane pane = panes.get(index);
|
||||
double px = Math.round(pane.x());
|
||||
double py = Math.round(pane.y());
|
||||
double pw = pane.width();
|
||||
double ph = pane.height();
|
||||
boolean kitty = config.kittyGraphics() && paneHasKittyGraphics(pane);
|
||||
|
||||
double regionY0;
|
||||
double regionY1;
|
||||
gc.save();
|
||||
clipRect(gc, px, py, pw, ph);
|
||||
if (kitty) {
|
||||
drawPaneContent(gc, pane, font, metrics, pane.renderSnapshotFull(), px, py, pw, ph, true);
|
||||
regionY0 = py;
|
||||
regionY1 = py + ph;
|
||||
} else {
|
||||
RenderStateSnapshot snapshot = pane.renderSnapshot();
|
||||
int dirty = snapshot == null ? DIRTY_FULL : snapshot.dirty();
|
||||
if (dirty == DIRTY_FULL) {
|
||||
drawPaneContent(gc, pane, font, metrics, snapshot, px, py, pw, ph, false);
|
||||
regionY0 = py;
|
||||
regionY1 = py + ph;
|
||||
} else if (dirty == DIRTY_PARTIAL) {
|
||||
double[] band = drawDirtyRows(gc, pane, font, metrics, snapshot, px, py, pw, ph);
|
||||
gc.restore();
|
||||
if (band == null) {
|
||||
return;
|
||||
}
|
||||
restoreStackedAbove(gc, panes, index, font, metrics, px, band[0], pw, band[1] - band[0]);
|
||||
return;
|
||||
} else {
|
||||
gc.restore();
|
||||
return; // dirty == FALSE: nothing visible changed.
|
||||
}
|
||||
}
|
||||
gc.restore();
|
||||
restoreStackedAbove(gc, panes, index, font, metrics, px, regionY0, pw, regionY1 - regionY0);
|
||||
}
|
||||
|
||||
// Redraw any panes above `index` in z-order that intersect the given screen rect, so a
|
||||
// lower pane's repaint doesn't leak through a pane stacked on top of it.
|
||||
private void restoreStackedAbove(GraphicsContext gc, List<TerminalPane> panes, int index,
|
||||
Font font, FontMetrics metrics, double rx, double ry, double rw, double rh) {
|
||||
for (int j = index + 1; j < panes.size(); j++) {
|
||||
TerminalPane above = panes.get(j);
|
||||
double ax = Math.round(above.x());
|
||||
double ay = Math.round(above.y());
|
||||
double ox0 = Math.max(rx, ax);
|
||||
double oy0 = Math.max(ry, ay);
|
||||
double ox1 = Math.min(rx + rw, ax + above.width());
|
||||
double oy1 = Math.min(ry + rh, ay + above.height());
|
||||
if (ox1 <= ox0 || oy1 <= oy0) {
|
||||
continue;
|
||||
}
|
||||
gc.save();
|
||||
clipRect(gc, ox0, oy0, ox1 - ox0, oy1 - oy0);
|
||||
drawPaneContent(gc, above, font, metrics, above.renderSnapshotFull(), ax, ay, above.width(), above.height(),
|
||||
config.kittyGraphics() && paneHasKittyGraphics(above));
|
||||
gc.restore();
|
||||
}
|
||||
}
|
||||
|
||||
private static void clipRect(GraphicsContext gc, double x, double y, double width, double height) {
|
||||
gc.beginPath();
|
||||
gc.rect(x, y, width, height);
|
||||
gc.clip();
|
||||
}
|
||||
|
||||
// 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 = workspace.tabCount();
|
||||
int currentIndex = workspace.currentTabIndex();
|
||||
Font barFont = Font.font(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 == currentIndex;
|
||||
gc.setFill(current ? Color.rgb(45, 55, 72) : Color.rgb(22, 24, 28));
|
||||
gc.fillRect(x, 0.0, segmentWidth - gap, barHeight);
|
||||
gc.setFill(current ? DEFAULT_FOREGROUND : 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);
|
||||
}
|
||||
|
||||
// Full content render: background, border, all rows, cursor, and (when enabled) kitty
|
||||
// graphics. Used by the kitty direct path and by full offscreen redraws.
|
||||
private void drawPaneContent(
|
||||
GraphicsContext gc,
|
||||
TerminalPane pane,
|
||||
Font font,
|
||||
FontMetrics metrics,
|
||||
RenderStateSnapshot snapshot,
|
||||
double x,
|
||||
double y,
|
||||
double width,
|
||||
double height,
|
||||
boolean clear
|
||||
boolean withKitty
|
||||
) {
|
||||
if (clear) {
|
||||
gc.clearRect(x, y, width, height);
|
||||
}
|
||||
gc.setFontSmoothingType(FontSmoothingType.LCD);
|
||||
if (pane.floating()) {
|
||||
gc.setGlobalAlpha(0.96);
|
||||
}
|
||||
gc.setFill(Color.rgb(9, 10, 12));
|
||||
gc.setFill(PANE_BACKGROUND);
|
||||
gc.fillRect(x, y, width, height);
|
||||
gc.setGlobalAlpha(1.0);
|
||||
|
||||
gc.setStroke(workspace.isActive(pane) ? Color.rgb(87, 166, 255) : Color.rgb(52, 57, 65));
|
||||
gc.setLineWidth(workspace.isActive(pane) ? 2.0 : 1.0);
|
||||
gc.strokeRect(x + 0.5, y + 0.5, width - 1.0, height - 1.0);
|
||||
|
||||
gc.setFont(font);
|
||||
|
||||
int columns = Math.max(1, (int) ((width - 24.0) / metrics.cellWidth));
|
||||
int rows = Math.max(1, (int) ((height - 24.0) / metrics.lineHeight));
|
||||
pane.resize(columns, rows, (int) Math.round(metrics.cellWidth), (int) Math.round(metrics.lineHeight));
|
||||
|
||||
double left = x + 12.0;
|
||||
double top = y + 12.0;
|
||||
double baseline = top + metrics.baselineOffset;
|
||||
|
||||
RenderStateSnapshot snapshot = pane.renderSnapshot();
|
||||
Map<KittyPlaceholderKey, KittyPlaceholderBounds> placeholderBounds = config.kittyGraphics()
|
||||
Map<KittyPlaceholderKey, KittyPlaceholderBounds> placeholderBounds = withKitty
|
||||
? kittyPlaceholderBounds(snapshot)
|
||||
: Map.of();
|
||||
|
||||
if (config.kittyGraphics()) {
|
||||
if (withKitty) {
|
||||
drawKittyGraphics(gc, pane, KittyPlacementLayer.BELOW_TEXT, placeholderBounds, left, top, metrics.cellWidth, metrics.lineHeight);
|
||||
}
|
||||
|
||||
if (snapshot != null) {
|
||||
double contentBottom = top + snapshot.rows() * metrics.lineHeight;
|
||||
fillVerticalPadding(gc, snapshot, x, y, width, height, top, contentBottom);
|
||||
for (RenderRow row : snapshot.renderRows()) {
|
||||
double y0 = Math.floor(top + (row.row() * metrics.lineHeight));
|
||||
double y1 = Math.ceil(top + ((row.row() + 1) * metrics.lineHeight));
|
||||
paintSidePadding(gc, row, x, width, left, metrics.cellWidth, y0, y1 - y0);
|
||||
drawRow(gc, row, left, top, baseline, metrics.cellWidth, metrics.lineHeight);
|
||||
}
|
||||
}
|
||||
|
||||
if (snapshot != null) {
|
||||
drawCursor(gc, snapshot, left, top, metrics.cellWidth, metrics.lineHeight);
|
||||
}
|
||||
|
||||
if (config.kittyGraphics()) {
|
||||
if (withKitty) {
|
||||
drawKittyGraphics(gc, pane, KittyPlacementLayer.ABOVE_TEXT, placeholderBounds, left, top, metrics.cellWidth, metrics.lineHeight);
|
||||
}
|
||||
|
||||
drawBorder(gc, pane, x, y, width, height);
|
||||
}
|
||||
|
||||
// Effective background colour of a cell as it is drawn (reverse video swaps fg/bg, an
|
||||
// unset colour falls back to the defaults).
|
||||
private static Color cellBackgroundColor(RenderCell cell) {
|
||||
if (cell.inverse()) {
|
||||
var fg = cell.foreground();
|
||||
return fg.isPresent() ? toFxColor(fg.get()) : DEFAULT_FOREGROUND;
|
||||
}
|
||||
var bg = cell.background();
|
||||
return bg.isPresent() ? toFxColor(bg.get()) : PANE_BACKGROUND;
|
||||
}
|
||||
|
||||
private static Color rowEdgeBackground(RenderRow row, boolean firstCell) {
|
||||
List<RenderCell> cells = row.cells();
|
||||
if (cells.isEmpty()) {
|
||||
return PANE_BACKGROUND;
|
||||
}
|
||||
return cellBackgroundColor(firstCell ? cells.get(0) : cells.get(cells.size() - 1));
|
||||
}
|
||||
|
||||
// Extend the row's edge-cell backgrounds into the left/right padding (the 12px margin and
|
||||
// the right-edge rounding sliver), so the unused space matches the rendered content.
|
||||
private void paintSidePadding(GraphicsContext gc, RenderRow row, double paneX, double paneWidth,
|
||||
double contentLeft, double cellWidth, double yTop, double bandHeight) {
|
||||
int columns = row.cells().size();
|
||||
if (columns == 0) {
|
||||
return;
|
||||
}
|
||||
double contentRight = contentLeft + (columns * cellWidth);
|
||||
gc.setFill(rowEdgeBackground(row, true));
|
||||
gc.fillRect(paneX, yTop, contentLeft - paneX, bandHeight);
|
||||
gc.setFill(rowEdgeBackground(row, false));
|
||||
gc.fillRect(contentRight, yTop, paneX + paneWidth - contentRight, bandHeight);
|
||||
}
|
||||
|
||||
// Fill the top/bottom padding strips with the top/bottom row's edge colour.
|
||||
private void fillVerticalPadding(GraphicsContext gc, RenderStateSnapshot snapshot,
|
||||
double paneX, double paneY, double paneWidth, double paneHeight, double contentTop, double contentBottom) {
|
||||
List<RenderRow> rows = snapshot.renderRows();
|
||||
if (rows.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
gc.setFill(rowEdgeBackground(rows.get(0), true));
|
||||
gc.fillRect(paneX, paneY, paneWidth, contentTop - paneY);
|
||||
gc.setFill(rowEdgeBackground(rows.get(rows.size() - 1), true));
|
||||
gc.fillRect(paneX, contentBottom, paneWidth, paneY + paneHeight - contentBottom);
|
||||
}
|
||||
|
||||
// Incremental render: repaint only the rows ghostty flagged dirty, at the pane's screen
|
||||
// origin, then restore the cursor and border. Returns the screen-space [minY, maxY] band
|
||||
// that was repainted (for restoring panes stacked above), or null if nothing was dirty.
|
||||
private double[] drawDirtyRows(
|
||||
GraphicsContext gc,
|
||||
TerminalPane pane,
|
||||
Font font,
|
||||
FontMetrics metrics,
|
||||
RenderStateSnapshot snapshot,
|
||||
double px,
|
||||
double py,
|
||||
double pw,
|
||||
double ph
|
||||
) {
|
||||
gc.setFontSmoothingType(FontSmoothingType.LCD);
|
||||
gc.setFont(font);
|
||||
double left = px + 12.0;
|
||||
double top = py + 12.0;
|
||||
double baseline = top + metrics.baselineOffset;
|
||||
|
||||
double contentBottom = top + snapshot.rows() * metrics.lineHeight;
|
||||
int lastRow = snapshot.rows() - 1;
|
||||
boolean cursorRowDirty = false;
|
||||
double bandMin = Double.POSITIVE_INFINITY;
|
||||
double bandMax = Double.NEGATIVE_INFINITY;
|
||||
for (RenderRow row : snapshot.renderRows()) {
|
||||
if (!row.dirty()) {
|
||||
continue;
|
||||
}
|
||||
// Snap the row band to integer pixels and paint opaque: a fractional-height fill
|
||||
// would leave sub-pixel seams between rows.
|
||||
double y0 = Math.floor(top + (row.row() * metrics.lineHeight));
|
||||
double y1 = Math.ceil(top + ((row.row() + 1) * metrics.lineHeight));
|
||||
gc.setFill(PANE_BACKGROUND);
|
||||
gc.fillRect(px, y0, pw, y1 - y0);
|
||||
paintSidePadding(gc, row, px, pw, left, metrics.cellWidth, y0, y1 - y0);
|
||||
drawRow(gc, row, left, top, baseline, metrics.cellWidth, metrics.lineHeight);
|
||||
bandMin = Math.min(bandMin, y0);
|
||||
bandMax = Math.max(bandMax, y1);
|
||||
// Edge rows also own the top/bottom padding strip; repaint it and extend the
|
||||
// band so panes stacked above get restored over it too.
|
||||
if (row.row() == 0) {
|
||||
gc.setFill(rowEdgeBackground(row, true));
|
||||
gc.fillRect(px, py, pw, top - py);
|
||||
bandMin = Math.min(bandMin, py);
|
||||
}
|
||||
if (row.row() == lastRow) {
|
||||
gc.setFill(rowEdgeBackground(row, true));
|
||||
gc.fillRect(px, contentBottom, pw, py + ph - contentBottom);
|
||||
bandMax = Math.max(bandMax, py + ph);
|
||||
}
|
||||
if (snapshot.cursorViewportHasValue() && row.row() == snapshot.cursorViewportY()) {
|
||||
cursorRowDirty = true;
|
||||
}
|
||||
}
|
||||
if (bandMin > bandMax) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// The cursor overlays its cell; redraw it only when its row was repainted, so we
|
||||
// neither leave a stale cursor nor stack the translucent overlay on itself.
|
||||
if (cursorRowDirty) {
|
||||
drawCursor(gc, snapshot, left, top, metrics.cellWidth, metrics.lineHeight);
|
||||
}
|
||||
// Repainting rows clears the side borders within the band; restore just those
|
||||
// segments. Clipping to the band is important: the full border rectangle extends
|
||||
// outside the repainted region, and only the band gets restored over panes stacked
|
||||
// above — an unclipped stroke would leave this pane's outline on top of them.
|
||||
gc.save();
|
||||
clipRect(gc, px, bandMin, pw, bandMax - bandMin);
|
||||
drawBorder(gc, pane, px, py, pw, ph);
|
||||
gc.restore();
|
||||
return new double[] {bandMin, bandMax};
|
||||
}
|
||||
|
||||
private void drawBorder(GraphicsContext gc, TerminalPane pane, double x, double y, double width, double height) {
|
||||
gc.setStroke(workspace.isActive(pane) ? Color.rgb(87, 166, 255) : Color.rgb(52, 57, 65));
|
||||
gc.setLineWidth(workspace.isActive(pane) ? 2.0 : 1.0);
|
||||
gc.strokeRect(x + 0.5, y + 0.5, width - 1.0, height - 1.0);
|
||||
}
|
||||
|
||||
private static FontMetrics measureFontMetrics(Font font) {
|
||||
Text text = new Text("┃MgÅjy");
|
||||
text.setFont(font);
|
||||
double lineHeight = Math.max(1.0, text.getLayoutBounds().getHeight());
|
||||
// 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.
|
||||
double lineHeight = Math.max(1.0, Math.round(text.getLayoutBounds().getHeight()));
|
||||
double baselineOffset = -text.getLayoutBounds().getMinY();
|
||||
|
||||
Text cell = new Text("M");
|
||||
cell.setFont(font);
|
||||
double cellWidth = Math.max(1.0, cell.getLayoutBounds().getWidth());
|
||||
double cellWidth = Math.max(1.0, Math.round(cell.getLayoutBounds().getWidth()));
|
||||
return new FontMetrics(cellWidth, lineHeight, baselineOffset);
|
||||
}
|
||||
|
||||
@@ -234,40 +497,9 @@ public final class TerminalCanvasView {
|
||||
return cachedMetrics;
|
||||
}
|
||||
|
||||
private String renderKey(double width, double height, FontMetrics metrics, List<TerminalPane> panes) {
|
||||
StringBuilder builder = new StringBuilder();
|
||||
builder.append(width).append(':')
|
||||
.append(height).append(':')
|
||||
.append(workspace.version()).append(':')
|
||||
.append(fontFamily).append(':')
|
||||
.append(fontSize).append(':')
|
||||
.append(metrics.cellWidth).append(':')
|
||||
.append(metrics.lineHeight);
|
||||
for (TerminalPane pane : panes) {
|
||||
builder.append('|')
|
||||
.append(System.identityHashCode(pane)).append(',')
|
||||
.append(pane.renderVersion()).append(',')
|
||||
.append(workspace.isActive(pane)).append(',')
|
||||
.append(pane.x()).append(',')
|
||||
.append(pane.y()).append(',')
|
||||
.append(pane.width()).append(',')
|
||||
.append(pane.height());
|
||||
}
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
private String paneCacheKey(TerminalPane pane, FontMetrics metrics) {
|
||||
return pane.renderVersion()
|
||||
+ ":" + workspace.isActive(pane)
|
||||
+ ":" + pane.width()
|
||||
+ ":" + pane.height()
|
||||
+ ":" + fontFamily
|
||||
+ ":" + fontSize
|
||||
+ ":" + metrics.cellWidth
|
||||
+ ":" + metrics.lineHeight
|
||||
+ ":" + config.kittyGraphics();
|
||||
}
|
||||
|
||||
// Layout identity of a pane: everything that forces a redraw EXCEPT terminal content
|
||||
// (which is tracked separately by renderVersion). Deliberately omits renderVersion so
|
||||
// content changes go through the incremental dirty-row path instead of a full redraw.
|
||||
private static Map<KittyPlaceholderKey, KittyPlaceholderBounds> kittyPlaceholderBounds(RenderStateSnapshot snapshot) {
|
||||
if (snapshot == null) {
|
||||
return Map.of();
|
||||
@@ -475,10 +707,26 @@ public final class TerminalCanvasView {
|
||||
|
||||
double x = left + (cell.column() * cellWidth);
|
||||
double cellTop = top + (row.row() * lineHeight);
|
||||
cell.background().ifPresent(background -> {
|
||||
gc.setFill(toFxColor(background));
|
||||
|
||||
// Resolve fg/bg (null bg = terminal default, painted by the pane background).
|
||||
// Avoid Optional.map's allocation on this hot path.
|
||||
var fgOpt = cell.foreground();
|
||||
var bgOpt = cell.background();
|
||||
Color fg = fgOpt.isPresent() ? toFxColor(fgOpt.get()) : DEFAULT_FOREGROUND;
|
||||
Color bg = bgOpt.isPresent() ? toFxColor(bgOpt.get()) : null;
|
||||
|
||||
// Reverse video: ghostty does not bake inverse into the resolved colours, so we
|
||||
// swap them here, falling back to the terminal defaults for whichever is unset.
|
||||
if (cell.inverse()) {
|
||||
Color swappedBg = fg;
|
||||
fg = (bg != null) ? bg : PANE_BACKGROUND;
|
||||
bg = swappedBg;
|
||||
}
|
||||
|
||||
if (bg != null) {
|
||||
gc.setFill(bg);
|
||||
gc.fillRect(x, cellTop, cellWidth, lineHeight);
|
||||
});
|
||||
}
|
||||
if (cell.selected()) {
|
||||
gc.setFill(SELECTED_BACKGROUND);
|
||||
gc.fillRect(x, cellTop, cellWidth, lineHeight);
|
||||
@@ -488,14 +736,28 @@ public final class TerminalCanvasView {
|
||||
}
|
||||
|
||||
double y = baseline + (row.row() * lineHeight);
|
||||
Color foreground = cell.foreground().map(TerminalCanvasView::toFxColor).orElse(DEFAULT_FOREGROUND);
|
||||
gc.setFill(foreground);
|
||||
gc.setFill(fg);
|
||||
gc.fillText(cell.text(), x, y);
|
||||
}
|
||||
}
|
||||
|
||||
// A full-screen redraw asks for one Color per cell; most cells share a handful of
|
||||
// colors, so cache them by packed RGB instead of allocating a Color each time. Bounded
|
||||
// so a truecolor gradient can't grow it without limit.
|
||||
private static final Map<Integer, Color> COLOR_CACHE = new HashMap<>();
|
||||
|
||||
private static Color toFxColor(RenderColor color) {
|
||||
return Color.rgb(color.red(), color.green(), color.blue());
|
||||
int key = (color.red() << 16) | (color.green() << 8) | color.blue();
|
||||
Color cached = COLOR_CACHE.get(key);
|
||||
if (cached != null) {
|
||||
return cached;
|
||||
}
|
||||
if (COLOR_CACHE.size() >= 4096) {
|
||||
COLOR_CACHE.clear();
|
||||
}
|
||||
Color created = Color.rgb(color.red(), color.green(), color.blue());
|
||||
COLOR_CACHE.put(key, created);
|
||||
return created;
|
||||
}
|
||||
|
||||
private static void drawCursor(GraphicsContext gc, RenderStateSnapshot snapshot, double left, double top, double cellWidth, double lineHeight) {
|
||||
@@ -718,7 +980,12 @@ public final class TerminalCanvasView {
|
||||
private record MouseTarget(MouseEncoderSize size, long screenWidth, long screenHeight) {
|
||||
}
|
||||
|
||||
private record KittyImageKey(long id, long number, long width, long height, KittyImageFormat format, int dataLength, long fingerprint) {
|
||||
// A kitty image is immutable for a given (id, number); re-transmitting under the same
|
||||
// id changes the number (and the snapshot below evicts stale entries by id anyway). So
|
||||
// the identity + dimensions + payload length are enough to key the decoded-image cache,
|
||||
// and we avoid fingerprinting the whole payload — which previously ran once per frame
|
||||
// per placement (O(image size)) just to look the image up.
|
||||
private record KittyImageKey(long id, long number, long width, long height, KittyImageFormat format, int dataLength) {
|
||||
private static KittyImageKey of(KittyImageSnapshot snapshot, byte[] data) {
|
||||
return new KittyImageKey(
|
||||
snapshot.id(),
|
||||
@@ -726,19 +993,9 @@ public final class TerminalCanvasView {
|
||||
snapshot.width(),
|
||||
snapshot.height(),
|
||||
snapshot.format(),
|
||||
data.length,
|
||||
fingerprint(data)
|
||||
data.length
|
||||
);
|
||||
}
|
||||
|
||||
private static long fingerprint(byte[] data) {
|
||||
long hash = 0xcbf29ce484222325L;
|
||||
for (byte value : data) {
|
||||
hash ^= Byte.toUnsignedInt(value);
|
||||
hash *= 0x100000001b3L;
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
}
|
||||
|
||||
private record KittyPlaceholderKey(long imageId, long placementId) {
|
||||
@@ -788,12 +1045,4 @@ public final class TerminalCanvasView {
|
||||
return maxSourceColumn - minSourceColumn + 1;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class PaneRenderCache {
|
||||
private Canvas canvas;
|
||||
private WritableImage image;
|
||||
private int imageWidth;
|
||||
private int imageHeight;
|
||||
private String key;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import dev.jlibghostty.MouseAction;
|
||||
import dev.jlibghostty.MouseEncoder;
|
||||
import dev.jlibghostty.MouseEncoderSize;
|
||||
import dev.jlibghostty.MouseInput;
|
||||
import dev.jlibghostty.RenderState;
|
||||
import dev.jlibghostty.RenderStateSnapshot;
|
||||
import dev.jlibghostty.ScrollViewport;
|
||||
import dev.jlibghostty.Terminal;
|
||||
@@ -13,12 +14,23 @@ import dev.jlibghostty.TerminalOptions;
|
||||
import dev.jlibghostty.DeviceAttributes;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
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
|
||||
// "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 MouseEncoder mouseEncoder = new MouseEncoder();
|
||||
private final AtomicReference<RenderStateSnapshot> renderSnapshot = new AtomicReference<>();
|
||||
// A persistent render state (reused across frames) is what makes ghostty's per-row
|
||||
// dirty tracking meaningful: update() accumulates dirty since the last resetDirty().
|
||||
private final RenderState renderState = new RenderState();
|
||||
private RenderStateSnapshot cachedSnapshot;
|
||||
private ShellSession session;
|
||||
private boolean floating;
|
||||
private boolean visible = true;
|
||||
@@ -31,6 +43,7 @@ public final class TerminalPane implements AutoCloseable {
|
||||
private int pixelWidth;
|
||||
private int pixelHeight;
|
||||
private long renderVersion;
|
||||
private long snapshotVersion = -1;
|
||||
|
||||
private TerminalPane(Terminal terminal, int columns, int rows) {
|
||||
this.terminal = terminal;
|
||||
@@ -111,8 +124,39 @@ public final class TerminalPane implements AutoCloseable {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Incremental snapshot: cells are marshalled only for rows that changed since the last
|
||||
* frame (global dirty == PARTIAL), reused across calls for the same content version.
|
||||
* Snapshotting is deferred here rather than done in refresh(), so a burst of writes
|
||||
* between two frames collapses into a single snapshot.
|
||||
*/
|
||||
public RenderStateSnapshot renderSnapshot() {
|
||||
return renderSnapshot.get();
|
||||
return snapshot(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 renderSnapshotFull() {
|
||||
return snapshot(true);
|
||||
}
|
||||
|
||||
private RenderStateSnapshot snapshot(boolean full) {
|
||||
synchronized (terminal) {
|
||||
if (full) {
|
||||
renderState.update(terminal);
|
||||
cachedSnapshot = renderState.snapshot();
|
||||
renderState.resetDirty();
|
||||
snapshotVersion = renderVersion;
|
||||
} else if (snapshotVersion != renderVersion) {
|
||||
renderState.update(terminal);
|
||||
cachedSnapshot = renderState.snapshotIncremental();
|
||||
renderState.resetDirty();
|
||||
snapshotVersion = renderVersion;
|
||||
}
|
||||
return cachedSnapshot;
|
||||
}
|
||||
}
|
||||
|
||||
public String scrollbackText() {
|
||||
@@ -192,8 +236,10 @@ public final class TerminalPane implements AutoCloseable {
|
||||
}
|
||||
|
||||
private void refresh() {
|
||||
renderSnapshot.set(terminal.renderSnapshot());
|
||||
// Only mark the pane dirty; the snapshot itself is computed lazily in
|
||||
// renderSnapshot() so a burst of writes collapses into a single snapshot per frame.
|
||||
renderVersion++;
|
||||
RENDER_TICK.incrementAndGet();
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -203,6 +249,7 @@ public final class TerminalPane implements AutoCloseable {
|
||||
session = null;
|
||||
}
|
||||
mouseEncoder.close();
|
||||
renderState.close();
|
||||
terminal.close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,293 +1,139 @@
|
||||
package com.gregor.jprototerm;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
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<TerminalPane> panes = new ArrayList<>();
|
||||
private int activeIndex;
|
||||
private int hiddenFloatingFocusIndex = -1;
|
||||
private final List<Tab> tabs = new ArrayList<>();
|
||||
private int currentTab;
|
||||
private long version;
|
||||
|
||||
public TerminalWorkspace(AppConfig config) {
|
||||
this.config = config;
|
||||
panes.add(openPane(false));
|
||||
tabs.add(new Tab(config));
|
||||
}
|
||||
|
||||
public TerminalPane activePane() {
|
||||
return panes.get(activeIndex);
|
||||
}
|
||||
|
||||
public List<TerminalPane> panes() {
|
||||
List<TerminalPane> visible = panes.stream().filter(TerminalPane::visible).toList();
|
||||
TerminalPane active = activePane();
|
||||
if (!active.visible() || !active.floating()) {
|
||||
return visible;
|
||||
}
|
||||
|
||||
List<TerminalPane> ordered = new ArrayList<>(visible.size());
|
||||
visible.stream()
|
||||
.filter(pane -> pane != active)
|
||||
.forEach(ordered::add);
|
||||
ordered.add(active);
|
||||
return List.copyOf(ordered);
|
||||
}
|
||||
|
||||
public boolean isActive(TerminalPane pane) {
|
||||
return activePane() == pane;
|
||||
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) {
|
||||
int index = panes.indexOf(pane);
|
||||
if (index >= 0 && pane.visible() && activeIndex != index) {
|
||||
activeIndex = index;
|
||||
if (!tabs.isEmpty() && current().focus(pane)) {
|
||||
version++;
|
||||
}
|
||||
}
|
||||
|
||||
public void layout(double width, double height) {
|
||||
List<TerminalPane> tiled = panes.stream()
|
||||
.filter(TerminalPane::visible)
|
||||
.filter(pane -> !pane.floating())
|
||||
.toList();
|
||||
int tileCount = Math.max(1, tiled.size());
|
||||
double tileWidth = width / tileCount;
|
||||
for (int i = 0; i < tiled.size(); i++) {
|
||||
tiled.get(i).bounds(i * tileWidth, 0, tileWidth, height);
|
||||
}
|
||||
|
||||
List<TerminalPane> floating = panes.stream()
|
||||
.filter(TerminalPane::visible)
|
||||
.filter(TerminalPane::floating)
|
||||
.toList();
|
||||
for (int i = 0; i < floating.size(); i++) {
|
||||
TerminalPane pane = floating.get(i);
|
||||
if (pane.visible() && pane.floating()) {
|
||||
double floatingWidth = Math.max(420, width * 0.58);
|
||||
double floatingHeight = Math.max(260, height * 0.58);
|
||||
double offset = i * 28.0;
|
||||
pane.bounds(
|
||||
Math.min(width - floatingWidth - 12.0, ((width - floatingWidth) / 2.0) + offset),
|
||||
Math.min(height - floatingHeight - 12.0, ((height - floatingHeight) / 2.0) + offset),
|
||||
floatingWidth,
|
||||
floatingHeight
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void navigate(Direction direction) {
|
||||
TerminalPane current = activePane();
|
||||
if (current.floating() && navigateFloatingStack(direction)) {
|
||||
if (!tabs.isEmpty() && current().navigate(direction)) {
|
||||
version++;
|
||||
return;
|
||||
}
|
||||
|
||||
panes.stream()
|
||||
.filter(TerminalPane::visible)
|
||||
.filter(pane -> pane != current)
|
||||
.filter(pane -> directionFilter(direction, current, pane))
|
||||
.min(Comparator.comparingDouble(pane -> distance(current, pane)))
|
||||
.ifPresent(pane -> {
|
||||
activeIndex = panes.indexOf(pane);
|
||||
version++;
|
||||
});
|
||||
}
|
||||
|
||||
public void toggleFloating() {
|
||||
List<TerminalPane> floating = panes.stream()
|
||||
.filter(TerminalPane::floating)
|
||||
.toList();
|
||||
if (floating.isEmpty()) {
|
||||
createFloatingPane();
|
||||
if (tabs.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
boolean anyVisible = floating.stream().anyMatch(TerminalPane::visible);
|
||||
if (anyVisible) {
|
||||
TerminalPane active = activePane();
|
||||
hiddenFloatingFocusIndex = active.floating() ? activeIndex : firstVisibleFloatingIndex();
|
||||
floating.forEach(pane -> pane.setVisible(false));
|
||||
activeIndex = firstVisibleNonFloatingIndex();
|
||||
version++;
|
||||
} else {
|
||||
floating.forEach(pane -> pane.setVisible(true));
|
||||
activeIndex = visibleIndexOrFallback(hiddenFloatingFocusIndex, panes.indexOf(floating.get(floating.size() - 1)));
|
||||
hiddenFloatingFocusIndex = -1;
|
||||
current().toggleFloating();
|
||||
version++;
|
||||
}
|
||||
}
|
||||
|
||||
public void createFloatingPane() {
|
||||
TerminalPane pane = openPane(true);
|
||||
panes.add(pane);
|
||||
activeIndex = panes.size() - 1;
|
||||
public void createPane() {
|
||||
if (tabs.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
current().createPane();
|
||||
version++;
|
||||
}
|
||||
|
||||
public void nextFloatingPane() {
|
||||
TerminalPane next = nextFloatingAfter(activeIndex);
|
||||
next.setVisible(true);
|
||||
activeIndex = panes.indexOf(next);
|
||||
if (tabs.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
current().nextFloatingPane();
|
||||
version++;
|
||||
}
|
||||
|
||||
public void closeActivePane() {
|
||||
TerminalPane active = activePane();
|
||||
if (!active.floating() || panes.stream().filter(pane -> !pane.floating()).count() == 0) {
|
||||
if (tabs.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
int removed = activeIndex;
|
||||
int previous = previousVisibleIndex(removed);
|
||||
panes.remove(removed);
|
||||
active.close();
|
||||
activeIndex = adjustIndexAfterRemoval(previous, removed);
|
||||
hiddenFloatingFocusIndex = adjustHiddenFocusAfterRemoval(hiddenFloatingFocusIndex, removed);
|
||||
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++;
|
||||
}
|
||||
|
||||
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();
|
||||
public void newTab() {
|
||||
tabs.add(new Tab(config));
|
||||
currentTab = tabs.size() - 1;
|
||||
version++;
|
||||
}
|
||||
|
||||
private TerminalPane createAndReturnFloatingPane() {
|
||||
TerminalPane pane = openPane(true);
|
||||
panes.add(pane);
|
||||
return pane;
|
||||
public void nextTab() {
|
||||
if (tabs.size() > 1) {
|
||||
currentTab = (currentTab + 1) % tabs.size();
|
||||
version++;
|
||||
}
|
||||
}
|
||||
|
||||
private boolean navigateFloatingStack(Direction direction) {
|
||||
List<TerminalPane> floating = panes.stream()
|
||||
.filter(TerminalPane::visible)
|
||||
.filter(TerminalPane::floating)
|
||||
.toList();
|
||||
if (floating.size() < 2) {
|
||||
return false;
|
||||
public void previousTab() {
|
||||
if (tabs.size() > 1) {
|
||||
currentTab = (currentTab - 1 + tabs.size()) % tabs.size();
|
||||
version++;
|
||||
}
|
||||
|
||||
int current = floating.indexOf(activePane());
|
||||
if (current < 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
int next = switch (direction) {
|
||||
case LEFT, UP -> current - 1;
|
||||
case DOWN, RIGHT -> current + 1;
|
||||
};
|
||||
if (next < 0 || next >= floating.size()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
activeIndex = panes.indexOf(floating.get(next));
|
||||
return true;
|
||||
}
|
||||
|
||||
private int firstVisibleFloatingIndex() {
|
||||
for (int i = 0; i < panes.size(); i++) {
|
||||
TerminalPane pane = panes.get(i);
|
||||
if (pane.visible() && pane.floating()) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
private int firstVisibleNonFloatingIndex() {
|
||||
for (int i = 0; i < panes.size(); i++) {
|
||||
TerminalPane pane = panes.get(i);
|
||||
if (pane.visible() && !pane.floating()) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
private int previousVisibleIndex(int index) {
|
||||
for (int i = index - 1; i >= 0; i--) {
|
||||
if (panes.get(i).visible()) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
for (int i = index + 1; i < panes.size(); i++) {
|
||||
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) {
|
||||
double currentCenterX = current.x() + current.width() / 2.0;
|
||||
double currentCenterY = current.y() + current.height() / 2.0;
|
||||
double candidateCenterX = candidate.x() + candidate.width() / 2.0;
|
||||
double candidateCenterY = candidate.y() + candidate.height() / 2.0;
|
||||
|
||||
return switch (direction) {
|
||||
case LEFT -> candidateCenterX < currentCenterX;
|
||||
case DOWN -> candidateCenterY > currentCenterY;
|
||||
case UP -> candidateCenterY < currentCenterY;
|
||||
case RIGHT -> candidateCenterX > currentCenterX;
|
||||
};
|
||||
}
|
||||
|
||||
private static double distance(TerminalPane current, TerminalPane candidate) {
|
||||
double dx = (current.x() + current.width() / 2.0) - (candidate.x() + candidate.width() / 2.0);
|
||||
double dy = (current.y() + current.height() / 2.0) - (candidate.y() + candidate.height() / 2.0);
|
||||
return Math.sqrt(dx * dx + dy * dy);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
for (TerminalPane pane : panes) {
|
||||
pane.close();
|
||||
for (Tab tab : tabs) {
|
||||
tab.close();
|
||||
}
|
||||
panes.clear();
|
||||
tabs.clear();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user