diff --git a/src/main/java/com/gregor/jprototerm/AppConfig.java b/src/main/java/com/gregor/jprototerm/AppConfig.java index 63e36ea..a20a00c 100644 --- a/src/main/java/com/gregor/jprototerm/AppConfig.java +++ b/src/main/java/com/gregor/jprototerm/AppConfig.java @@ -5,6 +5,7 @@ import io.github.wasabithumb.jtoml.document.TomlDocument; import io.github.wasabithumb.jtoml.except.TomlException; import io.github.wasabithumb.jtoml.key.TomlKey; import io.github.wasabithumb.jtoml.value.TomlValue; +import io.github.wasabithumb.jtoml.value.array.TomlArray; import io.github.wasabithumb.jtoml.value.primitive.TomlPrimitive; import io.github.wasabithumb.jtoml.value.table.TomlTable; @@ -12,6 +13,7 @@ import java.nio.file.Files; import java.io.IOException; import java.nio.file.Path; import java.nio.file.StandardOpenOption; +import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -20,7 +22,7 @@ public record AppConfig( int columns, int rows, long maxScrollback, - String shell, + List shell, String fontFamily, double fontSize, double windowWidth, @@ -62,7 +64,7 @@ public record AppConfig( intValue(document, "terminal.columns", defaults.columns), intValue(document, "terminal.rows", defaults.rows), longValue(document, "terminal.max_scrollback", defaults.maxScrollback), - stringValue(document, "terminal.shell", defaults.shell), + stringListValue(document, "terminal.shell", defaults.shell), stringValue(document, "terminal.font_family", defaults.fontFamily), doubleValue(document, "terminal.font_size", defaults.fontSize), doubleValue(document, "window.width", defaults.windowWidth), @@ -140,8 +142,10 @@ public record AppConfig( return Path.of(System.getProperty("user.home"), ".config", "jprototerm", "config.toml"); } - private static String defaultShell() { - return "/bin/bash"; + private static List defaultShell() { + // The executable plus its arguments, spawned verbatim. -i makes bash interactive; a + // different shell can use whatever flags it needs (or none) by editing this list. + return List.of("/bin/bash", "-i"); } private static String defaultScrollbackEditorCommand() { @@ -188,7 +192,7 @@ public record AppConfig( builder.append("columns = ").append(columns).append('\n'); builder.append("rows = ").append(rows).append('\n'); builder.append("max_scrollback = ").append(maxScrollback).append('\n'); - builder.append("shell = ").append(quoted(shell)).append('\n'); + builder.append("shell = ").append(quotedList(shell)).append('\n'); builder.append("font_family = ").append(quoted(fontFamily)).append('\n'); builder.append("font_size = ").append(trimDouble(fontSize)).append("\n\n"); builder.append("[window]\n"); @@ -213,6 +217,17 @@ public record AppConfig( return builder.toString(); } + private static String quotedList(List values) { + StringBuilder builder = new StringBuilder("["); + for (int i = 0; i < values.size(); i++) { + if (i > 0) { + builder.append(", "); + } + builder.append(quoted(values.get(i))); + } + return builder.append("]").toString(); + } + private static String quoted(String value) { return "\"" + value .replace("\\", "\\\\") @@ -272,6 +287,26 @@ public record AppConfig( return primitive == null ? fallback : primitive.asString(); } + /** Reads a TOML array of strings (e.g. {@code shell = ["/bin/bash", "-i"]}), or the fallback. */ + private static List stringListValue(TomlTable table, String key, List fallback) { + TomlValue value = table.get(key); + if (value == null || !value.isArray()) { + return fallback; + } + List result = new ArrayList<>(); + for (TomlValue element : value.asArray()) { + if (element.isPrimitive()) { + try { + result.add(element.asPrimitive().asString()); + } catch (RuntimeException ignored) { + // Skip non-string entries; a shell command line is a list of strings. + } + } + } + // An empty or all-invalid array would mean "no program to run"; keep the default instead. + return result.isEmpty() ? fallback : List.copyOf(result); + } + private static int intValue(TomlTable table, String key, int fallback) { TomlPrimitive primitive = primitive(table, key); if (primitive == null) { diff --git a/src/main/java/com/gregor/jprototerm/Compositor.java b/src/main/java/com/gregor/jprototerm/Compositor.java index a15489a..24eac4c 100644 --- a/src/main/java/com/gregor/jprototerm/Compositor.java +++ b/src/main/java/com/gregor/jprototerm/Compositor.java @@ -61,11 +61,13 @@ public final class Compositor { private long lastContentVersion = Long.MIN_VALUE; private boolean mouseButtonPressed; private MouseButton pressedButton = MouseButton.UNKNOWN; + // Run when the last pane closes (so the window can quit). No-op until Main sets it. + private Runnable onEmpty = () -> {}; public Compositor(AppConfig config, TerminalMetrics metrics) { this.config = config; this.metrics = metrics; - tabs.add(new Tab(config, metrics)); + tabs.add(new Tab(config, metrics, this::closePane)); canvas.setFocusTraversable(true); canvas.setOnMousePressed(this::handleMousePressed); canvas.setOnMouseReleased(this::handleMouseReleased); @@ -78,6 +80,11 @@ public final class Compositor { return canvas; } + /** Sets the callback run when the last pane closes (e.g. to quit the application). */ + public void setOnEmpty(Runnable onEmpty) { + this.onEmpty = onEmpty; + } + /** The kitty-image overlay, to be stacked directly above {@link #canvas()} in the window. */ public Node imageOverlay() { return imageOverlay.node(); @@ -131,6 +138,19 @@ public final class Compositor { return pane; } + /** + * Opens a floating pane running {@code command} directly (auto-closing when it exits), makes it + * active, and returns it (null when no tab exists). + */ + public TerminalPane openFloatingPane(String command) { + if (isEmpty()) { + return null; + } + TerminalPane pane = currentTab().createFloatingPane(command); + layoutVersion++; + return pane; + } + public void nextFloatingPane() { if (isEmpty()) { return; @@ -151,23 +171,46 @@ public final class Compositor { if (isEmpty()) { return; } - currentTab().closeActivePane(); - if (currentTab().isEmpty()) { - // Closing a tab's last pane closes the tab. When no tabs remain the surface is - // empty and Main quits. - tabs.remove(currentTabIndex); - if (currentTabIndex >= tabs.size()) { - currentTabIndex = Math.max(0, tabs.size() - 1); + TerminalPane active = currentTab().activePane(); + if (active != null) { + closePane(active); + } + } + + /** + * Closes a specific pane, wherever it lives. Driven both by the key-bound close (via + * {@link #closeActivePane()}) and by a pane whose process exited on its own. Drops the owning + * tab if it becomes empty, and fires {@link #setOnEmpty} when the last pane is gone. Must run on + * the FX thread. + */ + public void closePane(TerminalPane pane) { + for (int i = 0; i < tabs.size(); i++) { + Tab tab = tabs.get(i); + if (tab.closePane(pane)) { + if (tab.isEmpty()) { + // Closing a tab's last pane closes the tab. Keep currentTabIndex pointing at the + // same tab (or clamp it when the current/last tab went away). + tabs.remove(i); + if (i < currentTabIndex) { + currentTabIndex--; + } else if (currentTabIndex >= tabs.size()) { + currentTabIndex = Math.max(0, tabs.size() - 1); + } + } + layoutVersion++; + if (isEmpty()) { + onEmpty.run(); + } + return; } } - layoutVersion++; } public void newTab() { // Open the new tab in the currently active pane's working directory, so it lands where the // user currently is rather than always in home. String workingDirectory = isEmpty() ? null : currentTab().activePane().currentWorkingDirectory(); - tabs.add(new Tab(config, metrics, workingDirectory)); + tabs.add(new Tab(config, metrics, workingDirectory, this::closePane)); currentTabIndex = tabs.size() - 1; layoutVersion++; } diff --git a/src/main/java/com/gregor/jprototerm/Main.java b/src/main/java/com/gregor/jprototerm/Main.java index 3ca6abd..db67345 100644 --- a/src/main/java/com/gregor/jprototerm/Main.java +++ b/src/main/java/com/gregor/jprototerm/Main.java @@ -35,6 +35,12 @@ public final class Main extends Application { metrics = new TerminalMetrics(config.fontFamily(), config.fontSize()); compositor = new Compositor(config, metrics); + // When the last pane closes — whether via the close-pane key or because a pane's process + // exited on its own — tear down and quit. + compositor.setOnEmpty(() -> { + compositor.close(); + Platform.exit(); + }); StackPane root = new StackPane(compositor.canvas(), compositor.imageOverlay()); compositor.canvas().widthProperty().bind(root.widthProperty()); @@ -113,13 +119,9 @@ public final class Main extends Application { compositor.promoteActiveFloating(); event.consume(); } else if (config.keybindings().get("close_pane").matches(event)) { + // Closing the last pane quits the app, via the compositor's onEmpty hook. compositor.closeActivePane(); event.consume(); - if (compositor.isEmpty()) { - // Closing the last pane quits the app. - compositor.close(); - Platform.exit(); - } } else if (config.keybindings().get("new_tab").matches(event)) { compositor.newTab(); event.consume(); @@ -217,10 +219,10 @@ public final class Main extends Application { Files.writeString(file, compositor.activePane().scrollbackText()); file.toFile().deleteOnExit(); - TerminalPane pane = compositor.openFloatingPane(); - if (pane != null) { - pane.send(scrollbackEditorCommand(file) + "\r"); - } + // Run the editor as the floating pane's process (via /bin/sh -c) rather than typing the + // command into an interactive shell. The command runs deterministically from the start + // — no shell startup/rc race — and the pane auto-closes when the editor exits. + compositor.openFloatingPane(scrollbackEditorCommand(file)); } catch (IOException ex) { System.err.println("Could not open scrollback in editor: " + ex.getMessage()); } diff --git a/src/main/java/com/gregor/jprototerm/ShellSession.java b/src/main/java/com/gregor/jprototerm/ShellSession.java index 0b9d3dd..0885a73 100644 --- a/src/main/java/com/gregor/jprototerm/ShellSession.java +++ b/src/main/java/com/gregor/jprototerm/ShellSession.java @@ -2,6 +2,7 @@ package com.gregor.jprototerm; import java.nio.charset.StandardCharsets; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -20,28 +21,56 @@ public final class ShellSession implements AutoCloseable { }); } - public static ShellSession start(String shell, Map envOverride, TerminalPane pane, + /** + * Starts the configured shell. {@code shellCommand} is the executable plus its arguments (e.g. + * {@code ["/bin/bash", "-i"]}), spawned verbatim — any interactive flag is the user's choice in + * config, not assumed here. + */ + public static ShellSession start(List shellCommand, Map envOverride, TerminalPane pane, int columns, int rows, String workingDirectory) { try { - Map environment = new HashMap<>(System.getenv()); - environment.put("TERM", "xterm-kitty"); - environment.put("COLORTERM", "truecolor"); - sanitizeWrapperEnvironment(environment); - environment.putAll(envOverride); - - LinuxPty pty = LinuxPty.spawn( - new String[] {shell, "-i"}, - environment, - workingDirectory != null ? workingDirectory : System.getProperty("user.home")); - ShellSession session = new ShellSession(pty); - session.resize(columns, rows); - return session; + return spawn(shellCommand.toArray(new String[0]), envOverride, columns, rows, workingDirectory); } catch (RuntimeException ex) { pane.write("failed to start shell: " + ex.getMessage() + "\r\n"); - throw new IllegalStateException("Could not start shell " + shell, ex); + throw new IllegalStateException("Could not start shell " + String.join(" ", shellCommand), ex); } } + /** + * Starts a session whose first and only process is {@code /bin/sh -c command}, so the program + * runs deterministically from the start rather than being typed into an interactive shell — + * there is no startup/rc race to lose or mangle the input. When the process exits the pty + * closes and the pane auto-closes. {@code /bin/sh -c} is used (not the user's configured shell) + * because it is the portable way to run a command line and does not depend on shell-specific + * flags. {@code command} must not be null. + */ + public static ShellSession startCommand(Map envOverride, TerminalPane pane, + int columns, int rows, String workingDirectory, String command) { + try { + return spawn(new String[] {"/bin/sh", "-c", command}, envOverride, columns, rows, workingDirectory); + } catch (RuntimeException ex) { + pane.write("failed to run command: " + ex.getMessage() + "\r\n"); + throw new IllegalStateException("Could not run command: " + command, ex); + } + } + + private static ShellSession spawn(String[] argv, Map envOverride, + int columns, int rows, String workingDirectory) { + Map environment = new HashMap<>(System.getenv()); + environment.put("TERM", "xterm-kitty"); + environment.put("COLORTERM", "truecolor"); + sanitizeWrapperEnvironment(environment); + environment.putAll(envOverride); + + LinuxPty pty = LinuxPty.spawn( + argv, + environment, + workingDirectory != null ? workingDirectory : System.getProperty("user.home")); + ShellSession session = new ShellSession(pty); + session.resize(columns, rows); + return session; + } + /** * Strips the variables injected by the Nix launcher wrapper from the shell's * environment so they do not leak into terminal subprocesses. @@ -119,6 +148,11 @@ public final class ShellSession implements AutoCloseable { pane.write("\r\nshell output stopped: " + ex.getMessage() + "\r\n"); } } + // The stream ended without us closing the session, so the process exited on its own (the + // user typed `exit`, or a one-shot command pane finished). Let the pane tear itself down. + if (!closed) { + pane.handleSessionExit(); + } } @Override diff --git a/src/main/java/com/gregor/jprototerm/Tab.java b/src/main/java/com/gregor/jprototerm/Tab.java index 793ff4b..c56fdde 100644 --- a/src/main/java/com/gregor/jprototerm/Tab.java +++ b/src/main/java/com/gregor/jprototerm/Tab.java @@ -7,6 +7,7 @@ import java.util.ArrayList; import java.util.Comparator; import java.util.List; import java.util.concurrent.atomic.AtomicLong; +import java.util.function.Consumer; import java.util.stream.Stream; /** @@ -19,6 +20,9 @@ import java.util.stream.Stream; final class Tab implements AutoCloseable { private final AppConfig config; private final TerminalMetrics metrics; + // Notified (on the FX thread) when one of this tab's panes' process exits on its own, so the + // compositor can close that pane and reap the tab/app if it was the last one. + private final Consumer onPaneExit; private final List tiled = new ArrayList<>(); private final List floating = new ArrayList<>(); private boolean floatingVisible; @@ -39,17 +43,19 @@ final class Tab implements AutoCloseable { // tab's value each frame as an O(1) "anything to repaint?" check. private final AtomicLong contentVersion = new AtomicLong(); - Tab(AppConfig config, TerminalMetrics metrics) { - this(config, metrics, null); + Tab(AppConfig config, TerminalMetrics metrics, Consumer onPaneExit) { + this(config, metrics, null, onPaneExit); } /** * Creates a tab whose first pane starts in {@code initialWorkingDirectory} (e.g. the cwd of the * pane that was active when this tab was opened), or the user's home when {@code null}. */ - Tab(AppConfig config, TerminalMetrics metrics, String initialWorkingDirectory) { + Tab(AppConfig config, TerminalMetrics metrics, String initialWorkingDirectory, + Consumer onPaneExit) { this.config = config; this.metrics = metrics; + this.onPaneExit = onPaneExit; this.lastWidth = config.windowWidth(); this.lastHeight = config.windowHeight(); this.initialWorkingDirectory = initialWorkingDirectory; @@ -241,19 +247,35 @@ final class Tab implements AutoCloseable { } void closeActivePane() { - TerminalPane closing = active; - boolean wasFloating = floating.remove(closing); - if (!wasFloating) { - tiled.remove(closing); + if (active != null) { + closePane(active); } + } + + /** + * Closes {@code closing} (the active pane on a key-bound close, or any pane whose process just + * exited) and re-selects the active pane only when the one that closed was active. Returns + * false when the pane is not in this tab. Leaves the tab empty ({@code active == null}) when its + * last pane closes, so the compositor can drop it. + */ + boolean closePane(TerminalPane closing) { + boolean wasFloating = floating.remove(closing); + boolean wasTiled = !wasFloating && tiled.remove(closing); + if (!wasFloating && !wasTiled) { + return false; // not one of this tab's panes (already gone) + } + boolean wasActive = closing == active; if (closing == lastFocusedFloating) { lastFocusedFloating = null; } + if (closing == lastFocusedTiled) { + lastFocusedTiled = null; + } closing.close(); if (tiled.isEmpty() && floating.isEmpty()) { active = null; // tab is now empty; the compositor drops it - return; + return true; } // Always keep a tiled base: if the last tiled pane just closed, promote a floating one @@ -265,19 +287,21 @@ final class Tab implements AutoCloseable { floating.remove(promote); tiled.add(promote); if (promote == lastFocusedFloating) { - lastFocusedFloating = null; - if (!floating.isEmpty()) { - lastFocusedFloating = floating.isEmpty() ? null : floating.get(nextFocussed); - } + lastFocusedFloating = floating.isEmpty() ? null : floating.get(nextFocussed); } } if (floating.isEmpty()) { floatingVisible = false; } - setActive(wasFloating && floatingVisible - ? floating.get(floating.size() - 1) - : tiled.contains(lastFocusedTiled) ? lastFocusedTiled : tiled.get(0)); + // Only the active pane closing forces a re-selection; closing a background pane (e.g. one + // whose process exited while another is focused) leaves focus where it is. + if (wasActive) { + setActive(wasFloating && floatingVisible + ? floating.get(floating.size() - 1) + : tiled.contains(lastFocusedTiled) ? lastFocusedTiled : tiled.get(0)); + } + return true; } private void setActive(TerminalPane pane) { @@ -293,7 +317,20 @@ final class Tab implements AutoCloseable { } TerminalPane createFloatingPane() { - TerminalPane pane = openPane(true); + return addFloating(openPane(true)); + } + + /** + * Opens a floating pane whose process runs {@code command} directly (auto-closing when it + * exits), rather than an interactive shell. Used for one-shot panes like the scrollback editor. + */ + TerminalPane createFloatingPane(String command) { + double[] size = paneSize(true); + return addFloating(register(TerminalPane.createWithCommand( + config, metrics, this::markContentChanged, size[0], size[1], paneWorkingDirectory(), command))); + } + + private TerminalPane addFloating(TerminalPane pane) { floating.add(pane); floatingVisible = true; setActive(pane); @@ -332,22 +369,31 @@ final class Tab implements AutoCloseable { } private TerminalPane openPane(boolean asFloating) { + double[] size = paneSize(asFloating); + return register(TerminalPane.create( + config, metrics, this::markContentChanged, size[0], size[1], paneWorkingDirectory())); + } + + private double[] paneSize(boolean asFloating) { double availHeight = lastHeight - lastTopInset; - double widthPx; - double heightPx; if (asFloating) { - widthPx = Math.max(420, lastWidth * 0.58); - heightPx = Math.max(260, availHeight * 0.58); - } else { - // A new tiled pane joins the row, so each gets 1/(n+1) of the width. - widthPx = lastWidth / (tiled.size() + 1); - heightPx = availHeight; + return new double[] {Math.max(420, lastWidth * 0.58), Math.max(260, availHeight * 0.58)}; } - // Open the new pane in the active pane's working directory, so a split/new pane lands - // where the user currently is. With no active pane yet (the tab's first pane), fall back to - // the directory this tab was opened in. null (cwd unknown) falls back to home downstream. - String workingDirectory = active != null ? active.currentWorkingDirectory() : initialWorkingDirectory; - return TerminalPane.create(config, metrics, this::markContentChanged, widthPx, heightPx, workingDirectory); + // A new tiled pane joins the row, so each gets 1/(n+1) of the width. + return new double[] {lastWidth / (tiled.size() + 1), availHeight}; + } + + // Open a new pane in the active pane's working directory, so a split/new pane lands where the + // user currently is. With no active pane yet (the tab's first pane), fall back to the directory + // this tab was opened in. null (cwd unknown) falls back to home downstream. + private String paneWorkingDirectory() { + return active != null ? active.currentWorkingDirectory() : initialWorkingDirectory; + } + + // Wire the pane's self-exit (process ended) back to the compositor so it gets reaped. + private TerminalPane register(TerminalPane pane) { + pane.setOnExit(() -> onPaneExit.accept(pane)); + return pane; } private static boolean directionFilter(Direction direction, TerminalPane current, TerminalPane candidate) { diff --git a/src/main/java/com/gregor/jprototerm/TerminalPane.java b/src/main/java/com/gregor/jprototerm/TerminalPane.java index 7c00f8a..360b283 100644 --- a/src/main/java/com/gregor/jprototerm/TerminalPane.java +++ b/src/main/java/com/gregor/jprototerm/TerminalPane.java @@ -12,6 +12,7 @@ import dev.jlibghostty.RenderStateSnapshot; import dev.jlibghostty.ScrollViewport; import dev.jlibghostty.Terminal; import dev.jlibghostty.TerminalOptions; +import javafx.application.Platform; import javafx.scene.canvas.GraphicsContext; import javafx.scene.shape.Shape; @@ -39,6 +40,10 @@ public final class TerminalPane implements AutoCloseable, RenderTarget { private final RenderState renderState = new RenderState(); private RenderStateSnapshot cachedSnapshot; private ShellSession session; + // Run once (on the FX thread) when this pane's process exits on its own, so the owning tab can + // remove it. Set by the Tab that creates the pane; null until then. + private Runnable onExit; + private boolean exited; // 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; @@ -74,6 +79,27 @@ public final class TerminalPane implements AutoCloseable, RenderTarget { */ public static TerminalPane create(AppConfig config, TerminalMetrics metrics, Runnable onContentChange, double widthPx, double heightPx, String workingDirectory) { + TerminalPane pane = newPane(config, metrics, onContentChange, widthPx, heightPx); + pane.attach(ShellSession.start(config.shell(), config.envOverride(), pane, pane.columns, pane.rows, + workingDirectory)); + return pane; + } + + /** + * Opens a pane whose process runs {@code command} directly (via {@code /bin/sh -c}) instead of + * an interactive shell. The pane auto-closes when the command exits. See + * {@link ShellSession#startCommand}. + */ + public static TerminalPane createWithCommand(AppConfig config, TerminalMetrics metrics, Runnable onContentChange, + double widthPx, double heightPx, String workingDirectory, String command) { + TerminalPane pane = newPane(config, metrics, onContentChange, widthPx, heightPx); + pane.attach(ShellSession.startCommand(config.envOverride(), pane, pane.columns, pane.rows, + workingDirectory, command)); + return pane; + } + + private static TerminalPane newPane(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())); @@ -81,10 +107,31 @@ public final class TerminalPane implements AutoCloseable, RenderTarget { 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, workingDirectory)); return pane; } + /** Sets the callback run when this pane's process exits on its own (see {@link #handleSessionExit}). */ + public void setOnExit(Runnable onExit) { + this.onExit = onExit; + } + + /** + * Called from the shell reader thread when the pty stream ends without us closing it (the + * process exited). Hops to the FX thread and fires {@link #onExit} once, so tab/compositor + * mutation happens on the thread that owns the layout. + */ + void handleSessionExit() { + Platform.runLater(() -> { + if (exited) { + return; + } + exited = true; + if (onExit != null) { + onExit.run(); + } + }); + } + private void attach(ShellSession session) { this.session = session; terminal.setPtyWriter(bytes -> {