close panes when process closes, rework open scrollback to not open with /bin/sh -c
This commit is contained in:
@@ -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<String> 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<String> 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<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) {
|
||||
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<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) {
|
||||
TomlPrimitive primitive = primitive(table, key);
|
||||
if (primitive == null) {
|
||||
|
||||
@@ -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()) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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++;
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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,9 +21,41 @@ 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) {
|
||||
try {
|
||||
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 " + 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");
|
||||
@@ -30,16 +63,12 @@ public final class ShellSession implements AutoCloseable {
|
||||
environment.putAll(envOverride);
|
||||
|
||||
LinuxPty pty = LinuxPty.spawn(
|
||||
new String[] {shell, "-i"},
|
||||
argv,
|
||||
environment,
|
||||
workingDirectory != null ? workingDirectory : System.getProperty("user.home"));
|
||||
ShellSession session = new ShellSession(pty);
|
||||
session.resize(columns, rows);
|
||||
return session;
|
||||
} catch (RuntimeException ex) {
|
||||
pane.write("failed to start shell: " + ex.getMessage() + "\r\n");
|
||||
throw new IllegalStateException("Could not start shell " + shell, ex);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -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
|
||||
|
||||
@@ -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<TerminalPane> onPaneExit;
|
||||
private final List<TerminalPane> tiled = new ArrayList<>();
|
||||
private final List<TerminalPane> 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<TerminalPane> 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<TerminalPane> 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,20 +287,22 @@ 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (floating.isEmpty()) {
|
||||
floatingVisible = false;
|
||||
}
|
||||
|
||||
// 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) {
|
||||
active = 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 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;
|
||||
double[] size = paneSize(asFloating);
|
||||
return register(TerminalPane.create(
|
||||
config, metrics, this::markContentChanged, size[0], size[1], paneWorkingDirectory()));
|
||||
}
|
||||
// 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);
|
||||
|
||||
private double[] paneSize(boolean asFloating) {
|
||||
double availHeight = lastHeight - lastTopInset;
|
||||
if (asFloating) {
|
||||
return new double[] {Math.max(420, lastWidth * 0.58), Math.max(260, availHeight * 0.58)};
|
||||
}
|
||||
// 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) {
|
||||
|
||||
@@ -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 -> {
|
||||
|
||||
Reference in New Issue
Block a user