From 022cf224633e33524080ac891cc4e739e50da547 Mon Sep 17 00:00:00 2001 From: Gregor Lohaus Date: Fri, 29 May 2026 22:08:05 +0200 Subject: [PATCH] tabbar, background coloring --- src/main/java/com/gregor/jprototerm/Tab.java | 70 +++++++---- .../gregor/jprototerm/TerminalCanvasView.java | 114 +++++++++++++++++- .../gregor/jprototerm/TerminalWorkspace.java | 12 +- 3 files changed, 165 insertions(+), 31 deletions(-) diff --git a/src/main/java/com/gregor/jprototerm/Tab.java b/src/main/java/com/gregor/jprototerm/Tab.java index 1cbe39c..972f58e 100644 --- a/src/main/java/com/gregor/jprototerm/Tab.java +++ b/src/main/java/com/gregor/jprototerm/Tab.java @@ -15,6 +15,7 @@ final class Tab implements AutoCloseable { private final List panes = new ArrayList<>(); private int activeIndex; private int hiddenFloatingFocusIndex = -1; + private TerminalPane lastFocusedFloating; Tab(AppConfig config) { this.config = config; @@ -54,13 +55,14 @@ final class Tab implements AutoCloseable { boolean focus(TerminalPane pane) { int index = panes.indexOf(pane); if (index >= 0 && pane.visible() && activeIndex != index) { - activeIndex = index; + setActive(index); return true; } return false; } - void layout(double width, double height) { + void layout(double width, double height, double topInset) { + double availHeight = height - topInset; List tiled = panes.stream() .filter(TerminalPane::visible) .filter(pane -> !pane.floating()) @@ -68,7 +70,7 @@ final class Tab implements AutoCloseable { 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); + tiled.get(i).bounds(i * tileWidth, topInset, tileWidth, availHeight); } List floating = panes.stream() @@ -77,17 +79,15 @@ final class Tab implements AutoCloseable { .toList(); for (int i = 0; i < floating.size(); i++) { TerminalPane pane = floating.get(i); - if (pane.visible() && pane.floating()) { - double floatingWidth = Math.max(420, width * 0.58); - double floatingHeight = Math.max(260, height * 0.58); - double offset = i * 28.0; - pane.bounds( - Math.min(width - floatingWidth - 12.0, ((width - floatingWidth) / 2.0) + offset), - Math.min(height - floatingHeight - 12.0, ((height - floatingHeight) / 2.0) + offset), - floatingWidth, - floatingHeight - ); - } + double floatingWidth = Math.max(420, width * 0.58); + double floatingHeight = Math.max(260, availHeight * 0.58); + double offset = i * 28.0; + pane.bounds( + Math.min(width - floatingWidth - 12.0, ((width - floatingWidth) / 2.0) + offset), + Math.min(height - floatingHeight - 12.0, topInset + ((availHeight - floatingHeight) / 2.0) + offset), + floatingWidth, + floatingHeight + ); } } @@ -104,7 +104,7 @@ final class Tab implements AutoCloseable { .min(Comparator.comparingDouble(pane -> distance(current, pane))) .orElse(null); if (target != null) { - activeIndex = panes.indexOf(target); + setActive(panes.indexOf(target)); return true; } return false; @@ -124,10 +124,10 @@ final class Tab implements AutoCloseable { TerminalPane active = activePane(); hiddenFloatingFocusIndex = active.floating() ? activeIndex : firstVisibleFloatingIndex(); floating.forEach(pane -> pane.setVisible(false)); - activeIndex = firstVisibleNonFloatingIndex(); + setActive(firstVisibleNonFloatingIndex()); } else { floating.forEach(pane -> pane.setVisible(true)); - activeIndex = visibleIndexOrFallback(hiddenFloatingFocusIndex, panes.indexOf(floating.get(floating.size() - 1))); + setActive(visibleIndexOrFallback(hiddenFloatingFocusIndex, panes.indexOf(floating.get(floating.size() - 1)))); hiddenFloatingFocusIndex = -1; } } @@ -142,14 +142,14 @@ final class Tab implements AutoCloseable { } else { TerminalPane pane = openPane(false); panes.add(pane); - activeIndex = panes.size() - 1; + setActive(panes.size() - 1); } } void nextFloatingPane() { TerminalPane next = nextFloatingAfter(activeIndex); next.setVisible(true); - activeIndex = panes.indexOf(next); + setActive(panes.indexOf(next)); } void closeActivePane() { @@ -157,6 +157,9 @@ final class Tab implements AutoCloseable { int removed = activeIndex; int previous = previousVisibleIndex(removed); panes.remove(removed); + if (active == lastFocusedFloating) { + lastFocusedFloating = null; + } active.close(); if (panes.isEmpty()) { activeIndex = 0; @@ -164,17 +167,38 @@ final class Tab implements AutoCloseable { } activeIndex = adjustIndexAfterRemoval(previous, removed); hiddenFloatingFocusIndex = adjustHiddenFocusAfterRemoval(hiddenFloatingFocusIndex, removed); - // If only hidden panes remained (e.g. closed the last tiled pane while floating - // panes were stashed), reveal the one we're focusing so the screen isn't blank. + + // If the last tiled (main) pane was closed, promote a floating pane to be the new + // main pane so the layout has a base and rendering continues normally. Prefer the + // most recently focused floating pane. + if (panes.stream().noneMatch(pane -> !pane.floating())) { + TerminalPane promote = (lastFocusedFloating != null && panes.contains(lastFocusedFloating)) + ? lastFocusedFloating + : panes.get(activeIndex); + promote.setFloating(false); + promote.setVisible(true); + activeIndex = panes.indexOf(promote); + lastFocusedFloating = null; + } + + // If only hidden panes remained, reveal the one we're focusing so the screen isn't + // blank. if (!panes.get(activeIndex).visible()) { panes.get(activeIndex).setVisible(true); } } + private void setActive(int index) { + activeIndex = index; + if (index >= 0 && index < panes.size() && panes.get(index).floating()) { + lastFocusedFloating = panes.get(index); + } + } + private void createFloatingPane() { TerminalPane pane = openPane(true); panes.add(pane); - activeIndex = panes.size() - 1; + setActive(panes.size() - 1); } private boolean anyFloatingVisible() { @@ -225,7 +249,7 @@ final class Tab implements AutoCloseable { return false; } - activeIndex = panes.indexOf(floating.get(next)); + setActive(panes.indexOf(floating.get(next))); return true; } diff --git a/src/main/java/com/gregor/jprototerm/TerminalCanvasView.java b/src/main/java/com/gregor/jprototerm/TerminalCanvasView.java index b8f1ee0..d08dd1f 100644 --- a/src/main/java/com/gregor/jprototerm/TerminalCanvasView.java +++ b/src/main/java/com/gregor/jprototerm/TerminalCanvasView.java @@ -16,6 +16,7 @@ import dev.jlibghostty.RenderColor; import dev.jlibghostty.RenderCursorStyle; import dev.jlibghostty.RenderRow; import dev.jlibghostty.RenderStateSnapshot; +import javafx.geometry.VPos; import javafx.scene.canvas.Canvas; import javafx.scene.canvas.GraphicsContext; import javafx.scene.image.Image; @@ -29,6 +30,7 @@ import javafx.scene.paint.Color; import javafx.scene.text.Font; import javafx.scene.text.FontSmoothingType; import javafx.scene.text.Text; +import javafx.scene.text.TextAlignment; import java.io.ByteArrayInputStream; import java.util.HashMap; @@ -98,6 +100,9 @@ public final class TerminalCanvasView { private static final int DIRTY_PARTIAL = 1; private static final int DIRTY_FULL = 2; + // Thin tab strip shown at the top when more than one tab is open. + private static final double TAB_BAR_HEIGHT = 22.0; + public void render() { double width = canvas.getWidth(); double height = canvas.getHeight(); @@ -121,7 +126,8 @@ public final class TerminalCanvasView { lastWorkspaceVersion = workspaceVersion; lastRenderTick = renderTick; - workspace.layout(width, height); + double topInset = workspace.tabCount() > 1 ? TAB_BAR_HEIGHT : 0.0; + workspace.layout(width, height, topInset); Font font = currentFont(); FontMetrics metrics = currentFontMetrics(); List panes = workspace.panes(); @@ -141,6 +147,9 @@ public final class TerminalCanvasView { paneContentVersion.keySet().retainAll(panes); gc.setFill(GAP_BACKGROUND); gc.fillRect(0, 0, width, height); + if (topInset > 0.0) { + drawTabBar(gc, width, topInset); + } for (TerminalPane pane : panes) { paintPane(gc, pane, font, metrics, pane.renderSnapshotFull()); paneContentVersion.put(pane, pane.renderVersion()); @@ -249,6 +258,34 @@ public final class TerminalCanvasView { gc.clip(); } + // Thin tab strip: one equal-width segment per tab, the current one highlighted, with a + // small 1-based number centred in each segment. + private void drawTabBar(GraphicsContext gc, double width, double barHeight) { + int count = workspace.tabCount(); + int currentIndex = workspace.currentTabIndex(); + Font barFont = Font.font(fontFamily, Math.max(9.0, Math.min(13.0, barHeight * 0.62))); + gc.setFont(barFont); + gc.setFontSmoothingType(FontSmoothingType.GRAY); + gc.setTextAlign(TextAlignment.CENTER); + gc.setTextBaseline(VPos.CENTER); + + double gap = 1.0; + double segmentWidth = width / count; + for (int i = 0; i < count; i++) { + double x = i * segmentWidth; + boolean current = i == currentIndex; + gc.setFill(current ? Color.rgb(45, 55, 72) : Color.rgb(22, 24, 28)); + gc.fillRect(x, 0.0, segmentWidth - gap, barHeight); + gc.setFill(current ? DEFAULT_FOREGROUND : Color.rgb(128, 136, 148)); + gc.fillText(Integer.toString(i + 1), x + (segmentWidth - gap) / 2.0, barHeight / 2.0); + } + + // Restore the defaults the cell renderer relies on (left-aligned, baseline, LCD). + gc.setTextAlign(TextAlignment.LEFT); + gc.setTextBaseline(VPos.BASELINE); + gc.setFontSmoothingType(FontSmoothingType.LCD); + } + // Full content render: background, border, all rows, cursor, and (when enabled) kitty // graphics. Used by the kitty direct path and by full offscreen redraws. private void drawPaneContent( @@ -264,12 +301,8 @@ public final class TerminalCanvasView { boolean withKitty ) { gc.setFontSmoothingType(FontSmoothingType.LCD); - // Paint content fully opaque. LCD subpixel text rendering produces colour fringing - // on a translucent surface, so floating-pane translucency is applied by the caller - // when the finished (opaque) buffer is composited onto the canvas. - gc.setFill(Color.rgb(9, 10, 12)); + gc.setFill(PANE_BACKGROUND); gc.fillRect(x, y, width, height); - drawBorder(gc, pane, x, y, width, height); gc.setFont(font); double left = x + 12.0; @@ -285,7 +318,12 @@ public final class TerminalCanvasView { } if (snapshot != null) { + double contentBottom = top + snapshot.rows() * metrics.lineHeight; + fillVerticalPadding(gc, snapshot, x, y, width, height, top, contentBottom); for (RenderRow row : snapshot.renderRows()) { + double y0 = Math.floor(top + (row.row() * metrics.lineHeight)); + double y1 = Math.ceil(top + ((row.row() + 1) * metrics.lineHeight)); + paintSidePadding(gc, row, x, width, left, metrics.cellWidth, y0, y1 - y0); drawRow(gc, row, left, top, baseline, metrics.cellWidth, metrics.lineHeight); } drawCursor(gc, snapshot, left, top, metrics.cellWidth, metrics.lineHeight); @@ -294,6 +332,55 @@ public final class TerminalCanvasView { if (withKitty) { drawKittyGraphics(gc, pane, KittyPlacementLayer.ABOVE_TEXT, placeholderBounds, left, top, metrics.cellWidth, metrics.lineHeight); } + + drawBorder(gc, pane, x, y, width, height); + } + + // 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 Color rowEdgeBackground(RenderRow row, boolean firstCell) { + List cells = row.cells(); + if (cells.isEmpty()) { + return PANE_BACKGROUND; + } + return cellBackgroundColor(firstCell ? cells.get(0) : cells.get(cells.size() - 1)); + } + + // Extend the row's edge-cell backgrounds into the left/right padding (the 12px margin and + // the right-edge rounding sliver), so the unused space matches the rendered content. + private void paintSidePadding(GraphicsContext gc, RenderRow row, double paneX, double paneWidth, + double contentLeft, double cellWidth, double yTop, double bandHeight) { + int columns = row.cells().size(); + if (columns == 0) { + return; + } + double contentRight = contentLeft + (columns * cellWidth); + gc.setFill(rowEdgeBackground(row, true)); + gc.fillRect(paneX, yTop, contentLeft - paneX, bandHeight); + gc.setFill(rowEdgeBackground(row, false)); + gc.fillRect(contentRight, yTop, paneX + paneWidth - contentRight, bandHeight); + } + + // Fill the top/bottom padding strips with the top/bottom row's edge colour. + private void fillVerticalPadding(GraphicsContext gc, RenderStateSnapshot snapshot, + double paneX, double paneY, double paneWidth, double paneHeight, double contentTop, double contentBottom) { + List rows = snapshot.renderRows(); + if (rows.isEmpty()) { + return; + } + gc.setFill(rowEdgeBackground(rows.get(0), true)); + gc.fillRect(paneX, paneY, paneWidth, contentTop - paneY); + gc.setFill(rowEdgeBackground(rows.get(rows.size() - 1), true)); + gc.fillRect(paneX, contentBottom, paneWidth, paneY + paneHeight - contentBottom); } // Incremental render: repaint only the rows ghostty flagged dirty, at the pane's screen @@ -316,6 +403,8 @@ public final class TerminalCanvasView { double top = py + 12.0; double baseline = top + metrics.baselineOffset; + double contentBottom = top + snapshot.rows() * metrics.lineHeight; + int lastRow = snapshot.rows() - 1; boolean cursorRowDirty = false; double bandMin = Double.POSITIVE_INFINITY; double bandMax = Double.NEGATIVE_INFINITY; @@ -329,9 +418,22 @@ public final class TerminalCanvasView { double y1 = Math.ceil(top + ((row.row() + 1) * metrics.lineHeight)); gc.setFill(PANE_BACKGROUND); gc.fillRect(px, y0, pw, y1 - y0); + paintSidePadding(gc, row, px, pw, left, metrics.cellWidth, y0, y1 - y0); drawRow(gc, row, left, top, baseline, metrics.cellWidth, metrics.lineHeight); bandMin = Math.min(bandMin, y0); bandMax = Math.max(bandMax, y1); + // Edge rows also own the top/bottom padding strip; repaint it and extend the + // band so panes stacked above get restored over it too. + if (row.row() == 0) { + gc.setFill(rowEdgeBackground(row, true)); + gc.fillRect(px, py, pw, top - py); + bandMin = Math.min(bandMin, py); + } + if (row.row() == lastRow) { + gc.setFill(rowEdgeBackground(row, true)); + gc.fillRect(px, contentBottom, pw, py + ph - contentBottom); + bandMax = Math.max(bandMax, py + ph); + } if (snapshot.cursorViewportHasValue() && row.row() == snapshot.cursorViewportY()) { cursorRowDirty = true; } diff --git a/src/main/java/com/gregor/jprototerm/TerminalWorkspace.java b/src/main/java/com/gregor/jprototerm/TerminalWorkspace.java index 200f563..b1477e8 100644 --- a/src/main/java/com/gregor/jprototerm/TerminalWorkspace.java +++ b/src/main/java/com/gregor/jprototerm/TerminalWorkspace.java @@ -43,12 +43,20 @@ public final class TerminalWorkspace implements AutoCloseable { return !tabs.isEmpty() && current().isActive(pane); } - public void layout(double width, double height) { + public void layout(double width, double height, double topInset) { if (!tabs.isEmpty()) { - current().layout(width, height); + current().layout(width, height, topInset); } } + public int tabCount() { + return tabs.size(); + } + + public int currentTabIndex() { + return currentTab; + } + public void focus(TerminalPane pane) { if (!tabs.isEmpty() && current().focus(pane)) { version++;