Compare commits
18 Commits
c03d9245d0
...
refactor
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3017b99f87 | ||
|
|
0958c93b4f | ||
|
|
9c98d87783 | ||
|
|
76c731578f | ||
|
|
95619f5b4c | ||
|
|
174cfc00d3 | ||
|
|
29e84c9830 | ||
|
|
a7baa08e68 | ||
|
|
76f539d34a | ||
|
|
ba884cd0a2 | ||
|
|
7dbbf89b27 | ||
|
|
e2850f067e | ||
|
|
022cf22463 | ||
|
|
250b182060 | ||
|
|
ebba6cc44f | ||
|
|
4c3449129c | ||
|
|
40d6287867 | ||
|
|
ff21bf3544 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -13,3 +13,4 @@ devenv.local.yaml
|
||||
build
|
||||
build
|
||||
.gradle
|
||||
bin
|
||||
|
||||
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"))
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
496
src/main/java/com/gregor/jprototerm/Compositor.java
Normal file
496
src/main/java/com/gregor/jprototerm/Compositor.java
Normal file
@@ -0,0 +1,496 @@
|
||||
package com.gregor.jprototerm;
|
||||
|
||||
import dev.jlibghostty.KeyModifiers;
|
||||
import dev.jlibghostty.MouseButton;
|
||||
import dev.jlibghostty.MouseEncoderSize;
|
||||
import dev.jlibghostty.MouseInput;
|
||||
import javafx.geometry.VPos;
|
||||
import javafx.scene.canvas.Canvas;
|
||||
import javafx.scene.canvas.GraphicsContext;
|
||||
import javafx.scene.input.InputEvent;
|
||||
import javafx.scene.input.MouseEvent;
|
||||
import javafx.scene.input.ScrollEvent;
|
||||
import javafx.scene.input.ScrollEvent.VerticalTextScrollUnits;
|
||||
import javafx.scene.paint.Color;
|
||||
import javafx.scene.text.Font;
|
||||
import javafx.scene.text.FontSmoothingType;
|
||||
import javafx.scene.text.TextAlignment;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Owns the window's tabs and drives rendering and input. It composites only the current tab:
|
||||
* each frame it lays that tab out, paints the panes bottom-to-top (so the active floating pane
|
||||
* lands on top) and lets each pane paint its own content, clipped to the region the layout gave
|
||||
* it. The cross-pane concerns live here — the dirty-frame bookkeeping, the tab strip, routing
|
||||
* mouse/scroll to the pane under the pointer, and the tab/pane lifecycle that {@link Main}'s key
|
||||
* bindings invoke.
|
||||
*/
|
||||
public final class Compositor {
|
||||
// Canvas background shown wherever no pane covers (gaps). Painted on a full recomposite.
|
||||
private static final Color GAP_BACKGROUND = Color.rgb(16, 16, 18);
|
||||
private static final Color TAB_TEXT = Color.rgb(225, 229, 235);
|
||||
// Thin tab strip shown at the top when more than one tab is open.
|
||||
private static final double TAB_BAR_HEIGHT = 22.0;
|
||||
|
||||
private final Canvas canvas = new Canvas();
|
||||
private final AppConfig config;
|
||||
private final TerminalMetrics metrics;
|
||||
private final List<Tab> tabs = new ArrayList<>();
|
||||
private int currentTabIndex;
|
||||
// Bumped on any structural change (tab switch, pane add/close/focus/move) so render()
|
||||
// knows to recomposite. Terminal *content* changes are tracked separately through each
|
||||
// tab's content version.
|
||||
private long layoutVersion;
|
||||
// Last content version drawn to the canvas per pane, so a content frame repaints only
|
||||
// the panes that actually changed.
|
||||
private final Map<TerminalPane, Long> paneContentVersion = new HashMap<>();
|
||||
// Cheap per-frame dirty signal: skip the whole render when none of these changed.
|
||||
private double lastWidth = -1.0;
|
||||
private double lastHeight = -1.0;
|
||||
private String lastFontFamily;
|
||||
private double lastFontSize = -1.0;
|
||||
private long lastLayoutVersion = Long.MIN_VALUE;
|
||||
private long lastContentVersion = Long.MIN_VALUE;
|
||||
private boolean mouseButtonPressed;
|
||||
private MouseButton pressedButton = MouseButton.UNKNOWN;
|
||||
|
||||
public Compositor(AppConfig config, TerminalMetrics metrics) {
|
||||
this.config = config;
|
||||
this.metrics = metrics;
|
||||
tabs.add(new Tab(config, metrics));
|
||||
canvas.setFocusTraversable(true);
|
||||
canvas.setOnMousePressed(this::handleMousePressed);
|
||||
canvas.setOnMouseReleased(this::handleMouseReleased);
|
||||
canvas.setOnMouseDragged(this::handleMouseDragged);
|
||||
canvas.setOnMouseMoved(this::handleMouseMoved);
|
||||
canvas.setOnScroll(this::handleScroll);
|
||||
}
|
||||
|
||||
public Canvas canvas() {
|
||||
return canvas;
|
||||
}
|
||||
|
||||
public void setFont(String family, double size) {
|
||||
metrics.setFont(family, size);
|
||||
paneContentVersion.clear();
|
||||
lastWidth = -1.0; // force a redraw on the next frame
|
||||
}
|
||||
|
||||
// ---- Tabs and panes -------------------------------------------------------------
|
||||
|
||||
public boolean isEmpty() {
|
||||
return tabs.isEmpty();
|
||||
}
|
||||
|
||||
public TerminalPane activePane() {
|
||||
return currentTab().activePane();
|
||||
}
|
||||
|
||||
public void navigate(Direction direction) {
|
||||
if (!isEmpty() && currentTab().navigate(direction)) {
|
||||
layoutVersion++;
|
||||
}
|
||||
}
|
||||
|
||||
public void toggleFloating() {
|
||||
if (isEmpty()) {
|
||||
return;
|
||||
}
|
||||
currentTab().toggleFloating();
|
||||
layoutVersion++;
|
||||
}
|
||||
|
||||
public void createPane() {
|
||||
if (isEmpty()) {
|
||||
return;
|
||||
}
|
||||
currentTab().createPane();
|
||||
layoutVersion++;
|
||||
}
|
||||
|
||||
public void nextFloatingPane() {
|
||||
if (isEmpty()) {
|
||||
return;
|
||||
}
|
||||
currentTab().nextFloatingPane();
|
||||
layoutVersion++;
|
||||
}
|
||||
|
||||
public void closeActivePane() {
|
||||
if (isEmpty()) {
|
||||
return;
|
||||
}
|
||||
currentTab().closeActivePane();
|
||||
if (currentTab().isEmpty()) {
|
||||
// Closing a tab's last pane closes the tab. When no tabs remain the surface is
|
||||
// empty and Main quits.
|
||||
tabs.remove(currentTabIndex);
|
||||
if (currentTabIndex >= tabs.size()) {
|
||||
currentTabIndex = Math.max(0, tabs.size() - 1);
|
||||
}
|
||||
}
|
||||
layoutVersion++;
|
||||
}
|
||||
|
||||
public void newTab() {
|
||||
tabs.add(new Tab(config, metrics));
|
||||
currentTabIndex = tabs.size() - 1;
|
||||
layoutVersion++;
|
||||
}
|
||||
|
||||
public void nextTab() {
|
||||
if (tabs.size() > 1) {
|
||||
currentTabIndex = (currentTabIndex + 1) % tabs.size();
|
||||
layoutVersion++;
|
||||
}
|
||||
}
|
||||
|
||||
public void previousTab() {
|
||||
if (tabs.size() > 1) {
|
||||
currentTabIndex = (currentTabIndex - 1 + tabs.size()) % tabs.size();
|
||||
layoutVersion++;
|
||||
}
|
||||
}
|
||||
|
||||
public void close() {
|
||||
for (Tab tab : tabs) {
|
||||
tab.close();
|
||||
}
|
||||
tabs.clear();
|
||||
}
|
||||
|
||||
private Tab currentTab() {
|
||||
return tabs.get(currentTabIndex);
|
||||
}
|
||||
|
||||
private List<TerminalPane> currentPanes() {
|
||||
return tabs.isEmpty() ? List.of() : currentTab().panes();
|
||||
}
|
||||
|
||||
private boolean isActive(TerminalPane pane) {
|
||||
return !tabs.isEmpty() && currentTab().isActive(pane);
|
||||
}
|
||||
|
||||
private void focus(TerminalPane pane) {
|
||||
if (!tabs.isEmpty() && currentTab().focus(pane)) {
|
||||
layoutVersion++;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Rendering ------------------------------------------------------------------
|
||||
|
||||
public void render() {
|
||||
switch (nextFrameType()) {
|
||||
case IDLE -> { }
|
||||
case LAYOUT -> renderLayoutFrame();
|
||||
case CONTENT -> renderContentFrame();
|
||||
}
|
||||
}
|
||||
|
||||
// Classify this frame and commit the change trackers. A layout change (size, font,
|
||||
// tab/pane set, z-order, active pane) needs a full recomposite; otherwise a change to the
|
||||
// current tab's content version repaints only the panes that changed; otherwise nothing
|
||||
// changed and the frame is idle.
|
||||
private FrameType nextFrameType() {
|
||||
double width = canvas.getWidth();
|
||||
double height = canvas.getHeight();
|
||||
long contentVersion = tabs.isEmpty() ? 0 : currentTab().contentVersion();
|
||||
|
||||
boolean layoutChanged = width != lastWidth || height != lastHeight
|
||||
|| metrics.fontSize() != lastFontSize || !Objects.equals(metrics.fontFamily(), lastFontFamily)
|
||||
|| layoutVersion != lastLayoutVersion;
|
||||
boolean contentChanged = contentVersion != lastContentVersion;
|
||||
|
||||
lastWidth = width;
|
||||
lastHeight = height;
|
||||
lastFontFamily = metrics.fontFamily();
|
||||
lastFontSize = metrics.fontSize();
|
||||
lastLayoutVersion = layoutVersion;
|
||||
lastContentVersion = contentVersion;
|
||||
|
||||
if (layoutChanged) {
|
||||
return FrameType.LAYOUT;
|
||||
}
|
||||
if (contentChanged) {
|
||||
return FrameType.CONTENT;
|
||||
}
|
||||
return FrameType.IDLE;
|
||||
}
|
||||
|
||||
// Full recomposite onto the retained canvas: lay the tab out, clear to the gap colour,
|
||||
// draw the tab strip, then paint every pane bottom-to-top (panes() puts the active
|
||||
// floating pane last == on top).
|
||||
private void renderLayoutFrame() {
|
||||
double topInset = tabs.size() > 1 ? TAB_BAR_HEIGHT : 0.0;
|
||||
if (!tabs.isEmpty()) {
|
||||
currentTab().layout(canvas.getWidth(), canvas.getHeight(), topInset);
|
||||
}
|
||||
List<TerminalPane> panes = currentPanes();
|
||||
// Sync each pane's ghostty grid to its (possibly new) bounds; a no-op when unchanged.
|
||||
for (TerminalPane pane : panes) {
|
||||
pane.fitToBounds();
|
||||
}
|
||||
|
||||
GraphicsContext gc = beginFrame();
|
||||
paneContentVersion.keySet().retainAll(panes);
|
||||
gc.setFill(GAP_BACKGROUND);
|
||||
gc.fillRect(0, 0, canvas.getWidth(), canvas.getHeight());
|
||||
if (topInset > 0.0) {
|
||||
drawTabBar(gc, canvas.getWidth(), topInset);
|
||||
}
|
||||
for (TerminalPane pane : panes) {
|
||||
pane.paintFull(gc, isActive(pane));
|
||||
paneContentVersion.put(pane, pane.contentVersion());
|
||||
}
|
||||
}
|
||||
|
||||
// Repaint just the panes whose content changed, directly on the retained canvas. Each pane
|
||||
// clips itself to its rect minus the panes above it, so a lower pane's repaint can't bleed
|
||||
// over one stacked on top — no restore pass needed. Bounds and grids can't have changed
|
||||
// without a layout frame, so a content frame reuses the existing layout untouched.
|
||||
private void renderContentFrame() {
|
||||
List<TerminalPane> panes = currentPanes();
|
||||
GraphicsContext gc = beginFrame();
|
||||
|
||||
for (TerminalPane pane : panes) {
|
||||
Long drawn = paneContentVersion.get(pane);
|
||||
if (drawn != null && drawn == pane.contentVersion()) {
|
||||
continue;
|
||||
}
|
||||
pane.paintIncremental(gc, isActive(pane));
|
||||
paneContentVersion.put(pane, pane.contentVersion());
|
||||
}
|
||||
}
|
||||
|
||||
private GraphicsContext beginFrame() {
|
||||
GraphicsContext gc = canvas.getGraphicsContext2D();
|
||||
gc.setFontSmoothingType(FontSmoothingType.LCD); // the per-cell renderer relies on LCD
|
||||
return gc;
|
||||
}
|
||||
|
||||
// Thin tab strip: one equal-width segment per tab, the current one highlighted, with a
|
||||
// small 1-based number centred in each segment.
|
||||
private void drawTabBar(GraphicsContext gc, double width, double barHeight) {
|
||||
int count = tabs.size();
|
||||
Font barFont = Font.font(metrics.fontFamily(), Math.max(9.0, Math.min(13.0, barHeight * 0.62)));
|
||||
gc.setFont(barFont);
|
||||
gc.setFontSmoothingType(FontSmoothingType.GRAY);
|
||||
gc.setTextAlign(TextAlignment.CENTER);
|
||||
gc.setTextBaseline(VPos.CENTER);
|
||||
|
||||
double gap = 1.0;
|
||||
double segmentWidth = width / count;
|
||||
for (int i = 0; i < count; i++) {
|
||||
double x = i * segmentWidth;
|
||||
boolean current = i == currentTabIndex;
|
||||
gc.setFill(current ? Color.rgb(45, 55, 72) : Color.rgb(22, 24, 28));
|
||||
gc.fillRect(x, 0.0, segmentWidth - gap, barHeight);
|
||||
gc.setFill(current ? TAB_TEXT : Color.rgb(128, 136, 148));
|
||||
gc.fillText(Integer.toString(i + 1), x + (segmentWidth - gap) / 2.0, barHeight / 2.0);
|
||||
}
|
||||
|
||||
// Restore the defaults the cell renderer relies on (left-aligned, baseline, LCD).
|
||||
gc.setTextAlign(TextAlignment.LEFT);
|
||||
gc.setTextBaseline(VPos.BASELINE);
|
||||
gc.setFontSmoothingType(FontSmoothingType.LCD);
|
||||
}
|
||||
|
||||
// ---- Input ----------------------------------------------------------------------
|
||||
|
||||
private void handleMousePressed(MouseEvent event) {
|
||||
canvas.requestFocus();
|
||||
TerminalPane pane = paneAt(event.getX(), event.getY());
|
||||
if (pane == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
focus(pane);
|
||||
pressedButton = mouseButton(event);
|
||||
mouseButtonPressed = true;
|
||||
MouseTarget target = mouseTarget(pane);
|
||||
if (target == null) {
|
||||
return;
|
||||
}
|
||||
send(pane, target, MouseInput.press(pressedButton, localX(event.getX(), pane, target), localY(event.getY(), pane, target), modifiers(event)), true, event);
|
||||
}
|
||||
|
||||
private void handleMouseReleased(MouseEvent event) {
|
||||
TerminalPane pane = paneAt(event.getX(), event.getY());
|
||||
if (pane == null) {
|
||||
pane = activePane();
|
||||
}
|
||||
|
||||
MouseButton button = pressedButton == MouseButton.UNKNOWN ? mouseButton(event) : pressedButton;
|
||||
MouseTarget target = mouseTarget(pane);
|
||||
if (target != null) {
|
||||
send(pane, target, MouseInput.release(button, localX(event.getX(), pane, target), localY(event.getY(), pane, target), modifiers(event)), false, event);
|
||||
}
|
||||
mouseButtonPressed = false;
|
||||
pressedButton = MouseButton.UNKNOWN;
|
||||
}
|
||||
|
||||
private void handleMouseDragged(MouseEvent event) {
|
||||
TerminalPane pane = paneAt(event.getX(), event.getY());
|
||||
if (pane == null) {
|
||||
pane = activePane();
|
||||
}
|
||||
|
||||
MouseButton button = pressedButton == MouseButton.UNKNOWN ? mouseButton(event) : pressedButton;
|
||||
MouseTarget target = mouseTarget(pane);
|
||||
if (target == null) {
|
||||
return;
|
||||
}
|
||||
send(pane, target, MouseInput.drag(button, localX(event.getX(), pane, target), localY(event.getY(), pane, target), modifiers(event)), true, event);
|
||||
}
|
||||
|
||||
private void handleMouseMoved(MouseEvent event) {
|
||||
TerminalPane pane = paneAt(event.getX(), event.getY());
|
||||
if (pane == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
MouseTarget target = mouseTarget(pane);
|
||||
if (target == null) {
|
||||
return;
|
||||
}
|
||||
send(pane, target, MouseInput.motion(localX(event.getX(), pane, target), localY(event.getY(), pane, target), modifiers(event)), mouseButtonPressed, event);
|
||||
}
|
||||
|
||||
private void handleScroll(ScrollEvent event) {
|
||||
TerminalPane pane = paneAt(event.getX(), event.getY());
|
||||
if (pane == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
canvas.requestFocus();
|
||||
focus(pane);
|
||||
int direction = scrollDirection(event);
|
||||
if (direction == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
MouseButton wheelButton = direction > 0 ? MouseButton.FOUR : MouseButton.FIVE;
|
||||
int rows = scrollRows(event);
|
||||
MouseTarget target = mouseTarget(pane);
|
||||
boolean sent = false;
|
||||
if (target != null) {
|
||||
// The wheel sends one button press per scrolled row; resolve the position once.
|
||||
double ex = localX(event.getX(), pane, target);
|
||||
double ey = localY(event.getY(), pane, target);
|
||||
KeyModifiers modifiers = modifiers(event);
|
||||
for (int i = 0; i < rows; i++) {
|
||||
sent |= send(pane, target, MouseInput.press(wheelButton, ex, ey, modifiers), mouseButtonPressed, event);
|
||||
}
|
||||
}
|
||||
if (!sent) {
|
||||
// Not consumed by the app (e.g. mouse reporting off): scroll the local viewport.
|
||||
pane.scrollViewport(direction > 0 ? -rows : rows);
|
||||
event.consume();
|
||||
}
|
||||
}
|
||||
|
||||
// Forward an already-positioned mouse event to the pane, consuming it if the pane (i.e.
|
||||
// the app running in it) acted on it. Returns whether it was sent.
|
||||
private boolean send(TerminalPane pane, MouseTarget target, MouseInput input, boolean anyButtonPressed, InputEvent event) {
|
||||
boolean sent = pane.sendMouse(input, target.size(), anyButtonPressed);
|
||||
if (sent) {
|
||||
event.consume();
|
||||
}
|
||||
return sent;
|
||||
}
|
||||
|
||||
private TerminalPane paneAt(double x, double y) {
|
||||
List<TerminalPane> panes = currentPanes();
|
||||
for (int i = panes.size() - 1; i >= 0; i--) {
|
||||
TerminalPane pane = panes.get(i);
|
||||
if (x >= pane.x() && x < pane.x() + pane.width() && y >= pane.y() && y < pane.y() + pane.height()) {
|
||||
return pane;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private MouseTarget mouseTarget(TerminalPane pane) {
|
||||
if (pane.width() <= 2 * TerminalMetrics.PADDING || pane.height() <= 2 * TerminalMetrics.PADDING) {
|
||||
return null;
|
||||
}
|
||||
|
||||
int columns = metrics.columnsFor(pane.width());
|
||||
int rows = metrics.rowsFor(pane.height());
|
||||
long cellWidth = Math.max(1L, Math.round(metrics.cellWidth()));
|
||||
long cellHeight = Math.max(1L, Math.round(metrics.lineHeight()));
|
||||
long screenWidth = Math.max(1L, Math.round(columns * metrics.cellWidth()));
|
||||
long screenHeight = Math.max(1L, Math.round(rows * metrics.lineHeight()));
|
||||
return new MouseTarget(MouseEncoderSize.of(screenWidth, screenHeight, cellWidth, cellHeight), screenWidth, screenHeight);
|
||||
}
|
||||
|
||||
// Resolve a canvas-space pointer position to a pane-local pixel coordinate, clamped to
|
||||
// the pane's reported screen size (what ghostty's mouse encoder expects).
|
||||
private static double localX(double canvasX, TerminalPane pane, MouseTarget target) {
|
||||
return clamp(canvasX - pane.x() - TerminalMetrics.PADDING, 0.0, target.screenWidth() - 1.0);
|
||||
}
|
||||
|
||||
private static double localY(double canvasY, TerminalPane pane, MouseTarget target) {
|
||||
return clamp(canvasY - pane.y() - TerminalMetrics.PADDING, 0.0, target.screenHeight() - 1.0);
|
||||
}
|
||||
|
||||
private static double clamp(double value, double min, double max) {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
private static KeyModifiers modifiers(MouseEvent event) {
|
||||
return KeyModifiers.of(event.isShiftDown(), event.isControlDown(), event.isAltDown(), event.isMetaDown());
|
||||
}
|
||||
|
||||
private static KeyModifiers modifiers(ScrollEvent event) {
|
||||
return KeyModifiers.of(event.isShiftDown(), event.isControlDown(), event.isAltDown(), event.isMetaDown());
|
||||
}
|
||||
|
||||
private static int scrollRows(ScrollEvent event) {
|
||||
double rows;
|
||||
if (event.getTextDeltaYUnits() == VerticalTextScrollUnits.LINES && event.getTextDeltaY() != 0.0) {
|
||||
rows = Math.abs(event.getTextDeltaY());
|
||||
} else if (event.getTextDeltaYUnits() == VerticalTextScrollUnits.PAGES && event.getTextDeltaY() != 0.0) {
|
||||
rows = Math.abs(event.getTextDeltaY()) * 24.0;
|
||||
} else if (event.getMultiplierY() > 0.0) {
|
||||
rows = Math.abs(event.getDeltaY()) / event.getMultiplierY();
|
||||
} else {
|
||||
rows = Math.abs(event.getDeltaY()) / 40.0;
|
||||
}
|
||||
return Math.max(1, Math.min(64, (int) Math.ceil(rows)));
|
||||
}
|
||||
|
||||
private static int scrollDirection(ScrollEvent event) {
|
||||
if (event.getDeltaY() != 0.0) {
|
||||
return event.getDeltaY() > 0.0 ? 1 : -1;
|
||||
}
|
||||
if (event.getTextDeltaYUnits() != VerticalTextScrollUnits.NONE && event.getTextDeltaY() != 0.0) {
|
||||
return event.getTextDeltaY() > 0.0 ? 1 : -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static MouseButton mouseButton(MouseEvent event) {
|
||||
return switch (event.getButton()) {
|
||||
case PRIMARY -> MouseButton.LEFT;
|
||||
case SECONDARY -> MouseButton.RIGHT;
|
||||
case MIDDLE -> MouseButton.MIDDLE;
|
||||
default -> MouseButton.UNKNOWN;
|
||||
};
|
||||
}
|
||||
|
||||
// What one render() pass should do, decided from the change trackers in nextFrame().
|
||||
private enum FrameType {
|
||||
IDLE, // nothing changed since the last frame
|
||||
LAYOUT, // geometry/font/tab/pane set changed: clear and repaint everything
|
||||
CONTENT // only terminal content changed: repaint the panes that changed
|
||||
}
|
||||
|
||||
private record MouseTarget(MouseEncoderSize size, long screenWidth, long screenHeight) {
|
||||
}
|
||||
}
|
||||
631
src/main/java/com/gregor/jprototerm/GhosttyTerminalRenderer.java
Normal file
631
src/main/java/com/gregor/jprototerm/GhosttyTerminalRenderer.java
Normal file
@@ -0,0 +1,631 @@
|
||||
package com.gregor.jprototerm;
|
||||
|
||||
import dev.jlibghostty.KittyImageCompression;
|
||||
import dev.jlibghostty.KittyImageFormat;
|
||||
import dev.jlibghostty.KittyImageSnapshot;
|
||||
import dev.jlibghostty.KittyPlacement;
|
||||
import dev.jlibghostty.KittyPlacementLayer;
|
||||
import dev.jlibghostty.KittyPlaceholder;
|
||||
import dev.jlibghostty.KittyRenderInfo;
|
||||
import dev.jlibghostty.RenderCell;
|
||||
import dev.jlibghostty.RenderColor;
|
||||
import dev.jlibghostty.RenderCursorStyle;
|
||||
import dev.jlibghostty.RenderRow;
|
||||
import dev.jlibghostty.RenderStateSnapshot;
|
||||
import javafx.scene.canvas.GraphicsContext;
|
||||
import javafx.scene.image.Image;
|
||||
import javafx.scene.image.PixelFormat;
|
||||
import javafx.scene.image.WritableImage;
|
||||
import javafx.scene.paint.Color;
|
||||
import javafx.scene.text.FontSmoothingType;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* The real terminal renderer: paints a pane's background, cell rows, cursor, border, padding
|
||||
* and (when enabled) kitty graphics. One instance per pane, since it caches that pane's
|
||||
* decoded kitty images.
|
||||
*/
|
||||
final class GhosttyTerminalRenderer extends TerminalRenderer {
|
||||
// GhosttyRenderStateDirty values (stable C ABI; see ghostty/vt/render.h).
|
||||
private static final int DIRTY_PARTIAL = 1;
|
||||
private static final int DIRTY_FULL = 2;
|
||||
|
||||
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);
|
||||
|
||||
// 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 final TerminalMetrics metrics;
|
||||
// Decoded kitty images for this renderer's pane (kitty graphics state is per-terminal).
|
||||
private final Map<KittyImageKey, Image> kittyImageCache = new HashMap<>();
|
||||
|
||||
GhosttyTerminalRenderer(TerminalMetrics metrics) {
|
||||
this.metrics = metrics;
|
||||
}
|
||||
|
||||
@Override
|
||||
void paintFull(GraphicsContext gc, RenderTarget target, boolean active) {
|
||||
double px = Math.round(target.x());
|
||||
double py = Math.round(target.y());
|
||||
double width = target.width();
|
||||
double height = target.height();
|
||||
gc.save();
|
||||
clip(gc, px, py, width, height, target.clip());
|
||||
drawContent(gc, target, target.snapshotFull(), px, py, width, height, active,
|
||||
target.kittyEnabled() && hasKittyGraphics(target));
|
||||
gc.restore();
|
||||
}
|
||||
|
||||
@Override
|
||||
void paintIncremental(GraphicsContext gc, RenderTarget target, boolean active) {
|
||||
double px = Math.round(target.x());
|
||||
double py = Math.round(target.y());
|
||||
double width = target.width();
|
||||
double height = target.height();
|
||||
gc.save();
|
||||
clip(gc, px, py, width, height, target.clip());
|
||||
if (target.kittyEnabled() && hasKittyGraphics(target)) {
|
||||
// Kitty placements can move without a per-row dirty flag, so always redraw whole.
|
||||
drawContent(gc, target, target.snapshotFull(), px, py, width, height, active, true);
|
||||
} else {
|
||||
RenderStateSnapshot snapshot = target.snapshot();
|
||||
int dirty = snapshot == null ? DIRTY_FULL : snapshot.dirty();
|
||||
if (dirty == DIRTY_FULL) {
|
||||
drawContent(gc, target, snapshot, px, py, width, height, active, false);
|
||||
} else if (dirty == DIRTY_PARTIAL) {
|
||||
drawDirtyRows(gc, snapshot, px, py, width, height, active);
|
||||
}
|
||||
// dirty == FALSE: nothing visible changed.
|
||||
}
|
||||
gc.restore();
|
||||
}
|
||||
|
||||
// Full content render: background, border, all rows, cursor, and (when enabled) kitty
|
||||
// graphics. Used by the kitty direct path and by full redraws.
|
||||
private void drawContent(
|
||||
GraphicsContext gc,
|
||||
RenderTarget target,
|
||||
RenderStateSnapshot snapshot,
|
||||
double x,
|
||||
double y,
|
||||
double width,
|
||||
double height,
|
||||
boolean active,
|
||||
boolean withKitty
|
||||
) {
|
||||
double cellWidth = metrics.cellWidth();
|
||||
double lineHeight = metrics.lineHeight();
|
||||
gc.setFontSmoothingType(FontSmoothingType.LCD);
|
||||
gc.setFill(PANE_BACKGROUND);
|
||||
gc.fillRect(x, y, width, height);
|
||||
gc.setFont(metrics.font());
|
||||
|
||||
double left = x + TerminalMetrics.PADDING;
|
||||
double top = y + TerminalMetrics.PADDING;
|
||||
double baseline = top + metrics.baselineOffset();
|
||||
|
||||
Map<KittyPlaceholderKey, KittyPlaceholderBounds> placeholderBounds = withKitty
|
||||
? kittyPlaceholderBounds(snapshot)
|
||||
: Map.of();
|
||||
|
||||
if (withKitty) {
|
||||
drawKittyGraphics(gc, target, KittyPlacementLayer.BELOW_TEXT, placeholderBounds, left, top, cellWidth, lineHeight);
|
||||
}
|
||||
|
||||
if (snapshot != null) {
|
||||
double contentBottom = top + snapshot.rows() * lineHeight;
|
||||
fillVerticalPadding(gc, snapshot, x, y, width, height, top, contentBottom);
|
||||
for (RenderRow row : snapshot.renderRows()) {
|
||||
double y0 = Math.floor(top + (row.row() * lineHeight));
|
||||
double y1 = Math.ceil(top + ((row.row() + 1) * lineHeight));
|
||||
paintSidePadding(gc, row, x, width, left, cellWidth, y0, y1 - y0);
|
||||
drawRow(gc, row, left, top, baseline, cellWidth, lineHeight);
|
||||
}
|
||||
drawCursor(gc, snapshot, left, top, cellWidth, lineHeight);
|
||||
}
|
||||
|
||||
if (withKitty) {
|
||||
drawKittyGraphics(gc, target, KittyPlacementLayer.ABOVE_TEXT, placeholderBounds, left, top, cellWidth, lineHeight);
|
||||
}
|
||||
|
||||
drawBorder(gc, x, y, width, height, active);
|
||||
}
|
||||
|
||||
// Incremental render: repaint only the rows ghostty flagged dirty, then restore the
|
||||
// cursor and border. The local band tracks the repainted span only so the border redraw
|
||||
// can be limited to it.
|
||||
private void drawDirtyRows(
|
||||
GraphicsContext gc,
|
||||
RenderStateSnapshot snapshot,
|
||||
double px,
|
||||
double py,
|
||||
double pw,
|
||||
double ph,
|
||||
boolean active
|
||||
) {
|
||||
double cellWidth = metrics.cellWidth();
|
||||
double lineHeight = metrics.lineHeight();
|
||||
gc.setFontSmoothingType(FontSmoothingType.LCD);
|
||||
gc.setFont(metrics.font());
|
||||
double left = px + TerminalMetrics.PADDING;
|
||||
double top = py + TerminalMetrics.PADDING;
|
||||
double baseline = top + metrics.baselineOffset();
|
||||
|
||||
double contentBottom = top + snapshot.rows() * 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() * lineHeight));
|
||||
double y1 = Math.ceil(top + ((row.row() + 1) * lineHeight));
|
||||
gc.setFill(PANE_BACKGROUND);
|
||||
gc.fillRect(px, y0, pw, y1 - y0);
|
||||
paintSidePadding(gc, row, px, pw, left, cellWidth, y0, y1 - y0);
|
||||
drawRow(gc, row, left, top, baseline, cellWidth, 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;
|
||||
}
|
||||
|
||||
// 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, cellWidth, lineHeight);
|
||||
}
|
||||
// Repainting rows clears the side borders within the band; restore just those
|
||||
// segments, clipped to the band so we don't redraw the whole outline.
|
||||
gc.save();
|
||||
clipRect(gc, px, bandMin, pw, bandMax - bandMin);
|
||||
drawBorder(gc, px, py, pw, ph, active);
|
||||
gc.restore();
|
||||
}
|
||||
|
||||
private void drawBorder(GraphicsContext gc, double x, double y, double width, double height, boolean active) {
|
||||
gc.setStroke(active ? Color.rgb(87, 166, 255) : Color.rgb(52, 57, 65));
|
||||
gc.setLineWidth(active ? 2.0 : 1.0);
|
||||
gc.strokeRect(x + 0.5, y + 0.5, width - 1.0, height - 1.0);
|
||||
}
|
||||
|
||||
// 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 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);
|
||||
}
|
||||
|
||||
private static void drawRow(
|
||||
GraphicsContext gc,
|
||||
RenderRow row,
|
||||
double left,
|
||||
double top,
|
||||
double baseline,
|
||||
double cellWidth,
|
||||
double lineHeight
|
||||
) {
|
||||
for (RenderCell cell : row.cells()) {
|
||||
if (cell.kittyPlaceholder().isPresent()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
double x = left + (cell.column() * cellWidth);
|
||||
double cellTop = top + (row.row() * lineHeight);
|
||||
|
||||
// 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);
|
||||
}
|
||||
if (cell.codepoints().length == 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
double y = baseline + (row.row() * lineHeight);
|
||||
gc.setFill(fg);
|
||||
gc.fillText(cell.text(), x, y);
|
||||
}
|
||||
}
|
||||
|
||||
private static Color toFxColor(RenderColor color) {
|
||||
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) {
|
||||
if (!snapshot.cursorVisible() || !snapshot.cursorViewportHasValue()) {
|
||||
return;
|
||||
}
|
||||
|
||||
double x = left + (snapshot.cursorViewportX() * cellWidth);
|
||||
double y = top + (snapshot.cursorViewportY() * lineHeight);
|
||||
gc.setStroke(Color.rgb(225, 229, 235));
|
||||
gc.setFill(Color.rgb(225, 229, 235, 0.28));
|
||||
gc.setLineWidth(1.5);
|
||||
|
||||
RenderCursorStyle style = snapshot.cursorStyle();
|
||||
if (style == RenderCursorStyle.BAR) {
|
||||
gc.strokeLine(x + 0.5, y + 2.0, x + 0.5, y + lineHeight - 2.0);
|
||||
} else if (style == RenderCursorStyle.UNDERLINE) {
|
||||
gc.strokeLine(x + 1.0, y + lineHeight - 2.0, x + cellWidth - 1.0, y + lineHeight - 2.0);
|
||||
} else if (style == RenderCursorStyle.BLOCK) {
|
||||
gc.fillRect(x + 0.5, y + 1.0, Math.max(1.0, cellWidth - 1.0), Math.max(1.0, lineHeight - 2.0));
|
||||
} else {
|
||||
gc.strokeRect(x + 0.5, y + 1.0, Math.max(1.0, cellWidth - 1.0), Math.max(1.0, lineHeight - 2.0));
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Kitty graphics --------------------------------------------------------------
|
||||
|
||||
private static boolean hasKittyGraphics(RenderTarget target) {
|
||||
return target.kittyGraphics()
|
||||
.map(graphics -> !graphics.placements().isEmpty())
|
||||
.orElse(false);
|
||||
}
|
||||
|
||||
private void drawKittyGraphics(
|
||||
GraphicsContext gc,
|
||||
RenderTarget target,
|
||||
KittyPlacementLayer layer,
|
||||
Map<KittyPlaceholderKey, KittyPlaceholderBounds> placeholderBounds,
|
||||
double originX,
|
||||
double originY,
|
||||
double cellWidth,
|
||||
double lineHeight
|
||||
) {
|
||||
target.kittyGraphics().ifPresent(graphics -> {
|
||||
for (KittyPlacement placement : graphics.placements(layer)) {
|
||||
Image image = imageFor(placement);
|
||||
if (image == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (placement.virtual()) {
|
||||
drawVirtualKittyPlacement(gc, placement, image, placeholderBounds, originX, originY, cellWidth, lineHeight);
|
||||
} else {
|
||||
drawPinnedKittyPlacement(gc, placement, image, originX, originY, cellWidth, lineHeight);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static void drawPinnedKittyPlacement(
|
||||
GraphicsContext gc,
|
||||
KittyPlacement placement,
|
||||
Image image,
|
||||
double originX,
|
||||
double originY,
|
||||
double cellWidth,
|
||||
double lineHeight
|
||||
) {
|
||||
KittyRenderInfo renderInfo = placement.renderInfo().orElse(null);
|
||||
if (renderInfo == null || !renderInfo.viewportVisible()) {
|
||||
return;
|
||||
}
|
||||
|
||||
double sourceX = renderInfo.sourceX();
|
||||
double sourceY = renderInfo.sourceY();
|
||||
double sourceWidth = renderInfo.sourceWidth();
|
||||
double sourceHeight = renderInfo.sourceHeight();
|
||||
if (sourceWidth <= 0.0 || sourceHeight <= 0.0) {
|
||||
return;
|
||||
}
|
||||
|
||||
double x = originX + (renderInfo.viewportColumn() * cellWidth) + placement.xOffset();
|
||||
double y = originY + (renderInfo.viewportRow() * lineHeight) + placement.yOffset();
|
||||
double width = renderInfo.pixelWidth() > 0 ? renderInfo.pixelWidth() : renderInfo.gridColumns() * cellWidth;
|
||||
double height = renderInfo.pixelHeight() > 0 ? renderInfo.pixelHeight() : renderInfo.gridRows() * lineHeight;
|
||||
if (width <= 0.0 || height <= 0.0) {
|
||||
return;
|
||||
}
|
||||
|
||||
gc.drawImage(image, sourceX, sourceY, sourceWidth, sourceHeight, x, y, width, height);
|
||||
}
|
||||
|
||||
private static void drawVirtualKittyPlacement(
|
||||
GraphicsContext gc,
|
||||
KittyPlacement placement,
|
||||
Image image,
|
||||
Map<KittyPlaceholderKey, KittyPlaceholderBounds> placeholderBounds,
|
||||
double originX,
|
||||
double originY,
|
||||
double cellWidth,
|
||||
double lineHeight
|
||||
) {
|
||||
KittyPlaceholderBounds bounds = placeholderBounds.get(new KittyPlaceholderKey(placement.imageId(), placement.placementId()));
|
||||
if (bounds == null) {
|
||||
bounds = placeholderBounds.get(new KittyPlaceholderKey(placement.imageId(), 0));
|
||||
}
|
||||
if (bounds == null && placement.placementId() == 0) {
|
||||
bounds = placeholderBounds.entrySet().stream()
|
||||
.filter(entry -> entry.getKey().imageId() == placement.imageId())
|
||||
.map(Map.Entry::getValue)
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
}
|
||||
if (bounds == null || bounds.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
SourceRect source = sourceRect(placement, image);
|
||||
if (source.width() <= 0.0 || source.height() <= 0.0) {
|
||||
return;
|
||||
}
|
||||
|
||||
long gridColumns = gridColumns(placement, bounds);
|
||||
long gridRows = gridRows(placement, bounds);
|
||||
double sourceCellWidth = source.width() / Math.max(1L, gridColumns);
|
||||
double sourceCellHeight = source.height() / Math.max(1L, gridRows);
|
||||
|
||||
double sourceX = source.x() + (bounds.minSourceColumn * sourceCellWidth);
|
||||
double sourceY = source.y() + (bounds.minSourceRow * sourceCellHeight);
|
||||
double sourceWidth = bounds.sourceColumns() * sourceCellWidth;
|
||||
double sourceHeight = bounds.sourceRows() * sourceCellHeight;
|
||||
double x = originX + (bounds.minColumn * cellWidth);
|
||||
double y = originY + (bounds.minRow * lineHeight);
|
||||
double availableWidth = bounds.columns() * cellWidth;
|
||||
double availableHeight = bounds.rows() * lineHeight;
|
||||
|
||||
if (sourceWidth <= 0.0 || sourceHeight <= 0.0 || availableWidth <= 0.0 || availableHeight <= 0.0) {
|
||||
return;
|
||||
}
|
||||
|
||||
double scale = Math.min(availableWidth / sourceWidth, availableHeight / sourceHeight);
|
||||
double width = sourceWidth * scale;
|
||||
double height = sourceHeight * scale;
|
||||
gc.drawImage(image, sourceX, sourceY, sourceWidth, sourceHeight, x, y, width, height);
|
||||
}
|
||||
|
||||
private static long gridColumns(KittyPlacement placement, KittyPlaceholderBounds bounds) {
|
||||
if (placement.columns() > 0) {
|
||||
return placement.columns();
|
||||
}
|
||||
return Math.max(bounds.maxSourceColumn + 1, bounds.sourceColumns());
|
||||
}
|
||||
|
||||
private static long gridRows(KittyPlacement placement, KittyPlaceholderBounds bounds) {
|
||||
if (placement.rows() > 0) {
|
||||
return placement.rows();
|
||||
}
|
||||
return Math.max(bounds.maxSourceRow + 1, bounds.sourceRows());
|
||||
}
|
||||
|
||||
private static SourceRect sourceRect(KittyPlacement placement, Image image) {
|
||||
double sourceX = placement.sourceX();
|
||||
double sourceY = placement.sourceY();
|
||||
double sourceWidth = placement.sourceWidth() > 0 ? placement.sourceWidth() : image.getWidth() - sourceX;
|
||||
double sourceHeight = placement.sourceHeight() > 0 ? placement.sourceHeight() : image.getHeight() - sourceY;
|
||||
return new SourceRect(sourceX, sourceY, Math.min(sourceWidth, image.getWidth() - sourceX), Math.min(sourceHeight, image.getHeight() - sourceY));
|
||||
}
|
||||
|
||||
private Image imageFor(KittyPlacement placement) {
|
||||
return placement.image().map(snapshot -> {
|
||||
byte[] data = snapshot.data();
|
||||
KittyImageKey key = KittyImageKey.of(snapshot, data);
|
||||
Image cached = kittyImageCache.get(key);
|
||||
if (cached != null) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
kittyImageCache.keySet().removeIf(existing -> existing.id() == snapshot.id());
|
||||
Image decoded = decodeImage(snapshot, data);
|
||||
if (decoded != null) {
|
||||
kittyImageCache.put(key, decoded);
|
||||
}
|
||||
return decoded;
|
||||
}).orElse(null);
|
||||
}
|
||||
|
||||
private Image decodeImage(KittyImageSnapshot snapshot, byte[] data) {
|
||||
if (snapshot.compression() != KittyImageCompression.NONE) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (snapshot.format() == KittyImageFormat.PNG) {
|
||||
return new Image(new ByteArrayInputStream(data));
|
||||
}
|
||||
|
||||
int width = Math.toIntExact(snapshot.width());
|
||||
int height = Math.toIntExact(snapshot.height());
|
||||
WritableImage image = new WritableImage(width, height);
|
||||
|
||||
if (snapshot.format() == KittyImageFormat.RGBA) {
|
||||
image.getPixelWriter().setPixels(0, 0, width, height, PixelFormat.getByteBgraInstance(), rgbaToBgra(data), 0, width * 4);
|
||||
} else if (snapshot.format() == KittyImageFormat.RGB) {
|
||||
image.getPixelWriter().setPixels(0, 0, width, height, PixelFormat.getByteRgbInstance(), data, 0, width * 3);
|
||||
}
|
||||
return image;
|
||||
}
|
||||
|
||||
private static byte[] rgbaToBgra(byte[] rgba) {
|
||||
byte[] bgra = new byte[rgba.length];
|
||||
for (int i = 0; i + 3 < rgba.length; i += 4) {
|
||||
bgra[i] = rgba[i + 2];
|
||||
bgra[i + 1] = rgba[i + 1];
|
||||
bgra[i + 2] = rgba[i];
|
||||
bgra[i + 3] = rgba[i + 3];
|
||||
}
|
||||
return bgra;
|
||||
}
|
||||
|
||||
private static Map<KittyPlaceholderKey, KittyPlaceholderBounds> kittyPlaceholderBounds(RenderStateSnapshot snapshot) {
|
||||
if (snapshot == null) {
|
||||
return Map.of();
|
||||
}
|
||||
|
||||
Map<KittyPlaceholderKey, KittyPlaceholderBounds> result = new HashMap<>();
|
||||
for (RenderRow row : snapshot.renderRows()) {
|
||||
for (RenderCell cell : row.cells()) {
|
||||
cell.kittyPlaceholder().ifPresent(placeholder -> {
|
||||
KittyPlaceholderKey key = new KittyPlaceholderKey(placeholder.imageId(), placeholder.placementId());
|
||||
result.computeIfAbsent(key, ignored -> new KittyPlaceholderBounds()).include(row.row(), cell.column(), placeholder);
|
||||
});
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// 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(),
|
||||
snapshot.number(),
|
||||
snapshot.width(),
|
||||
snapshot.height(),
|
||||
snapshot.format(),
|
||||
data.length
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private record KittyPlaceholderKey(long imageId, long placementId) {
|
||||
}
|
||||
|
||||
private record SourceRect(double x, double y, double width, double height) {
|
||||
}
|
||||
|
||||
private static final class KittyPlaceholderBounds {
|
||||
private int minRow = Integer.MAX_VALUE;
|
||||
private int maxRow = Integer.MIN_VALUE;
|
||||
private int minColumn = Integer.MAX_VALUE;
|
||||
private int maxColumn = Integer.MIN_VALUE;
|
||||
private long minSourceRow = Long.MAX_VALUE;
|
||||
private long maxSourceRow = Long.MIN_VALUE;
|
||||
private long minSourceColumn = Long.MAX_VALUE;
|
||||
private long maxSourceColumn = Long.MIN_VALUE;
|
||||
|
||||
private void include(int row, int column, KittyPlaceholder placeholder) {
|
||||
minRow = Math.min(minRow, row);
|
||||
maxRow = Math.max(maxRow, row);
|
||||
minColumn = Math.min(minColumn, column);
|
||||
maxColumn = Math.max(maxColumn, column);
|
||||
minSourceRow = Math.min(minSourceRow, placeholder.sourceRow());
|
||||
maxSourceRow = Math.max(maxSourceRow, placeholder.sourceRow());
|
||||
minSourceColumn = Math.min(minSourceColumn, placeholder.sourceColumn());
|
||||
maxSourceColumn = Math.max(maxSourceColumn, placeholder.sourceColumn());
|
||||
}
|
||||
|
||||
private boolean isEmpty() {
|
||||
return minRow == Integer.MAX_VALUE;
|
||||
}
|
||||
|
||||
private int rows() {
|
||||
return maxRow - minRow + 1;
|
||||
}
|
||||
|
||||
private int columns() {
|
||||
return maxColumn - minColumn + 1;
|
||||
}
|
||||
|
||||
private long sourceRows() {
|
||||
return maxSourceRow - minSourceRow + 1;
|
||||
}
|
||||
|
||||
private long sourceColumns() {
|
||||
return maxSourceColumn - minSourceColumn + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -20,20 +21,20 @@ import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
public final class Main extends Application {
|
||||
private TerminalWorkspace workspace;
|
||||
private TerminalCanvasView terminalView;
|
||||
private Compositor compositor;
|
||||
private TerminalMetrics metrics;
|
||||
private AppConfig config;
|
||||
|
||||
@Override
|
||||
public void start(Stage stage) {
|
||||
config = AppConfig.load();
|
||||
|
||||
workspace = new TerminalWorkspace(config);
|
||||
terminalView = new TerminalCanvasView(workspace, config);
|
||||
metrics = new TerminalMetrics(config.fontFamily(), config.fontSize());
|
||||
compositor = new Compositor(config, metrics);
|
||||
|
||||
StackPane root = new StackPane(terminalView.canvas());
|
||||
terminalView.canvas().widthProperty().bind(root.widthProperty());
|
||||
terminalView.canvas().heightProperty().bind(root.heightProperty());
|
||||
StackPane root = new StackPane(compositor.canvas());
|
||||
compositor.canvas().widthProperty().bind(root.widthProperty());
|
||||
compositor.canvas().heightProperty().bind(root.heightProperty());
|
||||
|
||||
Scene scene = new Scene(root, config.windowWidth(), config.windowHeight());
|
||||
scene.addEventFilter(KeyEvent.KEY_PRESSED, this::handlePressed);
|
||||
@@ -42,43 +43,57 @@ public final class Main extends Application {
|
||||
new AnimationTimer() {
|
||||
@Override
|
||||
public void handle(long now) {
|
||||
terminalView.render();
|
||||
compositor.render();
|
||||
}
|
||||
}.start();
|
||||
|
||||
stage.setTitle("jprototerm");
|
||||
stage.setScene(scene);
|
||||
stage.setOnCloseRequest(event -> {
|
||||
workspace.close();
|
||||
compositor.close();
|
||||
});
|
||||
stage.show();
|
||||
terminalView.canvas().requestFocus();
|
||||
compositor.canvas().requestFocus();
|
||||
}
|
||||
|
||||
private void handlePressed(KeyEvent event) {
|
||||
if (config.keybindings().get("navigate_left").matches(event)) {
|
||||
workspace.navigate(Direction.LEFT);
|
||||
compositor.navigate(Direction.LEFT);
|
||||
event.consume();
|
||||
} else if (config.keybindings().get("navigate_down").matches(event)) {
|
||||
workspace.navigate(Direction.DOWN);
|
||||
compositor.navigate(Direction.DOWN);
|
||||
event.consume();
|
||||
} else if (config.keybindings().get("navigate_up").matches(event)) {
|
||||
workspace.navigate(Direction.UP);
|
||||
compositor.navigate(Direction.UP);
|
||||
event.consume();
|
||||
} else if (config.keybindings().get("navigate_right").matches(event)) {
|
||||
workspace.navigate(Direction.RIGHT);
|
||||
compositor.navigate(Direction.RIGHT);
|
||||
event.consume();
|
||||
} else if (config.keybindings().get("toggle_floating").matches(event)) {
|
||||
workspace.toggleFloating();
|
||||
compositor.toggleFloating();
|
||||
event.consume();
|
||||
} else if (config.keybindings().get("new_floating").matches(event)) {
|
||||
workspace.createFloatingPane();
|
||||
} else if (config.keybindings().get("new_pane").matches(event)) {
|
||||
compositor.createPane();
|
||||
event.consume();
|
||||
} else if (config.keybindings().get("next_floating").matches(event)) {
|
||||
workspace.nextFloatingPane();
|
||||
compositor.nextFloatingPane();
|
||||
event.consume();
|
||||
} else if (config.keybindings().get("close_pane").matches(event)) {
|
||||
workspace.closeActivePane();
|
||||
compositor.closeActivePane();
|
||||
event.consume();
|
||||
if (compositor.isEmpty()) {
|
||||
// Closing the last pane quits the app.
|
||||
compositor.close();
|
||||
Platform.exit();
|
||||
}
|
||||
} else if (config.keybindings().get("new_tab").matches(event)) {
|
||||
compositor.newTab();
|
||||
event.consume();
|
||||
} else if (config.keybindings().get("previous_tab").matches(event)) {
|
||||
compositor.previousTab();
|
||||
event.consume();
|
||||
} else if (config.keybindings().get("next_tab").matches(event)) {
|
||||
compositor.nextTab();
|
||||
event.consume();
|
||||
} else if (config.keybindings().get("open_font_selector").matches(event)) {
|
||||
openFontSelector();
|
||||
@@ -89,7 +104,7 @@ public final class Main extends Application {
|
||||
} else {
|
||||
String encoded = KeyEncoder.encode(event);
|
||||
if (encoded != null) {
|
||||
workspace.activePane().send(encoded);
|
||||
compositor.activePane().send(encoded);
|
||||
event.consume();
|
||||
}
|
||||
}
|
||||
@@ -102,7 +117,7 @@ public final class Main extends Application {
|
||||
|
||||
String text = event.getCharacter();
|
||||
if (text != null && !text.isEmpty() && text.charAt(0) >= 0x20 && text.charAt(0) != 0x7f) {
|
||||
workspace.activePane().send(text);
|
||||
compositor.activePane().send(text);
|
||||
event.consume();
|
||||
}
|
||||
}
|
||||
@@ -145,18 +160,18 @@ public final class Main extends Application {
|
||||
double selectedSize = size.getValue();
|
||||
config = config.withFont(selectedFamily.trim(), selectedSize);
|
||||
config.save();
|
||||
terminalView.setFont(config.fontFamily(), config.fontSize());
|
||||
terminalView.canvas().requestFocus();
|
||||
compositor.setFont(config.fontFamily(), config.fontSize());
|
||||
compositor.canvas().requestFocus();
|
||||
});
|
||||
}
|
||||
|
||||
private void openScrollbackInEditor() {
|
||||
try {
|
||||
Path file = Files.createTempFile("jprototerm-scrollback-", ".txt");
|
||||
Files.writeString(file, workspace.activePane().scrollbackText());
|
||||
Files.writeString(file, compositor.activePane().scrollbackText());
|
||||
file.toFile().deleteOnExit();
|
||||
|
||||
workspace.activePane().send(scrollbackEditorCommand(file) + "\r");
|
||||
compositor.activePane().send(scrollbackEditorCommand(file) + "\r");
|
||||
} catch (IOException ex) {
|
||||
System.err.println("Could not open scrollback in editor: " + ex.getMessage());
|
||||
}
|
||||
|
||||
45
src/main/java/com/gregor/jprototerm/RenderTarget.java
Normal file
45
src/main/java/com/gregor/jprototerm/RenderTarget.java
Normal file
@@ -0,0 +1,45 @@
|
||||
package com.gregor.jprototerm;
|
||||
|
||||
import dev.jlibghostty.KittyGraphics;
|
||||
import dev.jlibghostty.RenderStateSnapshot;
|
||||
import javafx.scene.shape.Shape;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* The read-only view of a pane that a {@link TerminalRenderer} draws: its on-screen rect, its
|
||||
* current render snapshot, and its kitty-graphics state. Decoupling the renderer from
|
||||
* {@link TerminalPane} through this interface lets the renderer be swapped (e.g. a debug
|
||||
* renderer that just outlines bounds and clip bands) and unit-tested against a synthetic
|
||||
* target without a real terminal.
|
||||
*/
|
||||
interface RenderTarget {
|
||||
double x();
|
||||
|
||||
double y();
|
||||
|
||||
double width();
|
||||
|
||||
double height();
|
||||
|
||||
/** Whether kitty graphics should be drawn for this target at all. */
|
||||
boolean kittyEnabled();
|
||||
|
||||
Optional<KittyGraphics> kittyGraphics();
|
||||
|
||||
/**
|
||||
* Incremental snapshot: only rows that changed since the last frame are populated. May be
|
||||
* {@code null} before the first snapshot exists.
|
||||
*/
|
||||
RenderStateSnapshot snapshot();
|
||||
|
||||
/** Full snapshot with every row populated, regardless of dirty state. */
|
||||
RenderStateSnapshot snapshotFull();
|
||||
|
||||
/**
|
||||
* The region this target may draw into, or {@code null} to clip to its plain rect. Set at
|
||||
* layout time (a tiled pane gets its rect minus the floating panes that cover it), so the
|
||||
* renderer can clip its own output and never paint over a pane on top.
|
||||
*/
|
||||
Shape clip();
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
package com.gregor.jprototerm;
|
||||
|
||||
import javafx.application.Platform;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
@@ -94,23 +92,25 @@ public final class ShellSession implements AutoCloseable {
|
||||
}
|
||||
|
||||
private void readOutput(TerminalPane pane) {
|
||||
byte[] buffer = new byte[8192];
|
||||
byte[] buffer = new byte[65536];
|
||||
try {
|
||||
int read;
|
||||
while ((read = pty.read(buffer)) != -1) {
|
||||
if (!closed) {
|
||||
byte[] bytes = new byte[read];
|
||||
System.arraycopy(buffer, 0, bytes, 0, read);
|
||||
Platform.runLater(() -> {
|
||||
if (!closed) {
|
||||
pane.write(bytes);
|
||||
}
|
||||
});
|
||||
if (closed) {
|
||||
break;
|
||||
}
|
||||
byte[] bytes = new byte[read];
|
||||
System.arraycopy(buffer, 0, bytes, 0, read);
|
||||
// Feed the terminal model straight from the reader thread. terminal access is
|
||||
// guarded by the per-terminal lock, and the render loop picks the change up on
|
||||
// the next pulse. Avoiding a Platform.runLater hop per chunk removes a frame of
|
||||
// latency and stops write tasks from contending with rendering on the FX thread
|
||||
// when a TUI repaints heavily (the input-lag culprit).
|
||||
pane.write(bytes);
|
||||
}
|
||||
} catch (RuntimeException ex) {
|
||||
if (!closed) {
|
||||
Platform.runLater(() -> pane.write("\r\nshell output stopped: " + ex.getMessage() + "\r\n"));
|
||||
pane.write("\r\nshell output stopped: " + ex.getMessage() + "\r\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
339
src/main/java/com/gregor/jprototerm/Tab.java
Normal file
339
src/main/java/com/gregor/jprototerm/Tab.java
Normal file
@@ -0,0 +1,339 @@
|
||||
package com.gregor.jprototerm;
|
||||
|
||||
import javafx.scene.shape.Rectangle;
|
||||
import javafx.scene.shape.Shape;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
/**
|
||||
* One tab: a row of tiled panes with a group of floating panes shown over them. Floating panes
|
||||
* are shown/hidden as a group ({@code floatingVisible}), and there is always at least one tiled
|
||||
* pane — a floating pane is promoted if the last tiled one closes — so the layout always has a
|
||||
* 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 {
|
||||
private final AppConfig config;
|
||||
private final TerminalMetrics metrics;
|
||||
private final List<TerminalPane> tiled = new ArrayList<>();
|
||||
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;
|
||||
// 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, TerminalMetrics metrics) {
|
||||
this.config = config;
|
||||
this.metrics = metrics;
|
||||
this.lastWidth = config.windowWidth();
|
||||
this.lastHeight = config.windowHeight();
|
||||
TerminalPane first = openPane(false);
|
||||
tiled.add(first);
|
||||
active = first;
|
||||
}
|
||||
|
||||
TerminalPane activePane() {
|
||||
return active;
|
||||
}
|
||||
|
||||
boolean 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() {
|
||||
if (!floatingVisible || floating.isEmpty()) {
|
||||
return List.copyOf(tiled);
|
||||
}
|
||||
List<TerminalPane> ordered = new ArrayList<>(tiled.size() + floating.size());
|
||||
ordered.addAll(tiled);
|
||||
for (TerminalPane pane : floating) {
|
||||
if (pane != active) {
|
||||
ordered.add(pane);
|
||||
}
|
||||
}
|
||||
if (floating.contains(active)) {
|
||||
ordered.add(active); // active floating pane on top
|
||||
}
|
||||
return List.copyOf(ordered);
|
||||
}
|
||||
|
||||
boolean isActive(TerminalPane pane) {
|
||||
return pane != null && pane == active;
|
||||
}
|
||||
|
||||
boolean focus(TerminalPane pane) {
|
||||
if (pane == active || !isFocusable(pane)) {
|
||||
return false;
|
||||
}
|
||||
setActive(pane);
|
||||
return true;
|
||||
}
|
||||
|
||||
void layout(double width, double height, double topInset) {
|
||||
this.lastWidth = width;
|
||||
this.lastHeight = height;
|
||||
this.lastTopInset = topInset;
|
||||
double availHeight = height - topInset;
|
||||
|
||||
double tileWidth = width / Math.max(1, tiled.size());
|
||||
for (int i = 0; i < tiled.size(); i++) {
|
||||
tiled.get(i).bounds(i * tileWidth, topInset, tileWidth, availHeight);
|
||||
}
|
||||
|
||||
double floatingWidth = Math.max(420, width * 0.58);
|
||||
double floatingHeight = Math.max(260, availHeight * 0.58);
|
||||
for (int i = 0; i < floating.size(); i++) {
|
||||
double offset = i * 28.0;
|
||||
floating.get(i).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);
|
||||
}
|
||||
|
||||
assignClips();
|
||||
}
|
||||
|
||||
// Give each pane its clip region for the next paints, so repainting a pane on a content
|
||||
// frame can never bleed over one stacked on top of it. Each pane is clipped to its rect
|
||||
// minus the union of the panes above it: floating panes are clipped by the floating panes
|
||||
// higher in the stack, and tiled panes by the whole floating group. When nothing floats,
|
||||
// every pane clips to its plain bounds.
|
||||
private void assignClips() {
|
||||
if (!floatingVisible || floating.isEmpty()) {
|
||||
tiled.forEach(pane -> pane.setClip(null));
|
||||
floating.forEach(pane -> pane.setClip(null));
|
||||
return;
|
||||
}
|
||||
|
||||
// Floating panes bottom-to-top, matching panes(): insertion order, active pane on top.
|
||||
List<TerminalPane> order = new ArrayList<>(floating.size());
|
||||
for (TerminalPane pane : floating) {
|
||||
if (pane != active) {
|
||||
order.add(pane);
|
||||
}
|
||||
}
|
||||
if (floating.contains(active)) {
|
||||
order.add(active);
|
||||
}
|
||||
|
||||
// Walk top-to-bottom, accumulating the union of the panes above each one.
|
||||
Shape above = null;
|
||||
for (int i = order.size() - 1; i >= 0; i--) {
|
||||
Rectangle rect = rectOf(order.get(i));
|
||||
order.get(i).setClip(above == null ? null : Shape.subtract(rect, above));
|
||||
above = (above == null) ? rect : Shape.union(above, rect);
|
||||
}
|
||||
|
||||
// `above` is now the union of every floating pane; tiled panes sit under all of them.
|
||||
for (TerminalPane pane : tiled) {
|
||||
pane.setClip(Shape.subtract(rectOf(pane), above));
|
||||
}
|
||||
}
|
||||
|
||||
// Match the renderer's pixel snapping (round the origin, keep width/height) so the clip
|
||||
// lines up exactly with where the floating panes are drawn.
|
||||
private static Rectangle rectOf(TerminalPane pane) {
|
||||
return new Rectangle(Math.round(pane.x()), Math.round(pane.y()), pane.width(), pane.height());
|
||||
}
|
||||
|
||||
boolean navigate(Direction direction) {
|
||||
if (floating.contains(active) && navigateFloatingStack(direction)) {
|
||||
return true;
|
||||
}
|
||||
TerminalPane target = focusable()
|
||||
.filter(pane -> pane != active)
|
||||
.filter(pane -> directionFilter(direction, active, pane))
|
||||
.min(Comparator.comparingDouble(pane -> distance(active, pane)))
|
||||
.orElse(null);
|
||||
if (target != null) {
|
||||
setActive(target);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void toggleFloating() {
|
||||
if (floating.isEmpty()) {
|
||||
createFloatingPane();
|
||||
return;
|
||||
}
|
||||
if (floatingVisible) {
|
||||
floatingVisible = false;
|
||||
if (floating.contains(active)) {
|
||||
setActive(tiled.get(0));
|
||||
}
|
||||
} else {
|
||||
floatingVisible = true;
|
||||
setActive(floating.contains(lastFocusedFloating) ? lastFocusedFloating : floating.get(floating.size() - 1));
|
||||
}
|
||||
}
|
||||
|
||||
/** Adds a floating pane while the floating group is shown, otherwise a tiled pane. */
|
||||
void createPane() {
|
||||
if (floatingVisible) {
|
||||
createFloatingPane();
|
||||
} else {
|
||||
TerminalPane pane = openPane(false);
|
||||
tiled.add(pane);
|
||||
setActive(pane);
|
||||
}
|
||||
}
|
||||
|
||||
void nextFloatingPane() {
|
||||
if (floating.isEmpty()) {
|
||||
createFloatingPane();
|
||||
return;
|
||||
}
|
||||
floatingVisible = true;
|
||||
int current = floating.indexOf(active); // -1 when the active pane is tiled
|
||||
setActive(floating.get((current + 1 + floating.size()) % floating.size()));
|
||||
}
|
||||
|
||||
void closeActivePane() {
|
||||
TerminalPane closing = active;
|
||||
boolean wasFloating = floating.remove(closing);
|
||||
if (!wasFloating) {
|
||||
tiled.remove(closing);
|
||||
}
|
||||
if (closing == lastFocusedFloating) {
|
||||
lastFocusedFloating = null;
|
||||
}
|
||||
closing.close();
|
||||
|
||||
if (tiled.isEmpty() && floating.isEmpty()) {
|
||||
active = null; // tab is now empty; the compositor drops it
|
||||
return;
|
||||
}
|
||||
|
||||
// Always keep a tiled base: if the last tiled pane just closed, promote a floating one
|
||||
// (preferring the last focused).
|
||||
if (tiled.isEmpty()) {
|
||||
TerminalPane promote = floating.contains(lastFocusedFloating) ? lastFocusedFloating : floating.get(0);
|
||||
var promoteIndex = floating.indexOf(promote);
|
||||
var nextFocussed = promoteIndex == 0 ? 0 : promoteIndex - 1;
|
||||
floating.remove(promote);
|
||||
tiled.add(promote);
|
||||
if (promote == lastFocusedFloating) {
|
||||
lastFocusedFloating = null;
|
||||
if (!floating.isEmpty()) {
|
||||
lastFocusedFloating = floating.isEmpty() ? null : floating.get(nextFocussed);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (floating.isEmpty()) {
|
||||
floatingVisible = false;
|
||||
}
|
||||
|
||||
setActive(wasFloating && floatingVisible ? floating.get(floating.size() - 1) : tiled.get(0));
|
||||
}
|
||||
|
||||
private void setActive(TerminalPane pane) {
|
||||
active = pane;
|
||||
if (floating.contains(pane)) {
|
||||
lastFocusedFloating = pane;
|
||||
}
|
||||
}
|
||||
|
||||
private void createFloatingPane() {
|
||||
TerminalPane pane = openPane(true);
|
||||
floating.add(pane);
|
||||
floatingVisible = true;
|
||||
setActive(pane);
|
||||
}
|
||||
|
||||
private boolean navigateFloatingStack(Direction direction) {
|
||||
if (floating.size() < 2) {
|
||||
return false;
|
||||
}
|
||||
int current = floating.indexOf(active);
|
||||
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(floating.get(next));
|
||||
return true;
|
||||
}
|
||||
|
||||
private boolean isFocusable(TerminalPane pane) {
|
||||
return tiled.contains(pane) || (floatingVisible && floating.contains(pane));
|
||||
}
|
||||
|
||||
private Stream<TerminalPane> focusable() {
|
||||
return floatingVisible ? Stream.concat(tiled.stream(), floating.stream()) : tiled.stream();
|
||||
}
|
||||
|
||||
private void markContentChanged() {
|
||||
contentVersion++;
|
||||
}
|
||||
|
||||
private TerminalPane openPane(boolean asFloating) {
|
||||
double availHeight = lastHeight - lastTopInset;
|
||||
double widthPx;
|
||||
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;
|
||||
}
|
||||
return TerminalPane.create(config, metrics, this::markContentChanged, widthPx, heightPx);
|
||||
}
|
||||
|
||||
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() {
|
||||
tiled.forEach(TerminalPane::close);
|
||||
floating.forEach(TerminalPane::close);
|
||||
tiled.clear();
|
||||
floating.clear();
|
||||
}
|
||||
}
|
||||
@@ -1,799 +0,0 @@
|
||||
package com.gregor.jprototerm;
|
||||
|
||||
import dev.jlibghostty.KittyImageCompression;
|
||||
import dev.jlibghostty.KittyImageFormat;
|
||||
import dev.jlibghostty.KittyImageSnapshot;
|
||||
import dev.jlibghostty.KittyPlacement;
|
||||
import dev.jlibghostty.KittyPlacementLayer;
|
||||
import dev.jlibghostty.KittyPlaceholder;
|
||||
import dev.jlibghostty.KittyRenderInfo;
|
||||
import dev.jlibghostty.KeyModifiers;
|
||||
import dev.jlibghostty.MouseButton;
|
||||
import dev.jlibghostty.MouseEncoderSize;
|
||||
import dev.jlibghostty.MouseInput;
|
||||
import dev.jlibghostty.RenderCell;
|
||||
import dev.jlibghostty.RenderColor;
|
||||
import dev.jlibghostty.RenderCursorStyle;
|
||||
import dev.jlibghostty.RenderRow;
|
||||
import dev.jlibghostty.RenderStateSnapshot;
|
||||
import javafx.scene.canvas.Canvas;
|
||||
import javafx.scene.canvas.GraphicsContext;
|
||||
import javafx.scene.image.Image;
|
||||
import javafx.scene.image.PixelFormat;
|
||||
import javafx.scene.image.WritableImage;
|
||||
import javafx.scene.input.InputEvent;
|
||||
import javafx.scene.input.MouseEvent;
|
||||
import javafx.scene.input.ScrollEvent;
|
||||
import javafx.scene.input.ScrollEvent.VerticalTextScrollUnits;
|
||||
import javafx.scene.paint.Color;
|
||||
import javafx.scene.text.Font;
|
||||
import javafx.scene.text.FontSmoothingType;
|
||||
import javafx.scene.text.Text;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
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);
|
||||
|
||||
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<>();
|
||||
private String fontFamily;
|
||||
private double fontSize;
|
||||
private Font cachedFont;
|
||||
private FontMetrics cachedMetrics;
|
||||
private String cachedFontFamily;
|
||||
private double cachedFontSize;
|
||||
private String lastRenderKey;
|
||||
private boolean mouseButtonPressed;
|
||||
private MouseButton pressedButton = MouseButton.UNKNOWN;
|
||||
|
||||
public TerminalCanvasView(TerminalWorkspace workspace, AppConfig config) {
|
||||
this.workspace = workspace;
|
||||
this.config = config;
|
||||
this.fontFamily = config.fontFamily();
|
||||
this.fontSize = config.fontSize();
|
||||
canvas.setFocusTraversable(true);
|
||||
canvas.setOnMousePressed(this::handleMousePressed);
|
||||
canvas.setOnMouseReleased(this::handleMouseReleased);
|
||||
canvas.setOnMouseDragged(this::handleMouseDragged);
|
||||
canvas.setOnMouseMoved(this::handleMouseMoved);
|
||||
canvas.setOnScroll(this::handleScroll);
|
||||
}
|
||||
|
||||
public Canvas canvas() {
|
||||
return canvas;
|
||||
}
|
||||
|
||||
public void setFont(String family, double size) {
|
||||
this.fontFamily = family;
|
||||
this.fontSize = size;
|
||||
cachedFont = null;
|
||||
cachedMetrics = null;
|
||||
paneRenderCache.clear();
|
||||
lastRenderKey = null;
|
||||
}
|
||||
|
||||
public void render() {
|
||||
double width = canvas.getWidth();
|
||||
double height = canvas.getHeight();
|
||||
workspace.layout(width, height);
|
||||
Font font = currentFont();
|
||||
FontMetrics metrics = currentFontMetrics();
|
||||
List<TerminalPane> panes = workspace.panes();
|
||||
|
||||
String renderKey = renderKey(width, height, metrics, panes);
|
||||
if (renderKey.equals(lastRenderKey)) {
|
||||
return;
|
||||
}
|
||||
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));
|
||||
for (TerminalPane pane : panes) {
|
||||
drawPane(gc, pane, font, metrics);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
gc.drawImage(cache.image, pane.x(), pane.y());
|
||||
}
|
||||
|
||||
private void drawPaneContent(
|
||||
GraphicsContext gc,
|
||||
TerminalPane pane,
|
||||
Font font,
|
||||
FontMetrics metrics,
|
||||
double x,
|
||||
double y,
|
||||
double width,
|
||||
double height,
|
||||
boolean clear
|
||||
) {
|
||||
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.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()
|
||||
? kittyPlaceholderBounds(snapshot)
|
||||
: Map.of();
|
||||
|
||||
if (config.kittyGraphics()) {
|
||||
drawKittyGraphics(gc, pane, KittyPlacementLayer.BELOW_TEXT, placeholderBounds, left, top, metrics.cellWidth, metrics.lineHeight);
|
||||
}
|
||||
|
||||
if (snapshot != null) {
|
||||
for (RenderRow row : snapshot.renderRows()) {
|
||||
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()) {
|
||||
drawKittyGraphics(gc, pane, KittyPlacementLayer.ABOVE_TEXT, placeholderBounds, left, top, metrics.cellWidth, metrics.lineHeight);
|
||||
}
|
||||
}
|
||||
|
||||
private static FontMetrics measureFontMetrics(Font font) {
|
||||
Text text = new Text("┃MgÅjy");
|
||||
text.setFont(font);
|
||||
double lineHeight = Math.max(1.0, 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());
|
||||
return new FontMetrics(cellWidth, lineHeight, baselineOffset);
|
||||
}
|
||||
|
||||
private Font currentFont() {
|
||||
if (cachedFont == null || !fontFamily.equals(cachedFontFamily) || fontSize != cachedFontSize) {
|
||||
cachedFont = Font.font(fontFamily, fontSize);
|
||||
cachedMetrics = null;
|
||||
cachedFontFamily = fontFamily;
|
||||
cachedFontSize = fontSize;
|
||||
}
|
||||
return cachedFont;
|
||||
}
|
||||
|
||||
private FontMetrics currentFontMetrics() {
|
||||
if (cachedMetrics == null) {
|
||||
cachedMetrics = measureFontMetrics(currentFont());
|
||||
}
|
||||
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();
|
||||
}
|
||||
|
||||
private static Map<KittyPlaceholderKey, KittyPlaceholderBounds> kittyPlaceholderBounds(RenderStateSnapshot snapshot) {
|
||||
if (snapshot == null) {
|
||||
return Map.of();
|
||||
}
|
||||
|
||||
Map<KittyPlaceholderKey, KittyPlaceholderBounds> result = new HashMap<>();
|
||||
for (RenderRow row : snapshot.renderRows()) {
|
||||
for (RenderCell cell : row.cells()) {
|
||||
cell.kittyPlaceholder().ifPresent(placeholder -> {
|
||||
KittyPlaceholderKey key = new KittyPlaceholderKey(placeholder.imageId(), placeholder.placementId());
|
||||
result.computeIfAbsent(key, ignored -> new KittyPlaceholderBounds()).include(row.row(), cell.column(), placeholder);
|
||||
});
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private void handleMousePressed(MouseEvent event) {
|
||||
canvas.requestFocus();
|
||||
TerminalPane pane = paneAt(event.getX(), event.getY());
|
||||
if (pane == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
workspace.focus(pane);
|
||||
pressedButton = mouseButton(event);
|
||||
mouseButtonPressed = true;
|
||||
sendMouse(pane, MouseInput.press(pressedButton, eventX(pane, event.getX()), eventY(pane, event.getY()), modifiers(event)), true, event);
|
||||
}
|
||||
|
||||
private void handleMouseReleased(MouseEvent event) {
|
||||
TerminalPane pane = paneAt(event.getX(), event.getY());
|
||||
if (pane == null) {
|
||||
pane = workspace.activePane();
|
||||
}
|
||||
|
||||
MouseButton button = pressedButton == MouseButton.UNKNOWN ? mouseButton(event) : pressedButton;
|
||||
sendMouse(pane, MouseInput.release(button, eventX(pane, event.getX()), eventY(pane, event.getY()), modifiers(event)), false, event);
|
||||
mouseButtonPressed = false;
|
||||
pressedButton = MouseButton.UNKNOWN;
|
||||
}
|
||||
|
||||
private void handleMouseDragged(MouseEvent event) {
|
||||
TerminalPane pane = paneAt(event.getX(), event.getY());
|
||||
if (pane == null) {
|
||||
pane = workspace.activePane();
|
||||
}
|
||||
|
||||
MouseButton button = pressedButton == MouseButton.UNKNOWN ? mouseButton(event) : pressedButton;
|
||||
sendMouse(pane, MouseInput.drag(button, eventX(pane, event.getX()), eventY(pane, event.getY()), modifiers(event)), true, event);
|
||||
}
|
||||
|
||||
private void handleMouseMoved(MouseEvent event) {
|
||||
TerminalPane pane = paneAt(event.getX(), event.getY());
|
||||
if (pane == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
sendMouse(pane, MouseInput.motion(eventX(pane, event.getX()), eventY(pane, event.getY()), modifiers(event)), mouseButtonPressed, event);
|
||||
}
|
||||
|
||||
private void handleScroll(ScrollEvent event) {
|
||||
TerminalPane pane = paneAt(event.getX(), event.getY());
|
||||
if (pane == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
canvas.requestFocus();
|
||||
workspace.focus(pane);
|
||||
int direction = scrollDirection(event);
|
||||
if (direction == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
MouseButton wheelButton = direction > 0 ? MouseButton.FOUR : MouseButton.FIVE;
|
||||
int rows = scrollRows(event);
|
||||
boolean sent = false;
|
||||
for (int i = 0; i < rows; i++) {
|
||||
sent |= sendMouse(
|
||||
pane,
|
||||
MouseInput.press(wheelButton, eventX(pane, event.getX()), eventY(pane, event.getY()), modifiers(event)),
|
||||
mouseButtonPressed,
|
||||
event
|
||||
);
|
||||
}
|
||||
if (!sent) {
|
||||
pane.scrollViewport(direction > 0 ? -rows : rows);
|
||||
event.consume();
|
||||
}
|
||||
}
|
||||
|
||||
private boolean sendMouse(TerminalPane pane, MouseInput input, boolean anyButtonPressed, InputEvent event) {
|
||||
MouseTarget target = mouseTarget(pane);
|
||||
if (target == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
boolean sent = pane.sendMouse(input, target.size(), anyButtonPressed);
|
||||
if (sent) {
|
||||
event.consume();
|
||||
}
|
||||
return sent;
|
||||
}
|
||||
|
||||
private TerminalPane paneAt(double x, double y) {
|
||||
java.util.List<TerminalPane> panes = workspace.panes();
|
||||
for (int i = panes.size() - 1; i >= 0; i--) {
|
||||
TerminalPane pane = panes.get(i);
|
||||
if (x >= pane.x() && x < pane.x() + pane.width() && y >= pane.y() && y < pane.y() + pane.height()) {
|
||||
return pane;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private MouseTarget mouseTarget(TerminalPane pane) {
|
||||
if (pane.width() <= 24.0 || pane.height() <= 24.0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
FontMetrics metrics = currentFontMetrics();
|
||||
int columns = Math.max(1, (int) ((pane.width() - 24.0) / metrics.cellWidth));
|
||||
int rows = Math.max(1, (int) ((pane.height() - 24.0) / metrics.lineHeight));
|
||||
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 double eventX(TerminalPane pane, double canvasX) {
|
||||
MouseTarget target = mouseTarget(pane);
|
||||
if (target == null) {
|
||||
return 0.0;
|
||||
}
|
||||
return clamp(canvasX - pane.x() - 12.0, 0.0, target.screenWidth() - 1.0);
|
||||
}
|
||||
|
||||
private double eventY(TerminalPane pane, double canvasY) {
|
||||
MouseTarget target = mouseTarget(pane);
|
||||
if (target == null) {
|
||||
return 0.0;
|
||||
}
|
||||
return clamp(canvasY - pane.y() - 12.0, 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 static void drawRow(
|
||||
GraphicsContext gc,
|
||||
RenderRow row,
|
||||
double left,
|
||||
double top,
|
||||
double baseline,
|
||||
double cellWidth,
|
||||
double lineHeight
|
||||
) {
|
||||
for (RenderCell cell : row.cells()) {
|
||||
if (cell.kittyPlaceholder().isPresent()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
double x = left + (cell.column() * cellWidth);
|
||||
double cellTop = top + (row.row() * lineHeight);
|
||||
cell.background().ifPresent(background -> {
|
||||
gc.setFill(toFxColor(background));
|
||||
gc.fillRect(x, cellTop, cellWidth, lineHeight);
|
||||
});
|
||||
if (cell.selected()) {
|
||||
gc.setFill(SELECTED_BACKGROUND);
|
||||
gc.fillRect(x, cellTop, cellWidth, lineHeight);
|
||||
}
|
||||
if (cell.codepoints().length == 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
double y = baseline + (row.row() * lineHeight);
|
||||
Color foreground = cell.foreground().map(TerminalCanvasView::toFxColor).orElse(DEFAULT_FOREGROUND);
|
||||
gc.setFill(foreground);
|
||||
gc.fillText(cell.text(), x, y);
|
||||
}
|
||||
}
|
||||
|
||||
private static Color toFxColor(RenderColor color) {
|
||||
return Color.rgb(color.red(), color.green(), color.blue());
|
||||
}
|
||||
|
||||
private static void drawCursor(GraphicsContext gc, RenderStateSnapshot snapshot, double left, double top, double cellWidth, double lineHeight) {
|
||||
if (!snapshot.cursorVisible() || !snapshot.cursorViewportHasValue()) {
|
||||
return;
|
||||
}
|
||||
|
||||
double x = left + (snapshot.cursorViewportX() * cellWidth);
|
||||
double y = top + (snapshot.cursorViewportY() * lineHeight);
|
||||
gc.setStroke(Color.rgb(225, 229, 235));
|
||||
gc.setFill(Color.rgb(225, 229, 235, 0.28));
|
||||
gc.setLineWidth(1.5);
|
||||
|
||||
RenderCursorStyle style = snapshot.cursorStyle();
|
||||
if (style == RenderCursorStyle.BAR) {
|
||||
gc.strokeLine(x + 0.5, y + 2.0, x + 0.5, y + lineHeight - 2.0);
|
||||
} else if (style == RenderCursorStyle.UNDERLINE) {
|
||||
gc.strokeLine(x + 1.0, y + lineHeight - 2.0, x + cellWidth - 1.0, y + lineHeight - 2.0);
|
||||
} else if (style == RenderCursorStyle.BLOCK) {
|
||||
gc.fillRect(x + 0.5, y + 1.0, Math.max(1.0, cellWidth - 1.0), Math.max(1.0, lineHeight - 2.0));
|
||||
} else {
|
||||
gc.strokeRect(x + 0.5, y + 1.0, Math.max(1.0, cellWidth - 1.0), Math.max(1.0, lineHeight - 2.0));
|
||||
}
|
||||
}
|
||||
|
||||
private void drawKittyGraphics(
|
||||
GraphicsContext gc,
|
||||
TerminalPane pane,
|
||||
KittyPlacementLayer layer,
|
||||
Map<KittyPlaceholderKey, KittyPlaceholderBounds> placeholderBounds,
|
||||
double originX,
|
||||
double originY,
|
||||
double cellWidth,
|
||||
double lineHeight
|
||||
) {
|
||||
pane.kittyGraphics().ifPresent(graphics -> {
|
||||
for (KittyPlacement placement : graphics.placements(layer)) {
|
||||
Image image = imageFor(placement);
|
||||
if (image == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (placement.virtual()) {
|
||||
drawVirtualKittyPlacement(gc, placement, image, placeholderBounds, originX, originY, cellWidth, lineHeight);
|
||||
} else {
|
||||
drawPinnedKittyPlacement(gc, placement, image, originX, originY, cellWidth, lineHeight);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static void drawPinnedKittyPlacement(
|
||||
GraphicsContext gc,
|
||||
KittyPlacement placement,
|
||||
Image image,
|
||||
double originX,
|
||||
double originY,
|
||||
double cellWidth,
|
||||
double lineHeight
|
||||
) {
|
||||
KittyRenderInfo renderInfo = placement.renderInfo().orElse(null);
|
||||
if (renderInfo == null || !renderInfo.viewportVisible()) {
|
||||
return;
|
||||
}
|
||||
|
||||
double sourceX = renderInfo.sourceX();
|
||||
double sourceY = renderInfo.sourceY();
|
||||
double sourceWidth = renderInfo.sourceWidth();
|
||||
double sourceHeight = renderInfo.sourceHeight();
|
||||
if (sourceWidth <= 0.0 || sourceHeight <= 0.0) {
|
||||
return;
|
||||
}
|
||||
|
||||
double x = originX + (renderInfo.viewportColumn() * cellWidth) + placement.xOffset();
|
||||
double y = originY + (renderInfo.viewportRow() * lineHeight) + placement.yOffset();
|
||||
double width = renderInfo.pixelWidth() > 0 ? renderInfo.pixelWidth() : renderInfo.gridColumns() * cellWidth;
|
||||
double height = renderInfo.pixelHeight() > 0 ? renderInfo.pixelHeight() : renderInfo.gridRows() * lineHeight;
|
||||
if (width <= 0.0 || height <= 0.0) {
|
||||
return;
|
||||
}
|
||||
|
||||
gc.drawImage(image, sourceX, sourceY, sourceWidth, sourceHeight, x, y, width, height);
|
||||
}
|
||||
|
||||
private static void drawVirtualKittyPlacement(
|
||||
GraphicsContext gc,
|
||||
KittyPlacement placement,
|
||||
Image image,
|
||||
Map<KittyPlaceholderKey, KittyPlaceholderBounds> placeholderBounds,
|
||||
double originX,
|
||||
double originY,
|
||||
double cellWidth,
|
||||
double lineHeight
|
||||
) {
|
||||
KittyPlaceholderBounds bounds = placeholderBounds.get(new KittyPlaceholderKey(placement.imageId(), placement.placementId()));
|
||||
if (bounds == null) {
|
||||
bounds = placeholderBounds.get(new KittyPlaceholderKey(placement.imageId(), 0));
|
||||
}
|
||||
if (bounds == null && placement.placementId() == 0) {
|
||||
bounds = placeholderBounds.entrySet().stream()
|
||||
.filter(entry -> entry.getKey().imageId() == placement.imageId())
|
||||
.map(Map.Entry::getValue)
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
}
|
||||
if (bounds == null || bounds.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
SourceRect source = sourceRect(placement, image);
|
||||
if (source.width() <= 0.0 || source.height() <= 0.0) {
|
||||
return;
|
||||
}
|
||||
|
||||
long gridColumns = gridColumns(placement, bounds);
|
||||
long gridRows = gridRows(placement, bounds);
|
||||
double sourceCellWidth = source.width() / Math.max(1L, gridColumns);
|
||||
double sourceCellHeight = source.height() / Math.max(1L, gridRows);
|
||||
|
||||
double sourceX = source.x() + (bounds.minSourceColumn * sourceCellWidth);
|
||||
double sourceY = source.y() + (bounds.minSourceRow * sourceCellHeight);
|
||||
double sourceWidth = bounds.sourceColumns() * sourceCellWidth;
|
||||
double sourceHeight = bounds.sourceRows() * sourceCellHeight;
|
||||
double x = originX + (bounds.minColumn * cellWidth);
|
||||
double y = originY + (bounds.minRow * lineHeight);
|
||||
double availableWidth = bounds.columns() * cellWidth;
|
||||
double availableHeight = bounds.rows() * lineHeight;
|
||||
|
||||
if (sourceWidth <= 0.0 || sourceHeight <= 0.0 || availableWidth <= 0.0 || availableHeight <= 0.0) {
|
||||
return;
|
||||
}
|
||||
|
||||
double scale = Math.min(availableWidth / sourceWidth, availableHeight / sourceHeight);
|
||||
double width = sourceWidth * scale;
|
||||
double height = sourceHeight * scale;
|
||||
gc.drawImage(image, sourceX, sourceY, sourceWidth, sourceHeight, x, y, width, height);
|
||||
}
|
||||
|
||||
private static long gridColumns(KittyPlacement placement, KittyPlaceholderBounds bounds) {
|
||||
if (placement.columns() > 0) {
|
||||
return placement.columns();
|
||||
}
|
||||
return Math.max(bounds.maxSourceColumn + 1, bounds.sourceColumns());
|
||||
}
|
||||
|
||||
private static long gridRows(KittyPlacement placement, KittyPlaceholderBounds bounds) {
|
||||
if (placement.rows() > 0) {
|
||||
return placement.rows();
|
||||
}
|
||||
return Math.max(bounds.maxSourceRow + 1, bounds.sourceRows());
|
||||
}
|
||||
|
||||
private static SourceRect sourceRect(KittyPlacement placement, Image image) {
|
||||
double sourceX = placement.sourceX();
|
||||
double sourceY = placement.sourceY();
|
||||
double sourceWidth = placement.sourceWidth() > 0 ? placement.sourceWidth() : image.getWidth() - sourceX;
|
||||
double sourceHeight = placement.sourceHeight() > 0 ? placement.sourceHeight() : image.getHeight() - sourceY;
|
||||
return new SourceRect(sourceX, sourceY, Math.min(sourceWidth, image.getWidth() - sourceX), Math.min(sourceHeight, image.getHeight() - sourceY));
|
||||
}
|
||||
|
||||
private Image imageFor(KittyPlacement placement) {
|
||||
return placement.image().map(snapshot -> {
|
||||
byte[] data = snapshot.data();
|
||||
KittyImageKey key = KittyImageKey.of(snapshot, data);
|
||||
Image cached = kittyImageCache.get(key);
|
||||
if (cached != null) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
kittyImageCache.keySet().removeIf(existing -> existing.id() == snapshot.id());
|
||||
Image decoded = decodeImage(snapshot, data);
|
||||
if (decoded != null) {
|
||||
kittyImageCache.put(key, decoded);
|
||||
}
|
||||
return decoded;
|
||||
}).orElse(null);
|
||||
}
|
||||
|
||||
private boolean paneHasKittyGraphics(TerminalPane pane) {
|
||||
return pane.kittyGraphics()
|
||||
.map(graphics -> !graphics.placements().isEmpty())
|
||||
.orElse(false);
|
||||
}
|
||||
|
||||
private Image decodeImage(KittyImageSnapshot snapshot, byte[] data) {
|
||||
if (snapshot.compression() != KittyImageCompression.NONE) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (snapshot.format() == KittyImageFormat.PNG) {
|
||||
return new Image(new ByteArrayInputStream(data));
|
||||
}
|
||||
|
||||
int width = Math.toIntExact(snapshot.width());
|
||||
int height = Math.toIntExact(snapshot.height());
|
||||
WritableImage image = new WritableImage(width, height);
|
||||
|
||||
if (snapshot.format() == KittyImageFormat.RGBA) {
|
||||
image.getPixelWriter().setPixels(0, 0, width, height, PixelFormat.getByteBgraInstance(), rgbaToBgra(data), 0, width * 4);
|
||||
} else if (snapshot.format() == KittyImageFormat.RGB) {
|
||||
image.getPixelWriter().setPixels(0, 0, width, height, PixelFormat.getByteRgbInstance(), data, 0, width * 3);
|
||||
}
|
||||
return image;
|
||||
}
|
||||
|
||||
private static byte[] rgbaToBgra(byte[] rgba) {
|
||||
byte[] bgra = new byte[rgba.length];
|
||||
for (int i = 0; i + 3 < rgba.length; i += 4) {
|
||||
bgra[i] = rgba[i + 2];
|
||||
bgra[i + 1] = rgba[i + 1];
|
||||
bgra[i + 2] = rgba[i];
|
||||
bgra[i + 3] = rgba[i + 3];
|
||||
}
|
||||
return bgra;
|
||||
}
|
||||
|
||||
private record FontMetrics(double cellWidth, double lineHeight, double baselineOffset) {
|
||||
}
|
||||
|
||||
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) {
|
||||
private static KittyImageKey of(KittyImageSnapshot snapshot, byte[] data) {
|
||||
return new KittyImageKey(
|
||||
snapshot.id(),
|
||||
snapshot.number(),
|
||||
snapshot.width(),
|
||||
snapshot.height(),
|
||||
snapshot.format(),
|
||||
data.length,
|
||||
fingerprint(data)
|
||||
);
|
||||
}
|
||||
|
||||
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) {
|
||||
}
|
||||
|
||||
private record SourceRect(double x, double y, double width, double height) {
|
||||
}
|
||||
|
||||
private static final class KittyPlaceholderBounds {
|
||||
private int minRow = Integer.MAX_VALUE;
|
||||
private int maxRow = Integer.MIN_VALUE;
|
||||
private int minColumn = Integer.MAX_VALUE;
|
||||
private int maxColumn = Integer.MIN_VALUE;
|
||||
private long minSourceRow = Long.MAX_VALUE;
|
||||
private long maxSourceRow = Long.MIN_VALUE;
|
||||
private long minSourceColumn = Long.MAX_VALUE;
|
||||
private long maxSourceColumn = Long.MIN_VALUE;
|
||||
|
||||
private void include(int row, int column, KittyPlaceholder placeholder) {
|
||||
minRow = Math.min(minRow, row);
|
||||
maxRow = Math.max(maxRow, row);
|
||||
minColumn = Math.min(minColumn, column);
|
||||
maxColumn = Math.max(maxColumn, column);
|
||||
minSourceRow = Math.min(minSourceRow, placeholder.sourceRow());
|
||||
maxSourceRow = Math.max(maxSourceRow, placeholder.sourceRow());
|
||||
minSourceColumn = Math.min(minSourceColumn, placeholder.sourceColumn());
|
||||
maxSourceColumn = Math.max(maxSourceColumn, placeholder.sourceColumn());
|
||||
}
|
||||
|
||||
private boolean isEmpty() {
|
||||
return minRow == Integer.MAX_VALUE;
|
||||
}
|
||||
|
||||
private int rows() {
|
||||
return maxRow - minRow + 1;
|
||||
}
|
||||
|
||||
private int columns() {
|
||||
return maxColumn - minColumn + 1;
|
||||
}
|
||||
|
||||
private long sourceRows() {
|
||||
return maxSourceRow - minSourceRow + 1;
|
||||
}
|
||||
|
||||
private long sourceColumns() {
|
||||
return maxSourceColumn - minSourceColumn + 1;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class PaneRenderCache {
|
||||
private Canvas canvas;
|
||||
private WritableImage image;
|
||||
private int imageWidth;
|
||||
private int imageHeight;
|
||||
private String key;
|
||||
}
|
||||
}
|
||||
86
src/main/java/com/gregor/jprototerm/TerminalMetrics.java
Normal file
86
src/main/java/com/gregor/jprototerm/TerminalMetrics.java
Normal file
@@ -0,0 +1,86 @@
|
||||
package com.gregor.jprototerm;
|
||||
|
||||
import javafx.scene.text.Font;
|
||||
import javafx.scene.text.Text;
|
||||
|
||||
/**
|
||||
* Cell geometry shared by the {@link Compositor} and every {@link TerminalPane}.
|
||||
*
|
||||
* <p>The nominal cell width/height come from measuring the font, but a grid can't use
|
||||
* fractional cells, so the measured size is snapped to whole (logical) pixels here — that
|
||||
* snapping is why the value isn't purely a property of the font. The compositor owns the
|
||||
* single instance (it holds the canvas, which is the pixel context), hands it to panes so
|
||||
* they can turn their rect into a column/row count themselves, and re-measures it on a font
|
||||
* change so every pane observes the new geometry through the shared reference.
|
||||
*/
|
||||
public final class TerminalMetrics {
|
||||
/** Inset, in pixels, between a pane's edge and its content on every side. */
|
||||
public static final double PADDING = 12.0;
|
||||
|
||||
private String fontFamily;
|
||||
private double fontSize;
|
||||
private Font font;
|
||||
private double cellWidth;
|
||||
private double lineHeight;
|
||||
private double baselineOffset;
|
||||
|
||||
public TerminalMetrics(String fontFamily, double fontSize) {
|
||||
setFont(fontFamily, fontSize);
|
||||
}
|
||||
|
||||
public void setFont(String fontFamily, double fontSize) {
|
||||
this.fontFamily = fontFamily;
|
||||
this.fontSize = fontSize;
|
||||
this.font = Font.font(fontFamily, fontSize);
|
||||
measure(font);
|
||||
}
|
||||
|
||||
public String fontFamily() {
|
||||
return fontFamily;
|
||||
}
|
||||
|
||||
public double fontSize() {
|
||||
return fontSize;
|
||||
}
|
||||
|
||||
public Font font() {
|
||||
return font;
|
||||
}
|
||||
|
||||
public double cellWidth() {
|
||||
return cellWidth;
|
||||
}
|
||||
|
||||
public double lineHeight() {
|
||||
return lineHeight;
|
||||
}
|
||||
|
||||
public double baselineOffset() {
|
||||
return baselineOffset;
|
||||
}
|
||||
|
||||
/** Columns that fit in a pane of the given pixel width (after subtracting the padding). */
|
||||
public int columnsFor(double widthPx) {
|
||||
return Math.max(1, (int) ((widthPx - 2 * PADDING) / cellWidth));
|
||||
}
|
||||
|
||||
/** Rows that fit in a pane of the given pixel height (after subtracting the padding). */
|
||||
public int rowsFor(double heightPx) {
|
||||
return Math.max(1, (int) ((heightPx - 2 * PADDING) / lineHeight));
|
||||
}
|
||||
|
||||
private void measure(Font font) {
|
||||
Text text = new Text("┃MgÅjy");
|
||||
text.setFont(font);
|
||||
// Snap the cell size to whole pixels so cells tile on integer boundaries. Fractional
|
||||
// cell metrics put every cell edge on a sub-pixel position, leaving anti-aliased
|
||||
// seams that show up as a faint grid behind the themed cell backgrounds. Rounding
|
||||
// leaves a few pixels of unused space at the right/bottom edge, which is fine.
|
||||
this.lineHeight = Math.max(1.0, Math.round(text.getLayoutBounds().getHeight()));
|
||||
this.baselineOffset = -text.getLayoutBounds().getMinY();
|
||||
|
||||
Text cell = new Text("M");
|
||||
cell.setFont(font);
|
||||
this.cellWidth = Math.max(1.0, Math.round(cell.getLayoutBounds().getWidth()));
|
||||
}
|
||||
}
|
||||
@@ -1,27 +1,46 @@
|
||||
package com.gregor.jprototerm;
|
||||
|
||||
import dev.jlibghostty.DeviceAttributes;
|
||||
import dev.jlibghostty.Ghostty;
|
||||
import dev.jlibghostty.KittyGraphics;
|
||||
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;
|
||||
import dev.jlibghostty.TerminalOptions;
|
||||
import dev.jlibghostty.DeviceAttributes;
|
||||
import javafx.scene.canvas.GraphicsContext;
|
||||
import javafx.scene.shape.Shape;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
public final class TerminalPane implements AutoCloseable {
|
||||
/**
|
||||
* 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 — it is a {@link RenderTarget}
|
||||
* that a {@link TerminalRenderer} paints. {@link #paintFull}/{@link #paintIncremental} are the
|
||||
* only rendering API exposed to the {@link Compositor}, and they just delegate to that
|
||||
* renderer; the compositor decides z-order and which rect each pane occupies.
|
||||
*/
|
||||
public final class TerminalPane implements AutoCloseable, RenderTarget {
|
||||
private final Terminal terminal;
|
||||
private final TerminalMetrics metrics;
|
||||
private final boolean kittyEnabled;
|
||||
// Run on every content change so the owning tab can bump its content version — the
|
||||
// compositor's O(1) "did the current tab change?" gate.
|
||||
private final Runnable onContentChange;
|
||||
private final TerminalRenderer renderer;
|
||||
private final MouseEncoder mouseEncoder = new MouseEncoder();
|
||||
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;
|
||||
// Clip region for rendering (rect minus the panes covering this one), set at layout time;
|
||||
// null means clip to the plain bounds. See RenderTarget#clip().
|
||||
private Shape clip;
|
||||
private double x;
|
||||
private double y;
|
||||
private double width;
|
||||
@@ -30,22 +49,50 @@ public final class TerminalPane implements AutoCloseable {
|
||||
private int rows;
|
||||
private int pixelWidth;
|
||||
private int pixelHeight;
|
||||
private long renderVersion;
|
||||
private long contentVersion;
|
||||
private long snapshotVersion = -1;
|
||||
|
||||
private TerminalPane(Terminal terminal, int columns, int rows) {
|
||||
private TerminalPane(Terminal terminal, TerminalMetrics metrics, boolean kittyEnabled,
|
||||
Runnable onContentChange, TerminalRenderer renderer, int columns, int rows) {
|
||||
this.terminal = terminal;
|
||||
this.metrics = metrics;
|
||||
this.kittyEnabled = kittyEnabled;
|
||||
this.onContentChange = onContentChange;
|
||||
this.renderer = renderer;
|
||||
this.columns = columns;
|
||||
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);
|
||||
TerminalPane pane = new TerminalPane(terminal, columns, rows);
|
||||
TerminalPane pane = new TerminalPane(terminal, metrics, config.kittyGraphics(), onContentChange,
|
||||
new GhosttyTerminalRenderer(metrics), columns, rows);
|
||||
pane.refresh();
|
||||
pane.attach(ShellSession.start(config.shell(), config.envOverride(), pane, columns, rows));
|
||||
return pane;
|
||||
}
|
||||
|
||||
private void attach(ShellSession session) {
|
||||
this.session = session;
|
||||
terminal.setPtyWriter(bytes -> {
|
||||
ShellSession current = this.session;
|
||||
if (current != null) {
|
||||
current.send(bytes);
|
||||
}
|
||||
});
|
||||
session.startReading(this);
|
||||
}
|
||||
|
||||
public void write(String text) {
|
||||
synchronized (terminal) {
|
||||
terminal.write(text);
|
||||
@@ -60,17 +107,6 @@ public final class TerminalPane implements AutoCloseable {
|
||||
}
|
||||
}
|
||||
|
||||
public void attach(ShellSession session) {
|
||||
this.session = session;
|
||||
terminal.setPtyWriter(bytes -> {
|
||||
ShellSession current = this.session;
|
||||
if (current != null) {
|
||||
current.send(bytes);
|
||||
}
|
||||
});
|
||||
session.startReading(this);
|
||||
}
|
||||
|
||||
public void send(String text) {
|
||||
scrollViewportToBottom();
|
||||
if (session != null) {
|
||||
@@ -104,15 +140,48 @@ public final class TerminalPane implements AutoCloseable {
|
||||
}
|
||||
}
|
||||
|
||||
public void scrollViewportToBottom() {
|
||||
private void scrollViewportToBottom() {
|
||||
synchronized (terminal) {
|
||||
terminal.scrollViewport(ScrollViewport.bottom());
|
||||
refresh();
|
||||
}
|
||||
}
|
||||
|
||||
public RenderStateSnapshot renderSnapshot() {
|
||||
return renderSnapshot.get();
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
@Override
|
||||
public RenderStateSnapshot snapshot() {
|
||||
return takeSnapshot(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Full snapshot with every row's cells populated. Used where the whole pane is redrawn
|
||||
* regardless of dirty state (the kitty-graphics path).
|
||||
*/
|
||||
@Override
|
||||
public RenderStateSnapshot snapshotFull() {
|
||||
return takeSnapshot(true);
|
||||
}
|
||||
|
||||
private RenderStateSnapshot takeSnapshot(boolean full) {
|
||||
synchronized (terminal) {
|
||||
if (full) {
|
||||
renderState.update(terminal);
|
||||
cachedSnapshot = renderState.snapshot();
|
||||
renderState.resetDirty();
|
||||
snapshotVersion = contentVersion;
|
||||
} else if (snapshotVersion != contentVersion) {
|
||||
renderState.update(terminal);
|
||||
cachedSnapshot = renderState.snapshotIncremental();
|
||||
renderState.resetDirty();
|
||||
snapshotVersion = contentVersion;
|
||||
}
|
||||
return cachedSnapshot;
|
||||
}
|
||||
}
|
||||
|
||||
public String scrollbackText() {
|
||||
@@ -121,44 +190,39 @@ public final class TerminalPane implements AutoCloseable {
|
||||
}
|
||||
}
|
||||
|
||||
public long renderVersion() {
|
||||
return renderVersion;
|
||||
/** This pane's own content revision, bumped on every change (see {@link #refresh()}). */
|
||||
public long contentVersion() {
|
||||
return contentVersion;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean kittyEnabled() {
|
||||
return kittyEnabled;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<KittyGraphics> kittyGraphics() {
|
||||
synchronized (terminal) {
|
||||
return terminal.kittyGraphics();
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@Override
|
||||
public double x() {
|
||||
return x;
|
||||
}
|
||||
|
||||
@Override
|
||||
public double y() {
|
||||
return y;
|
||||
}
|
||||
|
||||
@Override
|
||||
public double width() {
|
||||
return width;
|
||||
}
|
||||
|
||||
@Override
|
||||
public double height() {
|
||||
return height;
|
||||
}
|
||||
@@ -170,7 +234,24 @@ public final class TerminalPane implements AutoCloseable {
|
||||
this.height = height;
|
||||
}
|
||||
|
||||
public void resize(int columns, int rows, int pixelWidth, int pixelHeight) {
|
||||
/** Set the clip region applied on the next paints (see {@link RenderTarget#clip()}). */
|
||||
public void setClip(Shape clip) {
|
||||
this.clip = clip;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Shape clip() {
|
||||
return clip;
|
||||
}
|
||||
|
||||
/** Recompute the ghostty grid from the current bounds and the shared cell metrics. */
|
||||
public void fitToBounds() {
|
||||
int columns = metrics.columnsFor(width);
|
||||
int rows = metrics.rowsFor(height);
|
||||
resize(columns, rows, (int) Math.round(metrics.cellWidth()), (int) Math.round(metrics.lineHeight()));
|
||||
}
|
||||
|
||||
private void resize(int columns, int rows, int pixelWidth, int pixelHeight) {
|
||||
if (columns <= 0 || rows <= 0 || pixelWidth <= 0 || pixelHeight <= 0) {
|
||||
return;
|
||||
}
|
||||
@@ -192,8 +273,21 @@ public final class TerminalPane implements AutoCloseable {
|
||||
}
|
||||
|
||||
private void refresh() {
|
||||
renderSnapshot.set(terminal.renderSnapshot());
|
||||
renderVersion++;
|
||||
// Mark this pane's content dirty (the snapshot is computed lazily in the paint path,
|
||||
// so a burst of writes collapses into one snapshot per frame) and tell the owning tab
|
||||
// one of its panes changed.
|
||||
contentVersion++;
|
||||
onContentChange.run();
|
||||
}
|
||||
|
||||
/** Paint the whole pane; see {@link TerminalRenderer#paintFull}. */
|
||||
public void paintFull(GraphicsContext gc, boolean active) {
|
||||
renderer.paintFull(gc, this, active);
|
||||
}
|
||||
|
||||
/** Repaint what changed; see {@link TerminalRenderer#paintIncremental}. */
|
||||
public void paintIncremental(GraphicsContext gc, boolean active) {
|
||||
renderer.paintIncremental(gc, this, active);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -203,6 +297,7 @@ public final class TerminalPane implements AutoCloseable {
|
||||
session = null;
|
||||
}
|
||||
mouseEncoder.close();
|
||||
renderState.close();
|
||||
terminal.close();
|
||||
}
|
||||
}
|
||||
|
||||
60
src/main/java/com/gregor/jprototerm/TerminalRenderer.java
Normal file
60
src/main/java/com/gregor/jprototerm/TerminalRenderer.java
Normal file
@@ -0,0 +1,60 @@
|
||||
package com.gregor.jprototerm;
|
||||
|
||||
import javafx.scene.canvas.GraphicsContext;
|
||||
import javafx.scene.shape.ClosePath;
|
||||
import javafx.scene.shape.LineTo;
|
||||
import javafx.scene.shape.MoveTo;
|
||||
import javafx.scene.shape.Path;
|
||||
import javafx.scene.shape.PathElement;
|
||||
import javafx.scene.shape.Shape;
|
||||
|
||||
/**
|
||||
* Draws a {@link RenderTarget} onto a JavaFX canvas. The {@link Compositor} owns positioning
|
||||
* and z-order; a renderer only fills the target's rect, clipped to the target's {@link
|
||||
* RenderTarget#clip() clip region} so a repaint can never bleed over a pane on top.
|
||||
* Implementations can change the look entirely — {@link GhosttyTerminalRenderer} is the real
|
||||
* terminal renderer; a debug renderer could outline pane bounds instead.
|
||||
*
|
||||
* <p>A renderer may hold per-target state (e.g. a decoded-image cache), so an instance belongs
|
||||
* to a single {@link TerminalPane}.
|
||||
*/
|
||||
abstract class TerminalRenderer {
|
||||
/** Paint the whole target into its rect, clipped to its clip region. */
|
||||
abstract void paintFull(GraphicsContext gc, RenderTarget target, boolean active);
|
||||
|
||||
/** Repaint only what changed since the last frame, clipped to the target's clip region. */
|
||||
abstract void paintIncremental(GraphicsContext gc, RenderTarget target, boolean active);
|
||||
|
||||
protected static void clipRect(GraphicsContext gc, double x, double y, double width, double height) {
|
||||
gc.beginPath();
|
||||
gc.rect(x, y, width, height);
|
||||
gc.clip();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clip to {@code region} if given (the pane's rect minus the panes covering it, computed by
|
||||
* {@code Shape.subtract} at layout), otherwise to the plain rect. The region is a rectilinear
|
||||
* path, so it replays onto the canvas as move/line/close segments.
|
||||
*/
|
||||
protected static void clip(GraphicsContext gc, double x, double y, double width, double height, Shape region) {
|
||||
if (region == null) {
|
||||
clipRect(gc, x, y, width, height);
|
||||
return;
|
||||
}
|
||||
var elements = ((Path) region).getElements();
|
||||
gc.beginPath();
|
||||
if (elements.isEmpty()) {
|
||||
gc.rect(x, y, 0.0, 0.0); // fully covered: clip to nothing
|
||||
}
|
||||
for (PathElement element : elements) {
|
||||
if (element instanceof MoveTo moveTo) {
|
||||
gc.moveTo(moveTo.getX(), moveTo.getY());
|
||||
} else if (element instanceof LineTo lineTo) {
|
||||
gc.lineTo(lineTo.getX(), lineTo.getY());
|
||||
} else if (element instanceof ClosePath) {
|
||||
gc.closePath();
|
||||
}
|
||||
}
|
||||
gc.clip();
|
||||
}
|
||||
}
|
||||
@@ -1,293 +0,0 @@
|
||||
package com.gregor.jprototerm;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
|
||||
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 long version;
|
||||
|
||||
public TerminalWorkspace(AppConfig config) {
|
||||
this.config = config;
|
||||
panes.add(openPane(false));
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
public long version() {
|
||||
return version;
|
||||
}
|
||||
|
||||
public void focus(TerminalPane pane) {
|
||||
int index = panes.indexOf(pane);
|
||||
if (index >= 0 && pane.visible() && activeIndex != index) {
|
||||
activeIndex = index;
|
||||
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)) {
|
||||
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();
|
||||
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;
|
||||
version++;
|
||||
}
|
||||
}
|
||||
|
||||
public void createFloatingPane() {
|
||||
TerminalPane pane = openPane(true);
|
||||
panes.add(pane);
|
||||
activeIndex = panes.size() - 1;
|
||||
version++;
|
||||
}
|
||||
|
||||
public void nextFloatingPane() {
|
||||
TerminalPane next = nextFloatingAfter(activeIndex);
|
||||
next.setVisible(true);
|
||||
activeIndex = panes.indexOf(next);
|
||||
version++;
|
||||
}
|
||||
|
||||
public void closeActivePane() {
|
||||
TerminalPane active = activePane();
|
||||
if (!active.floating() || panes.stream().filter(pane -> !pane.floating()).count() == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
int removed = activeIndex;
|
||||
int previous = previousVisibleIndex(removed);
|
||||
panes.remove(removed);
|
||||
active.close();
|
||||
activeIndex = adjustIndexAfterRemoval(previous, removed);
|
||||
hiddenFloatingFocusIndex = adjustHiddenFocusAfterRemoval(hiddenFloatingFocusIndex, removed);
|
||||
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();
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
panes.clear();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user