From 0fcba6a97da9f9679113afd047b8fa8df498efd5 Mon Sep 17 00:00:00 2001 From: Gregor Lohaus Date: Tue, 2 Jun 2026 10:18:48 +0200 Subject: [PATCH] daemon mode --- README.md | 40 +++ flake.nix | 19 ++ .../com/gregor/jprototerm/Compositor.java | 11 +- .../java/com/gregor/jprototerm/Daemon.java | 146 +++++++++ src/main/java/com/gregor/jprototerm/Main.java | 281 ++---------------- .../com/gregor/jprototerm/TerminalWindow.java | 279 +++++++++++++++++ .../com/gregor/jprototerm/WindowManager.java | 69 +++++ 7 files changed, 586 insertions(+), 259 deletions(-) create mode 100644 src/main/java/com/gregor/jprototerm/Daemon.java create mode 100644 src/main/java/com/gregor/jprototerm/TerminalWindow.java create mode 100644 src/main/java/com/gregor/jprototerm/WindowManager.java diff --git a/README.md b/README.md index 07a9bc2..2dfd65c 100644 --- a/README.md +++ b/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: diff --git a/flake.nix b/flake.nix index 1aebb9d..c22904e 100644 --- a/flake.nix +++ b/flake.nix @@ -120,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" < {}; 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); diff --git a/src/main/java/com/gregor/jprototerm/Daemon.java b/src/main/java/com/gregor/jprototerm/Daemon.java new file mode 100644 index 0000000..92e98fb --- /dev/null +++ b/src/main/java/com/gregor/jprototerm/Daemon.java @@ -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. + * + *

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. + } + } +} diff --git a/src/main/java/com/gregor/jprototerm/Main.java b/src/main/java/com/gregor/jprototerm/Main.java index 6330ce0..e42a1a5 100644 --- a/src/main/java/com/gregor/jprototerm/Main.java +++ b/src/main/java/com/gregor/jprototerm/Main.java @@ -1,266 +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) { - // First mark: time from JVM start through JavaFX toolkit + GL pipeline init (start() is the - // first app code the toolkit runs). Usually the dominant slice of cold startup. - StartupTiming.mark("toolkit ready (start)"); - config = AppConfig.load(); - StartupTiming.mark("config loaded"); - - metrics = new TerminalMetrics(config.fontFamily(), config.fontSize()); - StartupTiming.mark("fonts loaded"); - compositor = new Compositor(config, metrics); - // Includes the first Ghostty.open (native dlopen) and the first pty spawn. - StartupTiming.mark("compositor ready"); - // 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(); - }); - // If jprototerm itself is killed (SIGTERM/SIGINT/SIGHUP, e.g. a logout or `kill`), the JVM - // runs shutdown hooks before exiting. Send each pane's configured close signal here so the - // child shells are terminated rather than orphaned. Only the ptys are touched (not ghostty's - // native state), so this is safe to run concurrently with the still-live render loop. A - // SIGKILL of jprototerm bypasses hooks entirely; nothing can help there. - Runtime.getRuntime().addShutdownHook(new Thread(compositor::terminateSessions, "shell-cleanup")); - - 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(); - StartupTiming.firstFrame(); - } - }.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(); - 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(); - } - - // 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 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 dialog = new Dialog<>(); - dialog.setTitle("Font"); - dialog.getDialogPane().getButtonTypes().addAll(ButtonType.OK, ButtonType.CANCEL); - - ComboBox family = new ComboBox<>(); - family.getItems().setAll(Font.getFamilies()); - family.setEditable(true); - family.setMaxWidth(Double.MAX_VALUE); - family.setValue(config.fontFamily()); - - Spinner 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); } } diff --git a/src/main/java/com/gregor/jprototerm/TerminalWindow.java b/src/main/java/com/gregor/jprototerm/TerminalWindow.java new file mode 100644 index 0000000..3851181 --- /dev/null +++ b/src/main/java/com/gregor/jprototerm/TerminalWindow.java @@ -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 dialog = new Dialog<>(); + dialog.setTitle("Font"); + dialog.getDialogPane().getButtonTypes().addAll(ButtonType.OK, ButtonType.CANCEL); + + ComboBox family = new ComboBox<>(); + family.getItems().setAll(Font.getFamilies()); + family.setEditable(true); + family.setMaxWidth(Double.MAX_VALUE); + family.setValue(config.fontFamily()); + + Spinner 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 screens = Screen.getScreensForRectangle(at[0], at[1], 1.0, 1.0); + if (!screens.isEmpty()) { + return screens.get(0); + } + } + return Screen.getPrimary(); + } +} diff --git a/src/main/java/com/gregor/jprototerm/WindowManager.java b/src/main/java/com/gregor/jprototerm/WindowManager.java new file mode 100644 index 0000000..ede10a1 --- /dev/null +++ b/src/main/java/com/gregor/jprototerm/WindowManager.java @@ -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}. + * + *

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 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(); + } + } +}