3 Commits

Author SHA1 Message Date
08fcb93e01 some guard rails 2026-06-12 11:59:25 +02:00
8954482222 refactor config 2026-06-12 11:48:34 +02:00
692511e445 color performance improvements, unify pane ordering in tab 2026-06-12 11:38:48 +02:00
12 changed files with 287 additions and 289 deletions

View File

@@ -55,7 +55,7 @@ public record AppConfig(
AppConfig defaults = defaults();
Path path = configPath();
if (!Files.isRegularFile(path)) {
writeDefaultConfig(path, defaults);
save(path, defaults);
return defaults;
}
@@ -195,10 +195,6 @@ public record AppConfig(
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();
@@ -340,15 +336,7 @@ public record AppConfig(
}
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;
}
return (int) longValue(table, key, fallback);
}
private static long longValue(TomlTable table, String key, long fallback) {

View File

@@ -114,7 +114,7 @@ public final class Compositor {
public void setFont(String family, double size) {
metrics.setFont(family, size);
paneContentVersion.clear();
lastWidth = -1.0; // force a redraw on the next frame
layoutVersion++; // recomposite with the new metrics on the next frame
}
// ---- Tabs and panes -------------------------------------------------------------
@@ -123,8 +123,9 @@ public final class Compositor {
return tabs.isEmpty();
}
/** The active pane of the current tab, or {@code null} when no tab is left. */
public TerminalPane activePane() {
return currentTab().activePane();
return isEmpty() ? null : currentTab().activePane();
}
public void navigate(Direction direction) {
@@ -134,29 +135,11 @@ public final class Compositor {
}
public void toggleFloating() {
if (isEmpty()) {
return;
}
currentTab().toggleFloating();
layoutVersion++;
mutateCurrentTab(() -> currentTab().toggleFloating());
}
public void createPane() {
if (isEmpty()) {
return;
}
currentTab().createPane();
layoutVersion++;
}
/** Opens a new floating pane, makes it active, and returns it (null when no tab exists). */
public TerminalPane openFloatingPane() {
if (isEmpty()) {
return null;
}
TerminalPane pane = currentTab().createFloatingPane();
layoutVersion++;
return pane;
mutateCurrentTab(() -> currentTab().createPane());
}
/**
@@ -173,26 +156,25 @@ public final class Compositor {
}
public void nextFloatingPane() {
if (isEmpty()) {
return;
}
currentTab().nextFloatingPane();
layoutVersion++;
mutateCurrentTab(() -> currentTab().nextFloatingPane());
}
public void toggleActiveFloating() {
mutateCurrentTab(() -> currentTab().toggleActiveFloating());
}
// Run a structural change on the current tab and bump the layout version so the next frame
// recomposites. No-op when no tab is left.
private void mutateCurrentTab(Runnable change) {
if (isEmpty()) {
return;
}
currentTab().toggleActiveFloating();
change.run();
layoutVersion++;
}
public void closeActivePane() {
if (isEmpty()) {
return;
}
TerminalPane active = currentTab().activePane();
TerminalPane active = activePane();
if (active != null) {
closePane(active);
}
@@ -230,7 +212,8 @@ public final class Compositor {
public void newTab() {
// Open the new tab in the currently active pane's working directory, so it lands where the
// user currently is rather than always in home.
String workingDirectory = isEmpty() ? null : currentTab().activePane().currentWorkingDirectory();
TerminalPane active = activePane();
String workingDirectory = active != null ? active.currentWorkingDirectory() : null;
tabs.add(new Tab(config, metrics, workingDirectory, this::closePane));
currentTabIndex = tabs.size() - 1;
layoutVersion++;
@@ -470,11 +453,11 @@ public final class Compositor {
private void handleMouseReleased(MouseEvent event) {
TerminalPane pane = paneAt(event.getX(), event.getY());
if (pane == null) {
pane = activePane();
pane = activePane(); // released outside every pane (e.g. mid-drag): route to the active one
}
MouseButton button = pressedButton == MouseButton.UNKNOWN ? mouseButton(event) : pressedButton;
MouseTarget target = mouseTarget(pane);
MouseTarget target = pane == null ? null : mouseTarget(pane);
if (target != null) {
send(pane, target, MouseInput.release(button, localX(event.getX(), pane, target), localY(event.getY(), pane, target), modifiers(event)), false, event);
}
@@ -485,7 +468,10 @@ public final class Compositor {
private void handleMouseDragged(MouseEvent event) {
TerminalPane pane = paneAt(event.getX(), event.getY());
if (pane == null) {
pane = activePane();
pane = activePane(); // dragged outside every pane: route to the active one
}
if (pane == null) {
return;
}
MouseButton button = pressedButton == MouseButton.UNKNOWN ? mouseButton(event) : pressedButton;

View File

@@ -9,6 +9,7 @@ import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.attribute.PosixFilePermissions;
@@ -23,6 +24,11 @@ import java.nio.file.attribute.PosixFilePermissions;
* (mode 0700), so only the owning user can connect.
*/
public final class Daemon {
// One request is a single line holding a filesystem path; anything bigger is bogus.
private static final int MAX_REQUEST_BYTES = 4096;
// The accept loop is single-threaded, so a client that stalls must not wedge the daemon.
private static final long READ_TIMEOUT_NANOS = 5_000_000_000L;
private Daemon() {
}
@@ -31,9 +37,9 @@ public final class Daemon {
Path socket = socketPath();
try {
Files.createDirectories(socket.getParent());
trySecureDir(socket.getParent());
secureDir(socket.getParent());
} catch (IOException ex) {
System.err.println("jprototerm: cannot create socket dir " + socket.getParent() + ": " + ex.getMessage());
System.err.println("jprototerm: cannot secure socket dir " + socket.getParent() + ": " + ex.getMessage());
return;
}
@@ -107,25 +113,48 @@ public final class Daemon {
manager.openWindow(workingDirectory == null || workingDirectory.isBlank()
? null
: workingDirectory.trim());
connection.configureBlocking(true);
connection.write(ByteBuffer.wrap("OK\n".getBytes(StandardCharsets.UTF_8)));
}
}
// Reads the request line non-blocking with a deadline and a size cap: the accept loop is
// single-threaded, so a client that stalls or never sends a newline must fail the connection
// (an IOException logged by run()) rather than wedge the daemon or grow the buffer unbounded.
private static String readLine(SocketChannel channel) throws IOException {
channel.configureBlocking(false);
long deadline = System.nanoTime() + READ_TIMEOUT_NANOS;
ByteArrayOutputStream out = new ByteArrayOutputStream();
ByteBuffer buffer = ByteBuffer.allocate(4096);
while (channel.read(buffer) != -1) {
buffer.flip();
while (buffer.hasRemaining()) {
byte b = buffer.get();
if (b == '\n') {
return out.toString(StandardCharsets.UTF_8);
while (true) {
int n = channel.read(buffer);
if (n > 0) {
buffer.flip();
while (buffer.hasRemaining()) {
byte b = buffer.get();
if (b == '\n') {
return out.toString(StandardCharsets.UTF_8);
}
out.write(b);
if (out.size() > MAX_REQUEST_BYTES) {
throw new IOException("request line too long");
}
}
buffer.clear();
} else if (n == -1) {
return out.size() == 0 ? null : out.toString(StandardCharsets.UTF_8);
} else {
if (System.nanoTime() >= deadline) {
throw new IOException("request timed out");
}
try {
Thread.sleep(5);
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
throw new IOException("interrupted while reading request");
}
out.write(b);
}
buffer.clear();
}
return out.size() == 0 ? null : out.toString(StandardCharsets.UTF_8);
}
private static Path socketPath() {
@@ -136,11 +165,19 @@ public final class Daemon {
return dir.resolve("daemon.sock");
}
private static void trySecureDir(Path dir) {
// Make the socket dir private, and refuse to use it if it is not ours. The /tmp fallback
// path is predictable, so another user could have pre-created it (the classic /tmp race);
// binding a socket inside a directory someone else owns would hand them control of it.
private static void secureDir(Path dir) throws IOException {
try {
Files.setPosixFilePermissions(dir, PosixFilePermissions.fromString("rwx------"));
} catch (IOException | UnsupportedOperationException ignored) {
// Best effort: XDG_RUNTIME_DIR is already user-private; the /tmp fallback we try to lock.
} catch (UnsupportedOperationException ignored) {
return; // not a POSIX filesystem: nothing more we can check
}
String owner = Files.getOwner(dir, LinkOption.NOFOLLOW_LINKS).getName();
String user = System.getProperty("user.name");
if (!owner.equals(user)) {
throw new IOException(dir + " is owned by '" + owner + "', not '" + user + "'");
}
}
}

View File

@@ -18,7 +18,6 @@ import javafx.scene.image.Image;
import javafx.scene.image.PixelFormat;
import javafx.scene.image.PixelBuffer;
import javafx.scene.image.WritableImage;
import javafx.scene.paint.Color;
import java.io.ByteArrayInputStream;
import java.nio.IntBuffer;
@@ -40,19 +39,17 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
private static final int DIRTY_PARTIAL = 1;
private static final int DIRTY_FULL = 2;
private static final Color DEFAULT_FOREGROUND = Color.rgb(225, 229, 235);
private static final Color SELECTED_BACKGROUND = Color.rgb(52, 92, 140);
// All colors are packed ARGB ints (alpha always 0xff): the software backbuffer never needs
// a javafx Color, so cells go straight from RenderColor to the pixel format.
private static final int DEFAULT_FOREGROUND = 0xffe1e5eb; // rgb(225, 229, 235)
private static final int SELECTED_BACKGROUND = 0xff345c8c; // rgb(52, 92, 140)
// The default cell background (used for cells with no explicit bg, and as the foreground
// for reverse-video cells whose background is the terminal default).
private static final Color PANE_BACKGROUND = Color.rgb(9, 10, 12);
private static final Color ACTIVE_BORDER = Color.rgb(87, 166, 255);
private static final Color INACTIVE_BORDER = Color.rgb(52, 57, 65);
private static final Color CURSOR_FILL = Color.rgb(225, 229, 235, 0.28);
// A full-screen redraw asks for one Color per cell; most cells share a handful of colors,
// so cache them by packed RGB instead of allocating a Color each time. Bounded so a
// truecolor gradient can't grow it without limit.
private static final Map<Integer, Color> COLOR_CACHE = new HashMap<>();
private static final int PANE_BACKGROUND = 0xff090a0c; // rgb(9, 10, 12)
private static final int ACTIVE_BORDER = 0xff57a6ff; // rgb(87, 166, 255)
private static final int INACTIVE_BORDER = 0xff343941; // rgb(52, 57, 65)
// Block cursor: DEFAULT_FOREGROUND blended at 28% alpha by fillRectAlpha.
private static final int CURSOR_FILL_ALPHA = 71;
private final TerminalMetrics metrics;
// Decoded kitty images for this renderer's pane (kitty graphics state is per-terminal).
@@ -131,16 +128,12 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
// Effective background colour of a cell as it is drawn (reverse video swaps fg/bg, an
// unset colour falls back to the defaults).
private static Color cellBackgroundColor(RenderCell cell) {
if (cell.inverse()) {
var fg = cell.foreground();
return fg.isPresent() ? toFxColor(fg.get()) : DEFAULT_FOREGROUND;
}
var bg = cell.background();
return bg.isPresent() ? toFxColor(bg.get()) : PANE_BACKGROUND;
private static int cellBackgroundColor(RenderCell cell) {
int override = cellBackgroundOverride(cell);
return override != 0 ? override : PANE_BACKGROUND;
}
private static Color rowEdgeBackground(RenderRow row, boolean firstCell) {
private static int rowEdgeBackground(RenderRow row, boolean firstCell) {
List<RenderCell> cells = row.cells();
if (cells.isEmpty()) {
return PANE_BACKGROUND;
@@ -148,41 +141,28 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
return cellBackgroundColor(firstCell ? cells.get(0) : cells.get(cells.size() - 1));
}
// Background override for a cell: null means the pane default background already covers it.
private static Color cellBackgroundOverride(RenderCell cell) {
// Background override for a cell: 0 means the pane default background already covers it
// (real colors always carry 0xff alpha, so 0 is never a valid color).
private static int cellBackgroundOverride(RenderCell cell) {
if (cell.inverse()) {
var fg = cell.foreground();
return fg.isPresent() ? toFxColor(fg.get()) : DEFAULT_FOREGROUND;
return fg.isPresent() ? packArgb(fg.get()) : DEFAULT_FOREGROUND;
}
var bgOpt = cell.background();
Color bg = bgOpt.isPresent() ? toFxColor(bgOpt.get()) : null;
return bg;
var bg = cell.background();
return bg.isPresent() ? packArgb(bg.get()) : 0;
}
private static Color cellForegroundColor(RenderCell cell) {
var fgOpt = cell.foreground();
var bgOpt = cell.background();
Color fg = fgOpt.isPresent() ? toFxColor(fgOpt.get()) : DEFAULT_FOREGROUND;
Color bg = bgOpt.isPresent() ? toFxColor(bgOpt.get()) : null;
private static int cellForegroundColor(RenderCell cell) {
if (cell.inverse()) {
return (bg != null) ? bg : PANE_BACKGROUND;
var bg = cell.background();
return bg.isPresent() ? packArgb(bg.get()) : PANE_BACKGROUND;
}
return fg;
var fg = cell.foreground();
return fg.isPresent() ? packArgb(fg.get()) : DEFAULT_FOREGROUND;
}
private static Color toFxColor(RenderColor color) {
int key = (color.red() << 16) | (color.green() << 8) | color.blue();
Color cached = COLOR_CACHE.get(key);
if (cached != null) {
return cached;
}
if (COLOR_CACHE.size() >= 4096) {
COLOR_CACHE.clear();
}
Color created = Color.rgb(color.red(), color.green(), color.blue());
COLOR_CACHE.put(key, created);
return created;
private static int packArgb(RenderColor color) {
return 0xff000000 | (color.red() << 16) | (color.green() << 8) | color.blue();
}
// ---- Kitty graphics --------------------------------------------------------------
@@ -474,7 +454,7 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
private void paintFull(GraphicsContext gc, RenderStateSnapshot snapshot,
double px, double py, double paneWidth, double paneHeight, boolean active) {
ensure(paneWidth, paneHeight);
fillRect(0, 0, width, height, argbPre(PANE_BACKGROUND));
fillRect(0, 0, width, height, PANE_BACKGROUND);
if (snapshot != null) {
paintSnapshot(snapshot);
drawCursor(snapshot);
@@ -574,11 +554,6 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
return;
}
}
if (repaintedRowHasCursor(newCursorRow, repainted)
&& !repaintCursorRow(snapshot, newCursorRow, repainted)) {
paintFullOrShifted(gc, target.snapshotFull(), px, py, paneWidth, paneHeight, active);
return;
}
lastCursor = cursor;
if (needsCursorDraw) {
drawCursor(snapshot);
@@ -601,10 +576,6 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
return true;
}
private boolean repaintedRowHasCursor(int rowIndex, boolean[] repainted) {
return rowIndex >= 0 && rowIndex < repainted.length && repainted[rowIndex];
}
private RenderRow rowByIndex(RenderStateSnapshot snapshot, int rowIndex) {
for (RenderRow row : snapshot.renderRows()) {
if (row.row() == rowIndex) {
@@ -737,7 +708,7 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
// per-strip fillRect calls don't touch, so mark the full content band for upload.
markDirtyRows(top, top + contentHeight);
if (dy == 0 || Math.abs(dy) >= contentHeight) {
fillRect(0, top, width, contentHeight, argbPre(PANE_BACKGROUND));
fillRect(0, top, width, contentHeight, PANE_BACKGROUND);
return;
}
@@ -748,7 +719,7 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
for (int y = 0; y < copyHeight; y++) {
System.arraycopy(pixels, (srcY + y) * width, pixels, (dstY + y) * width, width);
}
fillRect(0, top + copyHeight, width, -dy, argbPre(PANE_BACKGROUND));
fillRect(0, top + copyHeight, width, -dy, PANE_BACKGROUND);
} else {
int srcY = top;
int dstY = top + dy;
@@ -756,7 +727,7 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
for (int y = copyHeight - 1; y >= 0; y--) {
System.arraycopy(pixels, (srcY + y) * width, pixels, (dstY + y) * width, width);
}
fillRect(0, top, width, dy, argbPre(PANE_BACKGROUND));
fillRect(0, top, width, dy, PANE_BACKGROUND);
}
}
@@ -767,8 +738,8 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
}
int top = contentTop();
int contentBottom = top + snapshot.rows() * lineHeight();
fillRect(0, 0, width, top, argbPre(rowEdgeBackground(rows.get(0), true)));
fillRect(0, contentBottom, width, height - contentBottom, argbPre(rowEdgeBackground(rows.get(rows.size() - 1), true)));
fillRect(0, 0, width, top, rowEdgeBackground(rows.get(0), true));
fillRect(0, contentBottom, width, height - contentBottom, rowEdgeBackground(rows.get(rows.size() - 1), true));
for (RenderRow row : rows) {
paintRow(row);
}
@@ -777,13 +748,13 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
private void paintRow(RenderRow row) {
int rowTop = contentTop() + (row.row() * lineHeight());
int rowHeight = lineHeight();
fillRect(0, rowTop, width, rowHeight, argbPre(PANE_BACKGROUND));
fillRect(0, rowTop, width, rowHeight, PANE_BACKGROUND);
if (row.row() == 0) {
fillRect(0, 0, width, contentTop(), argbPre(rowEdgeBackground(row, true)));
fillRect(0, 0, width, contentTop(), rowEdgeBackground(row, true));
}
if (row.row() == rowHashes.length - 1) {
int bottom = contentTop() + (rowHashes.length * lineHeight());
fillRect(0, bottom, width, height - bottom, argbPre(rowEdgeBackground(row, true)));
fillRect(0, bottom, width, height - bottom, rowEdgeBackground(row, true));
}
paintRowSidePadding(row, rowTop, rowHeight);
paintRowBackgrounds(row, rowTop, rowHeight);
@@ -797,31 +768,30 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
}
int left = contentLeft();
int contentRight = left + (cells.size() * cellWidth());
fillRect(0, rowTop, left, rowHeight, argbPre(rowEdgeBackground(row, true)));
fillRect(contentRight, rowTop, width - contentRight, rowHeight, argbPre(rowEdgeBackground(row, false)));
fillRect(0, rowTop, left, rowHeight, rowEdgeBackground(row, true));
fillRect(contentRight, rowTop, width - contentRight, rowHeight, rowEdgeBackground(row, false));
}
private void paintRowBackgrounds(RenderRow row, int rowTop, int rowHeight) {
int cellWidth = cellWidth();
Color runBackground = null;
int runBackground = 0;
int runStartColumn = 0;
int previousColumn = -1;
for (RenderCell cell : row.cells()) {
if (cell.kittyPlaceholder().isPresent()) {
flushBackground(runBackground, runStartColumn, previousColumn, rowTop, rowHeight);
runBackground = null;
runBackground = 0;
previousColumn = -1;
continue;
}
Color bg = cell.selected() ? SELECTED_BACKGROUND : cellBackgroundOverride(cell);
if (bg == null) {
int bg = cell.selected() ? SELECTED_BACKGROUND : cellBackgroundOverride(cell);
if (bg == 0) {
flushBackground(runBackground, runStartColumn, previousColumn, rowTop, rowHeight);
runBackground = null;
runBackground = 0;
previousColumn = -1;
continue;
}
if (runBackground == null || bg != runBackground || cell.column() != previousColumn + 1) {
if (bg != runBackground || cell.column() != previousColumn + 1) {
flushBackground(runBackground, runStartColumn, previousColumn, rowTop, rowHeight);
runBackground = bg;
runStartColumn = cell.column();
@@ -831,12 +801,12 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
flushBackground(runBackground, runStartColumn, previousColumn, rowTop, rowHeight);
}
private void flushBackground(Color background, int startColumn, int endColumn, int rowTop, int rowHeight) {
if (background == null || endColumn < startColumn) {
private void flushBackground(int background, int startColumn, int endColumn, int rowTop, int rowHeight) {
if (background == 0 || endColumn < startColumn) {
return;
}
fillRect(contentLeft() + (startColumn * cellWidth()), rowTop,
(endColumn - startColumn + 1) * cellWidth(), rowHeight, argbPre(background));
(endColumn - startColumn + 1) * cellWidth(), rowHeight, background);
}
private void paintRowText(RenderRow row, int rowTop) {
@@ -847,8 +817,7 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
continue;
}
GlyphCache.Glyph glyph = metrics.glyphCache().glyph(cell.text());
int color = rgb(cellForegroundColor(cell));
blitGlyph(glyph, x0 + (cell.column() * cellWidth), rowTop, color);
blitGlyph(glyph, x0 + (cell.column() * cellWidth), rowTop, cellForegroundColor(cell));
}
}
@@ -907,13 +876,13 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
saveCursorUnder(x, y, cw, lh);
RenderCursorStyle style = snapshot.cursorStyle();
if (style == RenderCursorStyle.BAR) {
fillRect(x, y + 2, 1, Math.max(1, lh - 4), argbPre(DEFAULT_FOREGROUND));
fillRect(x, y + 2, 1, Math.max(1, lh - 4), DEFAULT_FOREGROUND);
} else if (style == RenderCursorStyle.UNDERLINE) {
fillRect(x + 1, y + lh - 2, Math.max(1, cw - 2), 1, argbPre(DEFAULT_FOREGROUND));
fillRect(x + 1, y + lh - 2, Math.max(1, cw - 2), 1, DEFAULT_FOREGROUND);
} else if (style == RenderCursorStyle.BLOCK) {
fillRectAlpha(x, y + 1, Math.max(1, cw - 1), Math.max(1, lh - 2), CURSOR_FILL);
fillRectAlpha(x, y + 1, Math.max(1, cw - 1), Math.max(1, lh - 2), DEFAULT_FOREGROUND, CURSOR_FILL_ALPHA);
} else {
strokeRect(x, y + 1, Math.max(1, cw - 1), Math.max(1, lh - 2), argbPre(DEFAULT_FOREGROUND), 1);
strokeRect(x, y + 1, Math.max(1, cw - 1), Math.max(1, lh - 2), DEFAULT_FOREGROUND, 1);
}
}
@@ -957,7 +926,7 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
}
private void drawBorder(boolean active) {
strokeRect(0, 0, width, height, argbPre(active ? ACTIVE_BORDER : INACTIVE_BORDER), active ? 2 : 1);
strokeRect(0, 0, width, height, active ? ACTIVE_BORDER : INACTIVE_BORDER, active ? 2 : 1);
}
private void strokeRect(int x, int y, int w, int h, int color, int lineWidth) {
@@ -974,9 +943,7 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
}
}
private void fillRectAlpha(int x, int y, int w, int h, Color color) {
int alpha = (int) Math.round(color.getOpacity() * 255.0);
int rgb = rgb(color);
private void fillRectAlpha(int x, int y, int w, int h, int rgb, int alpha) {
int red = (rgb >> 16) & 0xff;
int green = (rgb >> 8) & 0xff;
int blue = rgb & 0xff;
@@ -1082,21 +1049,6 @@ final class GhosttyTerminalRenderer extends TerminalRenderer {
return hash * 0x100000001b3L;
}
private static int rgb(Color color) {
int red = (int) Math.round(color.getRed() * 255.0);
int green = (int) Math.round(color.getGreen() * 255.0);
int blue = (int) Math.round(color.getBlue() * 255.0);
return (red << 16) | (green << 8) | blue;
}
private static int argbPre(Color color) {
int alpha = (int) Math.round(color.getOpacity() * 255.0);
int red = (int) Math.round(color.getRed() * alpha);
int green = (int) Math.round(color.getGreen() * alpha);
int blue = (int) Math.round(color.getBlue() * alpha);
return (alpha << 24) | (red << 16) | (green << 8) | blue;
}
// A kitty image is immutable for a given (id, number); re-transmitting under the same id
// changes the number (and the snapshot below evicts stale entries by id anyway). So the
// identity + dimensions + payload length are enough to key the decoded-image cache, and

View File

@@ -29,6 +29,11 @@ final class GlyphCache {
record Glyph(int width, int height, byte[] alpha) {
}
// Bounds the atlas so pathological glyph diversity (e.g. a dump of distinct CJK/emoji cells)
// can't grow it without limit; on overflow it clears and rebuilds on demand, like any
// metrics change.
private static final int MAX_GLYPHS = 4096;
private final TerminalMetrics metrics;
private final Map<String, Glyph> glyphs = new HashMap<>();
// The metrics snapshot the cached glyphs were rasterized for; a mismatch clears the cache.
@@ -43,7 +48,16 @@ final class GlyphCache {
Glyph glyph(String text) {
ensureCurrent();
return glyphs.computeIfAbsent(text, this::renderGlyph);
Glyph cached = glyphs.get(text);
if (cached != null) {
return cached;
}
if (glyphs.size() >= MAX_GLYPHS) {
glyphs.clear();
}
Glyph rendered = renderGlyph(text);
glyphs.put(text, rendered);
return rendered;
}
// Drop the rasterized masks if the font/cell geometry changed since they were built. Cheap to

View File

@@ -5,17 +5,19 @@ import javafx.scene.input.KeyEvent;
import java.util.Locale;
public record KeyBinding(boolean alt, boolean control, boolean shift, KeyCode code) {
public record KeyBinding(boolean alt, boolean control, boolean shift, boolean meta, KeyCode code) {
public static KeyBinding parse(String value) {
boolean alt = false;
boolean control = false;
boolean shift = false;
boolean meta = false;
KeyCode code = null;
for (String part : value.split("\\+")) {
String token = part.trim().toUpperCase(Locale.ROOT);
switch (token) {
case "ALT", "META" -> alt = true;
case "ALT" -> alt = true;
case "META", "SUPER" -> meta = true;
case "CTRL", "CONTROL" -> control = true;
case "SHIFT" -> shift = true;
default -> code = keyCode(token);
@@ -25,13 +27,14 @@ public record KeyBinding(boolean alt, boolean control, boolean shift, KeyCode co
if (code == null) {
throw new IllegalArgumentException("Key binding has no key code: " + value);
}
return new KeyBinding(alt, control, shift, code);
return new KeyBinding(alt, control, shift, meta, code);
}
public boolean matches(KeyEvent event) {
return event.isAltDown() == alt
&& event.isControlDown() == control
&& event.isShiftDown() == shift
&& event.isMetaDown() == meta
&& event.getCode() == code;
}
@@ -44,6 +47,9 @@ public record KeyBinding(boolean alt, boolean control, boolean shift, KeyCode co
if (alt) {
builder.append("ALT+");
}
if (meta) {
builder.append("META+");
}
if (shift) {
builder.append("SHIFT+");
}

View File

@@ -317,6 +317,10 @@ public final class LinuxPty implements AutoCloseable {
}
private void closeMaster() {
// Note: closing the master fd does NOT wake a reader thread blocked in read() on it —
// the reader unblocks via EOF when the child exits and the slave end closes. The signal
// here usually does that; if the child ignores it, the SIGKILL escalation in reap()
// guarantees it shortly after.
callKill(pid, closeSignal);
callInt(CLOSE, masterFd);
}

View File

@@ -22,8 +22,11 @@ final class StartupTiming {
private StartupTiming() {
}
/** Records a phase boundary, printing the delta since the previous mark and since JVM start. */
static void mark(String phase) {
/**
* Records a phase boundary, printing the delta since the previous mark and since JVM start.
* Synchronized because marks come from both the launcher thread and the FX thread.
*/
static synchronized void mark(String phase) {
if (!ENABLED) {
return;
}
@@ -38,7 +41,7 @@ final class StartupTiming {
* Records the first rendered frame exactly once, then becomes a no-op. Safe and cheap to call
* from the render loop every frame (it only ever touches FX-thread state).
*/
static void firstFrame() {
static synchronized void firstFrame() {
if (!ENABLED || firstFrameSeen) {
return;
}

View File

@@ -18,6 +18,14 @@ import java.util.stream.Stream;
* return whether they actually changed anything so it can bump its layout version.
*/
final class Tab implements AutoCloseable {
// Floating-pane sizing policy: a fraction of the tab's size with a floor so panes stay
// usable in small windows, cascaded diagonally per pane, kept off the window edge.
private static final double FLOATING_SIZE_FRACTION = 0.58;
private static final double FLOATING_MIN_WIDTH = 420.0;
private static final double FLOATING_MIN_HEIGHT = 260.0;
private static final double FLOATING_CASCADE_OFFSET = 28.0;
private static final double FLOATING_EDGE_MARGIN = 12.0;
private final AppConfig config;
private final TerminalMetrics metrics;
// Notified (on the FX thread) when one of this tab's panes' process exits on its own, so the
@@ -86,15 +94,24 @@ final class Tab implements AutoCloseable {
}
List<TerminalPane> ordered = new ArrayList<>(tiled.size() + floating.size());
ordered.addAll(tiled);
ordered.addAll(floatingOrder());
return List.copyOf(ordered);
}
// Floating panes bottom-to-top: insertion order, with the active pane moved to the top.
// Single source of the stacking order, so the clips assigned in assignClips() always match
// the compositing order in panes().
private List<TerminalPane> floatingOrder() {
List<TerminalPane> order = new ArrayList<>(floating.size());
for (TerminalPane pane : floating) {
if (pane != active) {
ordered.add(pane);
order.add(pane);
}
}
if (floating.contains(active)) {
ordered.add(active); // active floating pane on top
order.add(active);
}
return List.copyOf(ordered);
return order;
}
boolean isActive(TerminalPane pane) {
@@ -133,13 +150,15 @@ final class Tab implements AutoCloseable {
tiled.get(i).bounds(i * tileWidth, topInset, tileWidth, availHeight);
}
double floatingWidth = Math.max(420, width * 0.58);
double floatingHeight = Math.max(260, availHeight * 0.58);
double floatingWidth = Math.max(FLOATING_MIN_WIDTH, width * FLOATING_SIZE_FRACTION);
double floatingHeight = Math.max(FLOATING_MIN_HEIGHT, availHeight * FLOATING_SIZE_FRACTION);
for (int i = 0; i < floating.size(); i++) {
double offset = i * 28.0;
double offset = i * FLOATING_CASCADE_OFFSET;
floating.get(i).bounds(
Math.min(width - floatingWidth - 12.0, ((width - floatingWidth) / 2.0) + offset),
Math.min(height - floatingHeight - 12.0, topInset + ((availHeight - floatingHeight) / 2.0) + offset),
Math.min(width - floatingWidth - FLOATING_EDGE_MARGIN,
((width - floatingWidth) / 2.0) + offset),
Math.min(height - floatingHeight - FLOATING_EDGE_MARGIN,
topInset + ((availHeight - floatingHeight) / 2.0) + offset),
floatingWidth,
floatingHeight);
}
@@ -154,27 +173,18 @@ final class Tab implements AutoCloseable {
// every pane clips to its plain bounds.
private void assignClips() {
if (!floatingVisible || floating.isEmpty()) {
tiled.forEach(pane -> pane.setClip(null));
floating.forEach(pane -> pane.setClip(null));
allPanes().forEach(pane -> pane.setClip(null));
return;
}
// Floating panes bottom-to-top, matching panes(): insertion order, active pane on top.
List<TerminalPane> order = new ArrayList<>(floating.size());
for (TerminalPane pane : floating) {
if (pane != active) {
order.add(pane);
}
}
if (floating.contains(active)) {
order.add(active);
}
// Walk top-to-bottom, accumulating the union of the panes above each one.
// Walk the floating stack top-to-bottom, accumulating the union of the panes above
// each one. The topmost pane has nothing above it and keeps an unclipped bounds.
List<TerminalPane> order = floatingOrder();
Shape above = null;
for (int i = order.size() - 1; i >= 0; i--) {
Rectangle rect = rectOf(order.get(i));
order.get(i).setClip(above == null ? null : Shape.subtract(rect, above));
TerminalPane pane = order.get(i);
Rectangle rect = rectOf(pane);
pane.setClip(above == null ? null : Shape.subtract(rect, above));
above = (above == null) ? rect : Shape.union(above, rect);
}
@@ -259,12 +269,6 @@ final class Tab implements AutoCloseable {
}
}
void closeActivePane() {
if (active != null) {
closePane(active);
}
}
/**
* Closes {@code closing} (the active pane on a key-bound close, or any pane whose process just
* exited) and re-selects the active pane only when the one that closed was active. Returns
@@ -329,8 +333,8 @@ final class Tab implements AutoCloseable {
}
}
TerminalPane createFloatingPane() {
return addFloating(openPane(true));
private void createFloatingPane() {
addFloating(openPane(true));
}
/**
@@ -390,7 +394,9 @@ final class Tab implements AutoCloseable {
private double[] paneSize(boolean asFloating) {
double availHeight = lastHeight - lastTopInset;
if (asFloating) {
return new double[] {Math.max(420, lastWidth * 0.58), Math.max(260, availHeight * 0.58)};
return new double[] {
Math.max(FLOATING_MIN_WIDTH, lastWidth * FLOATING_SIZE_FRACTION),
Math.max(FLOATING_MIN_HEIGHT, availHeight * FLOATING_SIZE_FRACTION)};
}
// A new tiled pane joins the row, so each gets 1/(n+1) of the width.
return new double[] {lastWidth / (tiled.size() + 1), availHeight};

View File

@@ -18,6 +18,7 @@ import javafx.scene.shape.Shape;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicLong;
import java.util.function.Supplier;
/**
* One terminal: owns its ghostty {@link Terminal}, the {@link ShellSession}/pty driving it,
@@ -80,8 +81,8 @@ public final class TerminalPane implements AutoCloseable, RenderTarget {
public static TerminalPane create(AppConfig config, TerminalMetrics metrics, Runnable onContentChange,
double widthPx, double heightPx, String workingDirectory) {
TerminalPane pane = newPane(config, metrics, onContentChange, widthPx, heightPx);
pane.attach(ShellSession.start(config.shell(), config.envOverride(), pane, pane.columns, pane.rows,
workingDirectory, config.closeSignalNumber()));
attachOrShowError(pane, () -> ShellSession.start(config.shell(), config.envOverride(), pane,
pane.columns, pane.rows, workingDirectory, config.closeSignalNumber()));
return pane;
}
@@ -93,11 +94,23 @@ public final class TerminalPane implements AutoCloseable, RenderTarget {
public static TerminalPane createWithCommand(AppConfig config, TerminalMetrics metrics, Runnable onContentChange,
double widthPx, double heightPx, String workingDirectory, String command) {
TerminalPane pane = newPane(config, metrics, onContentChange, widthPx, heightPx);
pane.attach(ShellSession.startCommand(config.envOverride(), pane, pane.columns, pane.rows,
workingDirectory, command, config.closeSignalNumber()));
attachOrShowError(pane, () -> ShellSession.startCommand(config.envOverride(), pane,
pane.columns, pane.rows, workingDirectory, command, config.closeSignalNumber()));
return pane;
}
// Start the pane's process, but never let a spawn failure (e.g. a bad `shell` in config)
// propagate and crash window/pane creation. ShellSession has already written the error into
// the pane, so the pane opens showing it; with no session attached it is inert (sends are
// dropped) and the user closes it with the close-pane key.
private static void attachOrShowError(TerminalPane pane, Supplier<ShellSession> start) {
try {
pane.attach(start.get());
} catch (RuntimeException ex) {
System.err.println("jprototerm: " + ex.getMessage());
}
}
private static TerminalPane newPane(AppConfig config, TerminalMetrics metrics, Runnable onContentChange,
double widthPx, double heightPx) {
int columns = widthPx > 0 ? metrics.columnsFor(widthPx) : config.columns();

View File

@@ -41,34 +41,28 @@ abstract class TerminalRenderer {
void release() {
}
protected static void clipRect(GraphicsContext gc, double x, double y, double width, double height) {
gc.beginPath();
gc.rect(x, y, width, height);
gc.clip();
}
/**
* Clip to {@code region} if given (the pane's rect minus the panes covering it, computed by
* {@code Shape.subtract} at layout), otherwise to the plain rect. The region is a rectilinear
* path, so it replays onto the canvas as move/line/close segments.
*/
protected static void clip(GraphicsContext gc, double x, double y, double width, double height, Shape region) {
if (region == null) {
clipRect(gc, x, y, width, height);
return;
}
var elements = ((Path) region).getElements();
gc.beginPath();
if (elements.isEmpty()) {
gc.rect(x, y, 0.0, 0.0); // fully covered: clip to nothing
}
for (PathElement element : elements) {
if (element instanceof MoveTo moveTo) {
gc.moveTo(moveTo.getX(), moveTo.getY());
} else if (element instanceof LineTo lineTo) {
gc.lineTo(lineTo.getX(), lineTo.getY());
} else if (element instanceof ClosePath) {
gc.closePath();
if (region == null) {
gc.rect(x, y, width, height);
} else {
var elements = ((Path) region).getElements();
if (elements.isEmpty()) {
gc.rect(x, y, 0.0, 0.0); // fully covered: clip to nothing
}
for (PathElement element : elements) {
if (element instanceof MoveTo moveTo) {
gc.moveTo(moveTo.getX(), moveTo.getY());
} else if (element instanceof LineTo lineTo) {
gc.lineTo(lineTo.getX(), lineTo.getY());
} else if (element instanceof ClosePath) {
gc.closePath();
}
}
}
gc.clip();

View File

@@ -20,7 +20,9 @@ import javafx.stage.Stage;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* One top-level terminal window: its own {@link Stage}, {@link Compositor}, config/metrics, render
@@ -34,6 +36,9 @@ final class TerminalWindow {
private final Compositor compositor;
private final Stage stage;
private final AnimationTimer renderLoop;
// Key-bound actions by their config keybinding name, checked in this order on each key press
// (the keys must match AppConfig's keybinding keys).
private final Map<String, Runnable> keyActions = new LinkedHashMap<>();
private AppConfig config;
private boolean closed;
@@ -51,6 +56,23 @@ final class TerminalWindow {
// The last pane closing closes this window (not the JVM); see teardown().
compositor.setOnEmpty(this::teardown);
keyActions.put("navigate_left", () -> compositor.navigate(Direction.LEFT));
keyActions.put("navigate_down", () -> compositor.navigate(Direction.DOWN));
keyActions.put("navigate_up", () -> compositor.navigate(Direction.UP));
keyActions.put("navigate_right", () -> compositor.navigate(Direction.RIGHT));
keyActions.put("toggle_floating", compositor::toggleFloating);
keyActions.put("new_pane", compositor::createPane);
keyActions.put("next_floating", compositor::nextFloatingPane);
keyActions.put("promote_floating", compositor::toggleActiveFloating);
// Closing the last pane closes this window, via the compositor's onEmpty hook.
keyActions.put("close_pane", compositor::closeActivePane);
keyActions.put("new_tab", compositor::newTab);
keyActions.put("previous_tab", compositor::previousTab);
keyActions.put("next_tab", compositor::nextTab);
keyActions.put("open_font_selector", this::openFontSelector);
keyActions.put("open_scrollback", this::openScrollbackInEditor);
keyActions.put("paste", this::pasteFromClipboard);
StackPane root = new StackPane(compositor.canvas(), compositor.imageOverlay());
compositor.canvas().widthProperty().bind(root.widthProperty());
compositor.canvas().heightProperty().bind(root.heightProperty());
@@ -107,59 +129,17 @@ final class TerminalWindow {
}
private void handlePressed(KeyEvent event) {
if (config.keybindings().get("navigate_left").matches(event)) {
compositor.navigate(Direction.LEFT);
event.consume();
} else if (config.keybindings().get("navigate_down").matches(event)) {
compositor.navigate(Direction.DOWN);
event.consume();
} else if (config.keybindings().get("navigate_up").matches(event)) {
compositor.navigate(Direction.UP);
event.consume();
} else if (config.keybindings().get("navigate_right").matches(event)) {
compositor.navigate(Direction.RIGHT);
event.consume();
} else if (config.keybindings().get("toggle_floating").matches(event)) {
compositor.toggleFloating();
event.consume();
} else if (config.keybindings().get("new_pane").matches(event)) {
compositor.createPane();
event.consume();
} else if (config.keybindings().get("next_floating").matches(event)) {
compositor.nextFloatingPane();
event.consume();
} else if (config.keybindings().get("promote_floating").matches(event)) {
compositor.toggleActiveFloating();
event.consume();
} else if (config.keybindings().get("close_pane").matches(event)) {
// Closing the last pane closes this window, via the compositor's onEmpty hook.
compositor.closeActivePane();
event.consume();
} else if (config.keybindings().get("new_tab").matches(event)) {
compositor.newTab();
event.consume();
} else if (config.keybindings().get("previous_tab").matches(event)) {
compositor.previousTab();
event.consume();
} else if (config.keybindings().get("next_tab").matches(event)) {
compositor.nextTab();
event.consume();
} else if (config.keybindings().get("open_font_selector").matches(event)) {
openFontSelector();
event.consume();
} else if (config.keybindings().get("open_scrollback").matches(event)) {
openScrollbackInEditor();
event.consume();
} else if (config.keybindings().get("paste").matches(event)) {
pasteFromClipboard();
event.consume();
} else {
String encoded = KeyEncoder.encode(event);
if (encoded != null) {
compositor.activePane().send(encoded);
for (Map.Entry<String, Runnable> action : keyActions.entrySet()) {
if (config.keybindings().get(action.getKey()).matches(event)) {
action.getValue().run();
event.consume();
return;
}
}
String encoded = KeyEncoder.encode(event);
if (encoded != null) {
sendToActivePane(encoded, event);
}
}
private void handleTyped(KeyEvent event) {
@@ -169,15 +149,25 @@ final class TerminalWindow {
String text = event.getCharacter();
if (text != null && !text.isEmpty() && text.charAt(0) >= 0x20 && text.charAt(0) != 0x7f) {
compositor.activePane().send(text);
sendToActivePane(text, event);
}
}
// Key handlers run on every keystroke, including any that race the window's teardown, so
// tolerate the no-pane-left state instead of assuming one exists.
private void sendToActivePane(String text, KeyEvent event) {
TerminalPane active = compositor.activePane();
if (active != null) {
active.send(text);
event.consume();
}
}
private void pasteFromClipboard() {
TerminalPane active = compositor.activePane();
Clipboard clipboard = Clipboard.getSystemClipboard();
if (clipboard.hasString()) {
compositor.activePane().paste(clipboard.getString());
if (active != null && clipboard.hasString()) {
active.paste(clipboard.getString());
}
}
@@ -225,17 +215,22 @@ final class TerminalWindow {
}
private void openScrollbackInEditor() {
// Capture the active pane's scrollback before opening the floating pane, since that
// makes the new pane active.
TerminalPane active = compositor.activePane();
if (active == null) {
return;
}
try {
// Capture the active pane's scrollback before opening the floating pane, since that
// makes the new pane active.
Path file = Files.createTempFile("jprototerm-scrollback-", ".txt");
Files.writeString(file, compositor.activePane().scrollbackText());
file.toFile().deleteOnExit();
Files.writeString(file, active.scrollbackText());
// Run the editor as the floating pane's process (via /bin/sh -c) rather than typing the
// command into an interactive shell. The command runs deterministically from the start
// — no shell startup/rc race — and the pane auto-closes when the editor exits.
compositor.openFloatingPane(scrollbackEditorCommand(file));
// — no shell startup/rc race — and the pane auto-closes when the editor exits. The
// trailing rm removes the file (which holds terminal contents) when the editor exits;
// deleteOnExit would leak files for the JVM's whole lifetime in daemon mode.
compositor.openFloatingPane(scrollbackEditorCommand(file) + "; rm -f " + shellQuote(file.toString()));
} catch (IOException ex) {
System.err.println("Could not open scrollback in editor: " + ex.getMessage());
}