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

View File

@@ -8,7 +8,11 @@ import io.github.wasabithumb.jtoml.value.primitive.TomlPrimitive;
import io.github.wasabithumb.jtoml.value.table.TomlTable;
import java.nio.file.Files;
import java.io.IOException;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
public record AppConfig(
@@ -22,10 +26,23 @@ public record AppConfig(
boolean kittyGraphics,
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() {
AppConfig defaults = defaults();
Path path = configPath();
if (!Files.isRegularFile(path)) {
writeDefaultConfig(path, defaults);
return defaults;
}
@@ -40,16 +57,7 @@ public record AppConfig(
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")),
"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"))
)
keybindings(document, defaults)
);
} catch (TomlException ex) {
System.err.println("Could not parse " + path + ": " + ex.getMessage());
@@ -75,11 +83,30 @@ public record AppConfig(
"toggle_floating", KeyBinding.parse("ALT+F"),
"new_floating", KeyBinding.parse("ALT+SHIFT+F"),
"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() {
String configHome = System.getenv("XDG_CONFIG_HOME");
if (configHome != null && !configHome.isBlank()) {
@@ -92,6 +119,76 @@ public record AppConfig(
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) {
String value = stringValue(table, key, null);
if (value == null) {

View File

@@ -35,6 +35,22 @@ public record KeyBinding(boolean alt, boolean control, boolean shift, KeyCode co
&& 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) {
KeyCode alias = switch (token) {
case "GRAVE", "BACKTICK", "BACK_QUOTE", "`" -> KeyCode.BACK_QUOTE;

View File

@@ -3,19 +3,29 @@ package com.gregor.jprototerm;
import javafx.animation.AnimationTimer;
import javafx.application.Application;
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.layout.GridPane;
import javafx.scene.layout.StackPane;
import javafx.scene.text.Font;
import javafx.stage.Stage;
public final class Main extends Application {
private TerminalWorkspace workspace;
private TerminalCanvasView terminalView;
private AppConfig config;
@Override
public void start(Stage stage) {
AppConfig config = AppConfig.load();
config = AppConfig.load();
workspace = new TerminalWorkspace(config);
TerminalCanvasView terminalView = new TerminalCanvasView(workspace, config);
terminalView = new TerminalCanvasView(workspace, config);
StackPane root = new StackPane(terminalView.canvas());
terminalView.canvas().widthProperty().bind(root.widthProperty());
@@ -23,7 +33,7 @@ public final class Main extends Application {
terminalView.canvas().setOnMousePressed(event -> terminalView.canvas().requestFocus());
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));
new AnimationTimer() {
@@ -42,7 +52,7 @@ public final class Main extends Application {
terminalView.canvas().requestFocus();
}
private void handlePressed(AppConfig config, KeyEvent event) {
private void handlePressed(KeyEvent event) {
if (config.keybindings().get("navigate_left").matches(event)) {
workspace.navigate(Direction.LEFT);
event.consume();
@@ -67,6 +77,9 @@ public final class Main extends Application {
} else if (config.keybindings().get("close_pane").matches(event)) {
workspace.closeActivePane();
event.consume();
} else if (config.keybindings().get("open_font_selector").matches(event)) {
openFontSelector();
event.consume();
} else {
String encoded = KeyEncoder.encode(event);
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) {
System.setProperty("prism.order", System.getProperty("prism.order", "es2,sw"));
launch(Main.class, args);

View File

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

View File

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

View File

@@ -5,6 +5,7 @@ import dev.jlibghostty.KittyGraphics;
import dev.jlibghostty.RenderStateSnapshot;
import dev.jlibghostty.Terminal;
import dev.jlibghostty.TerminalOptions;
import dev.jlibghostty.DeviceAttributes;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicReference;
@@ -32,6 +33,7 @@ public final class TerminalPane implements AutoCloseable {
public static TerminalPane create(int columns, int rows) {
Terminal terminal = Ghostty.open(TerminalOptions.of(columns, rows));
terminal.setDeviceAttributesProvider(DeviceAttributes::xtermCompatible);
TerminalPane pane = new TerminalPane(terminal, columns, rows);
pane.refresh();
return pane;
@@ -53,6 +55,13 @@ public final class TerminalPane implements AutoCloseable {
public void attach(ShellSession 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) {