daemon mode
This commit is contained in:
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:
|
||||
|
||||
19
flake.nix
19
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" <<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
|
||||
'';
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
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.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<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);
|
||||
}
|
||||
}
|
||||
|
||||
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