Compare commits
5 Commits
c2ccd056af
...
0fcba6a97d
| Author | SHA1 | Date | |
|---|---|---|---|
| 0fcba6a97d | |||
| a5dca9ca46 | |||
| 96674a3bf5 | |||
| c0ce81f125 | |||
| dcb70243aa |
40
README.md
40
README.md
@@ -62,6 +62,46 @@ gradle run
|
||||
|
||||
The Gradle project is the source of truth for the JavaFX build.
|
||||
|
||||
## Daemon (optional, faster launches)
|
||||
|
||||
Cold start pays for JVM + JavaFX + GL/X11 init every time. The optional daemon keeps one JVM
|
||||
(one toolkit) running and hosts every window in it, so a `jprototerm` launch just asks the
|
||||
daemon to open a window — it appears without paying that startup cost again.
|
||||
|
||||
Run it once in the background:
|
||||
|
||||
```sh
|
||||
jprototerm --daemon &
|
||||
```
|
||||
|
||||
After that, a bare `jprototerm` connects to the daemon and opens a window in the current
|
||||
directory. If no daemon is running, `jprototerm` falls back to a standalone in-process window
|
||||
(today's behavior), so it always works.
|
||||
|
||||
To start the daemon automatically with your graphical session, enable the bundled **user**
|
||||
service (it's a user service, not a system one, because X11 needs a display — which only
|
||||
exists after you log in):
|
||||
|
||||
```sh
|
||||
mkdir -p ~/.config/systemd/user
|
||||
ln -sf "$(dirname "$(readlink -f "$(command -v jprototerm)")")/../share/systemd/user/jprototerm.service" \
|
||||
~/.config/systemd/user/jprototerm.service
|
||||
systemctl --user enable --now jprototerm.service
|
||||
```
|
||||
|
||||
If the daemon can't reach your display (e.g. `systemctl --user status jprototerm` shows it
|
||||
failing to open a window), import the session variables once and restart it:
|
||||
|
||||
```sh
|
||||
systemctl --user import-environment DISPLAY XAUTHORITY
|
||||
systemctl --user restart jprototerm.service
|
||||
```
|
||||
|
||||
Closing a window (the WM close button, or the close-pane key on the last pane) tears that
|
||||
window down — its shell processes are signalled with the configured `close_signal` — without
|
||||
affecting other windows or the daemon. Stop the daemon (and all its windows) with
|
||||
`systemctl --user stop jprototerm.service`, or `pkill -f 'jprototerm --daemon'`.
|
||||
|
||||
## Config
|
||||
|
||||
Configuration is read from:
|
||||
|
||||
@@ -3,6 +3,9 @@ columns = 100
|
||||
rows = 30
|
||||
max_scrollback = 100000
|
||||
shell = "/bin/bash"
|
||||
# Signal sent to a pane's shell process when the pane is closed (e.g. ALT+X).
|
||||
# Use SIGKILL to force-kill instead of asking the shell to terminate.
|
||||
close_signal = "SIGTERM"
|
||||
font_family = "JetBrainsMono Nerd Font"
|
||||
font_size = 15
|
||||
|
||||
|
||||
20
flake.nix
20
flake.nix
@@ -109,6 +109,7 @@
|
||||
|
||||
makeWrapper "${pkgs.jdk25}/bin/java" "$out/bin/jprototerm" \
|
||||
--run 'export JPROTOTERM_HOST_LD_LIBRARY_PATH="''${LD_LIBRARY_PATH:-}"' \
|
||||
--run 'cdsDir="''${XDG_CACHE_HOME:-$HOME/.cache}/jprototerm"; mkdir -p "$cdsDir"; export JAVA_TOOL_OPTIONS="-XX:+AutoCreateSharedArchive -XX:SharedArchiveFile=$cdsDir/app.jsa ''${JAVA_TOOL_OPTIONS:-}"' \
|
||||
--add-flags "--enable-native-access=ALL-UNNAMED,javafx.graphics" \
|
||||
--add-flags "--module-path $out/share/jprototerm/javafx" \
|
||||
--add-flags "--add-modules javafx.controls,javafx.fxml" \
|
||||
@@ -119,6 +120,25 @@
|
||||
--set JLIBGHOSTTY_LIBRARY "${ghosttyVt}/lib/libghostty-vt.so" \
|
||||
--set GDK_BACKEND x11
|
||||
|
||||
# Optional background daemon: one JVM hosts every window, so client launches skip
|
||||
# cold JVM/JavaFX/GL startup. A *user* service tied to graphical-session.target (X11
|
||||
# needs a display, which only exists after login). Enable instructions are in README.
|
||||
mkdir -p "$out/share/systemd/user"
|
||||
cat > "$out/share/systemd/user/jprototerm.service" <<EOF
|
||||
[Unit]
|
||||
Description=jprototerm terminal daemon
|
||||
PartOf=graphical-session.target
|
||||
After=graphical-session.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=$out/bin/jprototerm --daemon
|
||||
Restart=on-failure
|
||||
|
||||
[Install]
|
||||
WantedBy=graphical-session.target
|
||||
EOF
|
||||
|
||||
runHook postInstall
|
||||
'';
|
||||
});
|
||||
|
||||
@@ -29,6 +29,7 @@ public record AppConfig(
|
||||
double windowHeight,
|
||||
boolean kittyGraphics,
|
||||
String scrollbackEditorCommand,
|
||||
String closeSignal,
|
||||
Map<String, String> envOverride,
|
||||
Map<String, KeyBinding> keybindings
|
||||
) {
|
||||
@@ -71,6 +72,7 @@ public record AppConfig(
|
||||
doubleValue(document, "window.height", defaults.windowHeight),
|
||||
booleanValue(document, "kitty_graphics.enabled", defaults.kittyGraphics),
|
||||
stringValue(document, "scrollback.editor_command", defaults.scrollbackEditorCommand),
|
||||
closeSignalValue(document, defaults.closeSignal),
|
||||
envOverride(document, defaults.envOverride),
|
||||
keybindings(document, defaults)
|
||||
);
|
||||
@@ -92,6 +94,7 @@ public record AppConfig(
|
||||
760.0,
|
||||
true,
|
||||
defaultScrollbackEditorCommand(),
|
||||
"SIGTERM",
|
||||
Map.of(),
|
||||
Map.ofEntries(
|
||||
Map.entry("navigate_left", KeyBinding.parse("ALT+H")),
|
||||
@@ -125,11 +128,22 @@ public record AppConfig(
|
||||
windowHeight,
|
||||
kittyGraphics,
|
||||
scrollbackEditorCommand,
|
||||
closeSignal,
|
||||
envOverride,
|
||||
keybindings
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The {@link #closeSignal} as a Linux signal number, sent to a pane's shell process when the
|
||||
* pane is closed (e.g. via the close-pane key). Falls back to SIGTERM (15) if the configured
|
||||
* name is somehow unresolvable.
|
||||
*/
|
||||
public int closeSignalNumber() {
|
||||
int number = LinuxPty.signalNumber(closeSignal);
|
||||
return number < 0 ? 15 : number;
|
||||
}
|
||||
|
||||
public void save() {
|
||||
save(configPath(), this);
|
||||
}
|
||||
@@ -156,6 +170,23 @@ public record AppConfig(
|
||||
return editor.trim() + " {file}";
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads {@code terminal.close_signal}, normalising it to a canonical {@code SIG*} name. An
|
||||
* unknown or unset value keeps {@code fallback} so a typo can't leave a pane unkillable.
|
||||
*/
|
||||
private static String closeSignalValue(TomlTable table, String fallback) {
|
||||
String value = stringValue(table, "terminal.close_signal", null);
|
||||
if (value == null) {
|
||||
return fallback;
|
||||
}
|
||||
if (LinuxPty.signalNumber(value) < 0) {
|
||||
System.err.println("Unknown terminal.close_signal '" + value + "', using " + fallback);
|
||||
return fallback;
|
||||
}
|
||||
String normalized = value.trim().toUpperCase(java.util.Locale.ROOT);
|
||||
return normalized.startsWith("SIG") ? normalized : "SIG" + normalized;
|
||||
}
|
||||
|
||||
private static Map<String, KeyBinding> keybindings(TomlTable table, AppConfig defaults) {
|
||||
Map<String, KeyBinding> parsed = new LinkedHashMap<>();
|
||||
for (String key : KEYBINDING_KEYS) {
|
||||
@@ -193,6 +224,7 @@ public record AppConfig(
|
||||
builder.append("rows = ").append(rows).append('\n');
|
||||
builder.append("max_scrollback = ").append(maxScrollback).append('\n');
|
||||
builder.append("shell = ").append(quotedList(shell)).append('\n');
|
||||
builder.append("close_signal = ").append(quoted(closeSignal)).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");
|
||||
|
||||
@@ -65,9 +65,18 @@ public final class Compositor {
|
||||
private Runnable onEmpty = () -> {};
|
||||
|
||||
public Compositor(AppConfig config, TerminalMetrics metrics) {
|
||||
this(config, metrics, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a compositor whose first tab's first pane starts in {@code workingDirectory} (e.g. the
|
||||
* cwd a client passed when asking the daemon to open this window), or the user's home when
|
||||
* {@code null}.
|
||||
*/
|
||||
public Compositor(AppConfig config, TerminalMetrics metrics, String workingDirectory) {
|
||||
this.config = config;
|
||||
this.metrics = metrics;
|
||||
tabs.add(new Tab(config, metrics, this::closePane));
|
||||
tabs.add(new Tab(config, metrics, workingDirectory, this::closePane));
|
||||
canvas.setFocusTraversable(true);
|
||||
canvas.setOnMousePressed(this::handleMousePressed);
|
||||
canvas.setOnMouseReleased(this::handleMouseReleased);
|
||||
@@ -236,6 +245,18 @@ public final class Compositor {
|
||||
tabs.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Signals and reaps every pane's shell process across all tabs, without tearing down render
|
||||
* state. Intended for a JVM shutdown hook (SIGTERM/SIGINT/SIGHUP), so child shells get the
|
||||
* configured close signal instead of being orphaned when jprototerm itself is killed. Safe to
|
||||
* call off the FX thread and idempotent; see {@link TerminalPane#terminateSession()}.
|
||||
*/
|
||||
public void terminateSessions() {
|
||||
for (Tab tab : List.copyOf(tabs)) {
|
||||
tab.terminateSessions();
|
||||
}
|
||||
}
|
||||
|
||||
private Tab currentTab() {
|
||||
return tabs.get(currentTabIndex);
|
||||
}
|
||||
|
||||
146
src/main/java/com/gregor/jprototerm/Daemon.java
Normal file
146
src/main/java/com/gregor/jprototerm/Daemon.java
Normal file
@@ -0,0 +1,146 @@
|
||||
package com.gregor.jprototerm;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.net.StandardProtocolFamily;
|
||||
import java.net.UnixDomainSocketAddress;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.channels.ServerSocketChannel;
|
||||
import java.nio.channels.SocketChannel;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.attribute.PosixFilePermissions;
|
||||
|
||||
/**
|
||||
* Single-instance server and its thin client, over a per-user Unix domain socket. The server hosts
|
||||
* every window in one JVM (see {@link WindowManager}); a client invocation just asks it to open a
|
||||
* window in the client's working directory and exits, so the window appears without paying cold
|
||||
* JVM/JavaFX/GL startup.
|
||||
*
|
||||
* <p>Protocol is deliberately trivial: the client writes one UTF-8 line — the absolute working
|
||||
* directory — and the server replies {@code OK\n}. The socket lives under {@code XDG_RUNTIME_DIR}
|
||||
* (mode 0700), so only the owning user can connect.
|
||||
*/
|
||||
public final class Daemon {
|
||||
private Daemon() {
|
||||
}
|
||||
|
||||
/** Runs the server: brings up the toolkit, binds the socket, and serves window-open requests. */
|
||||
public static void run() {
|
||||
Path socket = socketPath();
|
||||
try {
|
||||
Files.createDirectories(socket.getParent());
|
||||
trySecureDir(socket.getParent());
|
||||
} catch (IOException ex) {
|
||||
System.err.println("jprototerm: cannot create socket dir " + socket.getParent() + ": " + ex.getMessage());
|
||||
return;
|
||||
}
|
||||
|
||||
WindowManager manager = WindowManager.start(WindowManager.Mode.DAEMON);
|
||||
|
||||
try (ServerSocketChannel server = bind(socket)) {
|
||||
while (true) {
|
||||
try {
|
||||
handle(server.accept(), manager);
|
||||
} catch (IOException ex) {
|
||||
System.err.println("jprototerm: connection error: " + ex.getMessage());
|
||||
}
|
||||
}
|
||||
} catch (IOException ex) {
|
||||
System.err.println("jprototerm: daemon socket error: " + ex.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Client side: connect to a running daemon and ask it to open a window in {@code workingDirectory}.
|
||||
* Returns {@code true} if the daemon handled it, {@code false} if none is reachable (the caller
|
||||
* then falls back to a standalone in-process window).
|
||||
*/
|
||||
public static boolean tryClient(String workingDirectory) {
|
||||
Path socket = socketPath();
|
||||
if (!Files.exists(socket)) {
|
||||
return false;
|
||||
}
|
||||
try (SocketChannel channel = SocketChannel.open(UnixDomainSocketAddress.of(socket))) {
|
||||
channel.write(ByteBuffer.wrap((workingDirectory + "\n").getBytes(StandardCharsets.UTF_8)));
|
||||
// Best-effort wait for the ack so we don't race ahead of the window opening.
|
||||
channel.read(ByteBuffer.allocate(16));
|
||||
return true;
|
||||
} catch (IOException ex) {
|
||||
return false; // no daemon, or a stale socket file — fall back to standalone
|
||||
}
|
||||
}
|
||||
|
||||
private static ServerSocketChannel bind(Path socket) throws IOException {
|
||||
UnixDomainSocketAddress address = UnixDomainSocketAddress.of(socket);
|
||||
ServerSocketChannel channel = ServerSocketChannel.open(StandardProtocolFamily.UNIX);
|
||||
try {
|
||||
channel.bind(address);
|
||||
return channel;
|
||||
} catch (IOException firstTry) {
|
||||
// The path is taken. If a live daemon answers, this invocation lost the race; otherwise
|
||||
// it's a stale socket from a crashed daemon, so remove it and rebind.
|
||||
if (tryClient0(socket)) {
|
||||
channel.close();
|
||||
System.err.println("jprototerm: a daemon is already running");
|
||||
System.exit(0);
|
||||
}
|
||||
Files.deleteIfExists(socket);
|
||||
channel.bind(address);
|
||||
return channel;
|
||||
}
|
||||
}
|
||||
|
||||
/** A bare connect probe used by {@link #bind} to tell a live daemon from a stale socket file. */
|
||||
private static boolean tryClient0(Path socket) {
|
||||
try (SocketChannel channel = SocketChannel.open(UnixDomainSocketAddress.of(socket))) {
|
||||
return true;
|
||||
} catch (IOException ex) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static void handle(SocketChannel connection, WindowManager manager) throws IOException {
|
||||
try (connection) {
|
||||
String workingDirectory = readLine(connection);
|
||||
manager.openWindow(workingDirectory == null || workingDirectory.isBlank()
|
||||
? null
|
||||
: workingDirectory.trim());
|
||||
connection.write(ByteBuffer.wrap("OK\n".getBytes(StandardCharsets.UTF_8)));
|
||||
}
|
||||
}
|
||||
|
||||
private static String readLine(SocketChannel channel) throws IOException {
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
ByteBuffer buffer = ByteBuffer.allocate(4096);
|
||||
while (channel.read(buffer) != -1) {
|
||||
buffer.flip();
|
||||
while (buffer.hasRemaining()) {
|
||||
byte b = buffer.get();
|
||||
if (b == '\n') {
|
||||
return out.toString(StandardCharsets.UTF_8);
|
||||
}
|
||||
out.write(b);
|
||||
}
|
||||
buffer.clear();
|
||||
}
|
||||
return out.size() == 0 ? null : out.toString(StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
private static Path socketPath() {
|
||||
String runtimeDir = System.getenv("XDG_RUNTIME_DIR");
|
||||
Path dir = runtimeDir != null && !runtimeDir.isBlank()
|
||||
? Path.of(runtimeDir, "jprototerm")
|
||||
: Path.of("/tmp", "jprototerm-" + System.getProperty("user.name", "user"));
|
||||
return dir.resolve("daemon.sock");
|
||||
}
|
||||
|
||||
private static void trySecureDir(Path dir) {
|
||||
try {
|
||||
Files.setPosixFilePermissions(dir, PosixFilePermissions.fromString("rwx------"));
|
||||
} catch (IOException | UnsupportedOperationException ignored) {
|
||||
// Best effort: XDG_RUNTIME_DIR is already user-private; the /tmp fallback we try to lock.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -63,7 +63,10 @@ public final class LinuxPty implements AutoCloseable {
|
||||
private static final long TIOCSWINSZ = 0x5414L;
|
||||
private static final short POSIX_SPAWN_SETSID = 0x80;
|
||||
private static final int SIGHUP = 1;
|
||||
private static final int SIGINT = 2;
|
||||
private static final int SIGQUIT = 3;
|
||||
private static final int SIGKILL = 9;
|
||||
private static final int SIGTERM = 15;
|
||||
private static final int WNOHANG = 1;
|
||||
|
||||
// struct winsize { unsigned short ws_row, ws_col, ws_xpixel, ws_ypixel; }
|
||||
@@ -105,11 +108,36 @@ public final class LinuxPty implements AutoCloseable {
|
||||
private final Object writeLock = new Object();
|
||||
private final int masterFd;
|
||||
private final int pid;
|
||||
private final int closeSignal;
|
||||
private volatile boolean closed;
|
||||
|
||||
private LinuxPty(int masterFd, int pid) {
|
||||
private LinuxPty(int masterFd, int pid, int closeSignal) {
|
||||
this.masterFd = masterFd;
|
||||
this.pid = pid;
|
||||
this.closeSignal = closeSignal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a signal name (e.g. {@code "SIGTERM"}, {@code "TERM"}, {@code "SIGKILL"}) to its
|
||||
* Linux signal number, or {@code -1} if the name is not one we recognise. Case-insensitive and
|
||||
* tolerant of a missing {@code SIG} prefix.
|
||||
*/
|
||||
public static int signalNumber(String name) {
|
||||
if (name == null) {
|
||||
return -1;
|
||||
}
|
||||
String normalized = name.trim().toUpperCase(java.util.Locale.ROOT);
|
||||
if (normalized.startsWith("SIG")) {
|
||||
normalized = normalized.substring(3);
|
||||
}
|
||||
return switch (normalized) {
|
||||
case "HUP" -> SIGHUP;
|
||||
case "INT" -> SIGINT;
|
||||
case "QUIT" -> SIGQUIT;
|
||||
case "KILL" -> SIGKILL;
|
||||
case "TERM" -> SIGTERM;
|
||||
default -> -1;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -118,8 +146,10 @@ public final class LinuxPty implements AutoCloseable {
|
||||
* @param argv command and arguments (e.g. {@code {"/bin/zsh", "-i"}})
|
||||
* @param environment environment for the child, as KEY=VALUE pairs
|
||||
* @param workingDirectory directory the child starts in, or {@code null} to inherit
|
||||
* @param closeSignal signal number sent to the child on {@link #close()} (e.g. SIGTERM)
|
||||
*/
|
||||
public static LinuxPty spawn(String[] argv, Map<String, String> environment, String workingDirectory) {
|
||||
public static LinuxPty spawn(String[] argv, Map<String, String> environment, String workingDirectory,
|
||||
int closeSignal) {
|
||||
Arena setup = Arena.ofConfined();
|
||||
try {
|
||||
int master = check(callInt(POSIX_OPENPT, O_RDWR | O_NOCTTY), "posix_openpt");
|
||||
@@ -158,7 +188,7 @@ public final class LinuxPty implements AutoCloseable {
|
||||
if (rc != 0) {
|
||||
throw new IllegalStateException("posix_spawnp failed for " + argv[0] + " (rc=" + rc + ")");
|
||||
}
|
||||
return new LinuxPty(master, pidOut.get(C_INT, 0));
|
||||
return new LinuxPty(master, pidOut.get(C_INT, 0), closeSignal);
|
||||
} finally {
|
||||
callInt(ATTR_DESTROY, attr);
|
||||
callInt(FA_DESTROY, actions);
|
||||
@@ -249,7 +279,7 @@ public final class LinuxPty implements AutoCloseable {
|
||||
return;
|
||||
}
|
||||
closed = true;
|
||||
callKill(pid, SIGHUP);
|
||||
callKill(pid, closeSignal);
|
||||
callInt(CLOSE, masterFd);
|
||||
reap();
|
||||
arena.close();
|
||||
|
||||
@@ -1,251 +1,31 @@
|
||||
package com.gregor.jprototerm;
|
||||
|
||||
import javafx.animation.AnimationTimer;
|
||||
import javafx.application.Application;
|
||||
import javafx.application.Platform;
|
||||
import javafx.geometry.Rectangle2D;
|
||||
import javafx.scene.Scene;
|
||||
import javafx.scene.control.ButtonType;
|
||||
import javafx.scene.control.ComboBox;
|
||||
import javafx.scene.control.Dialog;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.Spinner;
|
||||
import javafx.scene.control.SpinnerValueFactory;
|
||||
import javafx.scene.input.Clipboard;
|
||||
import javafx.scene.input.KeyEvent;
|
||||
import javafx.scene.layout.GridPane;
|
||||
import javafx.scene.layout.StackPane;
|
||||
import javafx.scene.text.Font;
|
||||
import javafx.stage.Screen;
|
||||
import javafx.stage.Stage;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
|
||||
public final class Main extends Application {
|
||||
private Compositor compositor;
|
||||
private TerminalMetrics metrics;
|
||||
private AppConfig config;
|
||||
|
||||
@Override
|
||||
public void start(Stage stage) {
|
||||
config = AppConfig.load();
|
||||
|
||||
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());
|
||||
compositor.canvas().heightProperty().bind(root.heightProperty());
|
||||
|
||||
Scene scene = new Scene(root, config.windowWidth(), config.windowHeight());
|
||||
scene.addEventFilter(KeyEvent.KEY_PRESSED, this::handlePressed);
|
||||
scene.addEventFilter(KeyEvent.KEY_TYPED, event -> handleTyped(event));
|
||||
|
||||
new AnimationTimer() {
|
||||
@Override
|
||||
public void handle(long now) {
|
||||
compositor.render();
|
||||
}
|
||||
}.start();
|
||||
|
||||
stage.setTitle("jprototerm");
|
||||
stage.setScene(scene);
|
||||
stage.setOnCloseRequest(event -> {
|
||||
compositor.close();
|
||||
});
|
||||
// JavaFX centres a new stage on the primary screen; on X11 there's no "focused monitor"
|
||||
// to honour, so place it on the screen under the mouse pointer instead.
|
||||
centreOnActiveScreen(stage, config.windowWidth(), config.windowHeight());
|
||||
stage.show();
|
||||
// Ask the window manager to raise and focus the new window so the user can type right
|
||||
// away; the canvas requestFocus() below only routes events within the scene.
|
||||
stage.toFront();
|
||||
stage.requestFocus();
|
||||
compositor.canvas().requestFocus();
|
||||
}
|
||||
|
||||
// Centre the stage within the screen the mouse pointer is on (the best proxy for the
|
||||
// "active" monitor on X11, which exposes no focused-monitor concept to JavaFX).
|
||||
private static void centreOnActiveScreen(Stage stage, double width, double height) {
|
||||
Rectangle2D bounds = activeScreen().getVisualBounds();
|
||||
stage.setX(bounds.getMinX() + ((bounds.getWidth() - width) / 2.0));
|
||||
stage.setY(bounds.getMinY() + ((bounds.getHeight() - height) / 2.0));
|
||||
}
|
||||
|
||||
private static Screen activeScreen() {
|
||||
int[] at = X11Pointer.query();
|
||||
if (at != null) {
|
||||
// libX11 and JavaFX share a coordinate space on the X11 virtual screen.
|
||||
List<Screen> screens = Screen.getScreensForRectangle(at[0], at[1], 1.0, 1.0);
|
||||
if (!screens.isEmpty()) {
|
||||
return screens.get(0);
|
||||
}
|
||||
}
|
||||
return Screen.getPrimary();
|
||||
}
|
||||
|
||||
private void handlePressed(KeyEvent event) {
|
||||
if (config.keybindings().get("navigate_left").matches(event)) {
|
||||
compositor.navigate(Direction.LEFT);
|
||||
event.consume();
|
||||
} else if (config.keybindings().get("navigate_down").matches(event)) {
|
||||
compositor.navigate(Direction.DOWN);
|
||||
event.consume();
|
||||
} else if (config.keybindings().get("navigate_up").matches(event)) {
|
||||
compositor.navigate(Direction.UP);
|
||||
event.consume();
|
||||
} else if (config.keybindings().get("navigate_right").matches(event)) {
|
||||
compositor.navigate(Direction.RIGHT);
|
||||
event.consume();
|
||||
} else if (config.keybindings().get("toggle_floating").matches(event)) {
|
||||
compositor.toggleFloating();
|
||||
event.consume();
|
||||
} else if (config.keybindings().get("new_pane").matches(event)) {
|
||||
compositor.createPane();
|
||||
event.consume();
|
||||
} else if (config.keybindings().get("next_floating").matches(event)) {
|
||||
compositor.nextFloatingPane();
|
||||
event.consume();
|
||||
} else if (config.keybindings().get("promote_floating").matches(event)) {
|
||||
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();
|
||||
} else if (config.keybindings().get("new_tab").matches(event)) {
|
||||
compositor.newTab();
|
||||
event.consume();
|
||||
} else if (config.keybindings().get("previous_tab").matches(event)) {
|
||||
compositor.previousTab();
|
||||
event.consume();
|
||||
} else if (config.keybindings().get("next_tab").matches(event)) {
|
||||
compositor.nextTab();
|
||||
event.consume();
|
||||
} else if (config.keybindings().get("open_font_selector").matches(event)) {
|
||||
openFontSelector();
|
||||
event.consume();
|
||||
} else if (config.keybindings().get("open_scrollback").matches(event)) {
|
||||
openScrollbackInEditor();
|
||||
event.consume();
|
||||
} else if (config.keybindings().get("paste").matches(event)) {
|
||||
pasteFromClipboard();
|
||||
event.consume();
|
||||
} else {
|
||||
String encoded = KeyEncoder.encode(event);
|
||||
if (encoded != null) {
|
||||
compositor.activePane().send(encoded);
|
||||
event.consume();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void handleTyped(KeyEvent event) {
|
||||
if (event.isAltDown() || event.isControlDown() || event.isMetaDown()) {
|
||||
return;
|
||||
}
|
||||
|
||||
String text = event.getCharacter();
|
||||
if (text != null && !text.isEmpty() && text.charAt(0) >= 0x20 && text.charAt(0) != 0x7f) {
|
||||
compositor.activePane().send(text);
|
||||
event.consume();
|
||||
}
|
||||
}
|
||||
|
||||
private void pasteFromClipboard() {
|
||||
Clipboard clipboard = Clipboard.getSystemClipboard();
|
||||
if (clipboard.hasString()) {
|
||||
compositor.activePane().paste(clipboard.getString());
|
||||
}
|
||||
}
|
||||
|
||||
private void openFontSelector() {
|
||||
Dialog<ButtonType> dialog = new Dialog<>();
|
||||
dialog.setTitle("Font");
|
||||
dialog.getDialogPane().getButtonTypes().addAll(ButtonType.OK, ButtonType.CANCEL);
|
||||
|
||||
ComboBox<String> family = new ComboBox<>();
|
||||
family.getItems().setAll(Font.getFamilies());
|
||||
family.setEditable(true);
|
||||
family.setMaxWidth(Double.MAX_VALUE);
|
||||
family.setValue(config.fontFamily());
|
||||
|
||||
Spinner<Double> size = new Spinner<>();
|
||||
size.setEditable(true);
|
||||
size.setValueFactory(new SpinnerValueFactory.DoubleSpinnerValueFactory(6.0, 48.0, config.fontSize(), 0.5));
|
||||
|
||||
GridPane content = new GridPane();
|
||||
content.setHgap(10.0);
|
||||
content.setVgap(10.0);
|
||||
content.add(new Label("Family"), 0, 0);
|
||||
content.add(family, 1, 0);
|
||||
content.add(new Label("Size"), 0, 1);
|
||||
content.add(size, 1, 1);
|
||||
dialog.getDialogPane().setContent(content);
|
||||
|
||||
dialog.showAndWait()
|
||||
.filter(button -> button == ButtonType.OK)
|
||||
.ifPresent(ignored -> {
|
||||
String selectedFamily = family.getEditor().getText();
|
||||
if (selectedFamily == null || selectedFamily.isBlank()) {
|
||||
selectedFamily = family.getValue();
|
||||
}
|
||||
if (selectedFamily == null || selectedFamily.isBlank()) {
|
||||
return;
|
||||
}
|
||||
|
||||
double selectedSize = size.getValue();
|
||||
config = config.withFont(selectedFamily.trim(), selectedSize);
|
||||
config.save();
|
||||
compositor.setFont(config.fontFamily(), config.fontSize());
|
||||
compositor.canvas().requestFocus();
|
||||
});
|
||||
}
|
||||
|
||||
private void openScrollbackInEditor() {
|
||||
try {
|
||||
// Capture the active pane's scrollback before opening the floating pane, since that
|
||||
// makes the new pane active.
|
||||
Path file = Files.createTempFile("jprototerm-scrollback-", ".txt");
|
||||
Files.writeString(file, compositor.activePane().scrollbackText());
|
||||
file.toFile().deleteOnExit();
|
||||
|
||||
// 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());
|
||||
}
|
||||
}
|
||||
|
||||
private String scrollbackEditorCommand(Path file) {
|
||||
String quotedFile = shellQuote(file.toString());
|
||||
String command = config.scrollbackEditorCommand();
|
||||
if (command == null || command.isBlank()) {
|
||||
command = "vi {file}";
|
||||
}
|
||||
if (command.contains("{file}")) {
|
||||
return command.replace("{file}", quotedFile);
|
||||
}
|
||||
return command + " " + quotedFile;
|
||||
}
|
||||
|
||||
private static String shellQuote(String value) {
|
||||
return "'" + value.replace("'", "'\"'\"'") + "'";
|
||||
/**
|
||||
* Entry point and mode dispatch. A bare invocation is a thin client: it hands the request to a
|
||||
* running {@link Daemon}, or, if none is reachable, opens a single standalone window in this process
|
||||
* (today's behavior). {@code --daemon} runs the long-lived server that hosts every window in one
|
||||
* JVM, so client launches skip cold JVM/JavaFX/GL startup.
|
||||
*/
|
||||
public final class Main {
|
||||
private Main() {
|
||||
}
|
||||
|
||||
public static void main(String[] args) {
|
||||
// Match the renderer order the app was tuned for; honor an explicit override if present.
|
||||
System.setProperty("prism.order", System.getProperty("prism.order", "es2,sw"));
|
||||
launch(Main.class, args);
|
||||
|
||||
for (String arg : args) {
|
||||
if (arg.equals("--daemon")) {
|
||||
Daemon.run();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
String workingDirectory = System.getProperty("user.dir");
|
||||
if (Daemon.tryClient(workingDirectory)) {
|
||||
return; // a running daemon opened the window
|
||||
}
|
||||
// No daemon reachable: fall back to a standalone window; the JVM exits when it closes.
|
||||
WindowManager.start(WindowManager.Mode.STANDALONE).openWindow(workingDirectory);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,9 +27,10 @@ public final class ShellSession implements AutoCloseable {
|
||||
* 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, int closeSignal) {
|
||||
try {
|
||||
return spawn(shellCommand.toArray(new String[0]), envOverride, columns, rows, workingDirectory);
|
||||
return spawn(shellCommand.toArray(new String[0]), envOverride, columns, rows, workingDirectory,
|
||||
closeSignal);
|
||||
} catch (RuntimeException ex) {
|
||||
pane.write("failed to start shell: " + ex.getMessage() + "\r\n");
|
||||
throw new IllegalStateException("Could not start shell " + String.join(" ", shellCommand), ex);
|
||||
@@ -45,9 +46,10 @@ public final class ShellSession implements AutoCloseable {
|
||||
* 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) {
|
||||
int columns, int rows, String workingDirectory, String command, int closeSignal) {
|
||||
try {
|
||||
return spawn(new String[] {"/bin/sh", "-c", command}, envOverride, columns, rows, workingDirectory);
|
||||
return spawn(new String[] {"/bin/sh", "-c", command}, envOverride, columns, rows, workingDirectory,
|
||||
closeSignal);
|
||||
} catch (RuntimeException ex) {
|
||||
pane.write("failed to run command: " + ex.getMessage() + "\r\n");
|
||||
throw new IllegalStateException("Could not run command: " + command, ex);
|
||||
@@ -55,7 +57,7 @@ public final class ShellSession implements AutoCloseable {
|
||||
}
|
||||
|
||||
private static ShellSession spawn(String[] argv, Map<String, String> envOverride,
|
||||
int columns, int rows, String workingDirectory) {
|
||||
int columns, int rows, String workingDirectory, int closeSignal) {
|
||||
Map<String, String> environment = new HashMap<>(System.getenv());
|
||||
environment.put("TERM", "xterm-kitty");
|
||||
environment.put("COLORTERM", "truecolor");
|
||||
@@ -65,7 +67,8 @@ public final class ShellSession implements AutoCloseable {
|
||||
LinuxPty pty = LinuxPty.spawn(
|
||||
argv,
|
||||
environment,
|
||||
workingDirectory != null ? workingDirectory : System.getProperty("user.home"));
|
||||
workingDirectory != null ? workingDirectory : System.getProperty("user.home"),
|
||||
closeSignal);
|
||||
ShellSession session = new ShellSession(pty);
|
||||
session.resize(columns, rows);
|
||||
return session;
|
||||
|
||||
48
src/main/java/com/gregor/jprototerm/StartupTiming.java
Normal file
48
src/main/java/com/gregor/jprototerm/StartupTiming.java
Normal file
@@ -0,0 +1,48 @@
|
||||
package com.gregor.jprototerm;
|
||||
|
||||
import java.lang.management.ManagementFactory;
|
||||
|
||||
/**
|
||||
* Opt-in startup phase timing, enabled with {@code -Djprototerm.timing=true} (e.g. via
|
||||
* {@code JAVA_TOOL_OPTIONS}); otherwise every method is a cheap no-op and prints nothing.
|
||||
*
|
||||
* <p>Each {@link #mark(String)} prints one line to stderr with the time since the previous mark and
|
||||
* the total since JVM start, so a cold launch breaks down into its phases — toolkit/GL init vs
|
||||
* config load vs font loading vs first frame. The anchor is the JVM's own start time (the closest
|
||||
* proxy we have to "process start"), so the first mark includes JVM bootstrap and JavaFX toolkit
|
||||
* init, which is usually the dominant cost.
|
||||
*/
|
||||
final class StartupTiming {
|
||||
private static final boolean ENABLED = Boolean.getBoolean("jprototerm.timing");
|
||||
// Epoch millis; getStartTime() is the JVM's start, the earliest timestamp we can anchor to.
|
||||
private static final long JVM_START_MILLIS = ManagementFactory.getRuntimeMXBean().getStartTime();
|
||||
private static long lastMillis = -1;
|
||||
private static boolean firstFrameSeen;
|
||||
|
||||
private StartupTiming() {
|
||||
}
|
||||
|
||||
/** Records a phase boundary, printing the delta since the previous mark and since JVM start. */
|
||||
static void mark(String phase) {
|
||||
if (!ENABLED) {
|
||||
return;
|
||||
}
|
||||
long now = System.currentTimeMillis();
|
||||
long sinceStart = now - JVM_START_MILLIS;
|
||||
long sinceLast = lastMillis < 0 ? sinceStart : now - lastMillis;
|
||||
lastMillis = now;
|
||||
System.err.printf("[timing] %-22s +%5d ms (%5d ms since JVM start)%n", phase, sinceLast, sinceStart);
|
||||
}
|
||||
|
||||
/**
|
||||
* Records the first rendered frame exactly once, then becomes a no-op. Safe and cheap to call
|
||||
* from the render loop every frame (it only ever touches FX-thread state).
|
||||
*/
|
||||
static void firstFrame() {
|
||||
if (!ENABLED || firstFrameSeen) {
|
||||
return;
|
||||
}
|
||||
firstFrameSeen = true;
|
||||
mark("first frame");
|
||||
}
|
||||
}
|
||||
@@ -423,4 +423,14 @@ final class Tab implements AutoCloseable {
|
||||
tiled.clear();
|
||||
floating.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Signals and reaps every pane's shell process without tearing down render state. Safe to call
|
||||
* off the FX thread (see {@link TerminalPane#terminateSession()}); iterates snapshots so a
|
||||
* concurrent close on the FX thread can't trigger a {@link java.util.ConcurrentModificationException}.
|
||||
*/
|
||||
public void terminateSessions() {
|
||||
List.copyOf(tiled).forEach(TerminalPane::terminateSession);
|
||||
List.copyOf(floating).forEach(TerminalPane::terminateSession);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ public final class TerminalPane implements AutoCloseable, RenderTarget {
|
||||
// tracking meaningful: update() accumulates dirty since the last resetDirty().
|
||||
private final RenderState renderState = new RenderState();
|
||||
private RenderStateSnapshot cachedSnapshot;
|
||||
private ShellSession session;
|
||||
private volatile 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;
|
||||
@@ -81,7 +81,7 @@ public final class TerminalPane implements AutoCloseable, RenderTarget {
|
||||
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));
|
||||
workingDirectory, config.closeSignalNumber()));
|
||||
return pane;
|
||||
}
|
||||
|
||||
@@ -94,7 +94,7 @@ public final class TerminalPane implements AutoCloseable, RenderTarget {
|
||||
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));
|
||||
workingDirectory, command, config.closeSignalNumber()));
|
||||
return pane;
|
||||
}
|
||||
|
||||
@@ -380,4 +380,17 @@ public final class TerminalPane implements AutoCloseable, RenderTarget {
|
||||
renderState.close();
|
||||
terminal.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Signals and reaps just the shell process, leaving the render/native state untouched. Unlike
|
||||
* {@link #close()} this is safe to call off the FX thread — notably from a JVM shutdown hook,
|
||||
* which runs concurrently with the live render loop — because it only touches the pty (a child
|
||||
* process and fd), not ghostty's terminal handles. Idempotent; the OS reclaims the rest on exit.
|
||||
*/
|
||||
public void terminateSession() {
|
||||
ShellSession current = session;
|
||||
if (current != null) {
|
||||
current.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
279
src/main/java/com/gregor/jprototerm/TerminalWindow.java
Normal file
279
src/main/java/com/gregor/jprototerm/TerminalWindow.java
Normal file
@@ -0,0 +1,279 @@
|
||||
package com.gregor.jprototerm;
|
||||
|
||||
import javafx.animation.AnimationTimer;
|
||||
import javafx.geometry.Rectangle2D;
|
||||
import javafx.scene.Scene;
|
||||
import javafx.scene.control.ButtonType;
|
||||
import javafx.scene.control.ComboBox;
|
||||
import javafx.scene.control.Dialog;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.Spinner;
|
||||
import javafx.scene.control.SpinnerValueFactory;
|
||||
import javafx.scene.input.Clipboard;
|
||||
import javafx.scene.input.KeyEvent;
|
||||
import javafx.scene.layout.GridPane;
|
||||
import javafx.scene.layout.StackPane;
|
||||
import javafx.scene.text.Font;
|
||||
import javafx.stage.Screen;
|
||||
import javafx.stage.Stage;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* One top-level terminal window: its own {@link Stage}, {@link Compositor}, config/metrics, render
|
||||
* loop and input handling. Many of these live in a single JVM under a {@link WindowManager}; closing
|
||||
* one tears down only that window (its shells, its Stage) and leaves the rest — and, in daemon mode,
|
||||
* the JVM — running. Built on the FX thread.
|
||||
*/
|
||||
final class TerminalWindow {
|
||||
private final WindowManager manager;
|
||||
private final TerminalMetrics metrics;
|
||||
private final Compositor compositor;
|
||||
private final Stage stage;
|
||||
private final AnimationTimer renderLoop;
|
||||
private AppConfig config;
|
||||
private boolean closed;
|
||||
|
||||
TerminalWindow(WindowManager manager, String workingDirectory) {
|
||||
this.manager = manager;
|
||||
// Each window loads config independently, so edits (and per-window font changes) apply to
|
||||
// newly opened windows without disturbing existing ones.
|
||||
config = AppConfig.load();
|
||||
StartupTiming.mark("config loaded");
|
||||
metrics = new TerminalMetrics(config.fontFamily(), config.fontSize());
|
||||
StartupTiming.mark("fonts loaded");
|
||||
compositor = new Compositor(config, metrics, workingDirectory);
|
||||
StartupTiming.mark("compositor ready");
|
||||
|
||||
// The last pane closing closes this window (not the JVM); see teardown().
|
||||
compositor.setOnEmpty(this::teardown);
|
||||
|
||||
StackPane root = new StackPane(compositor.canvas(), compositor.imageOverlay());
|
||||
compositor.canvas().widthProperty().bind(root.widthProperty());
|
||||
compositor.canvas().heightProperty().bind(root.heightProperty());
|
||||
|
||||
Scene scene = new Scene(root, config.windowWidth(), config.windowHeight());
|
||||
scene.addEventFilter(KeyEvent.KEY_PRESSED, this::handlePressed);
|
||||
scene.addEventFilter(KeyEvent.KEY_TYPED, this::handleTyped);
|
||||
|
||||
renderLoop = new AnimationTimer() {
|
||||
@Override
|
||||
public void handle(long now) {
|
||||
compositor.render();
|
||||
StartupTiming.firstFrame();
|
||||
}
|
||||
};
|
||||
renderLoop.start();
|
||||
|
||||
stage = new Stage();
|
||||
stage.setTitle("jprototerm");
|
||||
stage.setScene(scene);
|
||||
// The X11 WM close button: tear this window down explicitly. With implicit exit disabled
|
||||
// (see WindowManager) nothing else would reap the shells or drop the window otherwise.
|
||||
stage.setOnCloseRequest(event -> teardown());
|
||||
centreOnActiveScreen(stage, config.windowWidth(), config.windowHeight());
|
||||
stage.show();
|
||||
StartupTiming.mark("stage shown");
|
||||
// Ask the window manager to raise and focus the new window so the user can type right
|
||||
// away; the canvas requestFocus() below only routes events within the scene.
|
||||
stage.toFront();
|
||||
stage.requestFocus();
|
||||
compositor.canvas().requestFocus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fully tears this window down (FX thread, idempotent): stops rendering, closes the compositor —
|
||||
* which reaps the pane shells via the configured {@code close_signal} — disposes the Stage, and
|
||||
* notifies the manager so it can drop the window (and, in standalone mode, exit the JVM). Both
|
||||
* the WM close button and the last-pane-closed hook route through here.
|
||||
*/
|
||||
void teardown() {
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
closed = true;
|
||||
renderLoop.stop();
|
||||
compositor.close();
|
||||
stage.close();
|
||||
manager.onWindowClosed(this);
|
||||
}
|
||||
|
||||
/** Signals and reaps this window's shell processes without touching render state (off-FX safe). */
|
||||
void terminateSessions() {
|
||||
compositor.terminateSessions();
|
||||
}
|
||||
|
||||
private void handlePressed(KeyEvent event) {
|
||||
if (config.keybindings().get("navigate_left").matches(event)) {
|
||||
compositor.navigate(Direction.LEFT);
|
||||
event.consume();
|
||||
} else if (config.keybindings().get("navigate_down").matches(event)) {
|
||||
compositor.navigate(Direction.DOWN);
|
||||
event.consume();
|
||||
} else if (config.keybindings().get("navigate_up").matches(event)) {
|
||||
compositor.navigate(Direction.UP);
|
||||
event.consume();
|
||||
} else if (config.keybindings().get("navigate_right").matches(event)) {
|
||||
compositor.navigate(Direction.RIGHT);
|
||||
event.consume();
|
||||
} else if (config.keybindings().get("toggle_floating").matches(event)) {
|
||||
compositor.toggleFloating();
|
||||
event.consume();
|
||||
} else if (config.keybindings().get("new_pane").matches(event)) {
|
||||
compositor.createPane();
|
||||
event.consume();
|
||||
} else if (config.keybindings().get("next_floating").matches(event)) {
|
||||
compositor.nextFloatingPane();
|
||||
event.consume();
|
||||
} else if (config.keybindings().get("promote_floating").matches(event)) {
|
||||
compositor.promoteActiveFloating();
|
||||
event.consume();
|
||||
} else if (config.keybindings().get("close_pane").matches(event)) {
|
||||
// Closing the last pane closes this window, via the compositor's onEmpty hook.
|
||||
compositor.closeActivePane();
|
||||
event.consume();
|
||||
} else if (config.keybindings().get("new_tab").matches(event)) {
|
||||
compositor.newTab();
|
||||
event.consume();
|
||||
} else if (config.keybindings().get("previous_tab").matches(event)) {
|
||||
compositor.previousTab();
|
||||
event.consume();
|
||||
} else if (config.keybindings().get("next_tab").matches(event)) {
|
||||
compositor.nextTab();
|
||||
event.consume();
|
||||
} else if (config.keybindings().get("open_font_selector").matches(event)) {
|
||||
openFontSelector();
|
||||
event.consume();
|
||||
} else if (config.keybindings().get("open_scrollback").matches(event)) {
|
||||
openScrollbackInEditor();
|
||||
event.consume();
|
||||
} else if (config.keybindings().get("paste").matches(event)) {
|
||||
pasteFromClipboard();
|
||||
event.consume();
|
||||
} else {
|
||||
String encoded = KeyEncoder.encode(event);
|
||||
if (encoded != null) {
|
||||
compositor.activePane().send(encoded);
|
||||
event.consume();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void handleTyped(KeyEvent event) {
|
||||
if (event.isAltDown() || event.isControlDown() || event.isMetaDown()) {
|
||||
return;
|
||||
}
|
||||
|
||||
String text = event.getCharacter();
|
||||
if (text != null && !text.isEmpty() && text.charAt(0) >= 0x20 && text.charAt(0) != 0x7f) {
|
||||
compositor.activePane().send(text);
|
||||
event.consume();
|
||||
}
|
||||
}
|
||||
|
||||
private void pasteFromClipboard() {
|
||||
Clipboard clipboard = Clipboard.getSystemClipboard();
|
||||
if (clipboard.hasString()) {
|
||||
compositor.activePane().paste(clipboard.getString());
|
||||
}
|
||||
}
|
||||
|
||||
private void openFontSelector() {
|
||||
Dialog<ButtonType> dialog = new Dialog<>();
|
||||
dialog.setTitle("Font");
|
||||
dialog.getDialogPane().getButtonTypes().addAll(ButtonType.OK, ButtonType.CANCEL);
|
||||
|
||||
ComboBox<String> family = new ComboBox<>();
|
||||
family.getItems().setAll(Font.getFamilies());
|
||||
family.setEditable(true);
|
||||
family.setMaxWidth(Double.MAX_VALUE);
|
||||
family.setValue(config.fontFamily());
|
||||
|
||||
Spinner<Double> size = new Spinner<>();
|
||||
size.setEditable(true);
|
||||
size.setValueFactory(new SpinnerValueFactory.DoubleSpinnerValueFactory(6.0, 48.0, config.fontSize(), 0.5));
|
||||
|
||||
GridPane content = new GridPane();
|
||||
content.setHgap(10.0);
|
||||
content.setVgap(10.0);
|
||||
content.add(new Label("Family"), 0, 0);
|
||||
content.add(family, 1, 0);
|
||||
content.add(new Label("Size"), 0, 1);
|
||||
content.add(size, 1, 1);
|
||||
dialog.getDialogPane().setContent(content);
|
||||
|
||||
dialog.showAndWait()
|
||||
.filter(button -> button == ButtonType.OK)
|
||||
.ifPresent(ignored -> {
|
||||
String selectedFamily = family.getEditor().getText();
|
||||
if (selectedFamily == null || selectedFamily.isBlank()) {
|
||||
selectedFamily = family.getValue();
|
||||
}
|
||||
if (selectedFamily == null || selectedFamily.isBlank()) {
|
||||
return;
|
||||
}
|
||||
|
||||
double selectedSize = size.getValue();
|
||||
config = config.withFont(selectedFamily.trim(), selectedSize);
|
||||
config.save();
|
||||
compositor.setFont(config.fontFamily(), config.fontSize());
|
||||
compositor.canvas().requestFocus();
|
||||
});
|
||||
}
|
||||
|
||||
private void openScrollbackInEditor() {
|
||||
try {
|
||||
// Capture the active pane's scrollback before opening the floating pane, since that
|
||||
// makes the new pane active.
|
||||
Path file = Files.createTempFile("jprototerm-scrollback-", ".txt");
|
||||
Files.writeString(file, compositor.activePane().scrollbackText());
|
||||
file.toFile().deleteOnExit();
|
||||
|
||||
// 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());
|
||||
}
|
||||
}
|
||||
|
||||
private String scrollbackEditorCommand(Path file) {
|
||||
String quotedFile = shellQuote(file.toString());
|
||||
String command = config.scrollbackEditorCommand();
|
||||
if (command == null || command.isBlank()) {
|
||||
command = "vi {file}";
|
||||
}
|
||||
if (command.contains("{file}")) {
|
||||
return command.replace("{file}", quotedFile);
|
||||
}
|
||||
return command + " " + quotedFile;
|
||||
}
|
||||
|
||||
private static String shellQuote(String value) {
|
||||
return "'" + value.replace("'", "'\"'\"'") + "'";
|
||||
}
|
||||
|
||||
// Centre the stage within the screen the mouse pointer is on (the best proxy for the
|
||||
// "active" monitor on X11, which exposes no focused-monitor concept to JavaFX).
|
||||
private static void centreOnActiveScreen(Stage stage, double width, double height) {
|
||||
Rectangle2D bounds = activeScreen().getVisualBounds();
|
||||
stage.setX(bounds.getMinX() + ((bounds.getWidth() - width) / 2.0));
|
||||
stage.setY(bounds.getMinY() + ((bounds.getHeight() - height) / 2.0));
|
||||
}
|
||||
|
||||
private static Screen activeScreen() {
|
||||
int[] at = X11Pointer.query();
|
||||
if (at != null) {
|
||||
// libX11 and JavaFX share a coordinate space on the X11 virtual screen.
|
||||
List<Screen> screens = Screen.getScreensForRectangle(at[0], at[1], 1.0, 1.0);
|
||||
if (!screens.isEmpty()) {
|
||||
return screens.get(0);
|
||||
}
|
||||
}
|
||||
return Screen.getPrimary();
|
||||
}
|
||||
}
|
||||
69
src/main/java/com/gregor/jprototerm/WindowManager.java
Normal file
69
src/main/java/com/gregor/jprototerm/WindowManager.java
Normal file
@@ -0,0 +1,69 @@
|
||||
package com.gregor.jprototerm;
|
||||
|
||||
import javafx.application.Platform;
|
||||
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.CopyOnWriteArraySet;
|
||||
|
||||
/**
|
||||
* Owns the JavaFX toolkit lifecycle and the set of live {@link TerminalWindow}s for one JVM. A
|
||||
* single JVM hosts every window, so the expensive toolkit/GL init is paid once; opening another
|
||||
* window is then just a new {@link javafx.stage.Stage}.
|
||||
*
|
||||
* <p>Two modes differ only in the empty policy: in {@link Mode#STANDALONE} (today's behavior, used
|
||||
* as the fallback when no daemon is reachable) the JVM exits once the last window closes; in
|
||||
* {@link Mode#DAEMON} the toolkit stays alive with zero windows, waiting for the next client
|
||||
* request. {@link Platform#setImplicitExit(boolean) implicit exit} is disabled in both so the
|
||||
* toolkit never tears itself down behind our back — every exit is an explicit decision here.
|
||||
*/
|
||||
public final class WindowManager {
|
||||
public enum Mode {
|
||||
STANDALONE,
|
||||
DAEMON
|
||||
}
|
||||
|
||||
private final Mode mode;
|
||||
// Mutated on the FX thread (register/deregister), iterated from the shutdown-hook thread.
|
||||
private final Set<TerminalWindow> windows = new CopyOnWriteArraySet<>();
|
||||
|
||||
private WindowManager(Mode mode) {
|
||||
this.mode = mode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Brings up the JavaFX toolkit (once per JVM) and returns a manager in {@code mode}. Registers a
|
||||
* shutdown hook that reaps every window's shell processes, so child shells are terminated rather
|
||||
* than orphaned if the JVM is killed (SIGTERM/SIGINT/SIGHUP) — see
|
||||
* {@link Compositor#terminateSessions()}.
|
||||
*/
|
||||
public static WindowManager start(Mode mode) {
|
||||
WindowManager manager = new WindowManager(mode);
|
||||
Platform.setImplicitExit(false);
|
||||
Platform.startup(() -> StartupTiming.mark("toolkit ready"));
|
||||
Runtime.getRuntime().addShutdownHook(new Thread(manager::terminateAllSessions, "shell-cleanup"));
|
||||
return manager;
|
||||
}
|
||||
|
||||
/** Opens a new window (on the FX thread) whose first pane starts in {@code workingDirectory}. */
|
||||
public void openWindow(String workingDirectory) {
|
||||
Platform.runLater(() -> windows.add(new TerminalWindow(this, workingDirectory)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by a window when it has finished tearing down (FX thread). Drops it from the registry
|
||||
* and, in standalone mode, exits the JVM once none remain.
|
||||
*/
|
||||
void onWindowClosed(TerminalWindow window) {
|
||||
windows.remove(window);
|
||||
if (mode == Mode.STANDALONE && windows.isEmpty()) {
|
||||
Platform.exit();
|
||||
}
|
||||
}
|
||||
|
||||
/** Signals and reaps every live window's shell processes. Safe to call off the FX thread. */
|
||||
void terminateAllSessions() {
|
||||
for (TerminalWindow window : windows) {
|
||||
window.terminateSessions();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user