recover pane

This commit is contained in:
Gregor Lohaus
2026-05-31 16:24:43 +02:00
parent 0958c93b4f
commit 3017b99f87

View File

@@ -1,35 +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.AtomicLong;
public final class TerminalPane implements AutoCloseable {
// Monotonic across all panes, bumped on every content change. Lets the renderer detect
// "nothing changed" in O(1) without scanning panes or building a render key.
private static final AtomicLong RENDER_TICK = new AtomicLong();
public static long renderTick() {
return RENDER_TICK.get();
}
/**
* 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();
// 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;
@@ -38,47 +49,40 @@ public final class TerminalPane implements AutoCloseable {
private int rows;
private int pixelWidth;
private int pixelHeight;
// Bumped on the reader thread (terminal writes) and read on the FX thread (render loop),
// so it must be volatile.
private volatile long renderVersion;
private long contentVersion;
private long snapshotVersion = -1;
private volatile boolean closed;
private TerminalPane(Terminal terminal, int columns, int rows) {
private TerminalPane(Terminal terminal, TerminalMetrics metrics, boolean kittyEnabled,
Runnable onContentChange, TerminalRenderer renderer, int columns, int rows) {
this.terminal = terminal;
this.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;
}
public void write(String text) {
synchronized (terminal) {
if (closed) {
return;
}
terminal.write(text);
refresh();
}
}
public void write(byte[] bytes) {
synchronized (terminal) {
if (closed) {
return;
}
terminal.write(bytes);
refresh();
}
}
public void attach(ShellSession session) {
private void attach(ShellSession session) {
this.session = session;
terminal.setPtyWriter(bytes -> {
ShellSession current = this.session;
@@ -89,6 +93,20 @@ public final class TerminalPane implements AutoCloseable {
session.startReading(this);
}
public void write(String text) {
synchronized (terminal) {
terminal.write(text);
refresh();
}
}
public void write(byte[] bytes) {
synchronized (terminal) {
terminal.write(bytes);
refresh();
}
}
public void send(String text) {
scrollViewportToBottom();
if (session != null) {
@@ -122,7 +140,7 @@ public final class TerminalPane implements AutoCloseable {
}
}
public void scrollViewportToBottom() {
private void scrollViewportToBottom() {
synchronized (terminal) {
terminal.scrollViewport(ScrollViewport.bottom());
refresh();
@@ -130,16 +148,37 @@ public final class TerminalPane implements AutoCloseable {
}
/**
* Full render snapshot of the current screen, memoised per content version (so a burst
* of writes between two frames yields one snapshot). Uses a throwaway render state per
* snapshot, which always returns the complete, correct screen — a persistent render
* state's per-row dirty tracking proved unreliable across resizes and screen clears.
* 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() {
@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 (snapshotVersion != renderVersion) {
cachedSnapshot = terminal.renderSnapshot();
snapshotVersion = renderVersion;
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;
}
@@ -151,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;
}
@@ -200,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;
}
@@ -222,26 +273,31 @@ public final class TerminalPane implements AutoCloseable {
}
private void refresh() {
// Only mark the pane dirty; the snapshot itself is computed lazily in
// renderSnapshot() so a burst of writes collapses into a single snapshot per frame.
renderVersion++;
RENDER_TICK.incrementAndGet();
// 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
public void close() {
// Stop accepting reader-thread writes first, then shut the session (which unblocks
// and ends the reader), so terminal.close() can't race a write from that thread.
synchronized (terminal) {
closed = true;
}
if (session != null) {
session.close();
session = null;
}
mouseEncoder.close();
synchronized (terminal) {
renderState.close();
terminal.close();
}
}
}