This commit is contained in:
Gregor Lohaus
2026-05-27 15:42:31 +02:00
commit 332f4d7b3b
16 changed files with 1033 additions and 0 deletions

65
README.md Normal file
View File

@@ -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

32
build.gradle.kts Normal file
View File

@@ -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"
}

20
config.example.toml Normal file
View File

@@ -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"

70
flake.nix Normal file
View File

@@ -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"
'';
};
};
}

22
settings.gradle.kts Normal file
View File

@@ -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"

View File

@@ -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<String, KeyBinding> 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();
}
}

View File

@@ -0,0 +1,8 @@
package com.gregor.jprototerm;
public enum Direction {
LEFT,
DOWN,
UP,
RIGHT
}

View File

@@ -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;
}
}

View File

@@ -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;
};
}
}

View File

@@ -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<Integer, StringBuilder> chunks = new HashMap<>();
private final List<Placement> 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<String, String> 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<String, String> parseControl(String text) {
Map<String, String> 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<String, String> 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) {
}
}

View File

@@ -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);
}
}

View File

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

View File

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

View File

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

View File

@@ -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<TerminalPane> 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<TerminalPane> panes() {
return List.copyOf(panes);
}
public boolean isActive(TerminalPane pane) {
return activePane() == pane;
}
public void layout(double width, double height) {
List<TerminalPane> 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();
}
}

View File

@@ -0,0 +1,6 @@
{
"resources": [
{ "glob": "**/*.css" },
{ "glob": "**/*.toml" }
]
}