commit 332f4d7b3bfc87a70d0967d479531c6d090005ae Author: Gregor Lohaus Date: Wed May 27 15:42:31 2026 +0200 init diff --git a/README.md b/README.md new file mode 100644 index 0000000..8cc2276 --- /dev/null +++ b/README.md @@ -0,0 +1,65 @@ +# jprototerm + +JavaFX canvas terminal prototype using `jlibghostty` for terminal emulation, Nix for the build environment, and GluonFX/GraalVM Native Image for the Linux binary. + +## Build + +```sh +nix build +``` + +For development: + +```sh +nix develop +gradle -PjlibghosttyMavenRepo="$JLIBGHOSTTY_MAVEN_REPO" run +gradle -PjlibghosttyMavenRepo="$JLIBGHOSTTY_MAVEN_REPO" nativeCompile +``` + +The current flake follows the normal Gradle dependency-resolution shape. For a fully pure Nix build, vendor the Gradle dependency graph with `gradle2nix` or a checked-in Maven repository. + +## Config + +Configuration is read from: + +```text +$XDG_CONFIG_HOME/jprototerm/config.toml +``` + +If `XDG_CONFIG_HOME` is unset, the fallback is: + +```text +$HOME/.config/jprototerm/config.toml +``` + +Example, also available in `config.example.toml`: + +```toml +[terminal] +columns = 100 +rows = 30 +shell = "/bin/bash" +font_family = "JetBrainsMono Nerd Font" +font_size = 15 + +[window] +width = 1200 +height = 760 + +[kitty_graphics] +enabled = true + +[keybindings] +navigate_left = "ALT+H" +navigate_down = "ALT+J" +navigate_up = "ALT+K" +navigate_right = "ALT+L" +toggle_floating = "ALT+F" +``` + +## Defaults + +- `Alt+h/j/k/l`: navigate panes +- `Alt+f`: open or close a floating pane +- Font default: `Symbols Nerd Font Mono` +- Kitty graphics protocol parsing is enabled by default diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..72e980d --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,32 @@ +plugins { + application + id("org.openjfx.javafxplugin") version "0.1.0" + id("com.gluonhq.gluonfx-gradle-plugin") version "1.0.28" +} + +group = "com.gregor" +version = "0.1.0" + +dependencies { + implementation("dev.jlibghostty:jlibghostty:0.1.0-SNAPSHOT") + implementation("io.github.wasabithumb:jtoml:1.5.2") +} + +java { + toolchain { + languageVersion.set(JavaLanguageVersion.of(25)) + } +} + +application { + mainClass.set("com.gregor.jprototerm.Main") +} + +javafx { + version = "25" + modules = listOf("javafx.controls", "javafx.graphics") +} + +gluonfx { + mainClassName = "com.gregor.jprototerm.Main" +} diff --git a/config.example.toml b/config.example.toml new file mode 100644 index 0000000..f6f5df2 --- /dev/null +++ b/config.example.toml @@ -0,0 +1,20 @@ +[terminal] +columns = 100 +rows = 30 +shell = "/bin/bash" +font_family = "JetBrainsMono Nerd Font" +font_size = 15 + +[window] +width = 1200 +height = 760 + +[kitty_graphics] +enabled = true + +[keybindings] +navigate_left = "ALT+H" +navigate_down = "ALT+J" +navigate_up = "ALT+K" +navigate_right = "ALT+L" +toggle_floating = "ALT+F" diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..a0b5626 --- /dev/null +++ b/flake.nix @@ -0,0 +1,70 @@ +{ + description = "JavaFX terminal using jlibghostty and GraalVM Native Image"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + jlibghostty.url = "git+https://gitea.gregorlohaus.com/gregor/jlibghostty.git"; + }; + + outputs = { self, nixpkgs, jlibghostty }: + let + system = "x86_64-linux"; + pkgs = import nixpkgs { inherit system; }; + + jlib = jlibghostty.packages.${system}.jlibghostty; + graalvm = pkgs.graalvmPackages.graalvm-ce; + in { + packages.${system}.default = pkgs.stdenvNoCC.mkDerivation { + pname = "jprototerm"; + version = "0.1.0"; + src = ./.; + + nativeBuildInputs = [ + graalvm + pkgs.gradle + pkgs.makeWrapper + ]; + + buildPhase = '' + runHook preBuild + + export HOME=$TMPDIR/home + export GRADLE_USER_HOME=$TMPDIR/gradle + mkdir -p "$HOME" "$GRADLE_USER_HOME" + + gradle \ + --no-daemon \ + -PjlibghosttyMavenRepo=${jlib}/maven \ + nativeCompile + + runHook postBuild + ''; + + installPhase = '' + runHook preInstall + + mkdir -p $out/bin + cp build/gluonfx/*/*/jprototerm $out/bin/jprototerm + + wrapProgram $out/bin/jprototerm \ + --set GDK_BACKEND x11 \ + --prefix PATH : ${pkgs.lib.makeBinPath [ pkgs.util-linux pkgs.bash ]} + + runHook postInstall + ''; + }; + + devShells.${system}.default = pkgs.mkShell { + packages = [ + graalvm + pkgs.gradle + pkgs.util-linux + ]; + + shellHook = '' + export JLIBGHOSTTY_MAVEN_REPO=${jlib}/maven + echo "Use: gradle -PjlibghosttyMavenRepo=$JLIBGHOSTTY_MAVEN_REPO run" + ''; + }; + }; +} diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..9ea7c35 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,22 @@ +pluginManagement { + repositories { + gradlePluginPortal() + mavenCentral() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + mavenCentral() + maven { + url = uri( + providers.gradleProperty("jlibghosttyMavenRepo") + .orElse("../jlibghostty/result/maven") + .get() + ) + } + } +} + +rootProject.name = "jprototerm" diff --git a/src/main/java/com/gregor/jprototerm/AppConfig.java b/src/main/java/com/gregor/jprototerm/AppConfig.java new file mode 100644 index 0000000..983f12b --- /dev/null +++ b/src/main/java/com/gregor/jprototerm/AppConfig.java @@ -0,0 +1,150 @@ +package com.gregor.jprototerm; + +import io.github.wasabithumb.jtoml.JToml; +import io.github.wasabithumb.jtoml.document.TomlDocument; +import io.github.wasabithumb.jtoml.except.TomlException; +import io.github.wasabithumb.jtoml.value.TomlValue; +import io.github.wasabithumb.jtoml.value.primitive.TomlPrimitive; +import io.github.wasabithumb.jtoml.value.table.TomlTable; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; + +public record AppConfig( + int columns, + int rows, + String shell, + String fontFamily, + double fontSize, + double windowWidth, + double windowHeight, + boolean kittyGraphics, + Map keybindings +) { + public static AppConfig load() { + AppConfig defaults = defaults(); + Path path = configPath(); + if (!Files.isRegularFile(path)) { + return defaults; + } + + try { + TomlDocument document = JToml.jToml().read(path); + return new AppConfig( + intValue(document, "terminal.columns", defaults.columns), + intValue(document, "terminal.rows", defaults.rows), + stringValue(document, "terminal.shell", defaults.shell), + stringValue(document, "terminal.font_family", defaults.fontFamily), + doubleValue(document, "terminal.font_size", defaults.fontSize), + doubleValue(document, "window.width", defaults.windowWidth), + doubleValue(document, "window.height", defaults.windowHeight), + booleanValue(document, "kitty_graphics.enabled", defaults.kittyGraphics), + Map.of( + "navigate_left", binding(document, "keybindings.navigate_left", defaults.keybindings.get("navigate_left")), + "navigate_down", binding(document, "keybindings.navigate_down", defaults.keybindings.get("navigate_down")), + "navigate_up", binding(document, "keybindings.navigate_up", defaults.keybindings.get("navigate_up")), + "navigate_right", binding(document, "keybindings.navigate_right", defaults.keybindings.get("navigate_right")), + "toggle_floating", binding(document, "keybindings.toggle_floating", defaults.keybindings.get("toggle_floating")) + ) + ); + } catch (TomlException ex) { + System.err.println("Could not parse " + path + ": " + ex.getMessage()); + return defaults; + } + } + + public static AppConfig defaults() { + return new AppConfig( + 100, + 30, + defaultShell(), + "Symbols Nerd Font Mono", + 15.0, + 1200.0, + 760.0, + true, + Map.of( + "navigate_left", KeyBinding.parse("ALT+H"), + "navigate_down", KeyBinding.parse("ALT+J"), + "navigate_up", KeyBinding.parse("ALT+K"), + "navigate_right", KeyBinding.parse("ALT+L"), + "toggle_floating", KeyBinding.parse("ALT+F") + ) + ); + } + + public static Path configPath() { + String configHome = System.getenv("XDG_CONFIG_HOME"); + if (configHome != null && !configHome.isBlank()) { + return Path.of(configHome, "jprototerm", "config.toml"); + } + return Path.of(System.getProperty("user.home"), ".config", "jprototerm", "config.toml"); + } + + private static String defaultShell() { + String shell = System.getenv("SHELL"); + return shell == null || shell.isBlank() ? "/bin/sh" : shell; + } + + private static KeyBinding binding(TomlTable table, String key, KeyBinding fallback) { + String value = stringValue(table, key, null); + if (value == null) { + return fallback; + } + try { + return KeyBinding.parse(value); + } catch (IllegalArgumentException ex) { + return fallback; + } + } + + private static String stringValue(TomlTable table, String key, String fallback) { + TomlPrimitive primitive = primitive(table, key); + return primitive == null ? fallback : primitive.asString(); + } + + private static int intValue(TomlTable table, String key, int fallback) { + TomlPrimitive primitive = primitive(table, key); + if (primitive == null) { + return fallback; + } + try { + return primitive.asInteger(); + } catch (RuntimeException ex) { + return fallback; + } + } + + private static double doubleValue(TomlTable table, String key, double fallback) { + TomlPrimitive primitive = primitive(table, key); + if (primitive == null) { + return fallback; + } + try { + return primitive.asDouble(); + } catch (RuntimeException ex) { + return fallback; + } + } + + private static boolean booleanValue(TomlTable table, String key, boolean fallback) { + TomlPrimitive primitive = primitive(table, key); + if (primitive == null) { + return fallback; + } + try { + return primitive.asBoolean(); + } catch (RuntimeException ex) { + return fallback; + } + } + + private static TomlPrimitive primitive(TomlTable table, String key) { + TomlValue value = table.get(key); + if (value == null || !value.isPrimitive()) { + return null; + } + return value.asPrimitive(); + } +} diff --git a/src/main/java/com/gregor/jprototerm/Direction.java b/src/main/java/com/gregor/jprototerm/Direction.java new file mode 100644 index 0000000..c0889ca --- /dev/null +++ b/src/main/java/com/gregor/jprototerm/Direction.java @@ -0,0 +1,8 @@ +package com.gregor.jprototerm; + +public enum Direction { + LEFT, + DOWN, + UP, + RIGHT +} diff --git a/src/main/java/com/gregor/jprototerm/KeyBinding.java b/src/main/java/com/gregor/jprototerm/KeyBinding.java new file mode 100644 index 0000000..996521d --- /dev/null +++ b/src/main/java/com/gregor/jprototerm/KeyBinding.java @@ -0,0 +1,37 @@ +package com.gregor.jprototerm; + +import javafx.scene.input.KeyCode; +import javafx.scene.input.KeyEvent; + +import java.util.Locale; + +public record KeyBinding(boolean alt, boolean control, boolean shift, KeyCode code) { + public static KeyBinding parse(String value) { + boolean alt = false; + boolean control = false; + boolean shift = false; + KeyCode code = null; + + for (String part : value.split("\\+")) { + String token = part.trim().toUpperCase(Locale.ROOT); + switch (token) { + case "ALT", "META" -> alt = true; + case "CTRL", "CONTROL" -> control = true; + case "SHIFT" -> shift = true; + default -> code = KeyCode.getKeyCode(token); + } + } + + if (code == null) { + throw new IllegalArgumentException("Key binding has no key code: " + value); + } + return new KeyBinding(alt, control, shift, code); + } + + public boolean matches(KeyEvent event) { + return event.isAltDown() == alt + && event.isControlDown() == control + && event.isShiftDown() == shift + && event.getCode() == code; + } +} diff --git a/src/main/java/com/gregor/jprototerm/KeyEncoder.java b/src/main/java/com/gregor/jprototerm/KeyEncoder.java new file mode 100644 index 0000000..795274c --- /dev/null +++ b/src/main/java/com/gregor/jprototerm/KeyEncoder.java @@ -0,0 +1,29 @@ +package com.gregor.jprototerm; + +import javafx.scene.input.KeyCode; +import javafx.scene.input.KeyEvent; + +final class KeyEncoder { + private KeyEncoder() { + } + + static String encode(KeyEvent event) { + KeyCode code = event.getCode(); + return switch (code) { + case ENTER -> "\r"; + case BACK_SPACE -> "\u007f"; + case TAB -> "\t"; + case ESCAPE -> "\u001b"; + case UP -> "\u001b[A"; + case DOWN -> "\u001b[B"; + case RIGHT -> "\u001b[C"; + case LEFT -> "\u001b[D"; + case HOME -> "\u001b[H"; + case END -> "\u001b[F"; + case DELETE -> "\u001b[3~"; + case PAGE_UP -> "\u001b[5~"; + case PAGE_DOWN -> "\u001b[6~"; + default -> null; + }; + } +} diff --git a/src/main/java/com/gregor/jprototerm/KittyGraphicsRegistry.java b/src/main/java/com/gregor/jprototerm/KittyGraphicsRegistry.java new file mode 100644 index 0000000..e35ab9e --- /dev/null +++ b/src/main/java/com/gregor/jprototerm/KittyGraphicsRegistry.java @@ -0,0 +1,143 @@ +package com.gregor.jprototerm; + +import javafx.scene.canvas.GraphicsContext; +import javafx.scene.image.Image; + +import java.io.ByteArrayInputStream; +import java.util.ArrayList; +import java.util.Base64; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public final class KittyGraphicsRegistry { + private final boolean enabled; + private final StringBuilder stream = new StringBuilder(); + private final Map chunks = new HashMap<>(); + private final List placements = new ArrayList<>(); + + public KittyGraphicsRegistry(boolean enabled) { + this.enabled = enabled; + } + + public synchronized void accept(String text) { + if (!enabled) { + return; + } + stream.append(text); + parseBufferedCommands(); + } + + public synchronized void draw(GraphicsContext gc, double originX, double originY, double cellWidth, double lineHeight) { + if (!enabled) { + return; + } + + for (Placement placement : placements) { + double x = originX + placement.column * cellWidth; + double y = originY + placement.row * lineHeight; + double width = placement.columns <= 0 ? placement.image.getWidth() : placement.columns * cellWidth; + double height = placement.rows <= 0 ? placement.image.getHeight() : placement.rows * lineHeight; + gc.drawImage(placement.image, x, y, width, height); + } + } + + public synchronized void clear() { + chunks.clear(); + placements.clear(); + stream.setLength(0); + } + + private void parseBufferedCommands() { + int start; + while ((start = stream.indexOf("\u001b_G")) >= 0) { + int end = commandEnd(start + 3); + if (end < 0) { + if (start > 0) { + stream.delete(0, start); + } + return; + } + + String command = stream.substring(start + 3, end); + handleCommand(command); + stream.delete(0, end + terminatorLength(end)); + } + + if (stream.length() > 16384) { + stream.delete(0, stream.length() - 4096); + } + } + + private int commandEnd(int from) { + int bell = stream.indexOf("\u0007", from); + int st = stream.indexOf("\u001b\\", from); + if (bell < 0) { + return st; + } + if (st < 0) { + return bell; + } + return Math.min(bell, st); + } + + private int terminatorLength(int end) { + return stream.charAt(end) == '\u0007' ? 1 : 2; + } + + private void handleCommand(String command) { + int separator = command.indexOf(';'); + if (separator < 0) { + return; + } + + Map control = parseControl(command.substring(0, separator)); + String payload = command.substring(separator + 1).replace("\n", "").replace("\r", ""); + + int id = intControl(control, "i", 1); + boolean more = intControl(control, "m", 0) == 1; + chunks.computeIfAbsent(id, ignored -> new StringBuilder()).append(payload); + if (more) { + return; + } + + String data = chunks.remove(id).toString(); + try { + byte[] bytes = Base64.getDecoder().decode(data); + Image image = new Image(new ByteArrayInputStream(bytes)); + if (!image.isError()) { + placements.add(new Placement( + image, + intControl(control, "x", 0), + intControl(control, "y", 0), + intControl(control, "c", 0), + intControl(control, "r", 0) + )); + } + } catch (IllegalArgumentException ignored) { + chunks.remove(id); + } + } + + private static Map parseControl(String text) { + Map result = new HashMap<>(); + for (String part : text.split(",")) { + int equals = part.indexOf('='); + if (equals > 0) { + result.put(part.substring(0, equals), part.substring(equals + 1)); + } + } + return result; + } + + private static int intControl(Map control, String key, int fallback) { + try { + return Integer.parseInt(control.getOrDefault(key, String.valueOf(fallback))); + } catch (NumberFormatException ex) { + return fallback; + } + } + + private record Placement(Image image, int column, int row, int columns, int rows) { + } +} diff --git a/src/main/java/com/gregor/jprototerm/Main.java b/src/main/java/com/gregor/jprototerm/Main.java new file mode 100644 index 0000000..296d70c --- /dev/null +++ b/src/main/java/com/gregor/jprototerm/Main.java @@ -0,0 +1,83 @@ +package com.gregor.jprototerm; + +import javafx.animation.AnimationTimer; +import javafx.application.Application; +import javafx.scene.Scene; +import javafx.scene.input.KeyEvent; +import javafx.scene.layout.StackPane; +import javafx.stage.Stage; + +public final class Main extends Application { + private TerminalWorkspace workspace; + + @Override + public void start(Stage stage) { + AppConfig config = AppConfig.load(); + + workspace = new TerminalWorkspace(config); + TerminalCanvasView terminalView = new TerminalCanvasView(workspace, config); + + StackPane root = new StackPane(terminalView.canvas()); + terminalView.canvas().widthProperty().bind(root.widthProperty()); + terminalView.canvas().heightProperty().bind(root.heightProperty()); + + Scene scene = new Scene(root, config.windowWidth(), config.windowHeight()); + scene.addEventFilter(KeyEvent.KEY_PRESSED, event -> handlePressed(config, event)); + scene.addEventFilter(KeyEvent.KEY_TYPED, event -> handleTyped(event)); + + new AnimationTimer() { + @Override + public void handle(long now) { + terminalView.render(); + } + }.start(); + + stage.setTitle("jprototerm"); + stage.setScene(scene); + stage.setOnCloseRequest(event -> { + workspace.close(); + }); + stage.show(); + } + + private void handlePressed(AppConfig config, KeyEvent event) { + if (config.keybindings().get("navigate_left").matches(event)) { + workspace.navigate(Direction.LEFT); + event.consume(); + } else if (config.keybindings().get("navigate_down").matches(event)) { + workspace.navigate(Direction.DOWN); + event.consume(); + } else if (config.keybindings().get("navigate_up").matches(event)) { + workspace.navigate(Direction.UP); + event.consume(); + } else if (config.keybindings().get("navigate_right").matches(event)) { + workspace.navigate(Direction.RIGHT); + event.consume(); + } else if (config.keybindings().get("toggle_floating").matches(event)) { + workspace.toggleFloating(); + event.consume(); + } else { + String encoded = KeyEncoder.encode(event); + if (encoded != null) { + workspace.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) { + workspace.activePane().send(text); + event.consume(); + } + } + + public static void main(String[] args) { + launch(args); + } +} diff --git a/src/main/java/com/gregor/jprototerm/ShellSession.java b/src/main/java/com/gregor/jprototerm/ShellSession.java new file mode 100644 index 0000000..7f38311 --- /dev/null +++ b/src/main/java/com/gregor/jprototerm/ShellSession.java @@ -0,0 +1,86 @@ +package com.gregor.jprototerm; + +import javafx.application.Platform; + +import java.io.IOException; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +public final class ShellSession implements AutoCloseable { + private final Process process; + private final OutputStream stdin; + private final ExecutorService reader; + private volatile boolean closed; + + private ShellSession(Process process, TerminalPane pane, KittyGraphicsRegistry graphicsRegistry) { + this.process = process; + this.stdin = process.getOutputStream(); + this.reader = Executors.newSingleThreadExecutor(runnable -> { + Thread thread = new Thread(runnable, "shell-output-reader"); + thread.setDaemon(true); + return thread; + }); + reader.submit(() -> readOutput(pane, graphicsRegistry)); + } + + public static ShellSession start(String shell, TerminalPane pane, KittyGraphicsRegistry graphicsRegistry) { + try { + ProcessBuilder processBuilder = new ProcessBuilder( + "script", + "-qfec", + shell + " -i", + "/dev/null" + ).redirectErrorStream(true); + processBuilder.environment().put("TERM", "xterm-kitty"); + processBuilder.environment().put("COLORTERM", "truecolor"); + Process process = processBuilder.start(); + return new ShellSession(process, pane, graphicsRegistry); + } catch (IOException ex) { + pane.write("failed to start shell: " + ex.getMessage() + "\r\n"); + throw new IllegalStateException("Could not start shell " + shell, ex); + } + } + + public void send(String text) { + if (closed) { + return; + } + try { + stdin.write(text.getBytes(StandardCharsets.UTF_8)); + stdin.flush(); + } catch (IOException ex) { + close(); + } + } + + private void readOutput(TerminalPane pane, KittyGraphicsRegistry graphicsRegistry) { + byte[] buffer = new byte[8192]; + try { + int read; + while ((read = process.getInputStream().read(buffer)) != -1) { + String text = new String(buffer, 0, read, StandardCharsets.UTF_8); + if (!closed) { + graphicsRegistry.accept(text); + Platform.runLater(() -> { + if (!closed) { + pane.write(text); + } + }); + } + } + } catch (IOException ex) { + if (!closed) { + Platform.runLater(() -> pane.write("\r\nshell output stopped: " + ex.getMessage() + "\r\n")); + } + } + } + + @Override + public void close() { + closed = true; + reader.shutdownNow(); + process.destroy(); + } +} diff --git a/src/main/java/com/gregor/jprototerm/TerminalCanvasView.java b/src/main/java/com/gregor/jprototerm/TerminalCanvasView.java new file mode 100644 index 0000000..24ecc70 --- /dev/null +++ b/src/main/java/com/gregor/jprototerm/TerminalCanvasView.java @@ -0,0 +1,74 @@ +package com.gregor.jprototerm; + +import javafx.scene.canvas.Canvas; +import javafx.scene.canvas.GraphicsContext; +import javafx.scene.paint.Color; +import javafx.scene.text.Font; +import javafx.scene.text.FontSmoothingType; + +public final class TerminalCanvasView { + private final Canvas canvas = new Canvas(); + private final TerminalWorkspace workspace; + private final AppConfig config; + + public TerminalCanvasView(TerminalWorkspace workspace, AppConfig config) { + this.workspace = workspace; + this.config = config; + canvas.setFocusTraversable(true); + } + + public Canvas canvas() { + return canvas; + } + + public void render() { + double width = canvas.getWidth(); + double height = canvas.getHeight(); + workspace.layout(width, height); + + GraphicsContext gc = canvas.getGraphicsContext2D(); + gc.setFill(Color.rgb(16, 16, 18)); + gc.fillRect(0, 0, width, height); + gc.setFontSmoothingType(FontSmoothingType.GRAY); + + for (TerminalPane pane : workspace.panes()) { + drawPane(gc, pane); + } + } + + private void drawPane(GraphicsContext gc, TerminalPane pane) { + gc.save(); + gc.beginPath(); + gc.rect(pane.x(), pane.y(), pane.width(), pane.height()); + gc.clip(); + + if (pane.floating()) { + gc.setGlobalAlpha(0.96); + } + gc.setFill(Color.rgb(9, 10, 12)); + gc.fillRect(pane.x(), pane.y(), pane.width(), pane.height()); + gc.setGlobalAlpha(1.0); + + gc.setStroke(workspace.isActive(pane) ? Color.rgb(87, 166, 255) : Color.rgb(52, 57, 65)); + gc.setLineWidth(workspace.isActive(pane) ? 2.0 : 1.0); + gc.strokeRect(pane.x() + 0.5, pane.y() + 0.5, pane.width() - 1.0, pane.height() - 1.0); + + Font font = Font.font(config.fontFamily(), config.fontSize()); + gc.setFont(font); + gc.setFill(Color.rgb(225, 229, 235)); + + double lineHeight = Math.ceil(config.fontSize() * 1.35); + double left = pane.x() + 12.0; + double baseline = pane.y() + 18.0; + int maxLines = Math.max(1, (int) ((pane.height() - 24.0) / lineHeight)); + + String[] lines = pane.snapshotText().split("\\R", -1); + int start = Math.max(0, lines.length - maxLines); + for (int i = start; i < lines.length; i++) { + gc.fillText(lines[i], left, baseline + ((i - start) * lineHeight)); + } + + pane.graphicsRegistry().draw(gc, pane.x() + 12.0, pane.y() + 12.0, config.fontSize() * 0.62, lineHeight); + gc.restore(); + } +} diff --git a/src/main/java/com/gregor/jprototerm/TerminalPane.java b/src/main/java/com/gregor/jprototerm/TerminalPane.java new file mode 100644 index 0000000..6c41cf0 --- /dev/null +++ b/src/main/java/com/gregor/jprototerm/TerminalPane.java @@ -0,0 +1,100 @@ +package com.gregor.jprototerm; + +import dev.jlibghostty.Ghostty; +import dev.jlibghostty.Terminal; +import dev.jlibghostty.TerminalOptions; + +import java.util.concurrent.atomic.AtomicReference; + +public final class TerminalPane implements AutoCloseable { + private final Terminal terminal; + private final KittyGraphicsRegistry graphicsRegistry; + private final AtomicReference snapshotText = new AtomicReference<>(""); + private ShellSession session; + private boolean floating; + private double x; + private double y; + private double width; + private double height; + + private TerminalPane(Terminal terminal, KittyGraphicsRegistry graphicsRegistry) { + this.terminal = terminal; + this.graphicsRegistry = graphicsRegistry; + } + + public static TerminalPane create(int columns, int rows, boolean kittyGraphics) { + Terminal terminal = Ghostty.open(TerminalOptions.of(columns, rows)); + TerminalPane pane = new TerminalPane(terminal, new KittyGraphicsRegistry(kittyGraphics)); + pane.refresh(); + return pane; + } + + public void write(String text) { + synchronized (terminal) { + terminal.write(text); + refresh(); + } + } + + public void attach(ShellSession session) { + this.session = session; + } + + public void send(String text) { + if (session != null) { + session.send(text); + } + } + + public String snapshotText() { + return snapshotText.get(); + } + + public KittyGraphicsRegistry graphicsRegistry() { + return graphicsRegistry; + } + + public boolean floating() { + return floating; + } + + public void setFloating(boolean floating) { + this.floating = floating; + } + + public double x() { + return x; + } + + public double y() { + return y; + } + + public double width() { + return width; + } + + public double height() { + return height; + } + + public void bounds(double x, double y, double width, double height) { + this.x = x; + this.y = y; + this.width = width; + this.height = height; + } + + private void refresh() { + snapshotText.set(String.valueOf(terminal.snapshot())); + } + + @Override + public void close() { + if (session != null) { + session.close(); + session = null; + } + terminal.close(); + } +} diff --git a/src/main/java/com/gregor/jprototerm/TerminalWorkspace.java b/src/main/java/com/gregor/jprototerm/TerminalWorkspace.java new file mode 100644 index 0000000..fc174c3 --- /dev/null +++ b/src/main/java/com/gregor/jprototerm/TerminalWorkspace.java @@ -0,0 +1,108 @@ +package com.gregor.jprototerm; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +public final class TerminalWorkspace implements AutoCloseable { + private final AppConfig config; + private final List panes = new ArrayList<>(); + private int activeIndex; + + public TerminalWorkspace(AppConfig config) { + this.config = config; + panes.add(openPane(false)); + } + + public TerminalPane activePane() { + return panes.get(activeIndex); + } + + public List panes() { + return List.copyOf(panes); + } + + public boolean isActive(TerminalPane pane) { + return activePane() == pane; + } + + public void layout(double width, double height) { + List tiled = panes.stream().filter(pane -> !pane.floating()).toList(); + int tileCount = Math.max(1, tiled.size()); + double tileWidth = width / tileCount; + for (int i = 0; i < tiled.size(); i++) { + tiled.get(i).bounds(i * tileWidth, 0, tileWidth, height); + } + + for (TerminalPane pane : panes) { + if (pane.floating()) { + double floatingWidth = Math.max(420, width * 0.58); + double floatingHeight = Math.max(260, height * 0.58); + pane.bounds( + (width - floatingWidth) / 2.0, + (height - floatingHeight) / 2.0, + floatingWidth, + floatingHeight + ); + } + } + } + + public void navigate(Direction direction) { + TerminalPane current = activePane(); + panes.stream() + .filter(pane -> pane != current) + .filter(pane -> directionFilter(direction, current, pane)) + .min(Comparator.comparingDouble(pane -> distance(current, pane))) + .ifPresent(pane -> activeIndex = panes.indexOf(pane)); + } + + public void toggleFloating() { + TerminalPane active = activePane(); + if (active.floating()) { + panes.remove(activeIndex); + active.close(); + activeIndex = Math.max(0, activeIndex - 1); + return; + } + + TerminalPane pane = openPane(true); + panes.add(pane); + activeIndex = panes.size() - 1; + } + + private TerminalPane openPane(boolean floating) { + TerminalPane pane = TerminalPane.create(config.columns(), config.rows(), config.kittyGraphics()); + pane.setFloating(floating); + pane.attach(ShellSession.start(config.shell(), pane, pane.graphicsRegistry())); + return pane; + } + + private static boolean directionFilter(Direction direction, TerminalPane current, TerminalPane candidate) { + double currentCenterX = current.x() + current.width() / 2.0; + double currentCenterY = current.y() + current.height() / 2.0; + double candidateCenterX = candidate.x() + candidate.width() / 2.0; + double candidateCenterY = candidate.y() + candidate.height() / 2.0; + + return switch (direction) { + case LEFT -> candidateCenterX < currentCenterX; + case DOWN -> candidateCenterY > currentCenterY; + case UP -> candidateCenterY < currentCenterY; + case RIGHT -> candidateCenterX > currentCenterX; + }; + } + + private static double distance(TerminalPane current, TerminalPane candidate) { + double dx = (current.x() + current.width() / 2.0) - (candidate.x() + candidate.width() / 2.0); + double dy = (current.y() + current.height() / 2.0) - (candidate.y() + candidate.height() / 2.0); + return Math.sqrt(dx * dx + dy * dy); + } + + @Override + public void close() { + for (TerminalPane pane : panes) { + pane.close(); + } + panes.clear(); + } +} diff --git a/src/main/resources/META-INF/native-image/com.gregor/jprototerm/resource-config.json b/src/main/resources/META-INF/native-image/com.gregor/jprototerm/resource-config.json new file mode 100644 index 0000000..81e9473 --- /dev/null +++ b/src/main/resources/META-INF/native-image/com.gregor/jprototerm/resource-config.json @@ -0,0 +1,6 @@ +{ + "resources": [ + { "glob": "**/*.css" }, + { "glob": "**/*.toml" } + ] +}