From 95619f5b4c7836277be18a62fed6f4d67569ac14 Mon Sep 17 00:00:00 2001 From: Gregor Lohaus Date: Sun, 31 May 2026 16:19:37 +0200 Subject: [PATCH] fuck did bad git reset hard to main, recovering from helix buffer --- .../com/gregor/jprototerm/Compositor.java | 496 ++++++++++++++ .../jprototerm/GhosttyTerminalRenderer.java | 631 ++++++++++++++++++ .../com/gregor/jprototerm/RenderTarget.java | 45 ++ 3 files changed, 1172 insertions(+) create mode 100644 src/main/java/com/gregor/jprototerm/Compositor.java create mode 100644 src/main/java/com/gregor/jprototerm/GhosttyTerminalRenderer.java create mode 100644 src/main/java/com/gregor/jprototerm/RenderTarget.java 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/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(); +}