init
This commit is contained in:
150
src/main/java/com/gregor/jprototerm/AppConfig.java
Normal file
150
src/main/java/com/gregor/jprototerm/AppConfig.java
Normal 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();
|
||||
}
|
||||
}
|
||||
8
src/main/java/com/gregor/jprototerm/Direction.java
Normal file
8
src/main/java/com/gregor/jprototerm/Direction.java
Normal file
@@ -0,0 +1,8 @@
|
||||
package com.gregor.jprototerm;
|
||||
|
||||
public enum Direction {
|
||||
LEFT,
|
||||
DOWN,
|
||||
UP,
|
||||
RIGHT
|
||||
}
|
||||
37
src/main/java/com/gregor/jprototerm/KeyBinding.java
Normal file
37
src/main/java/com/gregor/jprototerm/KeyBinding.java
Normal 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;
|
||||
}
|
||||
}
|
||||
29
src/main/java/com/gregor/jprototerm/KeyEncoder.java
Normal file
29
src/main/java/com/gregor/jprototerm/KeyEncoder.java
Normal 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;
|
||||
};
|
||||
}
|
||||
}
|
||||
143
src/main/java/com/gregor/jprototerm/KittyGraphicsRegistry.java
Normal file
143
src/main/java/com/gregor/jprototerm/KittyGraphicsRegistry.java
Normal 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) {
|
||||
}
|
||||
}
|
||||
83
src/main/java/com/gregor/jprototerm/Main.java
Normal file
83
src/main/java/com/gregor/jprototerm/Main.java
Normal 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);
|
||||
}
|
||||
}
|
||||
86
src/main/java/com/gregor/jprototerm/ShellSession.java
Normal file
86
src/main/java/com/gregor/jprototerm/ShellSession.java
Normal 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();
|
||||
}
|
||||
}
|
||||
74
src/main/java/com/gregor/jprototerm/TerminalCanvasView.java
Normal file
74
src/main/java/com/gregor/jprototerm/TerminalCanvasView.java
Normal 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();
|
||||
}
|
||||
}
|
||||
100
src/main/java/com/gregor/jprototerm/TerminalPane.java
Normal file
100
src/main/java/com/gregor/jprototerm/TerminalPane.java
Normal 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();
|
||||
}
|
||||
}
|
||||
108
src/main/java/com/gregor/jprototerm/TerminalWorkspace.java
Normal file
108
src/main/java/com/gregor/jprototerm/TerminalWorkspace.java
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"resources": [
|
||||
{ "glob": "**/*.css" },
|
||||
{ "glob": "**/*.toml" }
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user