init
This commit is contained in:
65
README.md
Normal file
65
README.md
Normal 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
32
build.gradle.kts
Normal 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
20
config.example.toml
Normal 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
70
flake.nix
Normal 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
22
settings.gradle.kts
Normal 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"
|
||||||
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