diff --git a/.gitignore b/.gitignore index dca9b7c..e7a29e8 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ devenv.local.yaml build build .gradle +bin diff --git a/src/main/java/com/gregor/jprototerm/Compositor.java b/src/main/java/com/gregor/jprototerm/Compositor.java new file mode 100644 index 0000000..58df043 --- /dev/null +++ b/src/main/java/com/gregor/jprototerm/Compositor.java @@ -0,0 +1,496 @@ +package com.gregor.jprototerm; + +import dev.jlibghostty.KeyModifiers; +import dev.jlibghostty.MouseButton; +import dev.jlibghostty.MouseEncoderSize; +import dev.jlibghostty.MouseInput; +import javafx.geometry.VPos; +import javafx.scene.canvas.Canvas; +import javafx.scene.canvas.GraphicsContext; +import javafx.scene.input.InputEvent; +import javafx.scene.input.MouseEvent; +import javafx.scene.input.ScrollEvent; +import javafx.scene.input.ScrollEvent.VerticalTextScrollUnits; +import javafx.scene.paint.Color; +import javafx.scene.text.Font; +import javafx.scene.text.FontSmoothingType; +import javafx.scene.text.TextAlignment; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * Owns the window's tabs and drives rendering and input. It composites only the current tab: + * each frame it lays that tab out, paints the panes bottom-to-top (so the active floating pane + * lands on top) and lets each pane paint its own content, clipped to the region the layout gave + * it. The cross-pane concerns live here — the dirty-frame bookkeeping, the tab strip, routing + * mouse/scroll to the pane under the pointer, and the tab/pane lifecycle that {@link Main}'s key + * bindings invoke. + */ +public final class Compositor { + // Canvas background shown wherever no pane covers (gaps). Painted on a full recomposite. + private static final Color GAP_BACKGROUND = Color.rgb(16, 16, 18); + private static final Color TAB_TEXT = Color.rgb(225, 229, 235); + // Thin tab strip shown at the top when more than one tab is open. + private static final double TAB_BAR_HEIGHT = 22.0; + + private final Canvas canvas = new Canvas(); + private final AppConfig config; + private final TerminalMetrics metrics; + private final List tabs = new ArrayList<>(); + private int currentTabIndex; + // Bumped on any structural change (tab switch, pane add/close/focus/move) so render() + // knows to recomposite. Terminal *content* changes are tracked separately through each + // tab's content version. + private long layoutVersion; + // Last content version drawn to the canvas per pane, so a content frame repaints only + // the panes that actually changed. + private final Map paneContentVersion = new HashMap<>(); + // Cheap per-frame dirty signal: skip the whole render when none of these changed. + private double lastWidth = -1.0; + private double lastHeight = -1.0; + private String lastFontFamily; + private double lastFontSize = -1.0; + private long lastLayoutVersion = Long.MIN_VALUE; + private long lastContentVersion = Long.MIN_VALUE; + private boolean mouseButtonPressed; + private MouseButton pressedButton = MouseButton.UNKNOWN; + + public Compositor(AppConfig config, TerminalMetrics metrics) { + this.config = config; + this.metrics = metrics; + tabs.add(new Tab(config, metrics)); + canvas.setFocusTraversable(true); + canvas.setOnMousePressed(this::handleMousePressed); + canvas.setOnMouseReleased(this::handleMouseReleased); + canvas.setOnMouseDragged(this::handleMouseDragged); + canvas.setOnMouseMoved(this::handleMouseMoved); + canvas.setOnScroll(this::handleScroll); + } + + public Canvas canvas() { + return canvas; + } + + public void setFont(String family, double size) { + metrics.setFont(family, size); + paneContentVersion.clear(); + lastWidth = -1.0; // force a redraw on the next frame + } + + // ---- Tabs and panes ------------------------------------------------------------- + + public boolean isEmpty() { + return tabs.isEmpty(); + } + + public TerminalPane activePane() { + return currentTab().activePane(); + } + + public void navigate(Direction direction) { + if (!isEmpty() && currentTab().navigate(direction)) { + layoutVersion++; + } + } + + public void toggleFloating() { + if (isEmpty()) { + return; + } + currentTab().toggleFloating(); + layoutVersion++; + } + + public void createPane() { + if (isEmpty()) { + return; + } + currentTab().createPane(); + layoutVersion++; + } + + public void nextFloatingPane() { + if (isEmpty()) { + return; + } + currentTab().nextFloatingPane(); + layoutVersion++; + } + + public void closeActivePane() { + if (isEmpty()) { + return; + } + currentTab().closeActivePane(); + if (currentTab().isEmpty()) { + // Closing a tab's last pane closes the tab. When no tabs remain the surface is + // empty and Main quits. + tabs.remove(currentTabIndex); + if (currentTabIndex >= tabs.size()) { + currentTabIndex = Math.max(0, tabs.size() - 1); + } + } + layoutVersion++; + } + + public void newTab() { + tabs.add(new Tab(config, metrics)); + currentTabIndex = tabs.size() - 1; + layoutVersion++; + } + + public void nextTab() { + if (tabs.size() > 1) { + currentTabIndex = (currentTabIndex + 1) % tabs.size(); + layoutVersion++; + } + } + + public void previousTab() { + if (tabs.size() > 1) { + currentTabIndex = (currentTabIndex - 1 + tabs.size()) % tabs.size(); + layoutVersion++; + } + } + + public void close() { + for (Tab tab : tabs) { + tab.close(); + } + tabs.clear(); + } + + private Tab currentTab() { + return tabs.get(currentTabIndex); + } + + private List currentPanes() { + return tabs.isEmpty() ? List.of() : currentTab().panes(); + } + + private boolean isActive(TerminalPane pane) { + return !tabs.isEmpty() && currentTab().isActive(pane); + } + + private void focus(TerminalPane pane) { + if (!tabs.isEmpty() && currentTab().focus(pane)) { + layoutVersion++; + } + } + + // ---- Rendering ------------------------------------------------------------------ + + public void render() { + switch (nextFrameType()) { + case IDLE -> { } + case LAYOUT -> renderLayoutFrame(); + case CONTENT -> renderContentFrame(); + } + } + + // Classify this frame and commit the change trackers. A layout change (size, font, + // tab/pane set, z-order, active pane) needs a full recomposite; otherwise a change to the + // current tab's content version repaints only the panes that changed; otherwise nothing + // changed and the frame is idle. + private FrameType nextFrameType() { + double width = canvas.getWidth(); + double height = canvas.getHeight(); + long contentVersion = tabs.isEmpty() ? 0 : currentTab().contentVersion(); + + boolean layoutChanged = width != lastWidth || height != lastHeight + || metrics.fontSize() != lastFontSize || !Objects.equals(metrics.fontFamily(), lastFontFamily) + || layoutVersion != lastLayoutVersion; + boolean contentChanged = contentVersion != lastContentVersion; + + lastWidth = width; + lastHeight = height; + lastFontFamily = metrics.fontFamily(); + lastFontSize = metrics.fontSize(); + lastLayoutVersion = layoutVersion; + lastContentVersion = contentVersion; + + if (layoutChanged) { + return FrameType.LAYOUT; + } + if (contentChanged) { + return FrameType.CONTENT; + } + return FrameType.IDLE; + } + + // Full recomposite onto the retained canvas: lay the tab out, clear to the gap colour, + // draw the tab strip, then paint every pane bottom-to-top (panes() puts the active + // floating pane last == on top). + private void renderLayoutFrame() { + double topInset = tabs.size() > 1 ? TAB_BAR_HEIGHT : 0.0; + if (!tabs.isEmpty()) { + currentTab().layout(canvas.getWidth(), canvas.getHeight(), topInset); + } + List panes = currentPanes(); + // Sync each pane's ghostty grid to its (possibly new) bounds; a no-op when unchanged. + for (TerminalPane pane : panes) { + pane.fitToBounds(); + } + + GraphicsContext gc = beginFrame(); + paneContentVersion.keySet().retainAll(panes); + gc.setFill(GAP_BACKGROUND); + gc.fillRect(0, 0, canvas.getWidth(), canvas.getHeight()); + if (topInset > 0.0) { + drawTabBar(gc, canvas.getWidth(), topInset); + } + for (TerminalPane pane : panes) { + pane.paintFull(gc, isActive(pane)); + paneContentVersion.put(pane, pane.contentVersion()); + } + } + + // Repaint just the panes whose content changed, directly on the retained canvas. Each pane + // clips itself to its rect minus the panes above it, so a lower pane's repaint can't bleed + // over one stacked on top — no restore pass needed. Bounds and grids can't have changed + // without a layout frame, so a content frame reuses the existing layout untouched. + private void renderContentFrame() { + List panes = currentPanes(); + GraphicsContext gc = beginFrame(); + + for (TerminalPane pane : panes) { + Long drawn = paneContentVersion.get(pane); + if (drawn != null && drawn == pane.contentVersion()) { + continue; + } + pane.paintIncremental(gc, isActive(pane)); + paneContentVersion.put(pane, pane.contentVersion()); + } + } + + private GraphicsContext beginFrame() { + GraphicsContext gc = canvas.getGraphicsContext2D(); + gc.setFontSmoothingType(FontSmoothingType.LCD); // the per-cell renderer relies on LCD + return gc; + } + + // 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 = tabs.size(); + Font barFont = Font.font(metrics.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 == currentTabIndex; + gc.setFill(current ? Color.rgb(45, 55, 72) : Color.rgb(22, 24, 28)); + gc.fillRect(x, 0.0, segmentWidth - gap, barHeight); + gc.setFill(current ? TAB_TEXT : 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); + } + + // ---- Input ---------------------------------------------------------------------- + + private void handleMousePressed(MouseEvent event) { + canvas.requestFocus(); + TerminalPane pane = paneAt(event.getX(), event.getY()); + if (pane == null) { + return; + } + + focus(pane); + pressedButton = mouseButton(event); + mouseButtonPressed = true; + MouseTarget target = mouseTarget(pane); + if (target == null) { + return; + } + send(pane, target, MouseInput.press(pressedButton, localX(event.getX(), pane, target), localY(event.getY(), pane, target), modifiers(event)), true, event); + } + + private void handleMouseReleased(MouseEvent event) { + TerminalPane pane = paneAt(event.getX(), event.getY()); + if (pane == null) { + pane = activePane(); + } + + MouseButton button = pressedButton == MouseButton.UNKNOWN ? mouseButton(event) : pressedButton; + MouseTarget target = 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); + } + mouseButtonPressed = false; + pressedButton = MouseButton.UNKNOWN; + } + + private void handleMouseDragged(MouseEvent event) { + TerminalPane pane = paneAt(event.getX(), event.getY()); + if (pane == null) { + pane = activePane(); + } + + MouseButton button = pressedButton == MouseButton.UNKNOWN ? mouseButton(event) : pressedButton; + MouseTarget target = mouseTarget(pane); + if (target == null) { + return; + } + send(pane, target, MouseInput.drag(button, localX(event.getX(), pane, target), localY(event.getY(), pane, target), modifiers(event)), true, event); + } + + private void handleMouseMoved(MouseEvent event) { + TerminalPane pane = paneAt(event.getX(), event.getY()); + if (pane == null) { + return; + } + + MouseTarget target = mouseTarget(pane); + if (target == null) { + return; + } + send(pane, target, MouseInput.motion(localX(event.getX(), pane, target), localY(event.getY(), pane, target), modifiers(event)), mouseButtonPressed, event); + } + + private void handleScroll(ScrollEvent event) { + TerminalPane pane = paneAt(event.getX(), event.getY()); + if (pane == null) { + return; + } + + canvas.requestFocus(); + focus(pane); + int direction = scrollDirection(event); + if (direction == 0) { + return; + } + + MouseButton wheelButton = direction > 0 ? MouseButton.FOUR : MouseButton.FIVE; + int rows = scrollRows(event); + MouseTarget target = mouseTarget(pane); + boolean sent = false; + if (target != null) { + // The wheel sends one button press per scrolled row; resolve the position once. + double ex = localX(event.getX(), pane, target); + double ey = localY(event.getY(), pane, target); + KeyModifiers modifiers = modifiers(event); + for (int i = 0; i < rows; i++) { + sent |= send(pane, target, MouseInput.press(wheelButton, ex, ey, modifiers), mouseButtonPressed, event); + } + } + if (!sent) { + // Not consumed by the app (e.g. mouse reporting off): scroll the local viewport. + pane.scrollViewport(direction > 0 ? -rows : rows); + event.consume(); + } + } + + // Forward an already-positioned mouse event to the pane, consuming it if the pane (i.e. + // the app running in it) acted on it. Returns whether it was sent. + private boolean send(TerminalPane pane, MouseTarget target, MouseInput input, boolean anyButtonPressed, InputEvent event) { + boolean sent = pane.sendMouse(input, target.size(), anyButtonPressed); + if (sent) { + event.consume(); + } + return sent; + } + + private TerminalPane paneAt(double x, double y) { + List panes = currentPanes(); + for (int i = panes.size() - 1; i >= 0; i--) { + TerminalPane pane = panes.get(i); + if (x >= pane.x() && x < pane.x() + pane.width() && y >= pane.y() && y < pane.y() + pane.height()) { + return pane; + } + } + return null; + } + + private MouseTarget mouseTarget(TerminalPane pane) { + if (pane.width() <= 2 * TerminalMetrics.PADDING || pane.height() <= 2 * TerminalMetrics.PADDING) { + return null; + } + + int columns = metrics.columnsFor(pane.width()); + int rows = metrics.rowsFor(pane.height()); + long cellWidth = Math.max(1L, Math.round(metrics.cellWidth())); + long cellHeight = Math.max(1L, Math.round(metrics.lineHeight())); + long screenWidth = Math.max(1L, Math.round(columns * metrics.cellWidth())); + long screenHeight = Math.max(1L, Math.round(rows * metrics.lineHeight())); + return new MouseTarget(MouseEncoderSize.of(screenWidth, screenHeight, cellWidth, cellHeight), screenWidth, screenHeight); + } + + // Resolve a canvas-space pointer position to a pane-local pixel coordinate, clamped to + // the pane's reported screen size (what ghostty's mouse encoder expects). + private static double localX(double canvasX, TerminalPane pane, MouseTarget target) { + return clamp(canvasX - pane.x() - TerminalMetrics.PADDING, 0.0, target.screenWidth() - 1.0); + } + + private static double localY(double canvasY, TerminalPane pane, MouseTarget target) { + return clamp(canvasY - pane.y() - TerminalMetrics.PADDING, 0.0, target.screenHeight() - 1.0); + } + + private static double clamp(double value, double min, double max) { + return Math.max(min, Math.min(max, value)); + } + + private static KeyModifiers modifiers(MouseEvent event) { + return KeyModifiers.of(event.isShiftDown(), event.isControlDown(), event.isAltDown(), event.isMetaDown()); + } + + private static KeyModifiers modifiers(ScrollEvent event) { + return KeyModifiers.of(event.isShiftDown(), event.isControlDown(), event.isAltDown(), event.isMetaDown()); + } + + private static int scrollRows(ScrollEvent event) { + double rows; + if (event.getTextDeltaYUnits() == VerticalTextScrollUnits.LINES && event.getTextDeltaY() != 0.0) { + rows = Math.abs(event.getTextDeltaY()); + } else if (event.getTextDeltaYUnits() == VerticalTextScrollUnits.PAGES && event.getTextDeltaY() != 0.0) { + rows = Math.abs(event.getTextDeltaY()) * 24.0; + } else if (event.getMultiplierY() > 0.0) { + rows = Math.abs(event.getDeltaY()) / event.getMultiplierY(); + } else { + rows = Math.abs(event.getDeltaY()) / 40.0; + } + return Math.max(1, Math.min(64, (int) Math.ceil(rows))); + } + + private static int scrollDirection(ScrollEvent event) { + if (event.getDeltaY() != 0.0) { + return event.getDeltaY() > 0.0 ? 1 : -1; + } + if (event.getTextDeltaYUnits() != VerticalTextScrollUnits.NONE && event.getTextDeltaY() != 0.0) { + return event.getTextDeltaY() > 0.0 ? 1 : -1; + } + return 0; + } + + private static MouseButton mouseButton(MouseEvent event) { + return switch (event.getButton()) { + case PRIMARY -> MouseButton.LEFT; + case SECONDARY -> MouseButton.RIGHT; + case MIDDLE -> MouseButton.MIDDLE; + default -> MouseButton.UNKNOWN; + }; + } + + // What one render() pass should do, decided from the change trackers in nextFrame(). + private enum FrameType { + IDLE, // nothing changed since the last frame + LAYOUT, // geometry/font/tab/pane set changed: clear and repaint everything + CONTENT // only terminal content changed: repaint the panes that changed + } + + private record MouseTarget(MouseEncoderSize size, long screenWidth, long screenHeight) { + } +} diff --git a/src/main/java/com/gregor/jprototerm/GhosttyTerminalRenderer.java b/src/main/java/com/gregor/jprototerm/GhosttyTerminalRenderer.java new file mode 100644 index 0000000..f6e20e8 --- /dev/null +++ b/src/main/java/com/gregor/jprototerm/GhosttyTerminalRenderer.java @@ -0,0 +1,631 @@ +package com.gregor.jprototerm; + +import dev.jlibghostty.KittyImageCompression; +import dev.jlibghostty.KittyImageFormat; +import dev.jlibghostty.KittyImageSnapshot; +import dev.jlibghostty.KittyPlacement; +import dev.jlibghostty.KittyPlacementLayer; +import dev.jlibghostty.KittyPlaceholder; +import dev.jlibghostty.KittyRenderInfo; +import dev.jlibghostty.RenderCell; +import dev.jlibghostty.RenderColor; +import dev.jlibghostty.RenderCursorStyle; +import dev.jlibghostty.RenderRow; +import dev.jlibghostty.RenderStateSnapshot; +import javafx.scene.canvas.GraphicsContext; +import javafx.scene.image.Image; +import javafx.scene.image.PixelFormat; +import javafx.scene.image.WritableImage; +import javafx.scene.paint.Color; +import javafx.scene.text.FontSmoothingType; + +import java.io.ByteArrayInputStream; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * The real terminal renderer: paints a pane's background, cell rows, cursor, border, padding + * and (when enabled) kitty graphics. One instance per pane, since it caches that pane's + * decoded kitty images. + */ +final class GhosttyTerminalRenderer extends TerminalRenderer { + // GhosttyRenderStateDirty values (stable C ABI; see ghostty/vt/render.h). + 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); + // 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); + + // 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 COLOR_CACHE = new HashMap<>(); + + private final TerminalMetrics metrics; + // Decoded kitty images for this renderer's pane (kitty graphics state is per-terminal). + private final Map kittyImageCache = new HashMap<>(); + + GhosttyTerminalRenderer(TerminalMetrics metrics) { + this.metrics = metrics; + } + + @Override + void paintFull(GraphicsContext gc, RenderTarget target, boolean active) { + double px = Math.round(target.x()); + double py = Math.round(target.y()); + double width = target.width(); + double height = target.height(); + gc.save(); + clip(gc, px, py, width, height, target.clip()); + drawContent(gc, target, target.snapshotFull(), px, py, width, height, active, + target.kittyEnabled() && hasKittyGraphics(target)); + gc.restore(); + } + + @Override + void paintIncremental(GraphicsContext gc, RenderTarget target, boolean active) { + double px = Math.round(target.x()); + double py = Math.round(target.y()); + double width = target.width(); + double height = target.height(); + gc.save(); + clip(gc, px, py, width, height, target.clip()); + if (target.kittyEnabled() && hasKittyGraphics(target)) { + // Kitty placements can move without a per-row dirty flag, so always redraw whole. + drawContent(gc, target, target.snapshotFull(), px, py, width, height, active, true); + } else { + RenderStateSnapshot snapshot = target.snapshot(); + int dirty = snapshot == null ? DIRTY_FULL : snapshot.dirty(); + if (dirty == DIRTY_FULL) { + drawContent(gc, target, snapshot, px, py, width, height, active, false); + } else if (dirty == DIRTY_PARTIAL) { + drawDirtyRows(gc, snapshot, px, py, width, height, active); + } + // dirty == FALSE: nothing visible changed. + } + gc.restore(); + } + + // Full content render: background, border, all rows, cursor, and (when enabled) kitty + // graphics. Used by the kitty direct path and by full redraws. + private void drawContent( + GraphicsContext gc, + RenderTarget target, + RenderStateSnapshot snapshot, + double x, + double y, + double width, + double height, + boolean active, + boolean withKitty + ) { + double cellWidth = metrics.cellWidth(); + double lineHeight = metrics.lineHeight(); + gc.setFontSmoothingType(FontSmoothingType.LCD); + gc.setFill(PANE_BACKGROUND); + gc.fillRect(x, y, width, height); + gc.setFont(metrics.font()); + + double left = x + TerminalMetrics.PADDING; + double top = y + TerminalMetrics.PADDING; + double baseline = top + metrics.baselineOffset(); + + Map placeholderBounds = withKitty + ? kittyPlaceholderBounds(snapshot) + : Map.of(); + + if (withKitty) { + drawKittyGraphics(gc, target, KittyPlacementLayer.BELOW_TEXT, placeholderBounds, left, top, cellWidth, lineHeight); + } + + if (snapshot != null) { + double contentBottom = top + snapshot.rows() * lineHeight; + fillVerticalPadding(gc, snapshot, x, y, width, height, top, contentBottom); + for (RenderRow row : snapshot.renderRows()) { + double y0 = Math.floor(top + (row.row() * lineHeight)); + double y1 = Math.ceil(top + ((row.row() + 1) * lineHeight)); + paintSidePadding(gc, row, x, width, left, cellWidth, y0, y1 - y0); + drawRow(gc, row, left, top, baseline, cellWidth, lineHeight); + } + drawCursor(gc, snapshot, left, top, cellWidth, lineHeight); + } + + if (withKitty) { + drawKittyGraphics(gc, target, KittyPlacementLayer.ABOVE_TEXT, placeholderBounds, left, top, cellWidth, lineHeight); + } + + drawBorder(gc, x, y, width, height, active); + } + + // Incremental render: repaint only the rows ghostty flagged dirty, then restore the + // cursor and border. The local band tracks the repainted span only so the border redraw + // can be limited to it. + private void drawDirtyRows( + GraphicsContext gc, + RenderStateSnapshot snapshot, + double px, + double py, + double pw, + double ph, + boolean active + ) { + double cellWidth = metrics.cellWidth(); + double lineHeight = metrics.lineHeight(); + gc.setFontSmoothingType(FontSmoothingType.LCD); + gc.setFont(metrics.font()); + double left = px + TerminalMetrics.PADDING; + double top = py + TerminalMetrics.PADDING; + double baseline = top + metrics.baselineOffset(); + + double contentBottom = top + snapshot.rows() * lineHeight; + int lastRow = snapshot.rows() - 1; + boolean cursorRowDirty = false; + double bandMin = Double.POSITIVE_INFINITY; + double bandMax = Double.NEGATIVE_INFINITY; + for (RenderRow row : snapshot.renderRows()) { + if (!row.dirty()) { + continue; + } + // Snap the row band to integer pixels and paint opaque: a fractional-height fill + // would leave sub-pixel seams between rows. + double y0 = Math.floor(top + (row.row() * lineHeight)); + double y1 = Math.ceil(top + ((row.row() + 1) * lineHeight)); + gc.setFill(PANE_BACKGROUND); + gc.fillRect(px, y0, pw, y1 - y0); + paintSidePadding(gc, row, px, pw, left, cellWidth, y0, y1 - y0); + drawRow(gc, row, left, top, baseline, cellWidth, 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; + } + } + if (bandMin > bandMax) { + return; + } + + // The cursor overlays its cell; redraw it only when its row was repainted, so we + // neither leave a stale cursor nor stack the translucent overlay on itself. + if (cursorRowDirty) { + drawCursor(gc, snapshot, left, top, cellWidth, lineHeight); + } + // Repainting rows clears the side borders within the band; restore just those + // segments, clipped to the band so we don't redraw the whole outline. + gc.save(); + clipRect(gc, px, bandMin, pw, bandMax - bandMin); + drawBorder(gc, px, py, pw, ph, active); + gc.restore(); + } + + private void drawBorder(GraphicsContext gc, double x, double y, double width, double height, boolean active) { + gc.setStroke(active ? Color.rgb(87, 166, 255) : Color.rgb(52, 57, 65)); + gc.setLineWidth(active ? 2.0 : 1.0); + gc.strokeRect(x + 0.5, y + 0.5, width - 1.0, height - 1.0); + } + + // 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 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); + } + + private static void drawRow( + GraphicsContext gc, + RenderRow row, + double left, + double top, + double baseline, + double cellWidth, + double lineHeight + ) { + for (RenderCell cell : row.cells()) { + if (cell.kittyPlaceholder().isPresent()) { + continue; + } + + double x = left + (cell.column() * cellWidth); + double cellTop = top + (row.row() * lineHeight); + + // Resolve fg/bg (null bg = terminal default, painted by the pane background). + // Avoid Optional.map's allocation on this hot path. + 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; + + // Reverse video: ghostty does not bake inverse into the resolved colours, so we + // swap them here, falling back to the terminal defaults for whichever is unset. + if (cell.inverse()) { + Color swappedBg = fg; + fg = (bg != null) ? bg : PANE_BACKGROUND; + bg = swappedBg; + } + + if (bg != null) { + gc.setFill(bg); + gc.fillRect(x, cellTop, cellWidth, lineHeight); + } + if (cell.selected()) { + gc.setFill(SELECTED_BACKGROUND); + gc.fillRect(x, cellTop, cellWidth, lineHeight); + } + if (cell.codepoints().length == 0) { + continue; + } + + double y = baseline + (row.row() * lineHeight); + gc.setFill(fg); + gc.fillText(cell.text(), x, y); + } + } + + 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 void drawCursor(GraphicsContext gc, RenderStateSnapshot snapshot, double left, double top, double cellWidth, double lineHeight) { + if (!snapshot.cursorVisible() || !snapshot.cursorViewportHasValue()) { + return; + } + + double x = left + (snapshot.cursorViewportX() * cellWidth); + double y = top + (snapshot.cursorViewportY() * lineHeight); + gc.setStroke(Color.rgb(225, 229, 235)); + gc.setFill(Color.rgb(225, 229, 235, 0.28)); + gc.setLineWidth(1.5); + + RenderCursorStyle style = snapshot.cursorStyle(); + if (style == RenderCursorStyle.BAR) { + gc.strokeLine(x + 0.5, y + 2.0, x + 0.5, y + lineHeight - 2.0); + } else if (style == RenderCursorStyle.UNDERLINE) { + gc.strokeLine(x + 1.0, y + lineHeight - 2.0, x + cellWidth - 1.0, y + lineHeight - 2.0); + } else if (style == RenderCursorStyle.BLOCK) { + gc.fillRect(x + 0.5, y + 1.0, Math.max(1.0, cellWidth - 1.0), Math.max(1.0, lineHeight - 2.0)); + } else { + gc.strokeRect(x + 0.5, y + 1.0, Math.max(1.0, cellWidth - 1.0), Math.max(1.0, lineHeight - 2.0)); + } + } + + // ---- Kitty graphics -------------------------------------------------------------- + + private static boolean hasKittyGraphics(RenderTarget target) { + return target.kittyGraphics() + .map(graphics -> !graphics.placements().isEmpty()) + .orElse(false); + } + + private void drawKittyGraphics( + GraphicsContext gc, + RenderTarget target, + KittyPlacementLayer layer, + Map placeholderBounds, + double originX, + double originY, + double cellWidth, + double lineHeight + ) { + target.kittyGraphics().ifPresent(graphics -> { + for (KittyPlacement placement : graphics.placements(layer)) { + Image image = imageFor(placement); + if (image == null) { + continue; + } + + if (placement.virtual()) { + drawVirtualKittyPlacement(gc, placement, image, placeholderBounds, originX, originY, cellWidth, lineHeight); + } else { + drawPinnedKittyPlacement(gc, placement, image, originX, originY, cellWidth, lineHeight); + } + } + }); + } + + private static void drawPinnedKittyPlacement( + GraphicsContext gc, + KittyPlacement placement, + Image image, + double originX, + double originY, + double cellWidth, + double lineHeight + ) { + KittyRenderInfo renderInfo = placement.renderInfo().orElse(null); + if (renderInfo == null || !renderInfo.viewportVisible()) { + return; + } + + double sourceX = renderInfo.sourceX(); + double sourceY = renderInfo.sourceY(); + double sourceWidth = renderInfo.sourceWidth(); + double sourceHeight = renderInfo.sourceHeight(); + if (sourceWidth <= 0.0 || sourceHeight <= 0.0) { + return; + } + + double x = originX + (renderInfo.viewportColumn() * cellWidth) + placement.xOffset(); + double y = originY + (renderInfo.viewportRow() * lineHeight) + placement.yOffset(); + double width = renderInfo.pixelWidth() > 0 ? renderInfo.pixelWidth() : renderInfo.gridColumns() * cellWidth; + double height = renderInfo.pixelHeight() > 0 ? renderInfo.pixelHeight() : renderInfo.gridRows() * lineHeight; + if (width <= 0.0 || height <= 0.0) { + return; + } + + gc.drawImage(image, sourceX, sourceY, sourceWidth, sourceHeight, x, y, width, height); + } + + private static void drawVirtualKittyPlacement( + GraphicsContext gc, + KittyPlacement placement, + Image image, + Map placeholderBounds, + double originX, + double originY, + double cellWidth, + double lineHeight + ) { + KittyPlaceholderBounds bounds = placeholderBounds.get(new KittyPlaceholderKey(placement.imageId(), placement.placementId())); + if (bounds == null) { + bounds = placeholderBounds.get(new KittyPlaceholderKey(placement.imageId(), 0)); + } + if (bounds == null && placement.placementId() == 0) { + bounds = placeholderBounds.entrySet().stream() + .filter(entry -> entry.getKey().imageId() == placement.imageId()) + .map(Map.Entry::getValue) + .findFirst() + .orElse(null); + } + if (bounds == null || bounds.isEmpty()) { + return; + } + + SourceRect source = sourceRect(placement, image); + if (source.width() <= 0.0 || source.height() <= 0.0) { + return; + } + + long gridColumns = gridColumns(placement, bounds); + long gridRows = gridRows(placement, bounds); + double sourceCellWidth = source.width() / Math.max(1L, gridColumns); + double sourceCellHeight = source.height() / Math.max(1L, gridRows); + + double sourceX = source.x() + (bounds.minSourceColumn * sourceCellWidth); + double sourceY = source.y() + (bounds.minSourceRow * sourceCellHeight); + double sourceWidth = bounds.sourceColumns() * sourceCellWidth; + double sourceHeight = bounds.sourceRows() * sourceCellHeight; + double x = originX + (bounds.minColumn * cellWidth); + double y = originY + (bounds.minRow * lineHeight); + double availableWidth = bounds.columns() * cellWidth; + double availableHeight = bounds.rows() * lineHeight; + + if (sourceWidth <= 0.0 || sourceHeight <= 0.0 || availableWidth <= 0.0 || availableHeight <= 0.0) { + return; + } + + double scale = Math.min(availableWidth / sourceWidth, availableHeight / sourceHeight); + double width = sourceWidth * scale; + double height = sourceHeight * scale; + gc.drawImage(image, sourceX, sourceY, sourceWidth, sourceHeight, x, y, width, height); + } + + private static long gridColumns(KittyPlacement placement, KittyPlaceholderBounds bounds) { + if (placement.columns() > 0) { + return placement.columns(); + } + return Math.max(bounds.maxSourceColumn + 1, bounds.sourceColumns()); + } + + private static long gridRows(KittyPlacement placement, KittyPlaceholderBounds bounds) { + if (placement.rows() > 0) { + return placement.rows(); + } + return Math.max(bounds.maxSourceRow + 1, bounds.sourceRows()); + } + + private static SourceRect sourceRect(KittyPlacement placement, Image image) { + double sourceX = placement.sourceX(); + double sourceY = placement.sourceY(); + double sourceWidth = placement.sourceWidth() > 0 ? placement.sourceWidth() : image.getWidth() - sourceX; + double sourceHeight = placement.sourceHeight() > 0 ? placement.sourceHeight() : image.getHeight() - sourceY; + return new SourceRect(sourceX, sourceY, Math.min(sourceWidth, image.getWidth() - sourceX), Math.min(sourceHeight, image.getHeight() - sourceY)); + } + + private Image imageFor(KittyPlacement placement) { + return placement.image().map(snapshot -> { + byte[] data = snapshot.data(); + KittyImageKey key = KittyImageKey.of(snapshot, data); + Image cached = kittyImageCache.get(key); + if (cached != null) { + return cached; + } + + kittyImageCache.keySet().removeIf(existing -> existing.id() == snapshot.id()); + Image decoded = decodeImage(snapshot, data); + if (decoded != null) { + kittyImageCache.put(key, decoded); + } + return decoded; + }).orElse(null); + } + + private Image decodeImage(KittyImageSnapshot snapshot, byte[] data) { + if (snapshot.compression() != KittyImageCompression.NONE) { + return null; + } + + if (snapshot.format() == KittyImageFormat.PNG) { + return new Image(new ByteArrayInputStream(data)); + } + + int width = Math.toIntExact(snapshot.width()); + int height = Math.toIntExact(snapshot.height()); + WritableImage image = new WritableImage(width, height); + + if (snapshot.format() == KittyImageFormat.RGBA) { + image.getPixelWriter().setPixels(0, 0, width, height, PixelFormat.getByteBgraInstance(), rgbaToBgra(data), 0, width * 4); + } else if (snapshot.format() == KittyImageFormat.RGB) { + image.getPixelWriter().setPixels(0, 0, width, height, PixelFormat.getByteRgbInstance(), data, 0, width * 3); + } + return image; + } + + private static byte[] rgbaToBgra(byte[] rgba) { + byte[] bgra = new byte[rgba.length]; + for (int i = 0; i + 3 < rgba.length; i += 4) { + bgra[i] = rgba[i + 2]; + bgra[i + 1] = rgba[i + 1]; + bgra[i + 2] = rgba[i]; + bgra[i + 3] = rgba[i + 3]; + } + return bgra; + } + + private static Map kittyPlaceholderBounds(RenderStateSnapshot snapshot) { + if (snapshot == null) { + return Map.of(); + } + + Map result = new HashMap<>(); + for (RenderRow row : snapshot.renderRows()) { + for (RenderCell cell : row.cells()) { + cell.kittyPlaceholder().ifPresent(placeholder -> { + KittyPlaceholderKey key = new KittyPlaceholderKey(placeholder.imageId(), placeholder.placementId()); + result.computeIfAbsent(key, ignored -> new KittyPlaceholderBounds()).include(row.row(), cell.column(), placeholder); + }); + } + } + return result; + } + + // 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 + // we avoid fingerprinting the whole payload — which previously ran once per frame per + // placement (O(image size)) just to look the image up. + private record KittyImageKey(long id, long number, long width, long height, KittyImageFormat format, int dataLength) { + private static KittyImageKey of(KittyImageSnapshot snapshot, byte[] data) { + return new KittyImageKey( + snapshot.id(), + snapshot.number(), + snapshot.width(), + snapshot.height(), + snapshot.format(), + data.length + ); + } + } + + private record KittyPlaceholderKey(long imageId, long placementId) { + } + + private record SourceRect(double x, double y, double width, double height) { + } + + private static final class KittyPlaceholderBounds { + private int minRow = Integer.MAX_VALUE; + private int maxRow = Integer.MIN_VALUE; + private int minColumn = Integer.MAX_VALUE; + private int maxColumn = Integer.MIN_VALUE; + private long minSourceRow = Long.MAX_VALUE; + private long maxSourceRow = Long.MIN_VALUE; + private long minSourceColumn = Long.MAX_VALUE; + private long maxSourceColumn = Long.MIN_VALUE; + + private void include(int row, int column, KittyPlaceholder placeholder) { + minRow = Math.min(minRow, row); + maxRow = Math.max(maxRow, row); + minColumn = Math.min(minColumn, column); + maxColumn = Math.max(maxColumn, column); + minSourceRow = Math.min(minSourceRow, placeholder.sourceRow()); + maxSourceRow = Math.max(maxSourceRow, placeholder.sourceRow()); + minSourceColumn = Math.min(minSourceColumn, placeholder.sourceColumn()); + maxSourceColumn = Math.max(maxSourceColumn, placeholder.sourceColumn()); + } + + private boolean isEmpty() { + return minRow == Integer.MAX_VALUE; + } + + private int rows() { + return maxRow - minRow + 1; + } + + private int columns() { + return maxColumn - minColumn + 1; + } + + private long sourceRows() { + return maxSourceRow - minSourceRow + 1; + } + + private long sourceColumns() { + return maxSourceColumn - minSourceColumn + 1; + } + } +} diff --git a/src/main/java/com/gregor/jprototerm/Main.java b/src/main/java/com/gregor/jprototerm/Main.java index 690e894..1b38207 100644 --- a/src/main/java/com/gregor/jprototerm/Main.java +++ b/src/main/java/com/gregor/jprototerm/Main.java @@ -21,20 +21,20 @@ import java.nio.file.Files; import java.nio.file.Path; public final class Main extends Application { - private TerminalWorkspace workspace; - private TerminalCanvasView terminalView; + private Compositor compositor; + private TerminalMetrics metrics; private AppConfig config; @Override public void start(Stage stage) { config = AppConfig.load(); - workspace = new TerminalWorkspace(config); - terminalView = new TerminalCanvasView(workspace, config); + metrics = new TerminalMetrics(config.fontFamily(), config.fontSize()); + compositor = new Compositor(config, metrics); - StackPane root = new StackPane(terminalView.canvas()); - terminalView.canvas().widthProperty().bind(root.widthProperty()); - terminalView.canvas().heightProperty().bind(root.heightProperty()); + StackPane root = new StackPane(compositor.canvas()); + compositor.canvas().widthProperty().bind(root.widthProperty()); + compositor.canvas().heightProperty().bind(root.heightProperty()); Scene scene = new Scene(root, config.windowWidth(), config.windowHeight()); scene.addEventFilter(KeyEvent.KEY_PRESSED, this::handlePressed); @@ -43,57 +43,57 @@ public final class Main extends Application { new AnimationTimer() { @Override public void handle(long now) { - terminalView.render(); + compositor.render(); } }.start(); stage.setTitle("jprototerm"); stage.setScene(scene); stage.setOnCloseRequest(event -> { - workspace.close(); + compositor.close(); }); stage.show(); - terminalView.canvas().requestFocus(); + compositor.canvas().requestFocus(); } private void handlePressed(KeyEvent event) { if (config.keybindings().get("navigate_left").matches(event)) { - workspace.navigate(Direction.LEFT); + compositor.navigate(Direction.LEFT); event.consume(); } else if (config.keybindings().get("navigate_down").matches(event)) { - workspace.navigate(Direction.DOWN); + compositor.navigate(Direction.DOWN); event.consume(); } else if (config.keybindings().get("navigate_up").matches(event)) { - workspace.navigate(Direction.UP); + compositor.navigate(Direction.UP); event.consume(); } else if (config.keybindings().get("navigate_right").matches(event)) { - workspace.navigate(Direction.RIGHT); + compositor.navigate(Direction.RIGHT); event.consume(); } else if (config.keybindings().get("toggle_floating").matches(event)) { - workspace.toggleFloating(); + compositor.toggleFloating(); event.consume(); } else if (config.keybindings().get("new_pane").matches(event)) { - workspace.createPane(); + compositor.createPane(); event.consume(); } else if (config.keybindings().get("next_floating").matches(event)) { - workspace.nextFloatingPane(); + compositor.nextFloatingPane(); event.consume(); } else if (config.keybindings().get("close_pane").matches(event)) { - workspace.closeActivePane(); + compositor.closeActivePane(); event.consume(); - if (workspace.isEmpty()) { + if (compositor.isEmpty()) { // Closing the last pane quits the app. - workspace.close(); + compositor.close(); Platform.exit(); } } else if (config.keybindings().get("new_tab").matches(event)) { - workspace.newTab(); + compositor.newTab(); event.consume(); } else if (config.keybindings().get("previous_tab").matches(event)) { - workspace.previousTab(); + compositor.previousTab(); event.consume(); } else if (config.keybindings().get("next_tab").matches(event)) { - workspace.nextTab(); + compositor.nextTab(); event.consume(); } else if (config.keybindings().get("open_font_selector").matches(event)) { openFontSelector(); @@ -104,7 +104,7 @@ public final class Main extends Application { } else { String encoded = KeyEncoder.encode(event); if (encoded != null) { - workspace.activePane().send(encoded); + compositor.activePane().send(encoded); event.consume(); } } @@ -117,7 +117,7 @@ public final class Main extends Application { String text = event.getCharacter(); if (text != null && !text.isEmpty() && text.charAt(0) >= 0x20 && text.charAt(0) != 0x7f) { - workspace.activePane().send(text); + compositor.activePane().send(text); event.consume(); } } @@ -160,18 +160,18 @@ public final class Main extends Application { double selectedSize = size.getValue(); config = config.withFont(selectedFamily.trim(), selectedSize); config.save(); - terminalView.setFont(config.fontFamily(), config.fontSize()); - terminalView.canvas().requestFocus(); + compositor.setFont(config.fontFamily(), config.fontSize()); + compositor.canvas().requestFocus(); }); } private void openScrollbackInEditor() { try { Path file = Files.createTempFile("jprototerm-scrollback-", ".txt"); - Files.writeString(file, workspace.activePane().scrollbackText()); + Files.writeString(file, compositor.activePane().scrollbackText()); file.toFile().deleteOnExit(); - workspace.activePane().send(scrollbackEditorCommand(file) + "\r"); + compositor.activePane().send(scrollbackEditorCommand(file) + "\r"); } catch (IOException ex) { System.err.println("Could not open scrollback in editor: " + ex.getMessage()); } diff --git a/src/main/java/com/gregor/jprototerm/RenderTarget.java b/src/main/java/com/gregor/jprototerm/RenderTarget.java new file mode 100644 index 0000000..9b538aa --- /dev/null +++ b/src/main/java/com/gregor/jprototerm/RenderTarget.java @@ -0,0 +1,45 @@ +package com.gregor.jprototerm; + +import dev.jlibghostty.KittyGraphics; +import dev.jlibghostty.RenderStateSnapshot; +import javafx.scene.shape.Shape; + +import java.util.Optional; + +/** + * The read-only view of a pane that a {@link TerminalRenderer} draws: its on-screen rect, its + * current render snapshot, and its kitty-graphics state. Decoupling the renderer from + * {@link TerminalPane} through this interface lets the renderer be swapped (e.g. a debug + * renderer that just outlines bounds and clip bands) and unit-tested against a synthetic + * target without a real terminal. + */ +interface RenderTarget { + double x(); + + double y(); + + double width(); + + double height(); + + /** Whether kitty graphics should be drawn for this target at all. */ + boolean kittyEnabled(); + + Optional kittyGraphics(); + + /** + * Incremental snapshot: only rows that changed since the last frame are populated. May be + * {@code null} before the first snapshot exists. + */ + RenderStateSnapshot snapshot(); + + /** Full snapshot with every row populated, regardless of dirty state. */ + RenderStateSnapshot snapshotFull(); + + /** + * The region this target may draw into, or {@code null} to clip to its plain rect. Set at + * layout time (a tiled pane gets its rect minus the floating panes that cover it), so the + * renderer can clip its own output and never paint over a pane on top. + */ + Shape clip(); +} diff --git a/src/main/java/com/gregor/jprototerm/Tab.java b/src/main/java/com/gregor/jprototerm/Tab.java index e7b963e..e90f492 100644 --- a/src/main/java/com/gregor/jprototerm/Tab.java +++ b/src/main/java/com/gregor/jprototerm/Tab.java @@ -1,262 +1,276 @@ package com.gregor.jprototerm; +import javafx.scene.shape.Rectangle; +import javafx.scene.shape.Shape; + import java.util.ArrayList; import java.util.Comparator; import java.util.List; +import java.util.stream.Stream; /** - * One tab: an isolated stack of panes (tiled + floating) with its own active pane and - * stashed-floating state. {@link TerminalWorkspace} owns the list of tabs and renders only - * the current one. Mutating methods return whether they actually changed anything so the - * workspace can bump its render version conditionally. + * One tab: a row of tiled panes with a group of floating panes shown over them. Floating panes + * are shown/hidden as a group ({@code floatingVisible}), and there is always at least one tiled + * pane — a floating pane is promoted if the last tiled one closes — so the layout always has a + * base. The {@link Compositor} owns the tabs and renders only the current one; mutating methods + * return whether they actually changed anything so it can bump its layout version. */ final class Tab implements AutoCloseable { private final AppConfig config; - private final List panes = new ArrayList<>(); - private int activeIndex; - private int hiddenFloatingFocusIndex = -1; + private final TerminalMetrics metrics; + private final List tiled = new ArrayList<>(); + private final List floating = new ArrayList<>(); + private boolean floatingVisible; + private TerminalPane active; + // The floating pane to re-focus when the group is shown again, and to prefer when promoting + // after the last tiled pane closes. private TerminalPane lastFocusedFloating; + // Last laid-out size, so a newly opened pane can be created at roughly its eventual rect + // (and thus grid). Seeded from the configured window size for the first pane, which is + // opened before any layout pass runs. + private double lastWidth; + private double lastHeight; + private double lastTopInset; + // Bumped whenever one of this tab's panes changes content; the compositor reads the current + // tab's value each frame as an O(1) "anything to repaint?" check. + private long contentVersion; - Tab(AppConfig config) { + Tab(AppConfig config, TerminalMetrics metrics) { this.config = config; - panes.add(openPane(false)); + this.metrics = metrics; + this.lastWidth = config.windowWidth(); + this.lastHeight = config.windowHeight(); + TerminalPane first = openPane(false); + tiled.add(first); + active = first; } TerminalPane activePane() { - return panes.get(activeIndex); + return active; } boolean isEmpty() { - return panes.isEmpty(); + return tiled.isEmpty() && floating.isEmpty(); } + long contentVersion() { + return contentVersion; + } + + /** + * Panes to composite, bottom-to-top: tiled first, then (when shown) the floating group with + * the active floating pane on top. + */ List panes() { - if (panes.isEmpty()) { - return List.of(); + if (!floatingVisible || floating.isEmpty()) { + return List.copyOf(tiled); } - List visible = panes.stream().filter(TerminalPane::visible).toList(); - if (visible.isEmpty()) { - return List.of(); - } - // Draw order = z-order: all tiled panes first (they never overlap), then floating - // panes on top, with the active floating pane last (topmost). This holds regardless - // of creation order, so a tiled pane created after a floating one still sits behind. - TerminalPane active = activePane(); - List ordered = new ArrayList<>(visible.size()); - for (TerminalPane pane : visible) { - if (!pane.floating()) { + List ordered = new ArrayList<>(tiled.size() + floating.size()); + ordered.addAll(tiled); + for (TerminalPane pane : floating) { + if (pane != active) { ordered.add(pane); } } - for (TerminalPane pane : visible) { - if (pane.floating() && pane != active) { - ordered.add(pane); - } - } - if (active.visible() && active.floating()) { - ordered.add(active); + if (floating.contains(active)) { + ordered.add(active); // active floating pane on top } return List.copyOf(ordered); } boolean isActive(TerminalPane pane) { - return !panes.isEmpty() && activePane() == pane; + return pane != null && pane == active; } boolean focus(TerminalPane pane) { - int index = panes.indexOf(pane); - if (index >= 0 && pane.visible() && activeIndex != index) { - setActive(index); - return true; + if (pane == active || !isFocusable(pane)) { + return false; } - return false; + setActive(pane); + return true; } void layout(double width, double height, double topInset) { + this.lastWidth = width; + this.lastHeight = height; + this.lastTopInset = topInset; double availHeight = height - topInset; - List tiled = panes.stream() - .filter(TerminalPane::visible) - .filter(pane -> !pane.floating()) - .toList(); - int tileCount = Math.max(1, tiled.size()); - double tileWidth = width / tileCount; + + double tileWidth = width / Math.max(1, tiled.size()); for (int i = 0; i < tiled.size(); i++) { tiled.get(i).bounds(i * tileWidth, topInset, tileWidth, availHeight); } - List floating = panes.stream() - .filter(TerminalPane::visible) - .filter(TerminalPane::floating) - .toList(); + double floatingWidth = Math.max(420, width * 0.58); + double floatingHeight = Math.max(260, availHeight * 0.58); for (int i = 0; i < floating.size(); i++) { - TerminalPane pane = floating.get(i); - double floatingWidth = Math.max(420, width * 0.58); - double floatingHeight = Math.max(260, availHeight * 0.58); double offset = i * 28.0; - pane.bounds( + 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), floatingWidth, - floatingHeight - ); + floatingHeight); + } + + assignClips(); + } + + // Give each pane its clip region for the next paints, so repainting a pane on a content + // frame can never bleed over one stacked on top of it. Each pane is clipped to its rect + // minus the union of the panes above it: floating panes are clipped by the floating panes + // higher in the stack, and tiled panes by the whole floating group. When nothing floats, + // 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)); + return; + } + + // Floating panes bottom-to-top, matching panes(): insertion order, active pane on top. + List 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. + 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)); + above = (above == null) ? rect : Shape.union(above, rect); + } + + // `above` is now the union of every floating pane; tiled panes sit under all of them. + for (TerminalPane pane : tiled) { + pane.setClip(Shape.subtract(rectOf(pane), above)); } } + // Match the renderer's pixel snapping (round the origin, keep width/height) so the clip + // lines up exactly with where the floating panes are drawn. + private static Rectangle rectOf(TerminalPane pane) { + return new Rectangle(Math.round(pane.x()), Math.round(pane.y()), pane.width(), pane.height()); + } + boolean navigate(Direction direction) { - TerminalPane current = activePane(); - if (current.floating() && navigateFloatingStack(direction)) { + if (floating.contains(active) && navigateFloatingStack(direction)) { return true; } - - TerminalPane target = panes.stream() - .filter(TerminalPane::visible) - .filter(pane -> pane != current) - .filter(pane -> directionFilter(direction, current, pane)) - .min(Comparator.comparingDouble(pane -> distance(current, pane))) + TerminalPane target = focusable() + .filter(pane -> pane != active) + .filter(pane -> directionFilter(direction, active, pane)) + .min(Comparator.comparingDouble(pane -> distance(active, pane))) .orElse(null); if (target != null) { - setActive(panes.indexOf(target)); + setActive(target); return true; } return false; } void toggleFloating() { - List floating = panes.stream() - .filter(TerminalPane::floating) - .toList(); if (floating.isEmpty()) { createFloatingPane(); return; } - - boolean anyVisible = floating.stream().anyMatch(TerminalPane::visible); - if (anyVisible) { - TerminalPane active = activePane(); - hiddenFloatingFocusIndex = active.floating() ? activeIndex : firstVisibleFloatingIndex(); - floating.forEach(pane -> pane.setVisible(false)); - setActive(firstVisibleNonFloatingIndex()); + if (floatingVisible) { + floatingVisible = false; + if (floating.contains(active)) { + setActive(tiled.get(0)); + } } else { - floating.forEach(pane -> pane.setVisible(true)); - setActive(visibleIndexOrFallback(hiddenFloatingFocusIndex, panes.indexOf(floating.get(floating.size() - 1)))); - hiddenFloatingFocusIndex = -1; + floatingVisible = true; + setActive(floating.contains(lastFocusedFloating) ? lastFocusedFloating : floating.get(floating.size() - 1)); } } - /** - * "New pane": adds a floating pane while floating panes are shown, otherwise adds a - * tiled pane (the tiled row is redistributed equally by the layout). - */ + /** Adds a floating pane while the floating group is shown, otherwise a tiled pane. */ void createPane() { - if (anyFloatingVisible()) { + if (floatingVisible) { createFloatingPane(); } else { TerminalPane pane = openPane(false); - panes.add(pane); - setActive(panes.size() - 1); + tiled.add(pane); + setActive(pane); } } void nextFloatingPane() { - TerminalPane next = nextFloatingAfter(activeIndex); - next.setVisible(true); - setActive(panes.indexOf(next)); + if (floating.isEmpty()) { + createFloatingPane(); + return; + } + floatingVisible = true; + int current = floating.indexOf(active); // -1 when the active pane is tiled + setActive(floating.get((current + 1 + floating.size()) % floating.size())); } void closeActivePane() { - TerminalPane active = activePane(); - int removed = activeIndex; - // When closing a floating pane, focus the next visible floating pane if there is one - // (don't jump to a tiled pane); otherwise fall back to the nearest visible pane. - int target = active.floating() ? nearestVisibleFloatingIndex(removed) : -1; - if (target < 0) { - target = previousVisibleIndex(removed); + TerminalPane closing = active; + boolean wasFloating = floating.remove(closing); + if (!wasFloating) { + tiled.remove(closing); } - panes.remove(removed); - if (active == lastFocusedFloating) { + if (closing == lastFocusedFloating) { lastFocusedFloating = null; } - active.close(); - if (panes.isEmpty()) { - activeIndex = 0; + closing.close(); + + if (tiled.isEmpty() && floating.isEmpty()) { + active = null; // tab is now empty; the compositor drops it return; } - activeIndex = adjustIndexAfterRemoval(target, removed); - hiddenFloatingFocusIndex = adjustHiddenFocusAfterRemoval(hiddenFloatingFocusIndex, removed); - // 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; + // Always keep a tiled base: if the last tiled pane just closed, promote a floating one + // (preferring the last focused). + if (tiled.isEmpty()) { + TerminalPane promote = floating.contains(lastFocusedFloating) ? lastFocusedFloating : floating.get(0); + var promoteIndex = floating.indexOf(promote); + var nextFocussed = promoteIndex == 0 ? 0 : promoteIndex - 1; + floating.remove(promote); + tiled.add(promote); + if (promote == lastFocusedFloating) { + lastFocusedFloating = null; + if (!floating.isEmpty()) { + lastFocusedFloating = floating.isEmpty() ? null : floating.get(nextFocussed); + } + } + } + if (floating.isEmpty()) { + floatingVisible = false; } - // 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); - } + setActive(wasFloating && floatingVisible ? floating.get(floating.size() - 1) : tiled.get(0)); } - private void setActive(int index) { - activeIndex = index; - if (index >= 0 && index < panes.size() && panes.get(index).floating()) { - lastFocusedFloating = panes.get(index); + private void setActive(TerminalPane pane) { + active = pane; + if (floating.contains(pane)) { + lastFocusedFloating = pane; } } private void createFloatingPane() { TerminalPane pane = openPane(true); - panes.add(pane); - setActive(panes.size() - 1); - } - - private boolean anyFloatingVisible() { - return panes.stream().anyMatch(pane -> pane.floating() && pane.visible()); - } - - private TerminalPane nextFloatingAfter(int index) { - for (int i = index + 1; i < panes.size(); i++) { - TerminalPane pane = panes.get(i); - if (pane.floating()) { - return pane; - } - } - for (int i = 0; i <= index && i < panes.size(); i++) { - TerminalPane pane = panes.get(i); - if (pane.floating()) { - return pane; - } - } - return createAndReturnFloatingPane(); - } - - private TerminalPane createAndReturnFloatingPane() { - TerminalPane pane = openPane(true); - panes.add(pane); - return pane; + floating.add(pane); + floatingVisible = true; + setActive(pane); } private boolean navigateFloatingStack(Direction direction) { - List floating = panes.stream() - .filter(TerminalPane::visible) - .filter(TerminalPane::floating) - .toList(); if (floating.size() < 2) { return false; } - - int current = floating.indexOf(activePane()); + int current = floating.indexOf(active); if (current < 0) { return false; } - int next = switch (direction) { case LEFT, UP -> current - 1; case DOWN, RIGHT -> current + 1; @@ -264,85 +278,35 @@ final class Tab implements AutoCloseable { if (next < 0 || next >= floating.size()) { return false; } - - setActive(panes.indexOf(floating.get(next))); + setActive(floating.get(next)); return true; } - private int firstVisibleFloatingIndex() { - for (int i = 0; i < panes.size(); i++) { - TerminalPane pane = panes.get(i); - if (pane.visible() && pane.floating()) { - return i; - } - } - return -1; + private boolean isFocusable(TerminalPane pane) { + return tiled.contains(pane) || (floatingVisible && floating.contains(pane)); } - private int firstVisibleNonFloatingIndex() { - for (int i = 0; i < panes.size(); i++) { - TerminalPane pane = panes.get(i); - if (pane.visible() && !pane.floating()) { - return i; - } - } - return 0; + private Stream focusable() { + return floatingVisible ? Stream.concat(tiled.stream(), floating.stream()) : tiled.stream(); } - private int nearestVisibleFloatingIndex(int index) { - for (int i = index + 1; i < panes.size(); i++) { - if (panes.get(i).visible() && panes.get(i).floating()) { - return i; - } - } - for (int i = index - 1; i >= 0; i--) { - if (panes.get(i).visible() && panes.get(i).floating()) { - return i; - } - } - return -1; + private void markContentChanged() { + contentVersion++; } - private int previousVisibleIndex(int index) { - for (int i = index - 1; i >= 0; i--) { - if (panes.get(i).visible()) { - return i; - } + private TerminalPane openPane(boolean asFloating) { + double availHeight = lastHeight - lastTopInset; + double widthPx; + double heightPx; + if (asFloating) { + widthPx = Math.max(420, lastWidth * 0.58); + heightPx = Math.max(260, availHeight * 0.58); + } else { + // A new tiled pane joins the row, so each gets 1/(n+1) of the width. + widthPx = lastWidth / (tiled.size() + 1); + heightPx = availHeight; } - for (int i = index + 1; i < panes.size(); i++) { - if (panes.get(i).visible()) { - return i; - } - } - return firstVisibleNonFloatingIndex(); - } - - private int visibleIndexOrFallback(int index, int fallback) { - if (index >= 0 && index < panes.size() && panes.get(index).visible()) { - return index; - } - return fallback; - } - - private static int adjustIndexAfterRemoval(int index, int removedIndex) { - if (index < 0) { - return 0; - } - return index > removedIndex ? index - 1 : index; - } - - private static int adjustHiddenFocusAfterRemoval(int index, int removedIndex) { - if (index < 0 || index == removedIndex) { - return -1; - } - return index > removedIndex ? index - 1 : index; - } - - private TerminalPane openPane(boolean floating) { - TerminalPane pane = TerminalPane.create(config.columns(), config.rows(), config.maxScrollback()); - pane.setFloating(floating); - pane.attach(ShellSession.start(config.shell(), config.envOverride(), pane, config.columns(), config.rows())); - return pane; + return TerminalPane.create(config, metrics, this::markContentChanged, widthPx, heightPx); } private static boolean directionFilter(Direction direction, TerminalPane current, TerminalPane candidate) { @@ -367,9 +331,9 @@ final class Tab implements AutoCloseable { @Override public void close() { - for (TerminalPane pane : panes) { - pane.close(); - } - panes.clear(); + tiled.forEach(TerminalPane::close); + floating.forEach(TerminalPane::close); + tiled.clear(); + floating.clear(); } } diff --git a/src/main/java/com/gregor/jprototerm/TerminalCanvasView.java b/src/main/java/com/gregor/jprototerm/TerminalCanvasView.java deleted file mode 100644 index 4127384..0000000 --- a/src/main/java/com/gregor/jprototerm/TerminalCanvasView.java +++ /dev/null @@ -1,1020 +0,0 @@ -package com.gregor.jprototerm; - -import dev.jlibghostty.KittyImageCompression; -import dev.jlibghostty.KittyImageFormat; -import dev.jlibghostty.KittyImageSnapshot; -import dev.jlibghostty.KittyPlacement; -import dev.jlibghostty.KittyPlacementLayer; -import dev.jlibghostty.KittyPlaceholder; -import dev.jlibghostty.KittyRenderInfo; -import dev.jlibghostty.KeyModifiers; -import dev.jlibghostty.MouseButton; -import dev.jlibghostty.MouseEncoderSize; -import dev.jlibghostty.MouseInput; -import dev.jlibghostty.RenderCell; -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; -import javafx.scene.image.PixelFormat; -import javafx.scene.image.WritableImage; -import javafx.scene.input.InputEvent; -import javafx.scene.input.MouseEvent; -import javafx.scene.input.ScrollEvent; -import javafx.scene.input.ScrollEvent.VerticalTextScrollUnits; -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; -import java.util.List; -import java.util.Map; - -public final class TerminalCanvasView { - private static final Color DEFAULT_FOREGROUND = Color.rgb(225, 229, 235); - private static final Color SELECTED_BACKGROUND = Color.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); - // Canvas background shown wherever no pane covers (gaps, behind nothing once tiled panes - // fill the canvas). Painted on a full recomposite. - private static final Color GAP_BACKGROUND = Color.rgb(16, 16, 18); - - private final Canvas canvas = new Canvas(); - private final TerminalWorkspace workspace; - private final AppConfig config; - private final Map kittyImageCache = new HashMap<>(); - // Last content version drawn to the canvas per pane, so a content frame repaints only - // the panes that actually changed. - private final Map paneContentVersion = new HashMap<>(); - // Pane list from the last layout pass; reused on content-only frames so typing doesn't - // re-run layout()/panes()/resize each frame. - private List cachedPanes = List.of(); - private String fontFamily; - private double fontSize; - private Font cachedFont; - private FontMetrics cachedMetrics; - private String cachedFontFamily; - private double cachedFontSize; - // Cheap per-frame dirty signal: skip the whole render when none of these changed. - private double lastWidth = -1.0; - private double lastHeight = -1.0; - private String lastFontFamily; - private double lastFontSize = -1.0; - private long lastWorkspaceVersion = Long.MIN_VALUE; - private long lastRenderTick = Long.MIN_VALUE; - private boolean mouseButtonPressed; - private MouseButton pressedButton = MouseButton.UNKNOWN; - - public TerminalCanvasView(TerminalWorkspace workspace, AppConfig config) { - this.workspace = workspace; - this.config = config; - this.fontFamily = config.fontFamily(); - this.fontSize = config.fontSize(); - canvas.setFocusTraversable(true); - canvas.setOnMousePressed(this::handleMousePressed); - canvas.setOnMouseReleased(this::handleMouseReleased); - canvas.setOnMouseDragged(this::handleMouseDragged); - canvas.setOnMouseMoved(this::handleMouseMoved); - canvas.setOnScroll(this::handleScroll); - } - - public Canvas canvas() { - return canvas; - } - - public void setFont(String family, double size) { - this.fontFamily = family; - this.fontSize = size; - cachedFont = null; - cachedMetrics = null; - paneContentVersion.clear(); - lastWidth = -1.0; // force a redraw on the next frame - } - - // GhosttyRenderStateDirty values (stable C ABI; see ghostty/vt/render.h). - // 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(); - long workspaceVersion = workspace.version(); - long renderTick = TerminalPane.renderTick(); - - // Two kinds of change: a layout change (size, font, active pane, pane set / z-order) - // forces a full recomposite; a content change (renderTick) only repaints the panes - // whose terminal content changed. Idle frames — neither — bail out immediately. - boolean layoutChanged = width != lastWidth || height != lastHeight - || fontSize != lastFontSize || !java.util.Objects.equals(fontFamily, lastFontFamily) - || workspaceVersion != lastWorkspaceVersion; - boolean contentChanged = renderTick != lastRenderTick; - if (!layoutChanged && !contentChanged) { - return; - } - lastWidth = width; - lastHeight = height; - lastFontFamily = fontFamily; - lastFontSize = fontSize; - lastWorkspaceVersion = workspaceVersion; - lastRenderTick = renderTick; - - Font font = currentFont(); - FontMetrics metrics = currentFontMetrics(); - GraphicsContext gc = canvas.getGraphicsContext2D(); - gc.setFontSmoothingType(FontSmoothingType.LCD); - - if (layoutChanged) { - // Geometry/pane-set changed: relayout, resize terminals, and recomposite the - // whole canvas, painting panes bottom-to-top (active floating pane last == top). - double topInset = workspace.tabCount() > 1 ? TAB_BAR_HEIGHT : 0.0; - workspace.layout(width, height, topInset); - List panes = workspace.panes(); - for (TerminalPane pane : panes) { - applyResize(pane, metrics); - } - cachedPanes = panes; - 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.renderSnapshot()); - paneContentVersion.put(pane, pane.renderVersion()); - } - return; - } - - // Content-only frame: geometry is unchanged, so skip layout/panes/resize entirely and - // reuse the cached pane list — repaint just the panes whose content changed. - List panes = cachedPanes; - for (int i = 0; i < panes.size(); i++) { - TerminalPane pane = panes.get(i); - Long drawn = paneContentVersion.get(pane); - if (drawn != null && drawn == pane.renderVersion()) { - continue; - } - repaintPaneContent(gc, panes, i, font, metrics); - paneContentVersion.put(pane, pane.renderVersion()); - } - } - - private void applyResize(TerminalPane pane, FontMetrics metrics) { - int columns = Math.max(1, (int) ((pane.width() - 24.0) / metrics.cellWidth)); - int rows = Math.max(1, (int) ((pane.height() - 24.0) / metrics.lineHeight)); - pane.resize(columns, rows, (int) Math.round(metrics.cellWidth), (int) Math.round(metrics.lineHeight)); - } - - // Paint a pane's whole body, clipped to its rect. Used for full recomposites. - private void paintPane(GraphicsContext gc, TerminalPane pane, Font font, FontMetrics metrics, RenderStateSnapshot snapshot) { - double px = Math.round(pane.x()); - double py = Math.round(pane.y()); - gc.save(); - clipRect(gc, px, py, pane.width(), pane.height()); - drawPaneContent(gc, pane, font, metrics, snapshot, px, py, pane.width(), pane.height(), - config.kittyGraphics() && paneHasKittyGraphics(pane)); - gc.restore(); - } - - // Repaint one pane whose content changed (a full, reliable repaint of the pane), then - // restore the (opaque) panes stacked above it where they overlap, keeping z-order. - private void repaintPaneContent(GraphicsContext gc, List panes, int index, Font font, FontMetrics metrics) { - TerminalPane pane = panes.get(index); - double px = Math.round(pane.x()); - double py = Math.round(pane.y()); - double pw = pane.width(); - double ph = pane.height(); - gc.save(); - clipRect(gc, px, py, pw, ph); - drawPaneContent(gc, pane, font, metrics, pane.renderSnapshot(), px, py, pw, ph, - config.kittyGraphics() && paneHasKittyGraphics(pane)); - gc.restore(); - restoreStackedAbove(gc, panes, index, font, metrics, px, py, pw, ph); - } - - // Redraw any panes above `index` in z-order that intersect the given screen rect, so a - // lower pane's repaint doesn't leak through a pane stacked on top of it. - private void restoreStackedAbove(GraphicsContext gc, List panes, int index, - Font font, FontMetrics metrics, double rx, double ry, double rw, double rh) { - for (int j = index + 1; j < panes.size(); j++) { - TerminalPane above = panes.get(j); - double ax = Math.round(above.x()); - double ay = Math.round(above.y()); - double ox0 = Math.max(rx, ax); - double oy0 = Math.max(ry, ay); - double ox1 = Math.min(rx + rw, ax + above.width()); - double oy1 = Math.min(ry + rh, ay + above.height()); - if (ox1 <= ox0 || oy1 <= oy0) { - continue; - } - gc.save(); - clipRect(gc, ox0, oy0, ox1 - ox0, oy1 - oy0); - drawPaneContent(gc, above, font, metrics, above.renderSnapshot(), ax, ay, above.width(), above.height(), - config.kittyGraphics() && paneHasKittyGraphics(above)); - gc.restore(); - } - } - - private static void clipRect(GraphicsContext gc, double x, double y, double width, double height) { - gc.beginPath(); - gc.rect(x, y, width, height); - 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( - GraphicsContext gc, - TerminalPane pane, - Font font, - FontMetrics metrics, - RenderStateSnapshot snapshot, - double x, - double y, - double width, - double height, - boolean withKitty - ) { - gc.setFontSmoothingType(FontSmoothingType.LCD); - gc.setFill(PANE_BACKGROUND); - gc.fillRect(x, y, width, height); - gc.setFont(font); - - double left = x + 12.0; - double top = y + 12.0; - double baseline = top + metrics.baselineOffset; - - Map placeholderBounds = withKitty - ? kittyPlaceholderBounds(snapshot) - : Map.of(); - - if (withKitty) { - drawKittyGraphics(gc, pane, KittyPlacementLayer.BELOW_TEXT, placeholderBounds, left, top, metrics.cellWidth, metrics.lineHeight); - } - - 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); - } - - 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 - // origin, then restore the cursor and border. Returns the screen-space [minY, maxY] band - // that was repainted (for restoring panes stacked above), or null if nothing was dirty. - private double[] drawDirtyRows( - GraphicsContext gc, - TerminalPane pane, - Font font, - FontMetrics metrics, - RenderStateSnapshot snapshot, - double px, - double py, - double pw, - double ph - ) { - gc.setFontSmoothingType(FontSmoothingType.LCD); - gc.setFont(font); - double left = px + 12.0; - 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; - for (RenderRow row : snapshot.renderRows()) { - if (!row.dirty()) { - continue; - } - // Snap the row band to integer pixels and paint opaque: a fractional-height fill - // would leave sub-pixel seams between rows. - double y0 = Math.floor(top + (row.row() * metrics.lineHeight)); - 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; - } - } - if (bandMin > bandMax) { - return null; - } - - // The cursor overlays its cell; redraw it only when its row was repainted, so we - // neither leave a stale cursor nor stack the translucent overlay on itself. - if (cursorRowDirty) { - drawCursor(gc, snapshot, left, top, metrics.cellWidth, metrics.lineHeight); - } - // Repainting rows clears the side borders within the band; restore just those - // segments. Clipping to the band is important: the full border rectangle extends - // outside the repainted region, and only the band gets restored over panes stacked - // above — an unclipped stroke would leave this pane's outline on top of them. - gc.save(); - clipRect(gc, px, bandMin, pw, bandMax - bandMin); - drawBorder(gc, pane, px, py, pw, ph); - gc.restore(); - return new double[] {bandMin, bandMax}; - } - - private void drawBorder(GraphicsContext gc, TerminalPane pane, double x, double y, double width, double height) { - gc.setStroke(workspace.isActive(pane) ? Color.rgb(87, 166, 255) : Color.rgb(52, 57, 65)); - gc.setLineWidth(workspace.isActive(pane) ? 2.0 : 1.0); - gc.strokeRect(x + 0.5, y + 0.5, width - 1.0, height - 1.0); - } - - private static FontMetrics measureFontMetrics(Font font) { - Text text = new Text("┃MgÅjy"); - text.setFont(font); - // Snap the cell size to whole pixels so cells tile on integer boundaries. Fractional - // cell metrics put every cell edge on a sub-pixel position, leaving anti-aliased - // seams that show up as a faint grid behind the themed cell backgrounds. Rounding - // leaves a few pixels of unused space at the right/bottom edge, which is fine. - double lineHeight = Math.max(1.0, Math.round(text.getLayoutBounds().getHeight())); - double baselineOffset = -text.getLayoutBounds().getMinY(); - - Text cell = new Text("M"); - cell.setFont(font); - double cellWidth = Math.max(1.0, Math.round(cell.getLayoutBounds().getWidth())); - return new FontMetrics(cellWidth, lineHeight, baselineOffset); - } - - private Font currentFont() { - if (cachedFont == null || !fontFamily.equals(cachedFontFamily) || fontSize != cachedFontSize) { - cachedFont = Font.font(fontFamily, fontSize); - cachedMetrics = null; - cachedFontFamily = fontFamily; - cachedFontSize = fontSize; - } - return cachedFont; - } - - private FontMetrics currentFontMetrics() { - if (cachedMetrics == null) { - cachedMetrics = measureFontMetrics(currentFont()); - } - return cachedMetrics; - } - - // Layout identity of a pane: everything that forces a redraw EXCEPT terminal content - // (which is tracked separately by renderVersion). Deliberately omits renderVersion so - // content changes go through the incremental dirty-row path instead of a full redraw. - private static Map kittyPlaceholderBounds(RenderStateSnapshot snapshot) { - if (snapshot == null) { - return Map.of(); - } - - Map result = new HashMap<>(); - for (RenderRow row : snapshot.renderRows()) { - for (RenderCell cell : row.cells()) { - cell.kittyPlaceholder().ifPresent(placeholder -> { - KittyPlaceholderKey key = new KittyPlaceholderKey(placeholder.imageId(), placeholder.placementId()); - result.computeIfAbsent(key, ignored -> new KittyPlaceholderBounds()).include(row.row(), cell.column(), placeholder); - }); - } - } - return result; - } - - private void handleMousePressed(MouseEvent event) { - canvas.requestFocus(); - TerminalPane pane = paneAt(event.getX(), event.getY()); - if (pane == null) { - return; - } - - workspace.focus(pane); - pressedButton = mouseButton(event); - mouseButtonPressed = true; - sendMouse(pane, MouseInput.press(pressedButton, eventX(pane, event.getX()), eventY(pane, event.getY()), modifiers(event)), true, event); - } - - private void handleMouseReleased(MouseEvent event) { - TerminalPane pane = paneAt(event.getX(), event.getY()); - if (pane == null) { - pane = workspace.activePane(); - } - - MouseButton button = pressedButton == MouseButton.UNKNOWN ? mouseButton(event) : pressedButton; - sendMouse(pane, MouseInput.release(button, eventX(pane, event.getX()), eventY(pane, event.getY()), modifiers(event)), false, event); - mouseButtonPressed = false; - pressedButton = MouseButton.UNKNOWN; - } - - private void handleMouseDragged(MouseEvent event) { - TerminalPane pane = paneAt(event.getX(), event.getY()); - if (pane == null) { - pane = workspace.activePane(); - } - - MouseButton button = pressedButton == MouseButton.UNKNOWN ? mouseButton(event) : pressedButton; - sendMouse(pane, MouseInput.drag(button, eventX(pane, event.getX()), eventY(pane, event.getY()), modifiers(event)), true, event); - } - - private void handleMouseMoved(MouseEvent event) { - TerminalPane pane = paneAt(event.getX(), event.getY()); - if (pane == null) { - return; - } - - sendMouse(pane, MouseInput.motion(eventX(pane, event.getX()), eventY(pane, event.getY()), modifiers(event)), mouseButtonPressed, event); - } - - private void handleScroll(ScrollEvent event) { - TerminalPane pane = paneAt(event.getX(), event.getY()); - if (pane == null) { - return; - } - - canvas.requestFocus(); - workspace.focus(pane); - int direction = scrollDirection(event); - if (direction == 0) { - return; - } - - MouseButton wheelButton = direction > 0 ? MouseButton.FOUR : MouseButton.FIVE; - int rows = scrollRows(event); - boolean sent = false; - for (int i = 0; i < rows; i++) { - sent |= sendMouse( - pane, - MouseInput.press(wheelButton, eventX(pane, event.getX()), eventY(pane, event.getY()), modifiers(event)), - mouseButtonPressed, - event - ); - } - if (!sent) { - pane.scrollViewport(direction > 0 ? -rows : rows); - event.consume(); - } - } - - private boolean sendMouse(TerminalPane pane, MouseInput input, boolean anyButtonPressed, InputEvent event) { - MouseTarget target = mouseTarget(pane); - if (target == null) { - return false; - } - - boolean sent = pane.sendMouse(input, target.size(), anyButtonPressed); - if (sent) { - event.consume(); - } - return sent; - } - - private TerminalPane paneAt(double x, double y) { - java.util.List panes = workspace.panes(); - for (int i = panes.size() - 1; i >= 0; i--) { - TerminalPane pane = panes.get(i); - if (x >= pane.x() && x < pane.x() + pane.width() && y >= pane.y() && y < pane.y() + pane.height()) { - return pane; - } - } - return null; - } - - private MouseTarget mouseTarget(TerminalPane pane) { - if (pane.width() <= 24.0 || pane.height() <= 24.0) { - return null; - } - - FontMetrics metrics = currentFontMetrics(); - int columns = Math.max(1, (int) ((pane.width() - 24.0) / metrics.cellWidth)); - int rows = Math.max(1, (int) ((pane.height() - 24.0) / metrics.lineHeight)); - long cellWidth = Math.max(1L, Math.round(metrics.cellWidth)); - long cellHeight = Math.max(1L, Math.round(metrics.lineHeight)); - long screenWidth = Math.max(1L, Math.round(columns * metrics.cellWidth)); - long screenHeight = Math.max(1L, Math.round(rows * metrics.lineHeight)); - return new MouseTarget(MouseEncoderSize.of(screenWidth, screenHeight, cellWidth, cellHeight), screenWidth, screenHeight); - } - - private double eventX(TerminalPane pane, double canvasX) { - MouseTarget target = mouseTarget(pane); - if (target == null) { - return 0.0; - } - return clamp(canvasX - pane.x() - 12.0, 0.0, target.screenWidth() - 1.0); - } - - private double eventY(TerminalPane pane, double canvasY) { - MouseTarget target = mouseTarget(pane); - if (target == null) { - return 0.0; - } - return clamp(canvasY - pane.y() - 12.0, 0.0, target.screenHeight() - 1.0); - } - - private static double clamp(double value, double min, double max) { - return Math.max(min, Math.min(max, value)); - } - - private static KeyModifiers modifiers(MouseEvent event) { - return KeyModifiers.of(event.isShiftDown(), event.isControlDown(), event.isAltDown(), event.isMetaDown()); - } - - private static KeyModifiers modifiers(ScrollEvent event) { - return KeyModifiers.of(event.isShiftDown(), event.isControlDown(), event.isAltDown(), event.isMetaDown()); - } - - private static int scrollRows(ScrollEvent event) { - double rows; - if (event.getTextDeltaYUnits() == VerticalTextScrollUnits.LINES && event.getTextDeltaY() != 0.0) { - rows = Math.abs(event.getTextDeltaY()); - } else if (event.getTextDeltaYUnits() == VerticalTextScrollUnits.PAGES && event.getTextDeltaY() != 0.0) { - rows = Math.abs(event.getTextDeltaY()) * 24.0; - } else if (event.getMultiplierY() > 0.0) { - rows = Math.abs(event.getDeltaY()) / event.getMultiplierY(); - } else { - rows = Math.abs(event.getDeltaY()) / 40.0; - } - return Math.max(1, Math.min(64, (int) Math.ceil(rows))); - } - - private static int scrollDirection(ScrollEvent event) { - if (event.getDeltaY() != 0.0) { - return event.getDeltaY() > 0.0 ? 1 : -1; - } - if (event.getTextDeltaYUnits() != VerticalTextScrollUnits.NONE && event.getTextDeltaY() != 0.0) { - return event.getTextDeltaY() > 0.0 ? 1 : -1; - } - return 0; - } - - private static MouseButton mouseButton(MouseEvent event) { - return switch (event.getButton()) { - case PRIMARY -> MouseButton.LEFT; - case SECONDARY -> MouseButton.RIGHT; - case MIDDLE -> MouseButton.MIDDLE; - default -> MouseButton.UNKNOWN; - }; - } - - private static void drawRow( - GraphicsContext gc, - RenderRow row, - double left, - double top, - double baseline, - double cellWidth, - double lineHeight - ) { - for (RenderCell cell : row.cells()) { - if (cell.kittyPlaceholder().isPresent()) { - continue; - } - - double x = left + (cell.column() * cellWidth); - double cellTop = top + (row.row() * lineHeight); - - // Resolve fg/bg (null bg = terminal default, painted by the pane background). - // Avoid Optional.map's allocation on this hot path. - 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; - - // Reverse video: ghostty does not bake inverse into the resolved colours, so we - // swap them here, falling back to the terminal defaults for whichever is unset. - if (cell.inverse()) { - Color swappedBg = fg; - fg = (bg != null) ? bg : PANE_BACKGROUND; - bg = swappedBg; - } - - if (bg != null) { - gc.setFill(bg); - gc.fillRect(x, cellTop, cellWidth, lineHeight); - } - if (cell.selected()) { - gc.setFill(SELECTED_BACKGROUND); - gc.fillRect(x, cellTop, cellWidth, lineHeight); - } - if (cell.codepoints().length == 0) { - continue; - } - - double y = baseline + (row.row() * lineHeight); - gc.setFill(fg); - gc.fillText(cell.text(), x, y); - } - } - - // 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 COLOR_CACHE = new HashMap<>(); - - 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 void drawCursor(GraphicsContext gc, RenderStateSnapshot snapshot, double left, double top, double cellWidth, double lineHeight) { - if (!snapshot.cursorVisible() || !snapshot.cursorViewportHasValue()) { - return; - } - - double x = left + (snapshot.cursorViewportX() * cellWidth); - double y = top + (snapshot.cursorViewportY() * lineHeight); - gc.setStroke(Color.rgb(225, 229, 235)); - gc.setFill(Color.rgb(225, 229, 235, 0.28)); - gc.setLineWidth(1.5); - - RenderCursorStyle style = snapshot.cursorStyle(); - if (style == RenderCursorStyle.BAR) { - gc.strokeLine(x + 0.5, y + 2.0, x + 0.5, y + lineHeight - 2.0); - } else if (style == RenderCursorStyle.UNDERLINE) { - gc.strokeLine(x + 1.0, y + lineHeight - 2.0, x + cellWidth - 1.0, y + lineHeight - 2.0); - } else if (style == RenderCursorStyle.BLOCK) { - gc.fillRect(x + 0.5, y + 1.0, Math.max(1.0, cellWidth - 1.0), Math.max(1.0, lineHeight - 2.0)); - } else { - gc.strokeRect(x + 0.5, y + 1.0, Math.max(1.0, cellWidth - 1.0), Math.max(1.0, lineHeight - 2.0)); - } - } - - private void drawKittyGraphics( - GraphicsContext gc, - TerminalPane pane, - KittyPlacementLayer layer, - Map placeholderBounds, - double originX, - double originY, - double cellWidth, - double lineHeight - ) { - pane.kittyGraphics().ifPresent(graphics -> { - for (KittyPlacement placement : graphics.placements(layer)) { - Image image = imageFor(placement); - if (image == null) { - continue; - } - - if (placement.virtual()) { - drawVirtualKittyPlacement(gc, placement, image, placeholderBounds, originX, originY, cellWidth, lineHeight); - } else { - drawPinnedKittyPlacement(gc, placement, image, originX, originY, cellWidth, lineHeight); - } - } - }); - } - - private static void drawPinnedKittyPlacement( - GraphicsContext gc, - KittyPlacement placement, - Image image, - double originX, - double originY, - double cellWidth, - double lineHeight - ) { - KittyRenderInfo renderInfo = placement.renderInfo().orElse(null); - if (renderInfo == null || !renderInfo.viewportVisible()) { - return; - } - - double sourceX = renderInfo.sourceX(); - double sourceY = renderInfo.sourceY(); - double sourceWidth = renderInfo.sourceWidth(); - double sourceHeight = renderInfo.sourceHeight(); - if (sourceWidth <= 0.0 || sourceHeight <= 0.0) { - return; - } - - double x = originX + (renderInfo.viewportColumn() * cellWidth) + placement.xOffset(); - double y = originY + (renderInfo.viewportRow() * lineHeight) + placement.yOffset(); - double width = renderInfo.pixelWidth() > 0 ? renderInfo.pixelWidth() : renderInfo.gridColumns() * cellWidth; - double height = renderInfo.pixelHeight() > 0 ? renderInfo.pixelHeight() : renderInfo.gridRows() * lineHeight; - if (width <= 0.0 || height <= 0.0) { - return; - } - - gc.drawImage(image, sourceX, sourceY, sourceWidth, sourceHeight, x, y, width, height); - } - - private static void drawVirtualKittyPlacement( - GraphicsContext gc, - KittyPlacement placement, - Image image, - Map placeholderBounds, - double originX, - double originY, - double cellWidth, - double lineHeight - ) { - KittyPlaceholderBounds bounds = placeholderBounds.get(new KittyPlaceholderKey(placement.imageId(), placement.placementId())); - if (bounds == null) { - bounds = placeholderBounds.get(new KittyPlaceholderKey(placement.imageId(), 0)); - } - if (bounds == null && placement.placementId() == 0) { - bounds = placeholderBounds.entrySet().stream() - .filter(entry -> entry.getKey().imageId() == placement.imageId()) - .map(Map.Entry::getValue) - .findFirst() - .orElse(null); - } - if (bounds == null || bounds.isEmpty()) { - return; - } - - SourceRect source = sourceRect(placement, image); - if (source.width() <= 0.0 || source.height() <= 0.0) { - return; - } - - long gridColumns = gridColumns(placement, bounds); - long gridRows = gridRows(placement, bounds); - double sourceCellWidth = source.width() / Math.max(1L, gridColumns); - double sourceCellHeight = source.height() / Math.max(1L, gridRows); - - double sourceX = source.x() + (bounds.minSourceColumn * sourceCellWidth); - double sourceY = source.y() + (bounds.minSourceRow * sourceCellHeight); - double sourceWidth = bounds.sourceColumns() * sourceCellWidth; - double sourceHeight = bounds.sourceRows() * sourceCellHeight; - double x = originX + (bounds.minColumn * cellWidth); - double y = originY + (bounds.minRow * lineHeight); - double availableWidth = bounds.columns() * cellWidth; - double availableHeight = bounds.rows() * lineHeight; - - if (sourceWidth <= 0.0 || sourceHeight <= 0.0 || availableWidth <= 0.0 || availableHeight <= 0.0) { - return; - } - - double scale = Math.min(availableWidth / sourceWidth, availableHeight / sourceHeight); - double width = sourceWidth * scale; - double height = sourceHeight * scale; - gc.drawImage(image, sourceX, sourceY, sourceWidth, sourceHeight, x, y, width, height); - } - - private static long gridColumns(KittyPlacement placement, KittyPlaceholderBounds bounds) { - if (placement.columns() > 0) { - return placement.columns(); - } - return Math.max(bounds.maxSourceColumn + 1, bounds.sourceColumns()); - } - - private static long gridRows(KittyPlacement placement, KittyPlaceholderBounds bounds) { - if (placement.rows() > 0) { - return placement.rows(); - } - return Math.max(bounds.maxSourceRow + 1, bounds.sourceRows()); - } - - private static SourceRect sourceRect(KittyPlacement placement, Image image) { - double sourceX = placement.sourceX(); - double sourceY = placement.sourceY(); - double sourceWidth = placement.sourceWidth() > 0 ? placement.sourceWidth() : image.getWidth() - sourceX; - double sourceHeight = placement.sourceHeight() > 0 ? placement.sourceHeight() : image.getHeight() - sourceY; - return new SourceRect(sourceX, sourceY, Math.min(sourceWidth, image.getWidth() - sourceX), Math.min(sourceHeight, image.getHeight() - sourceY)); - } - - private Image imageFor(KittyPlacement placement) { - return placement.image().map(snapshot -> { - byte[] data = snapshot.data(); - KittyImageKey key = KittyImageKey.of(snapshot, data); - Image cached = kittyImageCache.get(key); - if (cached != null) { - return cached; - } - - kittyImageCache.keySet().removeIf(existing -> existing.id() == snapshot.id()); - Image decoded = decodeImage(snapshot, data); - if (decoded != null) { - kittyImageCache.put(key, decoded); - } - return decoded; - }).orElse(null); - } - - private boolean paneHasKittyGraphics(TerminalPane pane) { - return pane.kittyGraphics() - .map(graphics -> !graphics.placements().isEmpty()) - .orElse(false); - } - - private Image decodeImage(KittyImageSnapshot snapshot, byte[] data) { - if (snapshot.compression() != KittyImageCompression.NONE) { - return null; - } - - if (snapshot.format() == KittyImageFormat.PNG) { - return new Image(new ByteArrayInputStream(data)); - } - - int width = Math.toIntExact(snapshot.width()); - int height = Math.toIntExact(snapshot.height()); - WritableImage image = new WritableImage(width, height); - - if (snapshot.format() == KittyImageFormat.RGBA) { - image.getPixelWriter().setPixels(0, 0, width, height, PixelFormat.getByteBgraInstance(), rgbaToBgra(data), 0, width * 4); - } else if (snapshot.format() == KittyImageFormat.RGB) { - image.getPixelWriter().setPixels(0, 0, width, height, PixelFormat.getByteRgbInstance(), data, 0, width * 3); - } - return image; - } - - private static byte[] rgbaToBgra(byte[] rgba) { - byte[] bgra = new byte[rgba.length]; - for (int i = 0; i + 3 < rgba.length; i += 4) { - bgra[i] = rgba[i + 2]; - bgra[i + 1] = rgba[i + 1]; - bgra[i + 2] = rgba[i]; - bgra[i + 3] = rgba[i + 3]; - } - return bgra; - } - - private record FontMetrics(double cellWidth, double lineHeight, double baselineOffset) { - } - - private record MouseTarget(MouseEncoderSize size, long screenWidth, long screenHeight) { - } - - // 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 we avoid fingerprinting the whole payload — which previously ran once per frame - // per placement (O(image size)) just to look the image up. - private record KittyImageKey(long id, long number, long width, long height, KittyImageFormat format, int dataLength) { - private static KittyImageKey of(KittyImageSnapshot snapshot, byte[] data) { - return new KittyImageKey( - snapshot.id(), - snapshot.number(), - snapshot.width(), - snapshot.height(), - snapshot.format(), - data.length - ); - } - } - - private record KittyPlaceholderKey(long imageId, long placementId) { - } - - private record SourceRect(double x, double y, double width, double height) { - } - - private static final class KittyPlaceholderBounds { - private int minRow = Integer.MAX_VALUE; - private int maxRow = Integer.MIN_VALUE; - private int minColumn = Integer.MAX_VALUE; - private int maxColumn = Integer.MIN_VALUE; - private long minSourceRow = Long.MAX_VALUE; - private long maxSourceRow = Long.MIN_VALUE; - private long minSourceColumn = Long.MAX_VALUE; - private long maxSourceColumn = Long.MIN_VALUE; - - private void include(int row, int column, KittyPlaceholder placeholder) { - minRow = Math.min(minRow, row); - maxRow = Math.max(maxRow, row); - minColumn = Math.min(minColumn, column); - maxColumn = Math.max(maxColumn, column); - minSourceRow = Math.min(minSourceRow, placeholder.sourceRow()); - maxSourceRow = Math.max(maxSourceRow, placeholder.sourceRow()); - minSourceColumn = Math.min(minSourceColumn, placeholder.sourceColumn()); - maxSourceColumn = Math.max(maxSourceColumn, placeholder.sourceColumn()); - } - - private boolean isEmpty() { - return minRow == Integer.MAX_VALUE; - } - - private int rows() { - return maxRow - minRow + 1; - } - - private int columns() { - return maxColumn - minColumn + 1; - } - - private long sourceRows() { - return maxSourceRow - minSourceRow + 1; - } - - private long sourceColumns() { - return maxSourceColumn - minSourceColumn + 1; - } - } -} diff --git a/src/main/java/com/gregor/jprototerm/TerminalMetrics.java b/src/main/java/com/gregor/jprototerm/TerminalMetrics.java new file mode 100644 index 0000000..e12e020 --- /dev/null +++ b/src/main/java/com/gregor/jprototerm/TerminalMetrics.java @@ -0,0 +1,86 @@ +package com.gregor.jprototerm; + +import javafx.scene.text.Font; +import javafx.scene.text.Text; + +/** + * Cell geometry shared by the {@link Compositor} and every {@link TerminalPane}. + * + *

The nominal cell width/height come from measuring the font, but a grid can't use + * fractional cells, so the measured size is snapped to whole (logical) pixels here — that + * snapping is why the value isn't purely a property of the font. The compositor owns the + * single instance (it holds the canvas, which is the pixel context), hands it to panes so + * they can turn their rect into a column/row count themselves, and re-measures it on a font + * change so every pane observes the new geometry through the shared reference. + */ +public final class TerminalMetrics { + /** Inset, in pixels, between a pane's edge and its content on every side. */ + public static final double PADDING = 12.0; + + private String fontFamily; + private double fontSize; + private Font font; + private double cellWidth; + private double lineHeight; + private double baselineOffset; + + public TerminalMetrics(String fontFamily, double fontSize) { + setFont(fontFamily, fontSize); + } + + public void setFont(String fontFamily, double fontSize) { + this.fontFamily = fontFamily; + this.fontSize = fontSize; + this.font = Font.font(fontFamily, fontSize); + measure(font); + } + + public String fontFamily() { + return fontFamily; + } + + public double fontSize() { + return fontSize; + } + + public Font font() { + return font; + } + + public double cellWidth() { + return cellWidth; + } + + public double lineHeight() { + return lineHeight; + } + + public double baselineOffset() { + return baselineOffset; + } + + /** Columns that fit in a pane of the given pixel width (after subtracting the padding). */ + public int columnsFor(double widthPx) { + return Math.max(1, (int) ((widthPx - 2 * PADDING) / cellWidth)); + } + + /** Rows that fit in a pane of the given pixel height (after subtracting the padding). */ + public int rowsFor(double heightPx) { + return Math.max(1, (int) ((heightPx - 2 * PADDING) / lineHeight)); + } + + private void measure(Font font) { + Text text = new Text("┃MgÅjy"); + text.setFont(font); + // Snap the cell size to whole pixels so cells tile on integer boundaries. Fractional + // cell metrics put every cell edge on a sub-pixel position, leaving anti-aliased + // seams that show up as a faint grid behind the themed cell backgrounds. Rounding + // leaves a few pixels of unused space at the right/bottom edge, which is fine. + this.lineHeight = Math.max(1.0, Math.round(text.getLayoutBounds().getHeight())); + this.baselineOffset = -text.getLayoutBounds().getMinY(); + + Text cell = new Text("M"); + cell.setFont(font); + this.cellWidth = Math.max(1.0, Math.round(cell.getLayoutBounds().getWidth())); + } +} diff --git a/src/main/java/com/gregor/jprototerm/TerminalPane.java b/src/main/java/com/gregor/jprototerm/TerminalPane.java index 6afd5bb..6ad0890 100644 --- a/src/main/java/com/gregor/jprototerm/TerminalPane.java +++ b/src/main/java/com/gregor/jprototerm/TerminalPane.java @@ -1,35 +1,46 @@ package com.gregor.jprototerm; +import dev.jlibghostty.DeviceAttributes; import dev.jlibghostty.Ghostty; import dev.jlibghostty.KittyGraphics; import dev.jlibghostty.MouseAction; import dev.jlibghostty.MouseEncoder; import dev.jlibghostty.MouseEncoderSize; import dev.jlibghostty.MouseInput; +import dev.jlibghostty.RenderState; import dev.jlibghostty.RenderStateSnapshot; import dev.jlibghostty.ScrollViewport; import dev.jlibghostty.Terminal; import dev.jlibghostty.TerminalOptions; -import dev.jlibghostty.DeviceAttributes; +import javafx.scene.canvas.GraphicsContext; +import javafx.scene.shape.Shape; import java.util.Optional; -import java.util.concurrent.atomic.AtomicLong; - -public final class TerminalPane implements AutoCloseable { - // Monotonic across all panes, bumped on every content change. Lets the renderer detect - // "nothing changed" in O(1) without scanning panes or building a render key. - private static final AtomicLong RENDER_TICK = new AtomicLong(); - - public static long renderTick() { - return RENDER_TICK.get(); - } +/** + * One terminal: owns its ghostty {@link Terminal}, the {@link ShellSession}/pty driving it, + * and its on-screen geometry and grid. It does not draw itself — it is a {@link RenderTarget} + * that a {@link TerminalRenderer} paints. {@link #paintFull}/{@link #paintIncremental} are the + * only rendering API exposed to the {@link Compositor}, and they just delegate to that + * renderer; the compositor decides z-order and which rect each pane occupies. + */ +public final class TerminalPane implements AutoCloseable, RenderTarget { private final Terminal terminal; + private final TerminalMetrics metrics; + private final boolean kittyEnabled; + // Run on every content change so the owning tab can bump its content version — the + // compositor's O(1) "did the current tab change?" gate. + private final Runnable onContentChange; + private final TerminalRenderer renderer; private final MouseEncoder mouseEncoder = new MouseEncoder(); + // A persistent render state (reused across frames) is what makes ghostty's per-row dirty + // tracking meaningful: update() accumulates dirty since the last resetDirty(). + private final RenderState renderState = new RenderState(); private RenderStateSnapshot cachedSnapshot; private ShellSession session; - private boolean floating; - private boolean visible = true; + // Clip region for rendering (rect minus the panes covering this one), set at layout time; + // null means clip to the plain bounds. See RenderTarget#clip(). + private Shape clip; private double x; private double y; private double width; @@ -38,47 +49,40 @@ public final class TerminalPane implements AutoCloseable { private int rows; private int pixelWidth; private int pixelHeight; - // Bumped on the reader thread (terminal writes) and read on the FX thread (render loop), - // so it must be volatile. - private volatile long renderVersion; + private long contentVersion; private long snapshotVersion = -1; - private volatile boolean closed; - private TerminalPane(Terminal terminal, int columns, int rows) { + private TerminalPane(Terminal terminal, TerminalMetrics metrics, boolean kittyEnabled, + Runnable onContentChange, TerminalRenderer renderer, int columns, int rows) { this.terminal = terminal; + this.metrics = metrics; + this.kittyEnabled = kittyEnabled; + this.onContentChange = onContentChange; + this.renderer = renderer; this.columns = columns; this.rows = rows; } - public static TerminalPane create(int columns, int rows, long maxScrollback) { - Terminal terminal = Ghostty.open(new TerminalOptions(columns, rows, maxScrollback)); + /** + * Opens a pane sized to fit the given pixel rect: the shared cell metrics decide how many + * columns and rows fit, and that grid is handed to ghostty and the shell at start-up. A + * non-positive size falls back to the configured default grid (used before the first + * layout, when no rect is known yet). The pane owns the shell session it starts and runs + * {@code onContentChange} on every content change. + */ + public static TerminalPane create(AppConfig config, TerminalMetrics metrics, Runnable onContentChange, double widthPx, double heightPx) { + int columns = widthPx > 0 ? metrics.columnsFor(widthPx) : config.columns(); + int rows = heightPx > 0 ? metrics.rowsFor(heightPx) : config.rows(); + Terminal terminal = Ghostty.open(new TerminalOptions(columns, rows, config.maxScrollback())); terminal.setDeviceAttributesProvider(DeviceAttributes::xtermCompatible); - TerminalPane pane = new TerminalPane(terminal, columns, rows); + TerminalPane pane = new TerminalPane(terminal, metrics, config.kittyGraphics(), onContentChange, + new GhosttyTerminalRenderer(metrics), columns, rows); pane.refresh(); + pane.attach(ShellSession.start(config.shell(), config.envOverride(), pane, columns, rows)); return pane; } - public void write(String text) { - synchronized (terminal) { - if (closed) { - return; - } - terminal.write(text); - refresh(); - } - } - - public void write(byte[] bytes) { - synchronized (terminal) { - if (closed) { - return; - } - terminal.write(bytes); - refresh(); - } - } - - public void attach(ShellSession session) { + private void attach(ShellSession session) { this.session = session; terminal.setPtyWriter(bytes -> { ShellSession current = this.session; @@ -89,6 +93,20 @@ public final class TerminalPane implements AutoCloseable { session.startReading(this); } + public void write(String text) { + synchronized (terminal) { + terminal.write(text); + refresh(); + } + } + + public void write(byte[] bytes) { + synchronized (terminal) { + terminal.write(bytes); + refresh(); + } + } + public void send(String text) { scrollViewportToBottom(); if (session != null) { @@ -122,7 +140,7 @@ public final class TerminalPane implements AutoCloseable { } } - public void scrollViewportToBottom() { + private void scrollViewportToBottom() { synchronized (terminal) { terminal.scrollViewport(ScrollViewport.bottom()); refresh(); @@ -130,16 +148,37 @@ public final class TerminalPane implements AutoCloseable { } /** - * Full render snapshot of the current screen, memoised per content version (so a burst - * of writes between two frames yields one snapshot). Uses a throwaway render state per - * snapshot, which always returns the complete, correct screen — a persistent render - * state's per-row dirty tracking proved unreliable across resizes and screen clears. + * Incremental snapshot: cells are marshalled only for rows that changed since the last + * frame (global dirty == PARTIAL), reused across calls for the same content version. + * Snapshotting is deferred here rather than done in refresh(), so a burst of writes + * between two frames collapses into a single snapshot. */ - public RenderStateSnapshot renderSnapshot() { + @Override + public RenderStateSnapshot snapshot() { + return takeSnapshot(false); + } + + /** + * Full snapshot with every row's cells populated. Used where the whole pane is redrawn + * regardless of dirty state (the kitty-graphics path). + */ + @Override + public RenderStateSnapshot snapshotFull() { + return takeSnapshot(true); + } + + private RenderStateSnapshot takeSnapshot(boolean full) { synchronized (terminal) { - if (snapshotVersion != renderVersion) { - cachedSnapshot = terminal.renderSnapshot(); - snapshotVersion = renderVersion; + if (full) { + renderState.update(terminal); + cachedSnapshot = renderState.snapshot(); + renderState.resetDirty(); + snapshotVersion = contentVersion; + } else if (snapshotVersion != contentVersion) { + renderState.update(terminal); + cachedSnapshot = renderState.snapshotIncremental(); + renderState.resetDirty(); + snapshotVersion = contentVersion; } return cachedSnapshot; } @@ -151,44 +190,39 @@ public final class TerminalPane implements AutoCloseable { } } - public long renderVersion() { - return renderVersion; + /** This pane's own content revision, bumped on every change (see {@link #refresh()}). */ + public long contentVersion() { + return contentVersion; } + @Override + public boolean kittyEnabled() { + return kittyEnabled; + } + + @Override public Optional kittyGraphics() { synchronized (terminal) { return terminal.kittyGraphics(); } } - public boolean floating() { - return floating; - } - - public void setFloating(boolean floating) { - this.floating = floating; - } - - public boolean visible() { - return visible; - } - - public void setVisible(boolean visible) { - this.visible = visible; - } - + @Override public double x() { return x; } + @Override public double y() { return y; } + @Override public double width() { return width; } + @Override public double height() { return height; } @@ -200,7 +234,24 @@ public final class TerminalPane implements AutoCloseable { this.height = height; } - public void resize(int columns, int rows, int pixelWidth, int pixelHeight) { + /** Set the clip region applied on the next paints (see {@link RenderTarget#clip()}). */ + public void setClip(Shape clip) { + this.clip = clip; + } + + @Override + public Shape clip() { + return clip; + } + + /** Recompute the ghostty grid from the current bounds and the shared cell metrics. */ + public void fitToBounds() { + int columns = metrics.columnsFor(width); + int rows = metrics.rowsFor(height); + resize(columns, rows, (int) Math.round(metrics.cellWidth()), (int) Math.round(metrics.lineHeight())); + } + + private void resize(int columns, int rows, int pixelWidth, int pixelHeight) { if (columns <= 0 || rows <= 0 || pixelWidth <= 0 || pixelHeight <= 0) { return; } @@ -222,26 +273,31 @@ public final class TerminalPane implements AutoCloseable { } private void refresh() { - // Only mark the pane dirty; the snapshot itself is computed lazily in - // renderSnapshot() so a burst of writes collapses into a single snapshot per frame. - renderVersion++; - RENDER_TICK.incrementAndGet(); + // Mark this pane's content dirty (the snapshot is computed lazily in the paint path, + // so a burst of writes collapses into one snapshot per frame) and tell the owning tab + // one of its panes changed. + contentVersion++; + onContentChange.run(); + } + + /** Paint the whole pane; see {@link TerminalRenderer#paintFull}. */ + public void paintFull(GraphicsContext gc, boolean active) { + renderer.paintFull(gc, this, active); + } + + /** Repaint what changed; see {@link TerminalRenderer#paintIncremental}. */ + public void paintIncremental(GraphicsContext gc, boolean active) { + renderer.paintIncremental(gc, this, active); } @Override public void close() { - // Stop accepting reader-thread writes first, then shut the session (which unblocks - // and ends the reader), so terminal.close() can't race a write from that thread. - synchronized (terminal) { - closed = true; - } if (session != null) { session.close(); session = null; } mouseEncoder.close(); - synchronized (terminal) { - terminal.close(); - } + renderState.close(); + terminal.close(); } } diff --git a/src/main/java/com/gregor/jprototerm/TerminalRenderer.java b/src/main/java/com/gregor/jprototerm/TerminalRenderer.java new file mode 100644 index 0000000..286de53 --- /dev/null +++ b/src/main/java/com/gregor/jprototerm/TerminalRenderer.java @@ -0,0 +1,60 @@ +package com.gregor.jprototerm; + +import javafx.scene.canvas.GraphicsContext; +import javafx.scene.shape.ClosePath; +import javafx.scene.shape.LineTo; +import javafx.scene.shape.MoveTo; +import javafx.scene.shape.Path; +import javafx.scene.shape.PathElement; +import javafx.scene.shape.Shape; + +/** + * Draws a {@link RenderTarget} onto a JavaFX canvas. The {@link Compositor} owns positioning + * and z-order; a renderer only fills the target's rect, clipped to the target's {@link + * RenderTarget#clip() clip region} so a repaint can never bleed over a pane on top. + * Implementations can change the look entirely — {@link GhosttyTerminalRenderer} is the real + * terminal renderer; a debug renderer could outline pane bounds instead. + * + *

A renderer may hold per-target state (e.g. a decoded-image cache), so an instance belongs + * to a single {@link TerminalPane}. + */ +abstract class TerminalRenderer { + /** Paint the whole target into its rect, clipped to its clip region. */ + abstract void paintFull(GraphicsContext gc, RenderTarget target, boolean active); + + /** Repaint only what changed since the last frame, clipped to the target's clip region. */ + abstract void paintIncremental(GraphicsContext gc, RenderTarget target, boolean active); + + 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(); + } + } + gc.clip(); + } +} diff --git a/src/main/java/com/gregor/jprototerm/TerminalWorkspace.java b/src/main/java/com/gregor/jprototerm/TerminalWorkspace.java deleted file mode 100644 index b1477e8..0000000 --- a/src/main/java/com/gregor/jprototerm/TerminalWorkspace.java +++ /dev/null @@ -1,139 +0,0 @@ -package com.gregor.jprototerm; - -import java.util.ArrayList; -import java.util.List; - -/** - * Holds the tabs and renders only the current one. Pane operations delegate to the current - * tab; tab operations switch which tab is current. A single render version bumps on any - * change (intra-tab or tab switch) so the renderer recomposites when needed. - */ -public final class TerminalWorkspace implements AutoCloseable { - private final AppConfig config; - private final List tabs = new ArrayList<>(); - private int currentTab; - private long version; - - public TerminalWorkspace(AppConfig config) { - this.config = config; - tabs.add(new Tab(config)); - } - - private Tab current() { - return tabs.get(currentTab); - } - - public long version() { - return version; - } - - public boolean isEmpty() { - return tabs.isEmpty(); - } - - public TerminalPane activePane() { - return current().activePane(); - } - - public List panes() { - return tabs.isEmpty() ? List.of() : current().panes(); - } - - public boolean isActive(TerminalPane pane) { - return !tabs.isEmpty() && current().isActive(pane); - } - - public void layout(double width, double height, double topInset) { - if (!tabs.isEmpty()) { - 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++; - } - } - - public void navigate(Direction direction) { - if (!tabs.isEmpty() && current().navigate(direction)) { - version++; - } - } - - public void toggleFloating() { - if (tabs.isEmpty()) { - return; - } - current().toggleFloating(); - version++; - } - - public void createPane() { - if (tabs.isEmpty()) { - return; - } - current().createPane(); - version++; - } - - public void nextFloatingPane() { - if (tabs.isEmpty()) { - return; - } - current().nextFloatingPane(); - version++; - } - - public void closeActivePane() { - if (tabs.isEmpty()) { - return; - } - current().closeActivePane(); - if (current().isEmpty()) { - // Closing a tab's last pane closes the tab. When no tabs remain the workspace - // is empty and Main quits. - tabs.remove(currentTab); - if (currentTab >= tabs.size()) { - currentTab = Math.max(0, tabs.size() - 1); - } - } - version++; - } - - public void newTab() { - tabs.add(new Tab(config)); - currentTab = tabs.size() - 1; - version++; - } - - public void nextTab() { - if (tabs.size() > 1) { - currentTab = (currentTab + 1) % tabs.size(); - version++; - } - } - - public void previousTab() { - if (tabs.size() > 1) { - currentTab = (currentTab - 1 + tabs.size()) % tabs.size(); - version++; - } - } - - @Override - public void close() { - for (Tab tab : tabs) { - tab.close(); - } - tabs.clear(); - } -}