diff --git a/src/main/java/com/gregor/jprototerm/ShellSession.java b/src/main/java/com/gregor/jprototerm/ShellSession.java index 5ee7514..09212e2 100644 --- a/src/main/java/com/gregor/jprototerm/ShellSession.java +++ b/src/main/java/com/gregor/jprototerm/ShellSession.java @@ -1,7 +1,5 @@ package com.gregor.jprototerm; -import javafx.application.Platform; - import java.nio.charset.StandardCharsets; import java.util.HashMap; import java.util.Map; @@ -94,23 +92,25 @@ public final class ShellSession implements AutoCloseable { } private void readOutput(TerminalPane pane) { - byte[] buffer = new byte[8192]; + byte[] buffer = new byte[65536]; try { int read; while ((read = pty.read(buffer)) != -1) { - if (!closed) { - byte[] bytes = new byte[read]; - System.arraycopy(buffer, 0, bytes, 0, read); - Platform.runLater(() -> { - if (!closed) { - pane.write(bytes); - } - }); + if (closed) { + break; } + byte[] bytes = new byte[read]; + System.arraycopy(buffer, 0, bytes, 0, read); + // Feed the terminal model straight from the reader thread. terminal access is + // guarded by the per-terminal lock, and the render loop picks the change up on + // the next pulse. Avoiding a Platform.runLater hop per chunk removes a frame of + // latency and stops write tasks from contending with rendering on the FX thread + // when a TUI repaints heavily (the input-lag culprit). + pane.write(bytes); } } catch (RuntimeException ex) { if (!closed) { - Platform.runLater(() -> pane.write("\r\nshell output stopped: " + ex.getMessage() + "\r\n")); + pane.write("\r\nshell output stopped: " + ex.getMessage() + "\r\n"); } } } diff --git a/src/main/java/com/gregor/jprototerm/Tab.java b/src/main/java/com/gregor/jprototerm/Tab.java index 972f58e..e7b963e 100644 --- a/src/main/java/com/gregor/jprototerm/Tab.java +++ b/src/main/java/com/gregor/jprototerm/Tab.java @@ -35,16 +35,27 @@ final class Tab implements AutoCloseable { return List.of(); } List visible = panes.stream().filter(TerminalPane::visible).toList(); - TerminalPane active = activePane(); - if (!active.visible() || !active.floating()) { - return visible; + if (visible.isEmpty()) { + return List.of(); } - + // Draw order = z-order: all tiled panes first (they never overlap), then floating + // panes on top, with the active floating pane last (topmost). This holds regardless + // of creation order, so a tiled pane created after a floating one still sits behind. + TerminalPane active = activePane(); List ordered = new ArrayList<>(visible.size()); - visible.stream() - .filter(pane -> pane != active) - .forEach(ordered::add); - ordered.add(active); + for (TerminalPane pane : visible) { + if (!pane.floating()) { + ordered.add(pane); + } + } + for (TerminalPane pane : visible) { + if (pane.floating() && pane != active) { + ordered.add(pane); + } + } + if (active.visible() && active.floating()) { + ordered.add(active); + } return List.copyOf(ordered); } @@ -155,7 +166,12 @@ final class Tab implements AutoCloseable { void closeActivePane() { TerminalPane active = activePane(); int removed = activeIndex; - int previous = previousVisibleIndex(removed); + // When closing a floating pane, focus the next visible floating pane if there is one + // (don't jump to a tiled pane); otherwise fall back to the nearest visible pane. + int target = active.floating() ? nearestVisibleFloatingIndex(removed) : -1; + if (target < 0) { + target = previousVisibleIndex(removed); + } panes.remove(removed); if (active == lastFocusedFloating) { lastFocusedFloating = null; @@ -165,7 +181,7 @@ final class Tab implements AutoCloseable { activeIndex = 0; return; } - activeIndex = adjustIndexAfterRemoval(previous, removed); + activeIndex = adjustIndexAfterRemoval(target, removed); hiddenFloatingFocusIndex = adjustHiddenFocusAfterRemoval(hiddenFloatingFocusIndex, removed); // If the last tiled (main) pane was closed, promote a floating pane to be the new @@ -273,6 +289,20 @@ final class Tab implements AutoCloseable { return 0; } + private int nearestVisibleFloatingIndex(int index) { + for (int i = index + 1; i < panes.size(); i++) { + if (panes.get(i).visible() && panes.get(i).floating()) { + return i; + } + } + for (int i = index - 1; i >= 0; i--) { + if (panes.get(i).visible() && panes.get(i).floating()) { + return i; + } + } + return -1; + } + private int previousVisibleIndex(int index) { for (int i = index - 1; i >= 0; i--) { if (panes.get(i).visible()) { diff --git a/src/main/java/com/gregor/jprototerm/TerminalCanvasView.java b/src/main/java/com/gregor/jprototerm/TerminalCanvasView.java index d08dd1f..764968b 100644 --- a/src/main/java/com/gregor/jprototerm/TerminalCanvasView.java +++ b/src/main/java/com/gregor/jprototerm/TerminalCanvasView.java @@ -54,6 +54,9 @@ public final class TerminalCanvasView { // Last content version drawn to the canvas per pane, so a content frame repaints only // the panes that actually changed. private final Map paneContentVersion = new HashMap<>(); + // Pane list from the last layout pass; reused on content-only frames so typing doesn't + // re-run layout()/panes()/resize each frame. + private List cachedPanes = List.of(); private String fontFamily; private double fontSize; private Font cachedFont; @@ -126,24 +129,21 @@ public final class TerminalCanvasView { lastWorkspaceVersion = workspaceVersion; lastRenderTick = renderTick; - double topInset = workspace.tabCount() > 1 ? TAB_BAR_HEIGHT : 0.0; - workspace.layout(width, height, topInset); Font font = currentFont(); FontMetrics metrics = currentFontMetrics(); - List panes = workspace.panes(); - - // Apply terminal resizes up front so snapshots reflect current geometry (a no-op - // when the grid is unchanged). - for (TerminalPane pane : panes) { - applyResize(pane, metrics); - } - GraphicsContext gc = canvas.getGraphicsContext2D(); gc.setFontSmoothingType(FontSmoothingType.LCD); if (layoutChanged) { - // Recomposite everything onto the retained canvas: clear, then paint panes - // bottom-to-top (workspace.panes() puts the active floating pane last == on top). + // Geometry/pane-set changed: relayout, resize terminals, and recomposite the + // whole canvas, painting panes bottom-to-top (active floating pane last == top). + double topInset = workspace.tabCount() > 1 ? TAB_BAR_HEIGHT : 0.0; + workspace.layout(width, height, topInset); + List panes = workspace.panes(); + for (TerminalPane pane : panes) { + applyResize(pane, metrics); + } + cachedPanes = panes; paneContentVersion.keySet().retainAll(panes); gc.setFill(GAP_BACKGROUND); gc.fillRect(0, 0, width, height); @@ -157,8 +157,9 @@ public final class TerminalCanvasView { return; } - // Content-only frame: repaint just the panes whose content changed, directly on the - // retained canvas, then restore any panes stacked above where they overlap. + // Content-only frame: geometry is unchanged, so skip layout/panes/resize entirely and + // reuse the cached pane list — repaint just the panes whose content changed. + List panes = cachedPanes; for (int i = 0; i < panes.size(); i++) { TerminalPane pane = panes.get(i); Long drawn = paneContentVersion.get(pane); @@ -197,12 +198,16 @@ public final class TerminalCanvasView { double ph = pane.height(); boolean kitty = config.kittyGraphics() && paneHasKittyGraphics(pane); + // A pane just resized (e.g. from a split) can't be trusted to report its dirty rows + // for the app's post-SIGWINCH redraw, so force a full snapshot once. + boolean forceFull = pane.consumeFullRender(); + 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); + if (kitty || forceFull) { + drawPaneContent(gc, pane, font, metrics, pane.renderSnapshotFull(), px, py, pw, ph, kitty); regionY0 = py; regionY1 = py + ph; } else { diff --git a/src/main/java/com/gregor/jprototerm/TerminalPane.java b/src/main/java/com/gregor/jprototerm/TerminalPane.java index 261ea41..517dd83 100644 --- a/src/main/java/com/gregor/jprototerm/TerminalPane.java +++ b/src/main/java/com/gregor/jprototerm/TerminalPane.java @@ -29,7 +29,7 @@ public final class TerminalPane implements AutoCloseable { 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 RenderState renderState = new RenderState(); private RenderStateSnapshot cachedSnapshot; private ShellSession session; private boolean floating; @@ -42,8 +42,12 @@ public final class TerminalPane implements AutoCloseable { private int rows; private int pixelWidth; private int pixelHeight; - private long renderVersion; + // 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 snapshotVersion = -1; + private volatile boolean closed; + private boolean needsFullRender; private TerminalPane(Terminal terminal, int columns, int rows) { this.terminal = terminal; @@ -61,6 +65,9 @@ public final class TerminalPane implements AutoCloseable { public void write(String text) { synchronized (terminal) { + if (closed) { + return; + } terminal.write(text); refresh(); } @@ -68,6 +75,9 @@ public final class TerminalPane implements AutoCloseable { public void write(byte[] bytes) { synchronized (terminal) { + if (closed) { + return; + } terminal.write(bytes); refresh(); } @@ -231,10 +241,27 @@ public final class TerminalPane implements AutoCloseable { this.rows = rows; this.pixelWidth = pixelWidth; this.pixelHeight = pixelHeight; + // A persistent render state gets corrupted by a terminal resize: its next snapshot + // comes back blank (a throwaway render state, as ghostty's API was originally used, + // never had this). Recreate it so the next snapshot is a clean full render of the + // resized grid, even for an idle pane that won't redraw on its own. + renderState.close(); + renderState = new RenderState(); + snapshotVersion = -1; + // The app (e.g. a TUI) also redraws a moment later via SIGWINCH; force the next + // content repaint to use a full snapshot so we don't rely on dirty across the resize. + needsFullRender = true; refresh(); } } + /** Returns and clears the "force a full repaint" flag set by {@link #resize}. */ + public boolean consumeFullRender() { + boolean pending = needsFullRender; + needsFullRender = false; + return pending; + } + 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. @@ -244,12 +271,19 @@ public final class TerminalPane implements AutoCloseable { @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(); renderState.close(); - terminal.close(); + synchronized (terminal) { + terminal.close(); + } } }