close panes when process closes, rework open scrollback to not open with /bin/sh -c

This commit is contained in:
2026-06-01 23:10:30 +02:00
parent 4ed2b82f2f
commit 11734d89f7
6 changed files with 276 additions and 69 deletions

View File

@@ -5,6 +5,7 @@ import io.github.wasabithumb.jtoml.document.TomlDocument;
import io.github.wasabithumb.jtoml.except.TomlException; import io.github.wasabithumb.jtoml.except.TomlException;
import io.github.wasabithumb.jtoml.key.TomlKey; import io.github.wasabithumb.jtoml.key.TomlKey;
import io.github.wasabithumb.jtoml.value.TomlValue; 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.primitive.TomlPrimitive;
import io.github.wasabithumb.jtoml.value.table.TomlTable; import io.github.wasabithumb.jtoml.value.table.TomlTable;
@@ -12,6 +13,7 @@ import java.nio.file.Files;
import java.io.IOException; import java.io.IOException;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.StandardOpenOption; import java.nio.file.StandardOpenOption;
import java.util.ArrayList;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@@ -20,7 +22,7 @@ public record AppConfig(
int columns, int columns,
int rows, int rows,
long maxScrollback, long maxScrollback,
String shell, List<String> shell,
String fontFamily, String fontFamily,
double fontSize, double fontSize,
double windowWidth, double windowWidth,
@@ -62,7 +64,7 @@ public record AppConfig(
intValue(document, "terminal.columns", defaults.columns), intValue(document, "terminal.columns", defaults.columns),
intValue(document, "terminal.rows", defaults.rows), intValue(document, "terminal.rows", defaults.rows),
longValue(document, "terminal.max_scrollback", defaults.maxScrollback), 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), stringValue(document, "terminal.font_family", defaults.fontFamily),
doubleValue(document, "terminal.font_size", defaults.fontSize), doubleValue(document, "terminal.font_size", defaults.fontSize),
doubleValue(document, "window.width", defaults.windowWidth), doubleValue(document, "window.width", defaults.windowWidth),
@@ -140,8 +142,10 @@ public record AppConfig(
return Path.of(System.getProperty("user.home"), ".config", "jprototerm", "config.toml"); return Path.of(System.getProperty("user.home"), ".config", "jprototerm", "config.toml");
} }
private static String defaultShell() { private static List<String> defaultShell() {
return "/bin/bash"; // 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() { private static String defaultScrollbackEditorCommand() {
@@ -188,7 +192,7 @@ public record AppConfig(
builder.append("columns = ").append(columns).append('\n'); builder.append("columns = ").append(columns).append('\n');
builder.append("rows = ").append(rows).append('\n'); builder.append("rows = ").append(rows).append('\n');
builder.append("max_scrollback = ").append(maxScrollback).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_family = ").append(quoted(fontFamily)).append('\n');
builder.append("font_size = ").append(trimDouble(fontSize)).append("\n\n"); builder.append("font_size = ").append(trimDouble(fontSize)).append("\n\n");
builder.append("[window]\n"); builder.append("[window]\n");
@@ -213,6 +217,17 @@ public record AppConfig(
return builder.toString(); return builder.toString();
} }
private static String quotedList(List<String> 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) { private static String quoted(String value) {
return "\"" + value return "\"" + value
.replace("\\", "\\\\") .replace("\\", "\\\\")
@@ -272,6 +287,26 @@ public record AppConfig(
return primitive == null ? fallback : primitive.asString(); 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<String> stringListValue(TomlTable table, String key, List<String> fallback) {
TomlValue value = table.get(key);
if (value == null || !value.isArray()) {
return fallback;
}
List<String> 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) { private static int intValue(TomlTable table, String key, int fallback) {
TomlPrimitive primitive = primitive(table, key); TomlPrimitive primitive = primitive(table, key);
if (primitive == null) { if (primitive == null) {

View File

@@ -61,11 +61,13 @@ public final class Compositor {
private long lastContentVersion = Long.MIN_VALUE; private long lastContentVersion = Long.MIN_VALUE;
private boolean mouseButtonPressed; private boolean mouseButtonPressed;
private MouseButton pressedButton = MouseButton.UNKNOWN; 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) { public Compositor(AppConfig config, TerminalMetrics metrics) {
this.config = config; this.config = config;
this.metrics = metrics; this.metrics = metrics;
tabs.add(new Tab(config, metrics)); tabs.add(new Tab(config, metrics, this::closePane));
canvas.setFocusTraversable(true); canvas.setFocusTraversable(true);
canvas.setOnMousePressed(this::handleMousePressed); canvas.setOnMousePressed(this::handleMousePressed);
canvas.setOnMouseReleased(this::handleMouseReleased); canvas.setOnMouseReleased(this::handleMouseReleased);
@@ -78,6 +80,11 @@ public final class Compositor {
return canvas; 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. */ /** The kitty-image overlay, to be stacked directly above {@link #canvas()} in the window. */
public Node imageOverlay() { public Node imageOverlay() {
return imageOverlay.node(); return imageOverlay.node();
@@ -131,6 +138,19 @@ public final class Compositor {
return pane; 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() { public void nextFloatingPane() {
if (isEmpty()) { if (isEmpty()) {
return; return;
@@ -151,23 +171,46 @@ public final class Compositor {
if (isEmpty()) { if (isEmpty()) {
return; return;
} }
currentTab().closeActivePane(); TerminalPane active = currentTab().activePane();
if (currentTab().isEmpty()) { if (active != null) {
// Closing a tab's last pane closes the tab. When no tabs remain the surface is closePane(active);
// empty and Main quits. }
tabs.remove(currentTabIndex); }
if (currentTabIndex >= tabs.size()) {
currentTabIndex = Math.max(0, tabs.size() - 1); /**
* 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() { public void newTab() {
// Open the new tab in the currently active pane's working directory, so it lands where the // 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. // user currently is rather than always in home.
String workingDirectory = isEmpty() ? null : currentTab().activePane().currentWorkingDirectory(); 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; currentTabIndex = tabs.size() - 1;
layoutVersion++; layoutVersion++;
} }

View File

@@ -35,6 +35,12 @@ public final class Main extends Application {
metrics = new TerminalMetrics(config.fontFamily(), config.fontSize()); metrics = new TerminalMetrics(config.fontFamily(), config.fontSize());
compositor = new Compositor(config, metrics); 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()); StackPane root = new StackPane(compositor.canvas(), compositor.imageOverlay());
compositor.canvas().widthProperty().bind(root.widthProperty()); compositor.canvas().widthProperty().bind(root.widthProperty());
@@ -113,13 +119,9 @@ public final class Main extends Application {
compositor.promoteActiveFloating(); compositor.promoteActiveFloating();
event.consume(); event.consume();
} else if (config.keybindings().get("close_pane").matches(event)) { } else if (config.keybindings().get("close_pane").matches(event)) {
// Closing the last pane quits the app, via the compositor's onEmpty hook.
compositor.closeActivePane(); compositor.closeActivePane();
event.consume(); 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)) { } else if (config.keybindings().get("new_tab").matches(event)) {
compositor.newTab(); compositor.newTab();
event.consume(); event.consume();
@@ -217,10 +219,10 @@ public final class Main extends Application {
Files.writeString(file, compositor.activePane().scrollbackText()); Files.writeString(file, compositor.activePane().scrollbackText());
file.toFile().deleteOnExit(); file.toFile().deleteOnExit();
TerminalPane pane = compositor.openFloatingPane(); // Run the editor as the floating pane's process (via /bin/sh -c) rather than typing the
if (pane != null) { // command into an interactive shell. The command runs deterministically from the start
pane.send(scrollbackEditorCommand(file) + "\r"); // — no shell startup/rc race — and the pane auto-closes when the editor exits.
} compositor.openFloatingPane(scrollbackEditorCommand(file));
} catch (IOException ex) { } catch (IOException ex) {
System.err.println("Could not open scrollback in editor: " + ex.getMessage()); System.err.println("Could not open scrollback in editor: " + ex.getMessage());
} }

View File

@@ -2,6 +2,7 @@ package com.gregor.jprototerm;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.HashMap; import java.util.HashMap;
import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.concurrent.ExecutorService; import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors; import java.util.concurrent.Executors;
@@ -20,28 +21,56 @@ public final class ShellSession implements AutoCloseable {
}); });
} }
public static ShellSession start(String shell, Map<String, String> 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<String> shellCommand, Map<String, String> envOverride, TerminalPane pane,
int columns, int rows, String workingDirectory) { int columns, int rows, String workingDirectory) {
try { try {
Map<String, String> environment = new HashMap<>(System.getenv()); return spawn(shellCommand.toArray(new String[0]), envOverride, columns, rows, workingDirectory);
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;
} catch (RuntimeException ex) { } catch (RuntimeException ex) {
pane.write("failed to start shell: " + ex.getMessage() + "\r\n"); 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<String, String> 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<String, String> envOverride,
int columns, int rows, String workingDirectory) {
Map<String, String> 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 * Strips the variables injected by the Nix launcher wrapper from the shell's
* environment so they do not leak into terminal subprocesses. * 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"); 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 @Override

View File

@@ -7,6 +7,7 @@ import java.util.ArrayList;
import java.util.Comparator; import java.util.Comparator;
import java.util.List; import java.util.List;
import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.AtomicLong;
import java.util.function.Consumer;
import java.util.stream.Stream; import java.util.stream.Stream;
/** /**
@@ -19,6 +20,9 @@ import java.util.stream.Stream;
final class Tab implements AutoCloseable { final class Tab implements AutoCloseable {
private final AppConfig config; private final AppConfig config;
private final TerminalMetrics metrics; 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<TerminalPane> onPaneExit;
private final List<TerminalPane> tiled = new ArrayList<>(); private final List<TerminalPane> tiled = new ArrayList<>();
private final List<TerminalPane> floating = new ArrayList<>(); private final List<TerminalPane> floating = new ArrayList<>();
private boolean floatingVisible; 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. // tab's value each frame as an O(1) "anything to repaint?" check.
private final AtomicLong contentVersion = new AtomicLong(); private final AtomicLong contentVersion = new AtomicLong();
Tab(AppConfig config, TerminalMetrics metrics) { Tab(AppConfig config, TerminalMetrics metrics, Consumer<TerminalPane> onPaneExit) {
this(config, metrics, null); this(config, metrics, null, onPaneExit);
} }
/** /**
* Creates a tab whose first pane starts in {@code initialWorkingDirectory} (e.g. the cwd of the * 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}. * 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<TerminalPane> onPaneExit) {
this.config = config; this.config = config;
this.metrics = metrics; this.metrics = metrics;
this.onPaneExit = onPaneExit;
this.lastWidth = config.windowWidth(); this.lastWidth = config.windowWidth();
this.lastHeight = config.windowHeight(); this.lastHeight = config.windowHeight();
this.initialWorkingDirectory = initialWorkingDirectory; this.initialWorkingDirectory = initialWorkingDirectory;
@@ -241,19 +247,35 @@ final class Tab implements AutoCloseable {
} }
void closeActivePane() { void closeActivePane() {
TerminalPane closing = active; if (active != null) {
boolean wasFloating = floating.remove(closing); closePane(active);
if (!wasFloating) {
tiled.remove(closing);
} }
}
/**
* 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) { if (closing == lastFocusedFloating) {
lastFocusedFloating = null; lastFocusedFloating = null;
} }
if (closing == lastFocusedTiled) {
lastFocusedTiled = null;
}
closing.close(); closing.close();
if (tiled.isEmpty() && floating.isEmpty()) { if (tiled.isEmpty() && floating.isEmpty()) {
active = null; // tab is now empty; the compositor drops it 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 // 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); floating.remove(promote);
tiled.add(promote); tiled.add(promote);
if (promote == lastFocusedFloating) { if (promote == lastFocusedFloating) {
lastFocusedFloating = null; lastFocusedFloating = floating.isEmpty() ? null : floating.get(nextFocussed);
if (!floating.isEmpty()) {
lastFocusedFloating = floating.isEmpty() ? null : floating.get(nextFocussed);
}
} }
} }
if (floating.isEmpty()) { if (floating.isEmpty()) {
floatingVisible = false; floatingVisible = false;
} }
setActive(wasFloating && floatingVisible // Only the active pane closing forces a re-selection; closing a background pane (e.g. one
? floating.get(floating.size() - 1) // whose process exited while another is focused) leaves focus where it is.
: tiled.contains(lastFocusedTiled) ? lastFocusedTiled : tiled.get(0)); if (wasActive) {
setActive(wasFloating && floatingVisible
? floating.get(floating.size() - 1)
: tiled.contains(lastFocusedTiled) ? lastFocusedTiled : tiled.get(0));
}
return true;
} }
private void setActive(TerminalPane pane) { private void setActive(TerminalPane pane) {
@@ -293,7 +317,20 @@ final class Tab implements AutoCloseable {
} }
TerminalPane createFloatingPane() { 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); floating.add(pane);
floatingVisible = true; floatingVisible = true;
setActive(pane); setActive(pane);
@@ -332,22 +369,31 @@ final class Tab implements AutoCloseable {
} }
private TerminalPane openPane(boolean asFloating) { 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 availHeight = lastHeight - lastTopInset;
double widthPx;
double heightPx;
if (asFloating) { if (asFloating) {
widthPx = Math.max(420, lastWidth * 0.58); return new double[] {Math.max(420, lastWidth * 0.58), Math.max(260, availHeight * 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;
} }
// Open the new pane in the active pane's working directory, so a split/new pane lands // A new tiled pane joins the row, so each gets 1/(n+1) of the width.
// where the user currently is. With no active pane yet (the tab's first pane), fall back to return new double[] {lastWidth / (tiled.size() + 1), availHeight};
// 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); // 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) { private static boolean directionFilter(Direction direction, TerminalPane current, TerminalPane candidate) {

View File

@@ -12,6 +12,7 @@ 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 javafx.application.Platform;
import javafx.scene.canvas.GraphicsContext; import javafx.scene.canvas.GraphicsContext;
import javafx.scene.shape.Shape; import javafx.scene.shape.Shape;
@@ -39,6 +40,10 @@ public final class TerminalPane implements AutoCloseable, RenderTarget {
private final RenderState renderState = new RenderState(); private final RenderState renderState = new RenderState();
private RenderStateSnapshot cachedSnapshot; private RenderStateSnapshot cachedSnapshot;
private ShellSession session; 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; // 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(). // null means clip to the plain bounds. See RenderTarget#clip().
private Shape 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, public static TerminalPane create(AppConfig config, TerminalMetrics metrics, Runnable onContentChange,
double widthPx, double heightPx, String workingDirectory) { 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 columns = widthPx > 0 ? metrics.columnsFor(widthPx) : config.columns();
int rows = heightPx > 0 ? metrics.rowsFor(heightPx) : config.rows(); int rows = heightPx > 0 ? metrics.rowsFor(heightPx) : config.rows();
Terminal terminal = Ghostty.open(new TerminalOptions(columns, rows, config.maxScrollback())); 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, TerminalPane pane = new TerminalPane(terminal, metrics, config.kittyGraphics(), onContentChange,
new GhosttyTerminalRenderer(metrics), columns, rows); new GhosttyTerminalRenderer(metrics), columns, rows);
pane.refresh(); pane.refresh();
pane.attach(ShellSession.start(config.shell(), config.envOverride(), pane, columns, rows, workingDirectory));
return pane; 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) { private void attach(ShellSession session) {
this.session = session; this.session = session;
terminal.setPtyWriter(bytes -> { terminal.setPtyWriter(bytes -> {