7 Commits

Author SHA1 Message Date
Gregor Lohaus
e2850f067e readme update 2026-05-29 22:10:36 +02:00
Gregor Lohaus
022cf22463 tabbar, background coloring 2026-05-29 22:08:05 +02:00
Gregor Lohaus
250b182060 tabs 2026-05-29 21:41:25 +02:00
Gregor Lohaus
ebba6cc44f main pane splitting 2026-05-29 21:27:17 +02:00
Gregor Lohaus
4c3449129c no image caching, no transparency for performance 2026-05-29 21:18:16 +02:00
Gregor Lohaus
40d6287867 inverse bg 2026-05-29 20:37:16 +02:00
Gregor Lohaus
ff21bf3544 incremental render 2026-05-29 19:50:09 +02:00
8 changed files with 924 additions and 397 deletions

View File

@@ -2,7 +2,15 @@
JavaFX canvas terminal prototype using `jlibghostty` for terminal emulation and Nix for 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) 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 ## Build
@@ -11,7 +19,7 @@ nix build
./result/bin/jprototerm ./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 ```sh
nix profile add . nix profile add .
@@ -97,9 +105,12 @@ navigate_down = "ALT+J"
navigate_up = "ALT+K" navigate_up = "ALT+K"
navigate_right = "ALT+L" navigate_right = "ALT+L"
toggle_floating = "ALT+F" toggle_floating = "ALT+F"
new_floating = "ALT+SHIFT+F" new_pane = "ALT+N"
next_floating = "ALT+F12" next_floating = "ALT+F12"
close_pane = "ALT+X" close_pane = "ALT+X"
new_tab = "ALT+A"
previous_tab = "ALT+SHIFT+H"
next_tab = "ALT+SHIFT+L"
open_font_selector = "ALT+T" open_font_selector = "ALT+T"
open_scrollback = "ALT+S" open_scrollback = "ALT+S"
``` ```
@@ -107,11 +118,19 @@ open_scrollback = "ALT+S"
## Defaults ## Defaults
- `Alt+h/j/k/l`: navigate panes - `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+f`: show or hide all floating panes
- `Alt+Shift+f`: create a new floating pane
- `Alt+F12`: cycle floating panes - `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+t`: open the font selector
- `Alt+s`: open the active pane scrollback in `$EDITOR` - `Alt+s`: open the active pane scrollback in `$EDITOR`
- Font default: `JetBrainsMono Nerd Font` - Font default: `JetBrainsMono Nerd Font`
- Kitty graphics protocol parsing is enabled by default - 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
View File

@@ -70,11 +70,11 @@
"nixpkgs": "nixpkgs" "nixpkgs": "nixpkgs"
}, },
"locked": { "locked": {
"lastModified": 1780050576, "lastModified": 1780079529,
"narHash": "sha256-u06xuO3QnLDpajIOZwDdhwI0HGzMuXG7x1pR+4Zb+RA=", "narHash": "sha256-AxlGTL8c5xSLcQHvWlm994IdOqxsN8iKrA02Cpv7vso=",
"ref": "refs/heads/main", "ref": "refs/heads/main",
"rev": "d558d554b360a76d03c2fc09d327e3ec4aade878", "rev": "68121d50b52fb56038871c97c97e7a12ffe987c2",
"revCount": 17, "revCount": 20,
"type": "git", "type": "git",
"url": "https://gitea.gregorlohaus.com/gregor/jlibghostty.git" "url": "https://gitea.gregorlohaus.com/gregor/jlibghostty.git"
}, },

View File

@@ -36,9 +36,12 @@ public record AppConfig(
"navigate_up", "navigate_up",
"navigate_right", "navigate_right",
"toggle_floating", "toggle_floating",
"new_floating", "new_pane",
"next_floating", "next_floating",
"close_pane", "close_pane",
"new_tab",
"previous_tab",
"next_tab",
"open_font_selector", "open_font_selector",
"open_scrollback" "open_scrollback"
); );
@@ -86,17 +89,20 @@ public record AppConfig(
true, true,
defaultScrollbackEditorCommand(), defaultScrollbackEditorCommand(),
Map.of(), Map.of(),
Map.of( Map.ofEntries(
"navigate_left", KeyBinding.parse("ALT+H"), Map.entry("navigate_left", KeyBinding.parse("ALT+H")),
"navigate_down", KeyBinding.parse("ALT+J"), Map.entry("navigate_down", KeyBinding.parse("ALT+J")),
"navigate_up", KeyBinding.parse("ALT+K"), Map.entry("navigate_up", KeyBinding.parse("ALT+K")),
"navigate_right", KeyBinding.parse("ALT+L"), Map.entry("navigate_right", KeyBinding.parse("ALT+L")),
"toggle_floating", KeyBinding.parse("ALT+F"), Map.entry("toggle_floating", KeyBinding.parse("ALT+F")),
"new_floating", KeyBinding.parse("ALT+SHIFT+F"), Map.entry("new_pane", KeyBinding.parse("ALT+N")),
"next_floating", KeyBinding.parse("ALT+F12"), Map.entry("next_floating", KeyBinding.parse("ALT+F12")),
"close_pane", KeyBinding.parse("ALT+X"), Map.entry("close_pane", KeyBinding.parse("ALT+X")),
"open_font_selector", KeyBinding.parse("ALT+T"), Map.entry("new_tab", KeyBinding.parse("ALT+A")),
"open_scrollback", KeyBinding.parse("ALT+S") 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"))
) )
); );
} }

View File

@@ -2,6 +2,7 @@ package com.gregor.jprototerm;
import javafx.animation.AnimationTimer; import javafx.animation.AnimationTimer;
import javafx.application.Application; import javafx.application.Application;
import javafx.application.Platform;
import javafx.scene.Scene; import javafx.scene.Scene;
import javafx.scene.control.ButtonType; import javafx.scene.control.ButtonType;
import javafx.scene.control.ComboBox; import javafx.scene.control.ComboBox;
@@ -71,8 +72,8 @@ public final class Main extends Application {
} else if (config.keybindings().get("toggle_floating").matches(event)) { } else if (config.keybindings().get("toggle_floating").matches(event)) {
workspace.toggleFloating(); workspace.toggleFloating();
event.consume(); event.consume();
} else if (config.keybindings().get("new_floating").matches(event)) { } else if (config.keybindings().get("new_pane").matches(event)) {
workspace.createFloatingPane(); workspace.createPane();
event.consume(); event.consume();
} else if (config.keybindings().get("next_floating").matches(event)) { } else if (config.keybindings().get("next_floating").matches(event)) {
workspace.nextFloatingPane(); workspace.nextFloatingPane();
@@ -80,6 +81,20 @@ public final class Main extends Application {
} else if (config.keybindings().get("close_pane").matches(event)) { } else if (config.keybindings().get("close_pane").matches(event)) {
workspace.closeActivePane(); workspace.closeActivePane();
event.consume(); event.consume();
if (workspace.isEmpty()) {
// Closing the last pane quits the app.
workspace.close();
Platform.exit();
}
} else if (config.keybindings().get("new_tab").matches(event)) {
workspace.newTab();
event.consume();
} else if (config.keybindings().get("previous_tab").matches(event)) {
workspace.previousTab();
event.consume();
} else if (config.keybindings().get("next_tab").matches(event)) {
workspace.nextTab();
event.consume();
} else if (config.keybindings().get("open_font_selector").matches(event)) { } else if (config.keybindings().get("open_font_selector").matches(event)) {
openFontSelector(); openFontSelector();
event.consume(); event.consume();

View File

@@ -0,0 +1,345 @@
package com.gregor.jprototerm;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
/**
* One tab: an isolated stack of panes (tiled + floating) with its own active pane and
* stashed-floating state. {@link TerminalWorkspace} owns the list of tabs and renders only
* the current one. Mutating methods return whether they actually changed anything so the
* workspace can bump its render version conditionally.
*/
final class Tab implements AutoCloseable {
private final AppConfig config;
private final List<TerminalPane> panes = new ArrayList<>();
private int activeIndex;
private int hiddenFloatingFocusIndex = -1;
private TerminalPane lastFocusedFloating;
Tab(AppConfig config) {
this.config = config;
panes.add(openPane(false));
}
TerminalPane activePane() {
return panes.get(activeIndex);
}
boolean isEmpty() {
return panes.isEmpty();
}
List<TerminalPane> panes() {
if (panes.isEmpty()) {
return List.of();
}
List<TerminalPane> visible = panes.stream().filter(TerminalPane::visible).toList();
TerminalPane active = activePane();
if (!active.visible() || !active.floating()) {
return visible;
}
List<TerminalPane> ordered = new ArrayList<>(visible.size());
visible.stream()
.filter(pane -> pane != active)
.forEach(ordered::add);
ordered.add(active);
return List.copyOf(ordered);
}
boolean isActive(TerminalPane pane) {
return !panes.isEmpty() && activePane() == pane;
}
boolean focus(TerminalPane pane) {
int index = panes.indexOf(pane);
if (index >= 0 && pane.visible() && activeIndex != index) {
setActive(index);
return true;
}
return false;
}
void layout(double width, double height, double topInset) {
double availHeight = height - topInset;
List<TerminalPane> tiled = panes.stream()
.filter(TerminalPane::visible)
.filter(pane -> !pane.floating())
.toList();
int tileCount = Math.max(1, tiled.size());
double tileWidth = width / tileCount;
for (int i = 0; i < tiled.size(); i++) {
tiled.get(i).bounds(i * tileWidth, topInset, tileWidth, availHeight);
}
List<TerminalPane> floating = panes.stream()
.filter(TerminalPane::visible)
.filter(TerminalPane::floating)
.toList();
for (int i = 0; i < floating.size(); i++) {
TerminalPane pane = floating.get(i);
double floatingWidth = Math.max(420, width * 0.58);
double floatingHeight = Math.max(260, availHeight * 0.58);
double offset = i * 28.0;
pane.bounds(
Math.min(width - floatingWidth - 12.0, ((width - floatingWidth) / 2.0) + offset),
Math.min(height - floatingHeight - 12.0, topInset + ((availHeight - floatingHeight) / 2.0) + offset),
floatingWidth,
floatingHeight
);
}
}
boolean navigate(Direction direction) {
TerminalPane current = activePane();
if (current.floating() && navigateFloatingStack(direction)) {
return true;
}
TerminalPane target = panes.stream()
.filter(TerminalPane::visible)
.filter(pane -> pane != current)
.filter(pane -> directionFilter(direction, current, pane))
.min(Comparator.comparingDouble(pane -> distance(current, pane)))
.orElse(null);
if (target != null) {
setActive(panes.indexOf(target));
return true;
}
return false;
}
void toggleFloating() {
List<TerminalPane> floating = panes.stream()
.filter(TerminalPane::floating)
.toList();
if (floating.isEmpty()) {
createFloatingPane();
return;
}
boolean anyVisible = floating.stream().anyMatch(TerminalPane::visible);
if (anyVisible) {
TerminalPane active = activePane();
hiddenFloatingFocusIndex = active.floating() ? activeIndex : firstVisibleFloatingIndex();
floating.forEach(pane -> pane.setVisible(false));
setActive(firstVisibleNonFloatingIndex());
} else {
floating.forEach(pane -> pane.setVisible(true));
setActive(visibleIndexOrFallback(hiddenFloatingFocusIndex, panes.indexOf(floating.get(floating.size() - 1))));
hiddenFloatingFocusIndex = -1;
}
}
/**
* "New pane": adds a floating pane while floating panes are shown, otherwise adds a
* tiled pane (the tiled row is redistributed equally by the layout).
*/
void createPane() {
if (anyFloatingVisible()) {
createFloatingPane();
} else {
TerminalPane pane = openPane(false);
panes.add(pane);
setActive(panes.size() - 1);
}
}
void nextFloatingPane() {
TerminalPane next = nextFloatingAfter(activeIndex);
next.setVisible(true);
setActive(panes.indexOf(next));
}
void closeActivePane() {
TerminalPane active = activePane();
int removed = activeIndex;
int previous = previousVisibleIndex(removed);
panes.remove(removed);
if (active == lastFocusedFloating) {
lastFocusedFloating = null;
}
active.close();
if (panes.isEmpty()) {
activeIndex = 0;
return;
}
activeIndex = adjustIndexAfterRemoval(previous, removed);
hiddenFloatingFocusIndex = adjustHiddenFocusAfterRemoval(hiddenFloatingFocusIndex, removed);
// If the last tiled (main) pane was closed, promote a floating pane to be the new
// main pane so the layout has a base and rendering continues normally. Prefer the
// most recently focused floating pane.
if (panes.stream().noneMatch(pane -> !pane.floating())) {
TerminalPane promote = (lastFocusedFloating != null && panes.contains(lastFocusedFloating))
? lastFocusedFloating
: panes.get(activeIndex);
promote.setFloating(false);
promote.setVisible(true);
activeIndex = panes.indexOf(promote);
lastFocusedFloating = null;
}
// If only hidden panes remained, reveal the one we're focusing so the screen isn't
// blank.
if (!panes.get(activeIndex).visible()) {
panes.get(activeIndex).setVisible(true);
}
}
private void setActive(int index) {
activeIndex = index;
if (index >= 0 && index < panes.size() && panes.get(index).floating()) {
lastFocusedFloating = panes.get(index);
}
}
private void createFloatingPane() {
TerminalPane pane = openPane(true);
panes.add(pane);
setActive(panes.size() - 1);
}
private boolean anyFloatingVisible() {
return panes.stream().anyMatch(pane -> pane.floating() && pane.visible());
}
private TerminalPane nextFloatingAfter(int index) {
for (int i = index + 1; i < panes.size(); i++) {
TerminalPane pane = panes.get(i);
if (pane.floating()) {
return pane;
}
}
for (int i = 0; i <= index && i < panes.size(); i++) {
TerminalPane pane = panes.get(i);
if (pane.floating()) {
return pane;
}
}
return createAndReturnFloatingPane();
}
private TerminalPane createAndReturnFloatingPane() {
TerminalPane pane = openPane(true);
panes.add(pane);
return pane;
}
private boolean navigateFloatingStack(Direction direction) {
List<TerminalPane> floating = panes.stream()
.filter(TerminalPane::visible)
.filter(TerminalPane::floating)
.toList();
if (floating.size() < 2) {
return false;
}
int current = floating.indexOf(activePane());
if (current < 0) {
return false;
}
int next = switch (direction) {
case LEFT, UP -> current - 1;
case DOWN, RIGHT -> current + 1;
};
if (next < 0 || next >= floating.size()) {
return false;
}
setActive(panes.indexOf(floating.get(next)));
return true;
}
private int firstVisibleFloatingIndex() {
for (int i = 0; i < panes.size(); i++) {
TerminalPane pane = panes.get(i);
if (pane.visible() && pane.floating()) {
return i;
}
}
return -1;
}
private int firstVisibleNonFloatingIndex() {
for (int i = 0; i < panes.size(); i++) {
TerminalPane pane = panes.get(i);
if (pane.visible() && !pane.floating()) {
return i;
}
}
return 0;
}
private int previousVisibleIndex(int index) {
for (int i = index - 1; i >= 0; i--) {
if (panes.get(i).visible()) {
return i;
}
}
for (int i = index + 1; i < panes.size(); i++) {
if (panes.get(i).visible()) {
return i;
}
}
return firstVisibleNonFloatingIndex();
}
private int visibleIndexOrFallback(int index, int fallback) {
if (index >= 0 && index < panes.size() && panes.get(index).visible()) {
return index;
}
return fallback;
}
private static int adjustIndexAfterRemoval(int index, int removedIndex) {
if (index < 0) {
return 0;
}
return index > removedIndex ? index - 1 : index;
}
private static int adjustHiddenFocusAfterRemoval(int index, int removedIndex) {
if (index < 0 || index == removedIndex) {
return -1;
}
return index > removedIndex ? index - 1 : index;
}
private TerminalPane openPane(boolean floating) {
TerminalPane pane = TerminalPane.create(config.columns(), config.rows(), config.maxScrollback());
pane.setFloating(floating);
pane.attach(ShellSession.start(config.shell(), config.envOverride(), pane, config.columns(), config.rows()));
return pane;
}
private static boolean directionFilter(Direction direction, TerminalPane current, TerminalPane candidate) {
double currentCenterX = current.x() + current.width() / 2.0;
double currentCenterY = current.y() + current.height() / 2.0;
double candidateCenterX = candidate.x() + candidate.width() / 2.0;
double candidateCenterY = candidate.y() + candidate.height() / 2.0;
return switch (direction) {
case LEFT -> candidateCenterX < currentCenterX;
case DOWN -> candidateCenterY > currentCenterY;
case UP -> candidateCenterY < currentCenterY;
case RIGHT -> candidateCenterX > currentCenterX;
};
}
private static double distance(TerminalPane current, TerminalPane candidate) {
double dx = (current.x() + current.width() / 2.0) - (candidate.x() + candidate.width() / 2.0);
double dy = (current.y() + current.height() / 2.0) - (candidate.y() + candidate.height() / 2.0);
return Math.sqrt(dx * dx + dy * dy);
}
@Override
public void close() {
for (TerminalPane pane : panes) {
pane.close();
}
panes.clear();
}
}

View File

@@ -16,6 +16,7 @@ import dev.jlibghostty.RenderColor;
import dev.jlibghostty.RenderCursorStyle; import dev.jlibghostty.RenderCursorStyle;
import dev.jlibghostty.RenderRow; import dev.jlibghostty.RenderRow;
import dev.jlibghostty.RenderStateSnapshot; import dev.jlibghostty.RenderStateSnapshot;
import javafx.geometry.VPos;
import javafx.scene.canvas.Canvas; import javafx.scene.canvas.Canvas;
import javafx.scene.canvas.GraphicsContext; import javafx.scene.canvas.GraphicsContext;
import javafx.scene.image.Image; import javafx.scene.image.Image;
@@ -29,6 +30,7 @@ import javafx.scene.paint.Color;
import javafx.scene.text.Font; import javafx.scene.text.Font;
import javafx.scene.text.FontSmoothingType; import javafx.scene.text.FontSmoothingType;
import javafx.scene.text.Text; import javafx.scene.text.Text;
import javafx.scene.text.TextAlignment;
import java.io.ByteArrayInputStream; import java.io.ByteArrayInputStream;
import java.util.HashMap; import java.util.HashMap;
@@ -38,19 +40,33 @@ import java.util.Map;
public final class TerminalCanvasView { public final class TerminalCanvasView {
private static final Color DEFAULT_FOREGROUND = Color.rgb(225, 229, 235); private static final Color DEFAULT_FOREGROUND = Color.rgb(225, 229, 235);
private static final Color SELECTED_BACKGROUND = Color.rgb(52, 92, 140); private static final Color SELECTED_BACKGROUND = Color.rgb(52, 92, 140);
// The default cell background (used for cells with no explicit bg, and as the foreground
// for reverse-video cells whose background is the terminal default).
private static final Color PANE_BACKGROUND = Color.rgb(9, 10, 12);
// Canvas background shown wherever no pane covers (gaps, behind nothing once tiled panes
// fill the canvas). Painted on a full recomposite.
private static final Color GAP_BACKGROUND = Color.rgb(16, 16, 18);
private final Canvas canvas = new Canvas(); private final Canvas canvas = new Canvas();
private final TerminalWorkspace workspace; private final TerminalWorkspace workspace;
private final AppConfig config; private final AppConfig config;
private final Map<KittyImageKey, Image> kittyImageCache = new HashMap<>(); private final Map<KittyImageKey, Image> kittyImageCache = new HashMap<>();
private final Map<TerminalPane, PaneRenderCache> paneRenderCache = new HashMap<>(); // Last content version drawn to the canvas per pane, so a content frame repaints only
// the panes that actually changed.
private final Map<TerminalPane, Long> paneContentVersion = new HashMap<>();
private String fontFamily; private String fontFamily;
private double fontSize; private double fontSize;
private Font cachedFont; private Font cachedFont;
private FontMetrics cachedMetrics; private FontMetrics cachedMetrics;
private String cachedFontFamily; private String cachedFontFamily;
private double cachedFontSize; private double cachedFontSize;
private String lastRenderKey; // Cheap per-frame dirty signal: skip the whole render when none of these changed.
private double lastWidth = -1.0;
private double lastHeight = -1.0;
private String lastFontFamily;
private double lastFontSize = -1.0;
private long lastWorkspaceVersion = Long.MIN_VALUE;
private long lastRenderTick = Long.MIN_VALUE;
private boolean mouseButtonPressed; private boolean mouseButtonPressed;
private MouseButton pressedButton = MouseButton.UNKNOWN; private MouseButton pressedButton = MouseButton.UNKNOWN;
@@ -76,144 +92,391 @@ public final class TerminalCanvasView {
this.fontSize = size; this.fontSize = size;
cachedFont = null; cachedFont = null;
cachedMetrics = null; cachedMetrics = null;
paneRenderCache.clear(); paneContentVersion.clear();
lastRenderKey = null; lastWidth = -1.0; // force a redraw on the next frame
} }
// GhosttyRenderStateDirty values (stable C ABI; see ghostty/vt/render.h).
private static final int DIRTY_PARTIAL = 1;
private static final int DIRTY_FULL = 2;
// Thin tab strip shown at the top when more than one tab is open.
private static final double TAB_BAR_HEIGHT = 22.0;
public void render() { public void render() {
double width = canvas.getWidth(); double width = canvas.getWidth();
double height = canvas.getHeight(); double height = canvas.getHeight();
workspace.layout(width, height); long workspaceVersion = workspace.version();
long renderTick = TerminalPane.renderTick();
// Two kinds of change: a layout change (size, font, active pane, pane set / z-order)
// forces a full recomposite; a content change (renderTick) only repaints the panes
// whose terminal content changed. Idle frames — neither — bail out immediately.
boolean layoutChanged = width != lastWidth || height != lastHeight
|| fontSize != lastFontSize || !java.util.Objects.equals(fontFamily, lastFontFamily)
|| workspaceVersion != lastWorkspaceVersion;
boolean contentChanged = renderTick != lastRenderTick;
if (!layoutChanged && !contentChanged) {
return;
}
lastWidth = width;
lastHeight = height;
lastFontFamily = fontFamily;
lastFontSize = fontSize;
lastWorkspaceVersion = workspaceVersion;
lastRenderTick = renderTick;
double topInset = workspace.tabCount() > 1 ? TAB_BAR_HEIGHT : 0.0;
workspace.layout(width, height, topInset);
Font font = currentFont(); Font font = currentFont();
FontMetrics metrics = currentFontMetrics(); FontMetrics metrics = currentFontMetrics();
List<TerminalPane> panes = workspace.panes(); List<TerminalPane> panes = workspace.panes();
String renderKey = renderKey(width, height, metrics, panes); // Apply terminal resizes up front so snapshots reflect current geometry (a no-op
if (renderKey.equals(lastRenderKey)) { // when the grid is unchanged).
return; for (TerminalPane pane : panes) {
applyResize(pane, metrics);
} }
lastRenderKey = renderKey;
GraphicsContext gc = canvas.getGraphicsContext2D(); GraphicsContext gc = canvas.getGraphicsContext2D();
gc.setFill(Color.rgb(16, 16, 18));
gc.fillRect(0, 0, width, height);
gc.setFontSmoothingType(FontSmoothingType.LCD); gc.setFontSmoothingType(FontSmoothingType.LCD);
paneRenderCache.keySet().removeIf(pane -> !panes.contains(pane)); if (layoutChanged) {
for (TerminalPane pane : panes) { // Recomposite everything onto the retained canvas: clear, then paint panes
drawPane(gc, pane, font, metrics); // bottom-to-top (workspace.panes() puts the active floating pane last == on top).
} paneContentVersion.keySet().retainAll(panes);
} gc.setFill(GAP_BACKGROUND);
gc.fillRect(0, 0, width, height);
private void drawPane(GraphicsContext gc, TerminalPane pane, Font font, FontMetrics metrics) { if (topInset > 0.0) {
if (config.kittyGraphics() && paneHasKittyGraphics(pane)) { drawTabBar(gc, width, topInset);
paneRenderCache.remove(pane); }
gc.save(); for (TerminalPane pane : panes) {
gc.beginPath(); paintPane(gc, pane, font, metrics, pane.renderSnapshotFull());
gc.rect(pane.x(), pane.y(), pane.width(), pane.height()); paneContentVersion.put(pane, pane.renderVersion());
gc.clip(); }
drawPaneContent(gc, pane, font, metrics, pane.x(), pane.y(), pane.width(), pane.height(), false);
gc.restore();
return; return;
} }
PaneRenderCache cache = paneRenderCache.computeIfAbsent(pane, ignored -> new PaneRenderCache()); // Content-only frame: repaint just the panes whose content changed, directly on the
String cacheKey = paneCacheKey(pane, metrics); // retained canvas, then restore any panes stacked above where they overlap.
int imageWidth = Math.max(1, (int) Math.ceil(pane.width())); for (int i = 0; i < panes.size(); i++) {
int imageHeight = Math.max(1, (int) Math.ceil(pane.height())); TerminalPane pane = panes.get(i);
Long drawn = paneContentVersion.get(pane);
// Allocate the offscreen buffers only when the pane size changes. Reallocating a if (drawn != null && drawn == pane.renderVersion()) {
// full-pane Canvas + WritableImage on every content change churns ~20 MB per frame, continue;
// 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) { repaintPaneContent(gc, panes, i, font, metrics);
cache.canvas = new Canvas(imageWidth, imageHeight); paneContentVersion.put(pane, pane.renderVersion());
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 applyResize(TerminalPane pane, FontMetrics metrics) {
int columns = Math.max(1, (int) ((pane.width() - 24.0) / metrics.cellWidth));
int rows = Math.max(1, (int) ((pane.height() - 24.0) / metrics.lineHeight));
pane.resize(columns, rows, (int) Math.round(metrics.cellWidth), (int) Math.round(metrics.lineHeight));
}
// Paint a pane's whole body, clipped to its rect. Used for full recomposites.
private void paintPane(GraphicsContext gc, TerminalPane pane, Font font, FontMetrics metrics, RenderStateSnapshot snapshot) {
double px = Math.round(pane.x());
double py = Math.round(pane.y());
gc.save();
clipRect(gc, px, py, pane.width(), pane.height());
drawPaneContent(gc, pane, font, metrics, snapshot, px, py, pane.width(), pane.height(),
config.kittyGraphics() && paneHasKittyGraphics(pane));
gc.restore();
}
// Repaint one pane whose content changed, then restore the (opaque) panes stacked above
// it wherever they overlap the repainted region, so the z-order stays correct.
private void repaintPaneContent(GraphicsContext gc, List<TerminalPane> panes, int index, Font font, FontMetrics metrics) {
TerminalPane pane = panes.get(index);
double px = Math.round(pane.x());
double py = Math.round(pane.y());
double pw = pane.width();
double ph = pane.height();
boolean kitty = config.kittyGraphics() && paneHasKittyGraphics(pane);
double regionY0;
double regionY1;
gc.save();
clipRect(gc, px, py, pw, ph);
if (kitty) {
drawPaneContent(gc, pane, font, metrics, pane.renderSnapshotFull(), px, py, pw, ph, true);
regionY0 = py;
regionY1 = py + ph;
} else {
RenderStateSnapshot snapshot = pane.renderSnapshot();
int dirty = snapshot == null ? DIRTY_FULL : snapshot.dirty();
if (dirty == DIRTY_FULL) {
drawPaneContent(gc, pane, font, metrics, snapshot, px, py, pw, ph, false);
regionY0 = py;
regionY1 = py + ph;
} else if (dirty == DIRTY_PARTIAL) {
double[] band = drawDirtyRows(gc, pane, font, metrics, snapshot, px, py, pw, ph);
gc.restore();
if (band == null) {
return;
}
restoreStackedAbove(gc, panes, index, font, metrics, px, band[0], pw, band[1] - band[0]);
return;
} else {
gc.restore();
return; // dirty == FALSE: nothing visible changed.
}
}
gc.restore();
restoreStackedAbove(gc, panes, index, font, metrics, px, regionY0, pw, regionY1 - regionY0);
}
// Redraw any panes above `index` in z-order that intersect the given screen rect, so a
// lower pane's repaint doesn't leak through a pane stacked on top of it.
private void restoreStackedAbove(GraphicsContext gc, List<TerminalPane> panes, int index,
Font font, FontMetrics metrics, double rx, double ry, double rw, double rh) {
for (int j = index + 1; j < panes.size(); j++) {
TerminalPane above = panes.get(j);
double ax = Math.round(above.x());
double ay = Math.round(above.y());
double ox0 = Math.max(rx, ax);
double oy0 = Math.max(ry, ay);
double ox1 = Math.min(rx + rw, ax + above.width());
double oy1 = Math.min(ry + rh, ay + above.height());
if (ox1 <= ox0 || oy1 <= oy0) {
continue;
}
gc.save();
clipRect(gc, ox0, oy0, ox1 - ox0, oy1 - oy0);
drawPaneContent(gc, above, font, metrics, above.renderSnapshotFull(), ax, ay, above.width(), above.height(),
config.kittyGraphics() && paneHasKittyGraphics(above));
gc.restore();
}
}
private static void clipRect(GraphicsContext gc, double x, double y, double width, double height) {
gc.beginPath();
gc.rect(x, y, width, height);
gc.clip();
}
// Thin tab strip: one equal-width segment per tab, the current one highlighted, with a
// small 1-based number centred in each segment.
private void drawTabBar(GraphicsContext gc, double width, double barHeight) {
int count = workspace.tabCount();
int currentIndex = workspace.currentTabIndex();
Font barFont = Font.font(fontFamily, Math.max(9.0, Math.min(13.0, barHeight * 0.62)));
gc.setFont(barFont);
gc.setFontSmoothingType(FontSmoothingType.GRAY);
gc.setTextAlign(TextAlignment.CENTER);
gc.setTextBaseline(VPos.CENTER);
double gap = 1.0;
double segmentWidth = width / count;
for (int i = 0; i < count; i++) {
double x = i * segmentWidth;
boolean current = i == currentIndex;
gc.setFill(current ? Color.rgb(45, 55, 72) : Color.rgb(22, 24, 28));
gc.fillRect(x, 0.0, segmentWidth - gap, barHeight);
gc.setFill(current ? DEFAULT_FOREGROUND : Color.rgb(128, 136, 148));
gc.fillText(Integer.toString(i + 1), x + (segmentWidth - gap) / 2.0, barHeight / 2.0);
}
// Restore the defaults the cell renderer relies on (left-aligned, baseline, LCD).
gc.setTextAlign(TextAlignment.LEFT);
gc.setTextBaseline(VPos.BASELINE);
gc.setFontSmoothingType(FontSmoothingType.LCD);
}
// Full content render: background, border, all rows, cursor, and (when enabled) kitty
// graphics. Used by the kitty direct path and by full offscreen redraws.
private void drawPaneContent( private void drawPaneContent(
GraphicsContext gc, GraphicsContext gc,
TerminalPane pane, TerminalPane pane,
Font font, Font font,
FontMetrics metrics, FontMetrics metrics,
RenderStateSnapshot snapshot,
double x, double x,
double y, double y,
double width, double width,
double height, double height,
boolean clear boolean withKitty
) { ) {
if (clear) {
gc.clearRect(x, y, width, height);
}
gc.setFontSmoothingType(FontSmoothingType.LCD); gc.setFontSmoothingType(FontSmoothingType.LCD);
if (pane.floating()) { gc.setFill(PANE_BACKGROUND);
gc.setGlobalAlpha(0.96);
}
gc.setFill(Color.rgb(9, 10, 12));
gc.fillRect(x, y, width, height); 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); 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 left = x + 12.0;
double top = y + 12.0; double top = y + 12.0;
double baseline = top + metrics.baselineOffset; double baseline = top + metrics.baselineOffset;
RenderStateSnapshot snapshot = pane.renderSnapshot(); Map<KittyPlaceholderKey, KittyPlaceholderBounds> placeholderBounds = withKitty
Map<KittyPlaceholderKey, KittyPlaceholderBounds> placeholderBounds = config.kittyGraphics()
? kittyPlaceholderBounds(snapshot) ? kittyPlaceholderBounds(snapshot)
: Map.of(); : Map.of();
if (config.kittyGraphics()) { if (withKitty) {
drawKittyGraphics(gc, pane, KittyPlacementLayer.BELOW_TEXT, placeholderBounds, left, top, metrics.cellWidth, metrics.lineHeight); drawKittyGraphics(gc, pane, KittyPlacementLayer.BELOW_TEXT, placeholderBounds, left, top, metrics.cellWidth, metrics.lineHeight);
} }
if (snapshot != null) { if (snapshot != null) {
double contentBottom = top + snapshot.rows() * metrics.lineHeight;
fillVerticalPadding(gc, snapshot, x, y, width, height, top, contentBottom);
for (RenderRow row : snapshot.renderRows()) { for (RenderRow row : snapshot.renderRows()) {
double y0 = Math.floor(top + (row.row() * metrics.lineHeight));
double y1 = Math.ceil(top + ((row.row() + 1) * metrics.lineHeight));
paintSidePadding(gc, row, x, width, left, metrics.cellWidth, y0, y1 - y0);
drawRow(gc, row, left, top, baseline, metrics.cellWidth, metrics.lineHeight); drawRow(gc, row, left, top, baseline, metrics.cellWidth, metrics.lineHeight);
} }
}
if (snapshot != null) {
drawCursor(gc, snapshot, left, top, metrics.cellWidth, metrics.lineHeight); drawCursor(gc, snapshot, left, top, metrics.cellWidth, metrics.lineHeight);
} }
if (config.kittyGraphics()) { if (withKitty) {
drawKittyGraphics(gc, pane, KittyPlacementLayer.ABOVE_TEXT, placeholderBounds, left, top, metrics.cellWidth, metrics.lineHeight); drawKittyGraphics(gc, pane, KittyPlacementLayer.ABOVE_TEXT, placeholderBounds, left, top, metrics.cellWidth, metrics.lineHeight);
} }
drawBorder(gc, pane, x, y, width, height);
}
// Effective background colour of a cell as it is drawn (reverse video swaps fg/bg, an
// unset colour falls back to the defaults).
private static Color cellBackgroundColor(RenderCell cell) {
if (cell.inverse()) {
var fg = cell.foreground();
return fg.isPresent() ? toFxColor(fg.get()) : DEFAULT_FOREGROUND;
}
var bg = cell.background();
return bg.isPresent() ? toFxColor(bg.get()) : PANE_BACKGROUND;
}
private static Color rowEdgeBackground(RenderRow row, boolean firstCell) {
List<RenderCell> cells = row.cells();
if (cells.isEmpty()) {
return PANE_BACKGROUND;
}
return cellBackgroundColor(firstCell ? cells.get(0) : cells.get(cells.size() - 1));
}
// Extend the row's edge-cell backgrounds into the left/right padding (the 12px margin and
// the right-edge rounding sliver), so the unused space matches the rendered content.
private void paintSidePadding(GraphicsContext gc, RenderRow row, double paneX, double paneWidth,
double contentLeft, double cellWidth, double yTop, double bandHeight) {
int columns = row.cells().size();
if (columns == 0) {
return;
}
double contentRight = contentLeft + (columns * cellWidth);
gc.setFill(rowEdgeBackground(row, true));
gc.fillRect(paneX, yTop, contentLeft - paneX, bandHeight);
gc.setFill(rowEdgeBackground(row, false));
gc.fillRect(contentRight, yTop, paneX + paneWidth - contentRight, bandHeight);
}
// Fill the top/bottom padding strips with the top/bottom row's edge colour.
private void fillVerticalPadding(GraphicsContext gc, RenderStateSnapshot snapshot,
double paneX, double paneY, double paneWidth, double paneHeight, double contentTop, double contentBottom) {
List<RenderRow> rows = snapshot.renderRows();
if (rows.isEmpty()) {
return;
}
gc.setFill(rowEdgeBackground(rows.get(0), true));
gc.fillRect(paneX, paneY, paneWidth, contentTop - paneY);
gc.setFill(rowEdgeBackground(rows.get(rows.size() - 1), true));
gc.fillRect(paneX, contentBottom, paneWidth, paneY + paneHeight - contentBottom);
}
// Incremental render: repaint only the rows ghostty flagged dirty, at the pane's screen
// origin, then restore the cursor and border. Returns the screen-space [minY, maxY] band
// that was repainted (for restoring panes stacked above), or null if nothing was dirty.
private double[] drawDirtyRows(
GraphicsContext gc,
TerminalPane pane,
Font font,
FontMetrics metrics,
RenderStateSnapshot snapshot,
double px,
double py,
double pw,
double ph
) {
gc.setFontSmoothingType(FontSmoothingType.LCD);
gc.setFont(font);
double left = px + 12.0;
double top = py + 12.0;
double baseline = top + metrics.baselineOffset;
double contentBottom = top + snapshot.rows() * metrics.lineHeight;
int lastRow = snapshot.rows() - 1;
boolean cursorRowDirty = false;
double bandMin = Double.POSITIVE_INFINITY;
double bandMax = Double.NEGATIVE_INFINITY;
for (RenderRow row : snapshot.renderRows()) {
if (!row.dirty()) {
continue;
}
// Snap the row band to integer pixels and paint opaque: a fractional-height fill
// would leave sub-pixel seams between rows.
double y0 = Math.floor(top + (row.row() * metrics.lineHeight));
double y1 = Math.ceil(top + ((row.row() + 1) * metrics.lineHeight));
gc.setFill(PANE_BACKGROUND);
gc.fillRect(px, y0, pw, y1 - y0);
paintSidePadding(gc, row, px, pw, left, metrics.cellWidth, y0, y1 - y0);
drawRow(gc, row, left, top, baseline, metrics.cellWidth, metrics.lineHeight);
bandMin = Math.min(bandMin, y0);
bandMax = Math.max(bandMax, y1);
// Edge rows also own the top/bottom padding strip; repaint it and extend the
// band so panes stacked above get restored over it too.
if (row.row() == 0) {
gc.setFill(rowEdgeBackground(row, true));
gc.fillRect(px, py, pw, top - py);
bandMin = Math.min(bandMin, py);
}
if (row.row() == lastRow) {
gc.setFill(rowEdgeBackground(row, true));
gc.fillRect(px, contentBottom, pw, py + ph - contentBottom);
bandMax = Math.max(bandMax, py + ph);
}
if (snapshot.cursorViewportHasValue() && row.row() == snapshot.cursorViewportY()) {
cursorRowDirty = true;
}
}
if (bandMin > bandMax) {
return null;
}
// The cursor overlays its cell; redraw it only when its row was repainted, so we
// neither leave a stale cursor nor stack the translucent overlay on itself.
if (cursorRowDirty) {
drawCursor(gc, snapshot, left, top, metrics.cellWidth, metrics.lineHeight);
}
// Repainting rows clears the side borders within the band; restore just those
// segments. Clipping to the band is important: the full border rectangle extends
// outside the repainted region, and only the band gets restored over panes stacked
// above — an unclipped stroke would leave this pane's outline on top of them.
gc.save();
clipRect(gc, px, bandMin, pw, bandMax - bandMin);
drawBorder(gc, pane, px, py, pw, ph);
gc.restore();
return new double[] {bandMin, bandMax};
}
private void drawBorder(GraphicsContext gc, TerminalPane pane, double x, double y, double width, double height) {
gc.setStroke(workspace.isActive(pane) ? Color.rgb(87, 166, 255) : Color.rgb(52, 57, 65));
gc.setLineWidth(workspace.isActive(pane) ? 2.0 : 1.0);
gc.strokeRect(x + 0.5, y + 0.5, width - 1.0, height - 1.0);
} }
private static FontMetrics measureFontMetrics(Font font) { private static FontMetrics measureFontMetrics(Font font) {
Text text = new Text("┃MgÅjy"); Text text = new Text("┃MgÅjy");
text.setFont(font); text.setFont(font);
double lineHeight = Math.max(1.0, text.getLayoutBounds().getHeight()); // Snap the cell size to whole pixels so cells tile on integer boundaries. Fractional
// cell metrics put every cell edge on a sub-pixel position, leaving anti-aliased
// seams that show up as a faint grid behind the themed cell backgrounds. Rounding
// leaves a few pixels of unused space at the right/bottom edge, which is fine.
double lineHeight = Math.max(1.0, Math.round(text.getLayoutBounds().getHeight()));
double baselineOffset = -text.getLayoutBounds().getMinY(); double baselineOffset = -text.getLayoutBounds().getMinY();
Text cell = new Text("M"); Text cell = new Text("M");
cell.setFont(font); cell.setFont(font);
double cellWidth = Math.max(1.0, cell.getLayoutBounds().getWidth()); double cellWidth = Math.max(1.0, Math.round(cell.getLayoutBounds().getWidth()));
return new FontMetrics(cellWidth, lineHeight, baselineOffset); return new FontMetrics(cellWidth, lineHeight, baselineOffset);
} }
@@ -234,40 +497,9 @@ public final class TerminalCanvasView {
return cachedMetrics; return cachedMetrics;
} }
private String renderKey(double width, double height, FontMetrics metrics, List<TerminalPane> panes) { // Layout identity of a pane: everything that forces a redraw EXCEPT terminal content
StringBuilder builder = new StringBuilder(); // (which is tracked separately by renderVersion). Deliberately omits renderVersion so
builder.append(width).append(':') // content changes go through the incremental dirty-row path instead of a full redraw.
.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) { private static Map<KittyPlaceholderKey, KittyPlaceholderBounds> kittyPlaceholderBounds(RenderStateSnapshot snapshot) {
if (snapshot == null) { if (snapshot == null) {
return Map.of(); return Map.of();
@@ -475,10 +707,26 @@ public final class TerminalCanvasView {
double x = left + (cell.column() * cellWidth); double x = left + (cell.column() * cellWidth);
double cellTop = top + (row.row() * lineHeight); double cellTop = top + (row.row() * lineHeight);
cell.background().ifPresent(background -> {
gc.setFill(toFxColor(background)); // Resolve fg/bg (null bg = terminal default, painted by the pane background).
// Avoid Optional.map's allocation on this hot path.
var fgOpt = cell.foreground();
var bgOpt = cell.background();
Color fg = fgOpt.isPresent() ? toFxColor(fgOpt.get()) : DEFAULT_FOREGROUND;
Color bg = bgOpt.isPresent() ? toFxColor(bgOpt.get()) : null;
// Reverse video: ghostty does not bake inverse into the resolved colours, so we
// swap them here, falling back to the terminal defaults for whichever is unset.
if (cell.inverse()) {
Color swappedBg = fg;
fg = (bg != null) ? bg : PANE_BACKGROUND;
bg = swappedBg;
}
if (bg != null) {
gc.setFill(bg);
gc.fillRect(x, cellTop, cellWidth, lineHeight); gc.fillRect(x, cellTop, cellWidth, lineHeight);
}); }
if (cell.selected()) { if (cell.selected()) {
gc.setFill(SELECTED_BACKGROUND); gc.setFill(SELECTED_BACKGROUND);
gc.fillRect(x, cellTop, cellWidth, lineHeight); gc.fillRect(x, cellTop, cellWidth, lineHeight);
@@ -488,14 +736,28 @@ public final class TerminalCanvasView {
} }
double y = baseline + (row.row() * lineHeight); double y = baseline + (row.row() * lineHeight);
Color foreground = cell.foreground().map(TerminalCanvasView::toFxColor).orElse(DEFAULT_FOREGROUND); gc.setFill(fg);
gc.setFill(foreground);
gc.fillText(cell.text(), x, y); gc.fillText(cell.text(), x, y);
} }
} }
// A full-screen redraw asks for one Color per cell; most cells share a handful of
// colors, so cache them by packed RGB instead of allocating a Color each time. Bounded
// so a truecolor gradient can't grow it without limit.
private static final Map<Integer, Color> COLOR_CACHE = new HashMap<>();
private static Color toFxColor(RenderColor color) { private static Color toFxColor(RenderColor color) {
return Color.rgb(color.red(), color.green(), color.blue()); int key = (color.red() << 16) | (color.green() << 8) | color.blue();
Color cached = COLOR_CACHE.get(key);
if (cached != null) {
return cached;
}
if (COLOR_CACHE.size() >= 4096) {
COLOR_CACHE.clear();
}
Color created = Color.rgb(color.red(), color.green(), color.blue());
COLOR_CACHE.put(key, created);
return created;
} }
private static void drawCursor(GraphicsContext gc, RenderStateSnapshot snapshot, double left, double top, double cellWidth, double lineHeight) { private static void drawCursor(GraphicsContext gc, RenderStateSnapshot snapshot, double left, double top, double cellWidth, double lineHeight) {
@@ -718,7 +980,12 @@ public final class TerminalCanvasView {
private record MouseTarget(MouseEncoderSize size, long screenWidth, long screenHeight) { private record MouseTarget(MouseEncoderSize size, long screenWidth, long screenHeight) {
} }
private record KittyImageKey(long id, long number, long width, long height, KittyImageFormat format, int dataLength, long fingerprint) { // A kitty image is immutable for a given (id, number); re-transmitting under the same
// id changes the number (and the snapshot below evicts stale entries by id anyway). So
// the identity + dimensions + payload length are enough to key the decoded-image cache,
// and we avoid fingerprinting the whole payload — which previously ran once per frame
// per placement (O(image size)) just to look the image up.
private record KittyImageKey(long id, long number, long width, long height, KittyImageFormat format, int dataLength) {
private static KittyImageKey of(KittyImageSnapshot snapshot, byte[] data) { private static KittyImageKey of(KittyImageSnapshot snapshot, byte[] data) {
return new KittyImageKey( return new KittyImageKey(
snapshot.id(), snapshot.id(),
@@ -726,19 +993,9 @@ public final class TerminalCanvasView {
snapshot.width(), snapshot.width(),
snapshot.height(), snapshot.height(),
snapshot.format(), snapshot.format(),
data.length, 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 KittyPlaceholderKey(long imageId, long placementId) {
@@ -788,12 +1045,4 @@ public final class TerminalCanvasView {
return maxSourceColumn - minSourceColumn + 1; return maxSourceColumn - minSourceColumn + 1;
} }
} }
private static final class PaneRenderCache {
private Canvas canvas;
private WritableImage image;
private int imageWidth;
private int imageHeight;
private String key;
}
} }

View File

@@ -6,6 +6,7 @@ import dev.jlibghostty.MouseAction;
import dev.jlibghostty.MouseEncoder; import dev.jlibghostty.MouseEncoder;
import dev.jlibghostty.MouseEncoderSize; import dev.jlibghostty.MouseEncoderSize;
import dev.jlibghostty.MouseInput; import dev.jlibghostty.MouseInput;
import dev.jlibghostty.RenderState;
import dev.jlibghostty.RenderStateSnapshot; import dev.jlibghostty.RenderStateSnapshot;
import dev.jlibghostty.ScrollViewport; import dev.jlibghostty.ScrollViewport;
import dev.jlibghostty.Terminal; import dev.jlibghostty.Terminal;
@@ -13,12 +14,23 @@ import dev.jlibghostty.TerminalOptions;
import dev.jlibghostty.DeviceAttributes; import dev.jlibghostty.DeviceAttributes;
import java.util.Optional; import java.util.Optional;
import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.atomic.AtomicLong;
public final class TerminalPane implements AutoCloseable { public final class TerminalPane implements AutoCloseable {
// Monotonic across all panes, bumped on every content change. Lets the renderer detect
// "nothing changed" in O(1) without scanning panes or building a render key.
private static final AtomicLong RENDER_TICK = new AtomicLong();
public static long renderTick() {
return RENDER_TICK.get();
}
private final Terminal terminal; private final Terminal terminal;
private final MouseEncoder mouseEncoder = new MouseEncoder(); 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 ShellSession session;
private boolean floating; private boolean floating;
private boolean visible = true; private boolean visible = true;
@@ -31,6 +43,7 @@ public final class TerminalPane implements AutoCloseable {
private int pixelWidth; private int pixelWidth;
private int pixelHeight; private int pixelHeight;
private long renderVersion; private long renderVersion;
private long snapshotVersion = -1;
private TerminalPane(Terminal terminal, int columns, int rows) { private TerminalPane(Terminal terminal, int columns, int rows) {
this.terminal = terminal; this.terminal = terminal;
@@ -111,8 +124,39 @@ public final class TerminalPane implements AutoCloseable {
} }
} }
/**
* Incremental snapshot: cells are marshalled only for rows that changed since the last
* frame (global dirty == PARTIAL), reused across calls for the same content version.
* Snapshotting is deferred here rather than done in refresh(), so a burst of writes
* between two frames collapses into a single snapshot.
*/
public RenderStateSnapshot renderSnapshot() { public RenderStateSnapshot renderSnapshot() {
return renderSnapshot.get(); return snapshot(false);
}
/**
* Full snapshot with every row's cells populated. Used where the whole pane is redrawn
* regardless of dirty state (the kitty-graphics path).
*/
public RenderStateSnapshot renderSnapshotFull() {
return snapshot(true);
}
private RenderStateSnapshot snapshot(boolean full) {
synchronized (terminal) {
if (full) {
renderState.update(terminal);
cachedSnapshot = renderState.snapshot();
renderState.resetDirty();
snapshotVersion = renderVersion;
} else if (snapshotVersion != renderVersion) {
renderState.update(terminal);
cachedSnapshot = renderState.snapshotIncremental();
renderState.resetDirty();
snapshotVersion = renderVersion;
}
return cachedSnapshot;
}
} }
public String scrollbackText() { public String scrollbackText() {
@@ -192,8 +236,10 @@ public final class TerminalPane implements AutoCloseable {
} }
private void refresh() { private void refresh() {
renderSnapshot.set(terminal.renderSnapshot()); // Only mark the pane dirty; the snapshot itself is computed lazily in
// renderSnapshot() so a burst of writes collapses into a single snapshot per frame.
renderVersion++; renderVersion++;
RENDER_TICK.incrementAndGet();
} }
@Override @Override
@@ -203,6 +249,7 @@ public final class TerminalPane implements AutoCloseable {
session = null; session = null;
} }
mouseEncoder.close(); mouseEncoder.close();
renderState.close();
terminal.close(); terminal.close();
} }
} }

View File

@@ -1,293 +1,139 @@
package com.gregor.jprototerm; package com.gregor.jprototerm;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Comparator;
import java.util.List; import java.util.List;
/**
* Holds the tabs and renders only the current one. Pane operations delegate to the current
* tab; tab operations switch which tab is current. A single render version bumps on any
* change (intra-tab or tab switch) so the renderer recomposites when needed.
*/
public final class TerminalWorkspace implements AutoCloseable { public final class TerminalWorkspace implements AutoCloseable {
private final AppConfig config; private final AppConfig config;
private final List<TerminalPane> panes = new ArrayList<>(); private final List<Tab> tabs = new ArrayList<>();
private int activeIndex; private int currentTab;
private int hiddenFloatingFocusIndex = -1;
private long version; private long version;
public TerminalWorkspace(AppConfig config) { public TerminalWorkspace(AppConfig config) {
this.config = config; this.config = config;
panes.add(openPane(false)); tabs.add(new Tab(config));
} }
public TerminalPane activePane() { private Tab current() {
return panes.get(activeIndex); return tabs.get(currentTab);
}
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() { public long version() {
return version; return version;
} }
public void focus(TerminalPane pane) { public boolean isEmpty() {
int index = panes.indexOf(pane); return tabs.isEmpty();
if (index >= 0 && pane.visible() && activeIndex != index) { }
activeIndex = index;
version++; public TerminalPane activePane() {
return current().activePane();
}
public List<TerminalPane> panes() {
return tabs.isEmpty() ? List.of() : current().panes();
}
public boolean isActive(TerminalPane pane) {
return !tabs.isEmpty() && current().isActive(pane);
}
public void layout(double width, double height, double topInset) {
if (!tabs.isEmpty()) {
current().layout(width, height, topInset);
} }
} }
public void layout(double width, double height) { public int tabCount() {
List<TerminalPane> tiled = panes.stream() return tabs.size();
.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() public int currentTabIndex() {
.filter(TerminalPane::visible) return currentTab;
.filter(TerminalPane::floating) }
.toList();
for (int i = 0; i < floating.size(); i++) { public void focus(TerminalPane pane) {
TerminalPane pane = floating.get(i); if (!tabs.isEmpty() && current().focus(pane)) {
if (pane.visible() && pane.floating()) { version++;
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) { public void navigate(Direction direction) {
TerminalPane current = activePane(); if (!tabs.isEmpty() && current().navigate(direction)) {
if (current.floating() && navigateFloatingStack(direction)) {
version++; 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() { public void toggleFloating() {
List<TerminalPane> floating = panes.stream() if (tabs.isEmpty()) {
.filter(TerminalPane::floating)
.toList();
if (floating.isEmpty()) {
createFloatingPane();
return; return;
} }
current().toggleFloating();
boolean anyVisible = floating.stream().anyMatch(TerminalPane::visible); version++;
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() { public void createPane() {
TerminalPane pane = openPane(true); if (tabs.isEmpty()) {
panes.add(pane); return;
activeIndex = panes.size() - 1; }
current().createPane();
version++; version++;
} }
public void nextFloatingPane() { public void nextFloatingPane() {
TerminalPane next = nextFloatingAfter(activeIndex); if (tabs.isEmpty()) {
next.setVisible(true); return;
activeIndex = panes.indexOf(next); }
current().nextFloatingPane();
version++; version++;
} }
public void closeActivePane() { public void closeActivePane() {
TerminalPane active = activePane(); if (tabs.isEmpty()) {
if (!active.floating() || panes.stream().filter(pane -> !pane.floating()).count() == 0) {
return; return;
} }
current().closeActivePane();
int removed = activeIndex; if (current().isEmpty()) {
int previous = previousVisibleIndex(removed); // Closing a tab's last pane closes the tab. When no tabs remain the workspace
panes.remove(removed); // is empty and Main quits.
active.close(); tabs.remove(currentTab);
activeIndex = adjustIndexAfterRemoval(previous, removed); if (currentTab >= tabs.size()) {
hiddenFloatingFocusIndex = adjustHiddenFocusAfterRemoval(hiddenFloatingFocusIndex, removed); currentTab = Math.max(0, tabs.size() - 1);
}
}
version++; version++;
} }
private TerminalPane nextFloatingAfter(int index) { public void newTab() {
for (int i = index + 1; i < panes.size(); i++) { tabs.add(new Tab(config));
TerminalPane pane = panes.get(i); currentTab = tabs.size() - 1;
if (pane.floating()) { version++;
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() { public void nextTab() {
TerminalPane pane = openPane(true); if (tabs.size() > 1) {
panes.add(pane); currentTab = (currentTab + 1) % tabs.size();
return pane; version++;
}
} }
private boolean navigateFloatingStack(Direction direction) { public void previousTab() {
List<TerminalPane> floating = panes.stream() if (tabs.size() > 1) {
.filter(TerminalPane::visible) currentTab = (currentTab - 1 + tabs.size()) % tabs.size();
.filter(TerminalPane::floating) version++;
.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 @Override
public void close() { public void close() {
for (TerminalPane pane : panes) { for (Tab tab : tabs) {
pane.close(); tab.close();
} }
panes.clear(); tabs.clear();
} }
} }