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; package com.gregor.jprototerm;
import dev.jlibghostty.DeviceAttributes;
import dev.jlibghostty.Ghostty; import dev.jlibghostty.Ghostty;
import dev.jlibghostty.KittyGraphics; import dev.jlibghostty.KittyGraphics;
import dev.jlibghostty.MouseAction; import dev.jlibghostty.MouseAction;
import dev.jlibghostty.MouseEncoder; import dev.jlibghostty.MouseEncoder;
import dev.jlibghostty.MouseEncoderSize; import dev.jlibghostty.MouseEncoderSize;
import dev.jlibghostty.MouseInput; import dev.jlibghostty.MouseInput;
import dev.jlibghostty.RenderState;
import dev.jlibghostty.RenderStateSnapshot; import dev.jlibghostty.RenderStateSnapshot;
import dev.jlibghostty.ScrollViewport; import dev.jlibghostty.ScrollViewport;
import dev.jlibghostty.Terminal; import dev.jlibghostty.Terminal;
import dev.jlibghostty.TerminalOptions; import dev.jlibghostty.TerminalOptions;
import dev.jlibghostty.DeviceAttributes; import javafx.scene.canvas.GraphicsContext;
import javafx.scene.shape.Shape;
import java.util.Optional; 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 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 MouseEncoder mouseEncoder = new MouseEncoder();
// A persistent render state (reused across frames) is what makes ghostty's per-row dirty
// tracking meaningful: update() accumulates dirty since the last resetDirty().
private final RenderState renderState = new RenderState();
private RenderStateSnapshot cachedSnapshot; private RenderStateSnapshot cachedSnapshot;
private ShellSession session; private ShellSession session;
private boolean floating; // Clip region for rendering (rect minus the panes covering this one), set at layout time;
private boolean visible = true; // null means clip to the plain bounds. See RenderTarget#clip().
private Shape clip;
private double x; private double x;
private double y; private double y;
private double width; private double width;
@@ -38,47 +49,40 @@ public final class TerminalPane implements AutoCloseable {
private int rows; private int rows;
private int pixelWidth; private int pixelWidth;
private int pixelHeight; private int pixelHeight;
// Bumped on the reader thread (terminal writes) and read on the FX thread (render loop), private long contentVersion;
// so it must be volatile.
private volatile long renderVersion;
private long snapshotVersion = -1; 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.terminal = terminal;
this.metrics = metrics;
this.kittyEnabled = kittyEnabled;
this.onContentChange = onContentChange;
this.renderer = renderer;
this.columns = columns; this.columns = columns;
this.rows = rows; this.rows = rows;
} }
public static TerminalPane create(int columns, int rows, long maxScrollback) { /**
Terminal terminal = Ghostty.open(new TerminalOptions(columns, rows, maxScrollback)); * Opens a pane sized to fit the given pixel rect: the shared cell metrics decide how many
* columns and rows fit, and that grid is handed to ghostty and the shell at start-up. A
* non-positive size falls back to the configured default grid (used before the first
* layout, when no rect is known yet). The pane owns the shell session it starts and runs
* {@code onContentChange} on every content change.
*/
public static TerminalPane create(AppConfig config, TerminalMetrics metrics, Runnable onContentChange, double widthPx, double heightPx) {
int columns = widthPx > 0 ? metrics.columnsFor(widthPx) : config.columns();
int rows = heightPx > 0 ? metrics.rowsFor(heightPx) : config.rows();
Terminal terminal = Ghostty.open(new TerminalOptions(columns, rows, config.maxScrollback()));
terminal.setDeviceAttributesProvider(DeviceAttributes::xtermCompatible); terminal.setDeviceAttributesProvider(DeviceAttributes::xtermCompatible);
TerminalPane pane = new TerminalPane(terminal, columns, rows); TerminalPane pane = new TerminalPane(terminal, metrics, config.kittyGraphics(), onContentChange,
new GhosttyTerminalRenderer(metrics), columns, rows);
pane.refresh(); pane.refresh();
pane.attach(ShellSession.start(config.shell(), config.envOverride(), pane, columns, rows));
return pane; return pane;
} }
public void write(String text) { private void attach(ShellSession session) {
synchronized (terminal) {
if (closed) {
return;
}
terminal.write(text);
refresh();
}
}
public void write(byte[] bytes) {
synchronized (terminal) {
if (closed) {
return;
}
terminal.write(bytes);
refresh();
}
}
public void attach(ShellSession session) {
this.session = session; this.session = session;
terminal.setPtyWriter(bytes -> { terminal.setPtyWriter(bytes -> {
ShellSession current = this.session; ShellSession current = this.session;
@@ -89,6 +93,20 @@ public final class TerminalPane implements AutoCloseable {
session.startReading(this); session.startReading(this);
} }
public void write(String text) {
synchronized (terminal) {
terminal.write(text);
refresh();
}
}
public void write(byte[] bytes) {
synchronized (terminal) {
terminal.write(bytes);
refresh();
}
}
public void send(String text) { public void send(String text) {
scrollViewportToBottom(); scrollViewportToBottom();
if (session != null) { if (session != null) {
@@ -122,7 +140,7 @@ public final class TerminalPane implements AutoCloseable {
} }
} }
public void scrollViewportToBottom() { private void scrollViewportToBottom() {
synchronized (terminal) { synchronized (terminal) {
terminal.scrollViewport(ScrollViewport.bottom()); terminal.scrollViewport(ScrollViewport.bottom());
refresh(); refresh();
@@ -130,16 +148,37 @@ public final class TerminalPane implements AutoCloseable {
} }
/** /**
* Full render snapshot of the current screen, memoised per content version (so a burst * Incremental snapshot: cells are marshalled only for rows that changed since the last
* of writes between two frames yields one snapshot). Uses a throwaway render state per * frame (global dirty == PARTIAL), reused across calls for the same content version.
* snapshot, which always returns the complete, correct screen — a persistent render * Snapshotting is deferred here rather than done in refresh(), so a burst of writes
* state's per-row dirty tracking proved unreliable across resizes and screen clears. * between two frames collapses into a single snapshot.
*/ */
public RenderStateSnapshot renderSnapshot() { @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) { synchronized (terminal) {
if (snapshotVersion != renderVersion) { if (full) {
cachedSnapshot = terminal.renderSnapshot(); renderState.update(terminal);
snapshotVersion = renderVersion; cachedSnapshot = renderState.snapshot();
renderState.resetDirty();
snapshotVersion = contentVersion;
} else if (snapshotVersion != contentVersion) {
renderState.update(terminal);
cachedSnapshot = renderState.snapshotIncremental();
renderState.resetDirty();
snapshotVersion = contentVersion;
} }
return cachedSnapshot; return cachedSnapshot;
} }
@@ -151,44 +190,39 @@ public final class TerminalPane implements AutoCloseable {
} }
} }
public long renderVersion() { /** This pane's own content revision, bumped on every change (see {@link #refresh()}). */
return renderVersion; public long contentVersion() {
return contentVersion;
} }
@Override
public boolean kittyEnabled() {
return kittyEnabled;
}
@Override
public Optional<KittyGraphics> kittyGraphics() { public Optional<KittyGraphics> kittyGraphics() {
synchronized (terminal) { synchronized (terminal) {
return terminal.kittyGraphics(); return terminal.kittyGraphics();
} }
} }
public boolean floating() { @Override
return floating;
}
public void setFloating(boolean floating) {
this.floating = floating;
}
public boolean visible() {
return visible;
}
public void setVisible(boolean visible) {
this.visible = visible;
}
public double x() { public double x() {
return x; return x;
} }
@Override
public double y() { public double y() {
return y; return y;
} }
@Override
public double width() { public double width() {
return width; return width;
} }
@Override
public double height() { public double height() {
return height; return height;
} }
@@ -200,7 +234,24 @@ public final class TerminalPane implements AutoCloseable {
this.height = height; 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) { if (columns <= 0 || rows <= 0 || pixelWidth <= 0 || pixelHeight <= 0) {
return; return;
} }
@@ -222,26 +273,31 @@ public final class TerminalPane implements AutoCloseable {
} }
private void refresh() { private void refresh() {
// Only mark the pane dirty; the snapshot itself is computed lazily in // Mark this pane's content dirty (the snapshot is computed lazily in the paint path,
// renderSnapshot() so a burst of writes collapses into a single snapshot per frame. // so a burst of writes collapses into one snapshot per frame) and tell the owning tab
renderVersion++; // one of its panes changed.
RENDER_TICK.incrementAndGet(); contentVersion++;
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 @Override
public void close() { public void close() {
// Stop accepting reader-thread writes first, then shut the session (which unblocks
// and ends the reader), so terminal.close() can't race a write from that thread.
synchronized (terminal) {
closed = true;
}
if (session != null) { if (session != null) {
session.close(); session.close();
session = null; session = null;
} }
mouseEncoder.close(); mouseEncoder.close();
synchronized (terminal) { renderState.close();
terminal.close(); terminal.close();
} }
}
} }