pretty good

This commit is contained in:
Gregor Lohaus
2026-05-28 02:07:44 +02:00
parent a3f4878fc7
commit f07e524fbb
34 changed files with 245 additions and 26 deletions

1
.gitignore vendored
View File

@@ -10,3 +10,4 @@ devenv.local.yaml
# pre-commit # pre-commit
.pre-commit-config.yaml .pre-commit-config.yaml
build

View File

@@ -34,6 +34,8 @@ If `XDG_CONFIG_HOME` is unset, the fallback is:
$HOME/.config/jprototerm/config.toml $HOME/.config/jprototerm/config.toml
``` ```
If no config file exists, jprototerm writes the default config on startup.
Example, also available in `config.example.toml`: Example, also available in `config.example.toml`:
```toml ```toml
@@ -60,6 +62,7 @@ toggle_floating = "ALT+F"
new_floating = "ALT+SHIFT+F" new_floating = "ALT+SHIFT+F"
next_floating = "ALT+F12" next_floating = "ALT+F12"
close_pane = "ALT+X" close_pane = "ALT+X"
open_font_selector = "ALT+T"
``` ```
## Defaults ## Defaults
@@ -69,5 +72,6 @@ close_pane = "ALT+X"
- `Alt+Shift+f`: create a new floating pane - `Alt+Shift+f`: create a new floating pane
- `Alt+F12`: cycle floating panes - `Alt+F12`: cycle floating panes
- `Alt+x`: close the active floating pane - `Alt+x`: close the active floating pane
- `Alt+t`: open the font selector
- Font default: `JetBrainsMono Nerd Font` - Font default: `JetBrainsMono Nerd Font`
- Kitty graphics protocol parsing is enabled by default - Kitty graphics protocol parsing is enabled by default

View File

@@ -21,3 +21,4 @@ toggle_floating = "ALT+F"
new_floating = "ALT+SHIFT+F" new_floating = "ALT+SHIFT+F"
next_floating = "ALT+F12" next_floating = "ALT+F12"
close_pane = "ALT+X" close_pane = "ALT+X"
open_font_selector = "ALT+T"

View File

@@ -7,6 +7,7 @@ let
"git+https://gitea.gregorlohaus.com/gregor/jlibghostty.git"; "git+https://gitea.gregorlohaus.com/gregor/jlibghostty.git";
jlib = jlibghostty.packages.${system}.jlibghostty; jlib = jlibghostty.packages.${system}.jlibghostty;
hostNvidiaLibs = ".devenv/host-nvidia-libs";
in in
{ {
packages = [ packages = [
@@ -26,9 +27,10 @@ in
pkgs.libGL pkgs.libGL
pkgs.gtk3 pkgs.gtk3
pkgs.alsa-lib pkgs.alsa-lib
pkgs.mesa-demos
]; ];
env.LD_LIBRARY_PATH = lib.makeLibraryPath [ env.LD_LIBRARY_PATH = "${hostNvidiaLibs}:" + lib.makeLibraryPath [
pkgs.openjfx pkgs.openjfx
pkgs.glib pkgs.glib
@@ -42,5 +44,23 @@ in
pkgs.gtk3 pkgs.gtk3
pkgs.alsa-lib pkgs.alsa-lib
] + ":/usr/lib/x86_64-linux-gnu/nvidia/current"; ] + ":/usr/lib/x86_64-linux-gnu/nvidia/current";
env.__GLX_VENDOR_LIBRARY_NAME = "nvidia";
env.__EGL_VENDOR_LIBRARY_FILENAMES = "/usr/share/glvnd/egl_vendor.d/10_nvidia.json";
env.JLIBGHOSTTY_MAVEN_REPO = "${jlib}/maven"; env.JLIBGHOSTTY_MAVEN_REPO = "${jlib}/maven";
enterShell = ''
mkdir -p ${hostNvidiaLibs}
for lib in \
/usr/lib/x86_64-linux-gnu/libnvidia*.so* \
/usr/lib/x86_64-linux-gnu/libGLX_nvidia.so* \
/usr/lib/x86_64-linux-gnu/libEGL_nvidia.so* \
/usr/lib/x86_64-linux-gnu/nvidia/current/libnvidia*.so* \
/usr/lib/x86_64-linux-gnu/nvidia/current/libGLX_nvidia.so* \
/usr/lib/x86_64-linux-gnu/nvidia/current/libEGL_nvidia.so*
do
if [ -e "$lib" ]; then
ln -sfn "$lib" ${hostNvidiaLibs}/"$(basename "$lib")"
fi
done
'';
} }

