diff --git a/src/main/java/com/gregor/jprototerm/TerminalPane.java b/src/main/java/com/gregor/jprototerm/TerminalPane.java index 6afd5bb..6ad0890 100644 --- a/src/main/java/com/gregor/jprototerm/TerminalPane.java +++ b/src/main/java/com/gregor/jprototerm/TerminalPane.java @@ -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() { 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) { - terminal.close(); - } + renderState.close(); + terminal.close(); } }