View File

@@ -8,7 +8,11 @@ import io.github.wasabithumb.jtoml.value.primitive.TomlPrimitive;
import io.github.wasabithumb.jtoml.value.table.TomlTable; import io.github.wasabithumb.jtoml.value.table.TomlTable;
import java.nio.file.Files; import java.nio.file.Files;
import java.io.IOException;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map; import java.util.Map;
public record AppConfig( public record AppConfig(
@@ -22,10 +26,23 @@ public record AppConfig(
boolean kittyGraphics, boolean kittyGraphics,
Map<String, KeyBinding> keybindings Map<String, KeyBinding> keybindings
) { ) {
private static final List<String> KEYBINDING_KEYS = List.of(
"navigate_left",
"navigate_down",
"navigate_up",
"navigate_right",
"toggle_floating",
"new_floating",
"next_floating",
"close_pane",
"open_font_selector"
);
public static AppConfig load() { public static AppConfig load() {
AppConfig defaults = defaults(); AppConfig defaults = defaults();
Path path = configPath(); Path path = configPath();
if (!Files.isRegularFile(path)) { if (!Files.isRegularFile(path)) {
writeDefaultConfig(path, defaults);
return defaults; return defaults;
} }
@@ -40,16 +57,7 @@ public record AppConfig(
doubleValue(document, "window.width", defaults.windowWidth), doubleValue(document, "window.width", defaults.windowWidth),
doubleValue(document, "window.height", defaults.windowHeight), doubleValue(document, "window.height", defaults.windowHeight),
booleanValue(document, "kitty_graphics.enabled", defaults.kittyGraphics), booleanValue(document, "kitty_graphics.enabled", defaults.kittyGraphics),
Map.of( keybindings(document, defaults)
"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")),
"new_floating", binding(document, "keybindings.new_floating", defaults.keybindings.get("new_floating")),
"next_floating", binding(document, "keybindings.next_floating", defaults.keybindings.get("next_floating")),
"close_pane", binding(document, "keybindings.close_pane", defaults.keybindings.get("close_pane"))
)
); );
} catch (TomlException ex) { } catch (TomlException ex) {
System.err.println("Could not parse " + path + ": " + ex.getMessage()); System.err.println("Could not parse " + path + ": " + ex.getMessage());
@@ -75,11 +83,30 @@ public record AppConfig(
"toggle_floating", KeyBinding.parse("ALT+F"), "toggle_floating", KeyBinding.parse("ALT+F"),
"new_floating", KeyBinding.parse("ALT+SHIFT+F"), "new_floating", KeyBinding.parse("ALT+SHIFT+F"),
"next_floating", KeyBinding.parse("ALT+F12"), "next_floating", KeyBinding.parse("ALT+F12"),
"close_pane", KeyBinding.parse("ALT+X") "close_pane", KeyBinding.parse("ALT+X"),
"open_font_selector", KeyBinding.parse("ALT+T")
) )
); );
} }
public AppConfig withFont(String family, double size) {
return new AppConfig(
columns,
rows,
shell,
family,
size,
windowWidth,
windowHeight,
kittyGraphics,
keybindings
);
}
public void save() {
save(configPath(), this);
}
public static Path configPath() { public static Path configPath() {
String configHome = System.getenv("XDG_CONFIG_HOME"); String configHome = System.getenv("XDG_CONFIG_HOME");
if (configHome != null && !configHome.isBlank()) { if (configHome != null && !configHome.isBlank()) {
@@ -92,6 +119,76 @@ public record AppConfig(
return "/bin/bash"; return "/bin/bash";
} }
private static Map<String, KeyBinding> keybindings(TomlTable table, AppConfig defaults) {
Map<String, KeyBinding> parsed = new LinkedHashMap<>();
for (String key : KEYBINDING_KEYS) {
parsed.put(key, binding(table, "keybindings." + key, defaults.keybindings.get(key)));
}
return Map.copyOf(parsed);
}
private static void writeDefaultConfig(Path path, AppConfig defaults) {
save(path, defaults);
}
private static void save(Path path, AppConfig config) {
try {
Path parent = path.getParent();
if (parent != null) {
Files.createDirectories(parent);
}
Files.writeString(
path,
config.toToml(),
StandardOpenOption.CREATE,
StandardOpenOption.TRUNCATE_EXISTING,
StandardOpenOption.WRITE
);
} catch (IOException ex) {
System.err.println("Could not write " + path + ": " + ex.getMessage());
}
}
private String toToml() {
StringBuilder builder = new StringBuilder();
builder.append("[terminal]\n");
builder.append("columns = ").append(columns).append('\n');
builder.append("rows = ").append(rows).append('\n');
builder.append("shell = ").append(quoted(shell)).append('\n');
builder.append("font_family = ").append(quoted(fontFamily)).append('\n');
builder.append("font_size = ").append(trimDouble(fontSize)).append("\n\n");
builder.append("[window]\n");
builder.append("width = ").append(trimDouble(windowWidth)).append('\n');
builder.append("height = ").append(trimDouble(windowHeight)).append("\n\n");
builder.append("[kitty_graphics]\n");
builder.append("enabled = ").append(kittyGraphics).append("\n\n");
builder.append("[keybindings]\n");
for (String key : KEYBINDING_KEYS) {
KeyBinding binding = keybindings.get(key);
if (binding != null) {
builder.append(key).append(" = ").append(quoted(binding.toString())).append('\n');
}
}
return builder.toString();
}
private static String quoted(String value) {
return "\"" + value
.replace("\\", "\\\\")
.replace("\"", "\\\"")
.replace("\n", "\\n")
.replace("\r", "\\r")
.replace("\t", "\\t")
+ "\"";
}
private static String trimDouble(double value) {
if (value == Math.rint(value)) {
return Long.toString((long) value);
}
return Double.toString(value);
}
private static KeyBinding binding(TomlTable table, String key, KeyBinding fallback) { private static KeyBinding binding(TomlTable table, String key, KeyBinding fallback) {
String value = stringValue(table, key, null); String value = stringValue(table, key, null);
if (value == null) { if (value == null) {

View File

@@ -35,6 +35,22 @@ public record KeyBinding(boolean alt, boolean control, boolean shift, KeyCode co
&& event.getCode() == code; && event.getCode() == code;
} }
@Override
public String toString() {
StringBuilder builder = new StringBuilder();
if (control) {
builder.append("CTRL+");
}
if (alt) {
builder.append("ALT+");
}
if (shift) {
builder.append("SHIFT+");
}
builder.append(code.getName().toUpperCase(Locale.ROOT).replace(' ', '_'));
return builder.toString();
}
private static KeyCode keyCode(String token) { private static KeyCode keyCode(String token) {
KeyCode alias = switch (token) { KeyCode alias = switch (token) {
case "GRAVE", "BACKTICK", "BACK_QUOTE", "`" -> KeyCode.BACK_QUOTE; case "GRAVE", "BACKTICK", "BACK_QUOTE", "`" -> KeyCode.BACK_QUOTE;

View File

@@ -3,19 +3,29 @@ package com.gregor.jprototerm;
import javafx.animation.AnimationTimer; import javafx.animation.AnimationTimer;
import javafx.application.Application; import javafx.application.Application;
import javafx.scene.Scene; import javafx.scene.Scene;
import javafx.scene.control.ButtonType;
import javafx.scene.control.ComboBox;
import javafx.scene.control.Dialog;
import javafx.scene.control.Label;
import javafx.scene.control.Spinner;
import javafx.scene.control.SpinnerValueFactory;
import javafx.scene.input.KeyEvent; import javafx.scene.input.KeyEvent;
import javafx.scene.layout.GridPane;
import javafx.scene.layout.StackPane; import javafx.scene.layout.StackPane;
import javafx.scene.text.Font;
import javafx.stage.Stage; import javafx.stage.Stage;
public final class Main extends Application { public final class Main extends Application {
private TerminalWorkspace workspace; private TerminalWorkspace workspace;
private TerminalCanvasView terminalView;
private AppConfig config;
@Override @Override
public void start(Stage stage) { public void start(Stage stage) {
AppConfig config = AppConfig.load(); config = AppConfig.load();
workspace = new TerminalWorkspace(config); workspace = new TerminalWorkspace(config);
TerminalCanvasView terminalView = new TerminalCanvasView(workspace, config); terminalView = new TerminalCanvasView(workspace, config);
StackPane root = new StackPane(terminalView.canvas()); StackPane root = new StackPane(terminalView.canvas());
terminalView.canvas().widthProperty().bind(root.widthProperty()); terminalView.canvas().widthProperty().bind(root.widthProperty());
@@ -23,7 +33,7 @@ public final class Main extends Application {
terminalView.canvas().setOnMousePressed(event -> terminalView.canvas().requestFocus()); terminalView.canvas().setOnMousePressed(event -> terminalView.canvas().requestFocus());
Scene scene = new Scene(root, config.windowWidth(), config.windowHeight()); Scene scene = new Scene(root, config.windowWidth(), config.windowHeight());
scene.addEventFilter(KeyEvent.KEY_PRESSED, event -> handlePressed(config, event)); scene.addEventFilter(KeyEvent.KEY_PRESSED, this::handlePressed);
scene.addEventFilter(KeyEvent.KEY_TYPED, event -> handleTyped(event)); scene.addEventFilter(KeyEvent.KEY_TYPED, event -> handleTyped(event));
new AnimationTimer() { new AnimationTimer() {
@@ -42,7 +52,7 @@ public final class Main extends Application {
terminalView.canvas().requestFocus(); terminalView.canvas().requestFocus();
} }
private void handlePressed(AppConfig config, KeyEvent event) { private void handlePressed(KeyEvent event) {
if (config.keybindings().get("navigate_left").matches(event)) { if (config.keybindings().get("navigate_left").matches(event)) {
workspace.navigate(Direction.LEFT); workspace.navigate(Direction.LEFT);
event.consume(); event.consume();
@@ -67,6 +77,9 @@ public final class Main extends Application {
} else if (config.keybindings().get("close_pane").matches(event)) { } else if (config.keybindings().get("close_pane").matches(event)) {
workspace.closeActivePane(); workspace.closeActivePane();
event.consume(); event.consume();
} else if (config.keybindings().get("open_font_selector").matches(event)) {
openFontSelector();
event.consume();
} else { } else {
String encoded = KeyEncoder.encode(event); String encoded = KeyEncoder.encode(event);
if (encoded != null) { if (encoded != null) {
@@ -88,6 +101,49 @@ public final class Main extends Application {
} }
} }
private void openFontSelector() {
Dialog<ButtonType> dialog = new Dialog<>();
dialog.setTitle("Font");
dialog.getDialogPane().getButtonTypes().addAll(ButtonType.OK, ButtonType.CANCEL);
ComboBox<String> family = new ComboBox<>();
family.getItems().setAll(Font.getFamilies());
family.setEditable(true);
family.setMaxWidth(Double.MAX_VALUE);
family.setValue(config.fontFamily());
Spinner<Double> size = new Spinner<>();
size.setEditable(true);
size.setValueFactory(new SpinnerValueFactory.DoubleSpinnerValueFactory(6.0, 48.0, config.fontSize(), 0.5));
GridPane content = new GridPane();
content.setHgap(10.0);
content.setVgap(10.0);
content.add(new Label("Family"), 0, 0);
content.add(family, 1, 0);
content.add(new Label("Size"), 0, 1);
content.add(size, 1, 1);
dialog.getDialogPane().setContent(content);
dialog.showAndWait()
.filter(button -> button == ButtonType.OK)
.ifPresent(ignored -> {
String selectedFamily = family.getEditor().getText();
if (selectedFamily == null || selectedFamily.isBlank()) {
selectedFamily = family.getValue();
}
if (selectedFamily == null || selectedFamily.isBlank()) {
return;
}
double selectedSize = size.getValue();
config = config.withFont(selectedFamily.trim(), selectedSize);
config.save();
terminalView.setFont(config.fontFamily(), config.fontSize());
terminalView.canvas().requestFocus();
});
}
public static void main(String[] args) { public static void main(String[] args) {
System.setProperty("prism.order", System.getProperty("prism.order", "es2,sw")); System.setProperty("prism.order", System.getProperty("prism.order", "es2,sw"));
launch(Main.class, args); launch(Main.class, args);

View File

@@ -19,7 +19,7 @@ public final class ShellSession implements AutoCloseable {
private final ExecutorService reader; private final ExecutorService reader;
private volatile boolean closed; private volatile boolean closed;
private ShellSession(PtyProcess process, TerminalPane pane) { private ShellSession(PtyProcess process) {
this.process = process; this.process = process;
this.stdin = process.getOutputStream(); this.stdin = process.getOutputStream();
this.reader = Executors.newSingleThreadExecutor(runnable -> { this.reader = Executors.newSingleThreadExecutor(runnable -> {
@@ -27,7 +27,6 @@ public final class ShellSession implements AutoCloseable {
thread.setDaemon(true); thread.setDaemon(true);
return thread; return thread;
}); });
reader.submit(() -> readOutput(pane));
} }
public static ShellSession start(String shell, TerminalPane pane, int columns, int rows) { public static ShellSession start(String shell, TerminalPane pane, int columns, int rows) {
@@ -42,13 +41,17 @@ public final class ShellSession implements AutoCloseable {
.setInitialRows(rows) .setInitialRows(rows)
.setDirectory(System.getProperty("user.home")) .setDirectory(System.getProperty("user.home"))
.start(); .start();
return new ShellSession(process, pane); return new ShellSession(process);
} catch (IOException ex) { } catch (IOException ex) {
pane.write("failed to start shell: " + ex.getMessage() + "\r\n"); pane.write("failed to start shell: " + ex.getMessage() + "\r\n");
throw new IllegalStateException("Could not start shell " + shell, ex); throw new IllegalStateException("Could not start shell " + shell, ex);
} }
} }
public void startReading(TerminalPane pane) {
reader.submit(() -> readOutput(pane));
}
public void resize(int columns, int rows) { public void resize(int columns, int rows) {
if (closed) { if (closed) {
return; return;
@@ -57,11 +60,15 @@ public final class ShellSession implements AutoCloseable {
} }
public void send(String text) { public void send(String text) {
send(text.getBytes(StandardCharsets.UTF_8));
}
public void send(byte[] bytes) {
if (closed) { if (closed) {
return; return;
} }
try { try {
stdin.write(text.getBytes(StandardCharsets.UTF_8)); stdin.write(bytes);
stdin.flush(); stdin.flush();
} catch (IOException ex) { } catch (IOException ex) {
close(); close();

View File

@@ -32,10 +32,14 @@ public final class TerminalCanvasView {
private final TerminalWorkspace workspace; private final TerminalWorkspace workspace;
private final AppConfig config; private final AppConfig config;
private final Map<Long, Image> kittyImageCache = new HashMap<>(); private final Map<Long, Image> kittyImageCache = new HashMap<>();
private String fontFamily;
private double fontSize;
public TerminalCanvasView(TerminalWorkspace workspace, AppConfig config) { public TerminalCanvasView(TerminalWorkspace workspace, AppConfig config) {
this.workspace = workspace; this.workspace = workspace;
this.config = config; this.config = config;
this.fontFamily = config.fontFamily();
this.fontSize = config.fontSize();
canvas.setFocusTraversable(true); canvas.setFocusTraversable(true);
} }
@@ -43,6 +47,11 @@ public final class TerminalCanvasView {
return canvas; return canvas;
} }
public void setFont(String family, double size) {
this.fontFamily = family;
this.fontSize = size;
}
public void render() { public void render() {
double width = canvas.getWidth(); double width = canvas.getWidth();
double height = canvas.getHeight(); double height = canvas.getHeight();
@@ -75,7 +84,7 @@ public final class TerminalCanvasView {
gc.setLineWidth(workspace.isActive(pane) ? 2.0 : 1.0); 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); 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()); Font font = Font.font(fontFamily, fontSize);
gc.setFont(font); gc.setFont(font);
FontMetrics metrics = measureFontMetrics(font); FontMetrics metrics = measureFontMetrics(font);
@@ -105,15 +114,14 @@ public final class TerminalCanvasView {
} }
private static FontMetrics measureFontMetrics(Font font) { private static FontMetrics measureFontMetrics(Font font) {
Text text = new Text("Mg"); Text text = new Text("MgÅjy");
text.setFont(font); text.setFont(font);
double textHeight = text.getLayoutBounds().getHeight(); double lineHeight = Math.max(1.0, text.getLayoutBounds().getHeight());
double lineHeight = Math.max(1.0, Math.ceil(textHeight * 1.2)); double baselineOffset = -text.getLayoutBounds().getMinY();
double baselineOffset = -text.getLayoutBounds().getMinY() + ((lineHeight - textHeight) / 2.0);
Text cell = new Text("M"); Text cell = new Text("M");
cell.setFont(font); cell.setFont(font);
double cellWidth = Math.max(1.0, Math.ceil(cell.getLayoutBounds().getWidth())); double cellWidth = Math.max(1.0, cell.getLayoutBounds().getWidth());
return new FontMetrics(cellWidth, lineHeight, baselineOffset); return new FontMetrics(cellWidth, lineHeight, baselineOffset);
} }

View File

@@ -5,6 +5,7 @@ import dev.jlibghostty.KittyGraphics;
import dev.jlibghostty.RenderStateSnapshot; import dev.jlibghostty.RenderStateSnapshot;
import dev.jlibghostty.Terminal; import dev.jlibghostty.Terminal;
import dev.jlibghostty.TerminalOptions; import dev.jlibghostty.TerminalOptions;
import dev.jlibghostty.DeviceAttributes;
import java.util.Optional; import java.util.Optional;
import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.atomic.AtomicReference;
@@ -32,6 +33,7 @@ public final class TerminalPane implements AutoCloseable {
public static TerminalPane create(int columns, int rows) { public static TerminalPane create(int columns, int rows) {
Terminal terminal = Ghostty.open(TerminalOptions.of(columns, rows)); Terminal terminal = Ghostty.open(TerminalOptions.of(columns, rows));
terminal.setDeviceAttributesProvider(DeviceAttributes::xtermCompatible);
TerminalPane pane = new TerminalPane(terminal, columns, rows); TerminalPane pane = new TerminalPane(terminal, columns, rows);
pane.refresh(); pane.refresh();
return pane; return pane;
@@ -53,6 +55,13 @@ public final class TerminalPane implements AutoCloseable {
public void attach(ShellSession session) { public void attach(ShellSession session) {
this.session = session; this.session = session;
terminal.setPtyWriter(bytes -> {
ShellSession current = this.session;
if (current != null) {
current.send(bytes);
}
});
session.startReading(this);
} }
public void send(String text) { public void send(String text) {