diff --git a/.settings/org.eclipse.buildship.core.prefs b/.settings/org.eclipse.buildship.core.prefs index ec93094..e801c51 100644 --- a/.settings/org.eclipse.buildship.core.prefs +++ b/.settings/org.eclipse.buildship.core.prefs @@ -5,7 +5,7 @@ connection.gradle.distribution=GRADLE_DISTRIBUTION(LOCAL_INSTALLATION(/home/anon connection.project.dir= eclipse.preferences.version=1 gradle.user.home= -java.home=/nix/store/c3pl7bqrx3d2rc3dh98z6yaj0mv1p52g-openjdk-21.0.10+7/lib/openjdk +java.home=/home/anon/.local/lib/graalvm-jdk-21.0.9+7.1 jvm.arguments= offline.mode=false override.workspace.settings=true diff --git a/flake.lock b/flake.lock index c35fd42..19c80b2 100644 --- a/flake.lock +++ b/flake.lock @@ -70,11 +70,11 @@ "nixpkgs": "nixpkgs" }, "locked": { - "lastModified": 1780079529, - "narHash": "sha256-AxlGTL8c5xSLcQHvWlm994IdOqxsN8iKrA02Cpv7vso=", + "lastModified": 1780258814, + "narHash": "sha256-8rxL7xaZ/loYg3zdt0w5+hfNyHFVknDZN360NzrtCsQ=", "ref": "refs/heads/main", - "rev": "68121d50b52fb56038871c97c97e7a12ffe987c2", - "revCount": 20, + "rev": "6a3d5aa0b0b1f738c958e2a2f0249574c07d9c4d", + "revCount": 23, "type": "git", "url": "https://gitea.gregorlohaus.com/gregor/jlibghostty.git" }, diff --git a/src/main/java/com/gregor/jprototerm/Compositor.java b/src/main/java/com/gregor/jprototerm/Compositor.java index 58df043..6a57cea 100644 --- a/src/main/java/com/gregor/jprototerm/Compositor.java +++ b/src/main/java/com/gregor/jprototerm/Compositor.java @@ -244,8 +244,7 @@ public final class Compositor { drawTabBar(gc, canvas.getWidth(), topInset); } for (TerminalPane pane : panes) { - pane.paintFull(gc, isActive(pane)); - paneContentVersion.put(pane, pane.contentVersion()); + paneContentVersion.put(pane, pane.paintFull(gc, isActive(pane))); } } @@ -262,8 +261,7 @@ public final class Compositor { if (drawn != null && drawn == pane.contentVersion()) { continue; } - pane.paintIncremental(gc, isActive(pane)); - paneContentVersion.put(pane, pane.contentVersion()); + paneContentVersion.put(pane, pane.paintIncremental(gc, isActive(pane))); } } @@ -384,7 +382,10 @@ public final class Compositor { 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 (!send(pane, target, MouseInput.press(wheelButton, ex, ey, modifiers), mouseButtonPressed, event)) { + break; + } + sent = true; } } if (!sent) { diff --git a/src/main/java/com/gregor/jprototerm/GhosttyTerminalRenderer.java b/src/main/java/com/gregor/jprototerm/GhosttyTerminalRenderer.java index f6e20e8..3ef383f 100644 --- a/src/main/java/com/gregor/jprototerm/GhosttyTerminalRenderer.java +++ b/src/main/java/com/gregor/jprototerm/GhosttyTerminalRenderer.java @@ -12,14 +12,22 @@ import dev.jlibghostty.RenderColor; import dev.jlibghostty.RenderCursorStyle; import dev.jlibghostty.RenderRow; import dev.jlibghostty.RenderStateSnapshot; +import javafx.geometry.Rectangle2D; +import javafx.scene.SnapshotParameters; +import javafx.scene.canvas.Canvas; import javafx.scene.canvas.GraphicsContext; import javafx.scene.image.Image; import javafx.scene.image.PixelFormat; +import javafx.scene.image.PixelBuffer; +import javafx.scene.image.PixelReader; import javafx.scene.image.WritableImage; import javafx.scene.paint.Color; import javafx.scene.text.FontSmoothingType; +import javafx.scene.text.Text; import java.io.ByteArrayInputStream; +import java.nio.IntBuffer; +import java.util.Arrays; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -39,6 +47,9 @@ final class GhosttyTerminalRenderer extends TerminalRenderer { // The default cell background (used for cells with no explicit bg, and as the foreground // for reverse-video cells whose background is the terminal default). private static final Color PANE_BACKGROUND = Color.rgb(9, 10, 12); + private static final Color ACTIVE_BORDER = Color.rgb(87, 166, 255); + private static final Color INACTIVE_BORDER = Color.rgb(52, 57, 65); + private static final Color CURSOR_FILL = Color.rgb(225, 229, 235, 0.28); // A full-screen redraw asks for one Color per cell; most cells share a handful of colors, // so cache them by packed RGB instead of allocating a Color each time. Bounded so a @@ -48,6 +59,7 @@ final class GhosttyTerminalRenderer extends TerminalRenderer { private final TerminalMetrics metrics; // Decoded kitty images for this renderer's pane (kitty graphics state is per-terminal). private final Map kittyImageCache = new HashMap<>(); + private final SoftwareBackbuffer software = new SoftwareBackbuffer(); GhosttyTerminalRenderer(TerminalMetrics metrics) { this.metrics = metrics; @@ -61,8 +73,14 @@ final class GhosttyTerminalRenderer extends TerminalRenderer { 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)); + boolean withKitty = target.kittyEnabled() && hasKittyGraphics(target); + RenderStateSnapshot snapshot = target.snapshotFull(); + if (withKitty) { + drawContent(gc, target, snapshot, px, py, width, height, active, true); + software.invalidate(); + } else { + software.paintFull(gc, snapshot, px, py, width, height, active); + } gc.restore(); } @@ -77,13 +95,14 @@ final class GhosttyTerminalRenderer extends TerminalRenderer { 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); + software.invalidate(); } 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); + software.paintFullOrShifted(gc, target.snapshotFull(), px, py, width, height, active); } else if (dirty == DIRTY_PARTIAL) { - drawDirtyRows(gc, snapshot, px, py, width, height, active); + software.paintDirty(gc, target, snapshot, px, py, width, height, active); } // dirty == FALSE: nothing visible changed. } @@ -163,10 +182,17 @@ final class GhosttyTerminalRenderer extends TerminalRenderer { double contentBottom = top + snapshot.rows() * lineHeight; int lastRow = snapshot.rows() - 1; + List rows = snapshot.renderRows(); + boolean allRowsDirty = allRowsDirty(snapshot, rows); + if (allRowsDirty) { + gc.setFill(PANE_BACKGROUND); + gc.fillRect(px, py, pw, ph); + } + boolean cursorRowDirty = false; double bandMin = Double.POSITIVE_INFINITY; double bandMax = Double.NEGATIVE_INFINITY; - for (RenderRow row : snapshot.renderRows()) { + for (RenderRow row : rows) { if (!row.dirty()) { continue; } @@ -174,8 +200,10 @@ final class GhosttyTerminalRenderer extends TerminalRenderer { // 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); + if (!allRowsDirty) { + 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); @@ -213,8 +241,21 @@ final class GhosttyTerminalRenderer extends TerminalRenderer { gc.restore(); } + private static boolean allRowsDirty(RenderStateSnapshot snapshot, List rows) { + if (rows.size() != snapshot.rows()) { + return false; + } + for (int i = 0; i < rows.size(); i++) { + RenderRow row = rows.get(i); + if (!row.dirty() || row.row() != i) { + return false; + } + } + return true; + } + 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.setStroke(active ? ACTIVE_BORDER : INACTIVE_BORDER); gc.setLineWidth(active ? 2.0 : 1.0); gc.strokeRect(x + 0.5, y + 0.5, width - 1.0, height - 1.0); } @@ -266,7 +307,7 @@ final class GhosttyTerminalRenderer extends TerminalRenderer { gc.fillRect(paneX, contentBottom, paneWidth, paneY + paneHeight - contentBottom); } - private static void drawRow( + private void drawRow( GraphicsContext gc, RenderRow row, double left, @@ -275,45 +316,108 @@ final class GhosttyTerminalRenderer extends TerminalRenderer { double cellWidth, double lineHeight ) { + drawRowBackgrounds(gc, row, left, top, cellWidth, lineHeight); + drawRowText(gc, row, left, baseline, cellWidth, lineHeight); + } + + private static void drawRowBackgrounds( + GraphicsContext gc, + RenderRow row, + double left, + double top, + double cellWidth, + double lineHeight + ) { + Color runBackground = null; + int runStartColumn = 0; + int previousColumn = -1; for (RenderCell cell : row.cells()) { if (cell.kittyPlaceholder().isPresent()) { + flushBackgroundRun(gc, runBackground, left, top, cellWidth, lineHeight, row.row(), runStartColumn, previousColumn); + runBackground = null; + previousColumn = -1; 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) { + Color bg = cell.selected() ? SELECTED_BACKGROUND : cellBackgroundOverride(cell); + if (bg == null) { + flushBackgroundRun(gc, runBackground, left, top, cellWidth, lineHeight, row.row(), runStartColumn, previousColumn); + runBackground = null; + previousColumn = -1; continue; } - double y = baseline + (row.row() * lineHeight); - gc.setFill(fg); - gc.fillText(cell.text(), x, y); + if (runBackground == null || bg != runBackground || cell.column() != previousColumn + 1) { + flushBackgroundRun(gc, runBackground, left, top, cellWidth, lineHeight, row.row(), runStartColumn, previousColumn); + runBackground = bg; + runStartColumn = cell.column(); + } + previousColumn = cell.column(); } + flushBackgroundRun(gc, runBackground, left, top, cellWidth, lineHeight, row.row(), runStartColumn, previousColumn); + } + + private static void flushBackgroundRun( + GraphicsContext gc, + Color background, + double left, + double top, + double cellWidth, + double lineHeight, + int row, + int startColumn, + int endColumn + ) { + if (background == null || endColumn < startColumn) { + return; + } + gc.setFill(background); + gc.fillRect( + left + (startColumn * cellWidth), + top + (row * lineHeight), + (endColumn - startColumn + 1) * cellWidth, + lineHeight); + } + + private void drawRowText( + GraphicsContext gc, + RenderRow row, + double left, + double baseline, + double cellWidth, + double lineHeight + ) { + for (RenderCell cell : row.cells()) { + if (cell.kittyPlaceholder().isPresent() || cell.codepoints().length == 0) { + continue; + } + + gc.setFill(cellForegroundColor(cell)); + gc.fillText(cell.text(), left + (cell.column() * cellWidth), baseline + (row.row() * lineHeight)); + } + } + + // Background override for a cell: null means the pane default background already covers it. + private static Color cellBackgroundOverride(RenderCell cell) { + if (cell.inverse()) { + var fg = cell.foreground(); + return fg.isPresent() ? toFxColor(fg.get()) : DEFAULT_FOREGROUND; + } + var bgOpt = cell.background(); + Color bg = bgOpt.isPresent() ? toFxColor(bgOpt.get()) : null; + return bg; + } + + private static Color cellForegroundColor(RenderCell cell) { + var fgOpt = cell.foreground(); + var bgOpt = cell.background(); + Color fg = fgOpt.isPresent() ? toFxColor(fgOpt.get()) : DEFAULT_FOREGROUND; + Color bg = bgOpt.isPresent() ? toFxColor(bgOpt.get()) : null; + + if (cell.inverse()) { + return (bg != null) ? bg : PANE_BACKGROUND; + } + return fg; } private static Color toFxColor(RenderColor color) { @@ -337,8 +441,8 @@ final class GhosttyTerminalRenderer extends TerminalRenderer { 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.setStroke(DEFAULT_FOREGROUND); + gc.setFill(CURSOR_FILL); gc.setLineWidth(1.5); RenderCursorStyle style = snapshot.cursorStyle(); @@ -563,6 +667,655 @@ final class GhosttyTerminalRenderer extends TerminalRenderer { return result; } + private final class SoftwareBackbuffer { + private int width; + private int height; + private int[] pixels = new int[0]; + private PixelBuffer pixelBuffer; + private WritableImage image; + private long[] rowHashes = new long[0]; + private CursorState lastCursor = CursorState.none(); + private GlyphCache glyphs; + // Half-open [min, max) vertical span of buffer rows written since the last present, so + // present() can upload only that band to the GPU instead of the whole pane texture. + private int dirtyMinY = Integer.MAX_VALUE; + private int dirtyMaxY = Integer.MIN_VALUE; + + private void invalidate() { + rowHashes = new long[0]; + lastCursor = CursorState.none(); + } + + // Record that buffer rows [y0, y1) changed; clamped to the buffer in dirtyRegion(). + private void markDirtyRows(int y0, int y1) { + if (y0 < dirtyMinY) { + dirtyMinY = y0; + } + if (y1 > dirtyMaxY) { + dirtyMaxY = y1; + } + } + + private void resetDirty() { + dirtyMinY = Integer.MAX_VALUE; + dirtyMaxY = Integer.MIN_VALUE; + } + + // The region to hand PixelBuffer.updateBuffer: a full-width band covering the rows + // written this frame (clamped to the buffer), or EMPTY when nothing changed. + private Rectangle2D dirtyRegion() { + int y0 = Math.max(0, dirtyMinY); + int y1 = Math.min(height, dirtyMaxY); + if (y0 >= y1) { + return Rectangle2D.EMPTY; + } + return new Rectangle2D(0, y0, width, y1 - y0); + } + + private void paintFull(GraphicsContext gc, RenderStateSnapshot snapshot, + double px, double py, double paneWidth, double paneHeight, boolean active) { + ensure(paneWidth, paneHeight); + fillRect(0, 0, width, height, argbPre(PANE_BACKGROUND)); + if (snapshot != null) { + paintSnapshot(snapshot); + drawCursor(snapshot); + rememberSnapshot(snapshot); + } else { + invalidate(); + } + drawBorder(active); + present(gc, px, py); + } + + private void paintFullOrShifted(GraphicsContext gc, RenderStateSnapshot snapshot, + double px, double py, double paneWidth, double paneHeight, boolean active) { + ensure(paneWidth, paneHeight); + if (snapshot == null || !canDiff(snapshot)) { + paintFull(gc, snapshot, px, py, paneWidth, paneHeight, active); + return; + } + + long[] currentHashes = hashes(snapshot); + int scroll = inferScroll(currentHashes); + if (scroll != 0) { + scrollContentPixels(scroll); + scrollHashes(scroll); + } + + CursorState cursor = CursorState.from(snapshot); + int oldCursorRow = shiftedCursorRow(lastCursor, scroll); + int newCursorRow = cursor.viewportRow(); + boolean cursorChanged = !cursor.equals(lastCursor); + for (RenderRow row : snapshot.renderRows()) { + int rowIndex = row.row(); + boolean repaint = currentHashes[rowIndex] != rowHashes[rowIndex] + || rowIndex == newCursorRow + || (cursorChanged && rowIndex == oldCursorRow); + if (repaint) { + paintRow(row); + rowHashes[rowIndex] = currentHashes[rowIndex]; + } + } + lastCursor = cursor; + drawCursor(snapshot); + drawBorder(active); + present(gc, px, py); + } + + private void paintDirty(GraphicsContext gc, RenderTarget target, RenderStateSnapshot snapshot, + double px, double py, double paneWidth, double paneHeight, boolean active) { + ensure(paneWidth, paneHeight); + if (snapshot == null) { + return; + } + if (rowHashes.length != snapshot.rows()) { + paintFull(gc, target.snapshotFull(), px, py, paneWidth, paneHeight, active); + return; + } + + CursorState cursor = CursorState.from(snapshot); + int oldCursorRow = lastCursor.viewportRow(); + int newCursorRow = cursor.viewportRow(); + boolean cursorChanged = !cursor.equals(lastCursor); + boolean[] repainted = new boolean[snapshot.rows()]; + boolean needsCursorDraw = cursorChanged; + + for (RenderRow row : snapshot.renderRows()) { + if (!row.dirty()) { + continue; + } + paintRow(row); + rowHashes[row.row()] = rowHash(row); + repainted[row.row()] = true; + if (row.row() == newCursorRow) { + needsCursorDraw = true; + } + } + + if (cursorChanged) { + if (!repaintCursorRow(snapshot, oldCursorRow, repainted)) { + paintFullOrShifted(gc, target.snapshotFull(), px, py, paneWidth, paneHeight, active); + return; + } + } + if (repaintedRowHasCursor(newCursorRow, repainted) + && !repaintCursorRow(snapshot, newCursorRow, repainted)) { + paintFullOrShifted(gc, target.snapshotFull(), px, py, paneWidth, paneHeight, active); + return; + } + lastCursor = cursor; + if (needsCursorDraw) { + drawCursor(snapshot); + } + drawBorder(active); + present(gc, px, py); + } + + private boolean repaintCursorRow(RenderStateSnapshot snapshot, int rowIndex, boolean[] repainted) { + if (rowIndex < 0 || rowIndex >= repainted.length || repainted[rowIndex]) { + return true; + } + RenderRow row = rowByIndex(snapshot, rowIndex); + if (row == null || !row.dirty()) { + return false; + } + paintRow(row); + rowHashes[rowIndex] = rowHash(row); + repainted[rowIndex] = true; + return true; + } + + private boolean repaintedRowHasCursor(int rowIndex, boolean[] repainted) { + return rowIndex >= 0 && rowIndex < repainted.length && repainted[rowIndex]; + } + + private RenderRow rowByIndex(RenderStateSnapshot snapshot, int rowIndex) { + for (RenderRow row : snapshot.renderRows()) { + if (row.row() == rowIndex) { + return row; + } + } + return null; + } + + private void ensure(double paneWidth, double paneHeight) { + int nextWidth = Math.max(1, (int) Math.round(paneWidth)); + int nextHeight = Math.max(1, (int) Math.round(paneHeight)); + if (nextWidth == width && nextHeight == height && image != null) { + ensureGlyphs(); + return; + } + + width = nextWidth; + height = nextHeight; + pixels = new int[width * height]; + pixelBuffer = new PixelBuffer<>(width, height, IntBuffer.wrap(pixels), PixelFormat.getIntArgbPreInstance()); + image = new WritableImage(pixelBuffer); + invalidate(); + ensureGlyphs(); + } + + private void ensureGlyphs() { + int cellWidth = cellWidth(); + int lineHeight = lineHeight(); + double baseline = metrics.baselineOffset(); + if (glyphs == null || glyphs.font != metrics.font() + || glyphs.cellWidth != cellWidth || glyphs.lineHeight != lineHeight || glyphs.baseline != baseline) { + glyphs = new GlyphCache(metrics.font(), cellWidth, lineHeight, baseline); + } + } + + private void present(GraphicsContext gc, double px, double py) { + // Only re-upload the rows that actually changed; the unchanged remainder of the pane + // texture is already correct on the GPU from the previous frame. + Rectangle2D dirty = dirtyRegion(); + resetDirty(); + pixelBuffer.updateBuffer(ignored -> dirty); + gc.drawImage(image, px, py); + } + + private boolean canDiff(RenderStateSnapshot snapshot) { + return rowHashes.length == snapshot.rows() && snapshot.renderRows().size() == snapshot.rows(); + } + + private void rememberSnapshot(RenderStateSnapshot snapshot) { + if (rowHashes.length != snapshot.rows()) { + rowHashes = new long[snapshot.rows()]; + } + for (RenderRow row : snapshot.renderRows()) { + rowHashes[row.row()] = rowHash(row); + } + lastCursor = CursorState.from(snapshot); + } + + private long[] hashes(RenderStateSnapshot snapshot) { + long[] hashes = new long[snapshot.rows()]; + for (RenderRow row : snapshot.renderRows()) { + hashes[row.row()] = rowHash(row); + } + return hashes; + } + + private int inferScroll(long[] currentHashes) { + int rows = currentHashes.length; + int bestDelta = 0; + int bestScore = 0; + int maxDelta = Math.min(8, Math.max(0, rows - 1)); + for (int delta = -maxDelta; delta <= maxDelta; delta++) { + if (delta == 0) { + continue; + } + int score = 0; + int overlap = 0; + for (int row = 0; row < rows; row++) { + int previous = row - delta; + if (previous < 0 || previous >= rows) { + continue; + } + overlap++; + if (currentHashes[row] == rowHashes[previous]) { + score++; + } + } + if (score > bestScore || (score == bestScore && Math.abs(delta) < Math.abs(bestDelta))) { + bestScore = score; + bestDelta = delta; + } + } + int threshold = Math.max(3, (int) Math.ceil(rows * 0.55)); + return bestScore >= threshold ? bestDelta : 0; + } + + private int shiftedCursorRow(CursorState cursor, int scroll) { + int row = cursor.viewportRow(); + if (row < 0) { + return -1; + } + row += scroll; + return row >= 0 && row < rowHashes.length ? row : -1; + } + + private void scrollHashes(int rows) { + long[] shifted = new long[rowHashes.length]; + for (int row = 0; row < shifted.length; row++) { + int previous = row - rows; + if (previous >= 0 && previous < rowHashes.length) { + shifted[row] = rowHashes[previous]; + } + } + rowHashes = shifted; + } + + private void scrollContentPixels(int rows) { + int dy = rows * lineHeight(); + int top = contentTop(); + int contentHeight = rowHashes.length * lineHeight(); + // The whole content region shifts; the arraycopy below moves pixels that the + // per-strip fillRect calls don't touch, so mark the full content band for upload. + markDirtyRows(top, top + contentHeight); + if (dy == 0 || Math.abs(dy) >= contentHeight) { + fillRect(0, top, width, contentHeight, argbPre(PANE_BACKGROUND)); + return; + } + + if (dy < 0) { + int srcY = top - dy; + int dstY = top; + int copyHeight = contentHeight + dy; + for (int y = 0; y < copyHeight; y++) { + System.arraycopy(pixels, (srcY + y) * width, pixels, (dstY + y) * width, width); + } + fillRect(0, top + copyHeight, width, -dy, argbPre(PANE_BACKGROUND)); + } else { + int srcY = top; + int dstY = top + dy; + int copyHeight = contentHeight - dy; + for (int y = copyHeight - 1; y >= 0; y--) { + System.arraycopy(pixels, (srcY + y) * width, pixels, (dstY + y) * width, width); + } + fillRect(0, top, width, dy, argbPre(PANE_BACKGROUND)); + } + } + + private void paintSnapshot(RenderStateSnapshot snapshot) { + List rows = snapshot.renderRows(); + if (rows.isEmpty()) { + return; + } + int top = contentTop(); + int contentBottom = top + snapshot.rows() * lineHeight(); + fillRect(0, 0, width, top, argbPre(rowEdgeBackground(rows.get(0), true))); + fillRect(0, contentBottom, width, height - contentBottom, argbPre(rowEdgeBackground(rows.get(rows.size() - 1), true))); + for (RenderRow row : rows) { + paintRow(row); + } + } + + private void paintRow(RenderRow row) { + int rowTop = contentTop() + (row.row() * lineHeight()); + int rowHeight = lineHeight(); + fillRect(0, rowTop, width, rowHeight, argbPre(PANE_BACKGROUND)); + if (row.row() == 0) { + fillRect(0, 0, width, contentTop(), argbPre(rowEdgeBackground(row, true))); + } + if (row.row() == rowHashes.length - 1) { + int bottom = contentTop() + (rowHashes.length * lineHeight()); + fillRect(0, bottom, width, height - bottom, argbPre(rowEdgeBackground(row, true))); + } + paintRowSidePadding(row, rowTop, rowHeight); + paintRowBackgrounds(row, rowTop, rowHeight); + paintRowText(row, rowTop); + } + + private void paintRowSidePadding(RenderRow row, int rowTop, int rowHeight) { + List cells = row.cells(); + if (cells.isEmpty()) { + return; + } + int left = contentLeft(); + int contentRight = left + (cells.size() * cellWidth()); + fillRect(0, rowTop, left, rowHeight, argbPre(rowEdgeBackground(row, true))); + fillRect(contentRight, rowTop, width - contentRight, rowHeight, argbPre(rowEdgeBackground(row, false))); + } + + private void paintRowBackgrounds(RenderRow row, int rowTop, int rowHeight) { + int cellWidth = cellWidth(); + Color runBackground = null; + int runStartColumn = 0; + int previousColumn = -1; + for (RenderCell cell : row.cells()) { + if (cell.kittyPlaceholder().isPresent()) { + flushBackground(runBackground, runStartColumn, previousColumn, rowTop, rowHeight); + runBackground = null; + previousColumn = -1; + continue; + } + + Color bg = cell.selected() ? SELECTED_BACKGROUND : cellBackgroundOverride(cell); + if (bg == null) { + flushBackground(runBackground, runStartColumn, previousColumn, rowTop, rowHeight); + runBackground = null; + previousColumn = -1; + continue; + } + if (runBackground == null || bg != runBackground || cell.column() != previousColumn + 1) { + flushBackground(runBackground, runStartColumn, previousColumn, rowTop, rowHeight); + runBackground = bg; + runStartColumn = cell.column(); + } + previousColumn = cell.column(); + } + flushBackground(runBackground, runStartColumn, previousColumn, rowTop, rowHeight); + } + + private void flushBackground(Color background, int startColumn, int endColumn, int rowTop, int rowHeight) { + if (background == null || endColumn < startColumn) { + return; + } + fillRect(contentLeft() + (startColumn * cellWidth()), rowTop, + (endColumn - startColumn + 1) * cellWidth(), rowHeight, argbPre(background)); + } + + private void paintRowText(RenderRow row, int rowTop) { + int cellWidth = cellWidth(); + int x0 = contentLeft(); + for (RenderCell cell : row.cells()) { + if (cell.kittyPlaceholder().isPresent() || cell.codepoints().length == 0) { + continue; + } + Glyph glyph = glyphs.glyph(cell.text()); + int color = rgb(cellForegroundColor(cell)); + blitGlyph(glyph, x0 + (cell.column() * cellWidth), rowTop, color); + } + } + + private void blitGlyph(Glyph glyph, int x, int y, int rgb) { + int red = (rgb >> 16) & 0xff; + int green = (rgb >> 8) & 0xff; + int blue = rgb & 0xff; + // Clamp the glyph rectangle to the buffer once, so the inner loops carry no + // per-pixel bounds check (this is the hottest pixel loop on a text repaint). + int gyStart = Math.max(0, -y); + int gyEnd = Math.min(glyph.height, height - y); + int gxStart = Math.max(0, -x); + int gxEnd = Math.min(glyph.width, width - x); + if (gyStart >= gyEnd || gxStart >= gxEnd) { + return; + } + for (int gy = gyStart; gy < gyEnd; gy++) { + int rowOffset = (y + gy) * width; + int glyphOffset = gy * glyph.width; + for (int gx = gxStart; gx < gxEnd; gx++) { + int alpha = glyph.alpha[glyphOffset + gx] & 0xff; + if (alpha == 0) { + continue; + } + int index = rowOffset + x + gx; + pixels[index] = blendOpaque(pixels[index], red, green, blue, alpha); + } + } + markDirtyRows(y + gyStart, y + gyEnd); + } + + private int blendOpaque(int dst, int red, int green, int blue, int alpha) { + int inv = 255 - alpha; + int dstRed = (dst >> 16) & 0xff; + int dstGreen = (dst >> 8) & 0xff; + int dstBlue = dst & 0xff; + int outRed = ((red * alpha) + (dstRed * inv) + 127) / 255; + int outGreen = ((green * alpha) + (dstGreen * inv) + 127) / 255; + int outBlue = ((blue * alpha) + (dstBlue * inv) + 127) / 255; + return 0xff000000 | (outRed << 16) | (outGreen << 8) | outBlue; + } + + private void drawCursor(RenderStateSnapshot snapshot) { + if (!snapshot.cursorVisible() || !snapshot.cursorViewportHasValue()) { + return; + } + int x = contentLeft() + ((int) snapshot.cursorViewportX() * cellWidth()); + int y = contentTop() + ((int) snapshot.cursorViewportY() * lineHeight()); + int cw = cellWidth(); + int lh = lineHeight(); + RenderCursorStyle style = snapshot.cursorStyle(); + if (style == RenderCursorStyle.BAR) { + fillRect(x, y + 2, 1, Math.max(1, lh - 4), argbPre(DEFAULT_FOREGROUND)); + } else if (style == RenderCursorStyle.UNDERLINE) { + fillRect(x + 1, y + lh - 2, Math.max(1, cw - 2), 1, argbPre(DEFAULT_FOREGROUND)); + } else if (style == RenderCursorStyle.BLOCK) { + fillRectAlpha(x, y + 1, Math.max(1, cw - 1), Math.max(1, lh - 2), CURSOR_FILL); + } else { + strokeRect(x, y + 1, Math.max(1, cw - 1), Math.max(1, lh - 2), argbPre(DEFAULT_FOREGROUND), 1); + } + } + + private void drawBorder(boolean active) { + strokeRect(0, 0, width, height, argbPre(active ? ACTIVE_BORDER : INACTIVE_BORDER), active ? 2 : 1); + } + + private void strokeRect(int x, int y, int w, int h, int color, int lineWidth) { + // The border is redrawn every frame to restore the side edges over the rows we + // repaint, but its pixels never change between incremental frames. Write it without + // marking the dirty band: the segments inside a repainted row's band are already + // covered by that band (and so re-uploaded), and the segments outside it are + // identical to what is already on the GPU, so they need no upload. + for (int i = 0; i < lineWidth; i++) { + fillRectRaw(x + i, y + i, w - (2 * i), 1, color); + fillRectRaw(x + i, y + h - 1 - i, w - (2 * i), 1, color); + fillRectRaw(x + i, y + i, 1, h - (2 * i), color); + fillRectRaw(x + w - 1 - i, y + i, 1, h - (2 * i), color); + } + } + + private void fillRectAlpha(int x, int y, int w, int h, Color color) { + int alpha = (int) Math.round(color.getOpacity() * 255.0); + int rgb = rgb(color); + int red = (rgb >> 16) & 0xff; + int green = (rgb >> 8) & 0xff; + int blue = rgb & 0xff; + int x0 = Math.max(0, x); + int y0 = Math.max(0, y); + int x1 = Math.min(width, x + w); + int y1 = Math.min(height, y + h); + for (int py = y0; py < y1; py++) { + int offset = py * width; + for (int px = x0; px < x1; px++) { + int index = offset + px; + pixels[index] = blendOpaque(pixels[index], red, green, blue, alpha); + } + } + markDirtyRows(y0, y1); + } + + private void fillRect(int x, int y, int w, int h, int color) { + int y0 = Math.max(0, y); + int y1 = Math.min(height, y + h); + if (fillRectRaw(x, y, w, h, color)) { + markDirtyRows(y0, y1); + } + } + + // Raw fill with no dirty-band tracking; returns whether any pixels were written. + private boolean fillRectRaw(int x, int y, int w, int h, int color) { + int x0 = Math.max(0, x); + int y0 = Math.max(0, y); + int x1 = Math.min(width, x + w); + int y1 = Math.min(height, y + h); + if (x0 >= x1 || y0 >= y1) { + return false; + } + for (int py = y0; py < y1; py++) { + Arrays.fill(pixels, (py * width) + x0, (py * width) + x1, color); + } + return true; + } + + private int contentLeft() { + return (int) Math.round(TerminalMetrics.PADDING); + } + + private int contentTop() { + return (int) Math.round(TerminalMetrics.PADDING); + } + + private int cellWidth() { + return Math.max(1, (int) Math.round(metrics.cellWidth())); + } + + private int lineHeight() { + return Math.max(1, (int) Math.round(metrics.lineHeight())); + } + } + + private final class GlyphCache { + private final javafx.scene.text.Font font; + private final int cellWidth; + private final int lineHeight; + private final double baseline; + private final Map glyphs = new HashMap<>(); + + private GlyphCache(javafx.scene.text.Font font, int cellWidth, int lineHeight, double baseline) { + this.font = font; + this.cellWidth = cellWidth; + this.lineHeight = lineHeight; + this.baseline = baseline; + } + + private Glyph glyph(String text) { + return glyphs.computeIfAbsent(text, this::renderGlyph); + } + + private Glyph renderGlyph(String value) { + Text measured = new Text(value); + measured.setFont(font); + int glyphWidth = Math.max(cellWidth, (int) Math.ceil(measured.getLayoutBounds().getWidth()) + 2); + Canvas canvas = new Canvas(glyphWidth, lineHeight); + GraphicsContext gc = canvas.getGraphicsContext2D(); + gc.setFontSmoothingType(FontSmoothingType.GRAY); + gc.setFont(font); + gc.setFill(Color.WHITE); + gc.fillText(value, 0.0, baseline); + + SnapshotParameters parameters = new SnapshotParameters(); + parameters.setFill(Color.TRANSPARENT); + WritableImage snapshot = canvas.snapshot(parameters, null); + PixelReader reader = snapshot.getPixelReader(); + byte[] alpha = new byte[glyphWidth * lineHeight]; + for (int y = 0; y < lineHeight; y++) { + int offset = y * glyphWidth; + for (int x = 0; x < glyphWidth; x++) { + alpha[offset + x] = (byte) ((reader.getArgb(x, y) >>> 24) & 0xff); + } + } + return new Glyph(glyphWidth, lineHeight, alpha); + } + } + + private record Glyph(int width, int height, byte[] alpha) { + } + + private record CursorState(boolean visible, boolean hasViewport, int column, int row, RenderCursorStyle style) { + private static CursorState none() { + return new CursorState(false, false, -1, -1, null); + } + + private static CursorState from(RenderStateSnapshot snapshot) { + if (snapshot == null || !snapshot.cursorVisible() || !snapshot.cursorViewportHasValue()) { + return none(); + } + return new CursorState(true, true, (int) snapshot.cursorViewportX(), (int) snapshot.cursorViewportY(), snapshot.cursorStyle()); + } + + private int viewportRow() { + return visible && hasViewport ? row : -1; + } + } + + private static long rowHash(RenderRow row) { + long hash = 0xcbf29ce484222325L; + for (RenderCell cell : row.cells()) { + hash = mix(hash, cell.column()); + hash = mix(hash, cell.inverse() ? 1 : 0); + hash = mix(hash, cell.selected() ? 1 : 0); + hash = mixColor(hash, cell.foreground().orElse(null)); + hash = mixColor(hash, cell.background().orElse(null)); + for (int codepoint : cell.codepoints()) { + hash = mix(hash, codepoint); + } + if (cell.kittyPlaceholder().isPresent()) { + KittyPlaceholder placeholder = cell.kittyPlaceholder().get(); + hash = mix(hash, placeholder.imageId()); + hash = mix(hash, placeholder.placementId()); + hash = mix(hash, placeholder.sourceRow()); + hash = mix(hash, placeholder.sourceColumn()); + } + } + return hash; + } + + private static long mixColor(long hash, RenderColor color) { + return color == null ? mix(hash, -1) : mix(hash, (color.red() << 16) | (color.green() << 8) | color.blue()); + } + + private static long mix(long hash, long value) { + hash ^= value; + return hash * 0x100000001b3L; + } + + private static int rgb(Color color) { + int red = (int) Math.round(color.getRed() * 255.0); + int green = (int) Math.round(color.getGreen() * 255.0); + int blue = (int) Math.round(color.getBlue() * 255.0); + return (red << 16) | (green << 8) | blue; + } + + private static int argbPre(Color color) { + int alpha = (int) Math.round(color.getOpacity() * 255.0); + int red = (int) Math.round(color.getRed() * alpha); + int green = (int) Math.round(color.getGreen() * alpha); + int blue = (int) Math.round(color.getBlue() * alpha); + return (alpha << 24) | (red << 16) | (green << 8) | blue; + } + // A kitty image is immutable for a given (id, number); re-transmitting under the same id // changes the number (and the snapshot below evicts stale entries by id anyway). So the // identity + dimensions + payload length are enough to key the decoded-image cache, and diff --git a/src/main/java/com/gregor/jprototerm/KeyEncoder.java b/src/main/java/com/gregor/jprototerm/KeyEncoder.java index 765b1b3..296f624 100644 --- a/src/main/java/com/gregor/jprototerm/KeyEncoder.java +++ b/src/main/java/com/gregor/jprototerm/KeyEncoder.java @@ -26,7 +26,7 @@ final class KeyEncoder { return switch (code) { case ENTER -> "\r"; case BACK_SPACE -> "\u007f"; - case TAB -> "\t"; + case TAB -> event.isShiftDown() ? "\u001b[Z" : "\t"; case ESCAPE -> "\u001b"; case UP -> "\u001b[A"; case DOWN -> "\u001b[B"; diff --git a/src/main/java/com/gregor/jprototerm/LinuxPty.java b/src/main/java/com/gregor/jprototerm/LinuxPty.java index 28e9d42..2b2eb81 100644 --- a/src/main/java/com/gregor/jprototerm/LinuxPty.java +++ b/src/main/java/com/gregor/jprototerm/LinuxPty.java @@ -97,6 +97,7 @@ public final class LinuxPty implements AutoCloseable { private final Arena arena = Arena.ofShared(); private final MemorySegment readBuffer = arena.allocate(65536); + private final MemorySegment writeBuffer = arena.allocate(65536); private final Object writeLock = new Object(); private final int masterFd; private final int pid; @@ -186,17 +187,20 @@ public final class LinuxPty implements AutoCloseable { return; } synchronized (writeLock) { - try (Arena a = Arena.ofConfined()) { - MemorySegment buf = a.allocate(data.length); - MemorySegment.copy(data, 0, buf, ValueLayout.JAVA_BYTE, 0, data.length); - long offset = 0; - while (offset < data.length) { - long n = callLong(WRITE, masterFd, buf.asSlice(offset), data.length - offset); - if (n < 0) { + int offset = 0; + while (offset < data.length) { + int chunk = (int) Math.min(writeBuffer.byteSize(), data.length - offset); + MemorySegment.copy(data, offset, writeBuffer, ValueLayout.JAVA_BYTE, 0, chunk); + + long written = 0; + while (written < chunk) { + long n = callLong(WRITE, masterFd, writeBuffer.asSlice(written), chunk - written); + if (n <= 0) { throw new IllegalStateException("write to pty failed"); } - offset += n; + written += n; } + offset += chunk; } } } diff --git a/src/main/java/com/gregor/jprototerm/Tab.java b/src/main/java/com/gregor/jprototerm/Tab.java index e90f492..4a8146e 100644 --- a/src/main/java/com/gregor/jprototerm/Tab.java +++ b/src/main/java/com/gregor/jprototerm/Tab.java @@ -6,6 +6,7 @@ import javafx.scene.shape.Shape; import java.util.ArrayList; import java.util.Comparator; import java.util.List; +import java.util.concurrent.atomic.AtomicLong; import java.util.stream.Stream; /** @@ -33,7 +34,7 @@ final class Tab implements AutoCloseable { 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; + private final AtomicLong contentVersion = new AtomicLong(); Tab(AppConfig config, TerminalMetrics metrics) { this.config = config; @@ -54,7 +55,7 @@ final class Tab implements AutoCloseable { } long contentVersion() { - return contentVersion; + return contentVersion.get(); } /** @@ -291,7 +292,7 @@ final class Tab implements AutoCloseable { } private void markContentChanged() { - contentVersion++; + contentVersion.incrementAndGet(); } private TerminalPane openPane(boolean asFloating) { diff --git a/src/main/java/com/gregor/jprototerm/TerminalPane.java b/src/main/java/com/gregor/jprototerm/TerminalPane.java index 6ad0890..a44f962 100644 --- a/src/main/java/com/gregor/jprototerm/TerminalPane.java +++ b/src/main/java/com/gregor/jprototerm/TerminalPane.java @@ -16,6 +16,7 @@ import javafx.scene.canvas.GraphicsContext; import javafx.scene.shape.Shape; import java.util.Optional; +import java.util.concurrent.atomic.AtomicLong; /** * One terminal: owns its ghostty {@link Terminal}, the {@link ShellSession}/pty driving it, @@ -49,7 +50,7 @@ public final class TerminalPane implements AutoCloseable, RenderTarget { private int rows; private int pixelWidth; private int pixelHeight; - private long contentVersion; + private final AtomicLong contentVersion = new AtomicLong(); private long snapshotVersion = -1; private TerminalPane(Terminal terminal, TerminalMetrics metrics, boolean kittyEnabled, @@ -169,16 +170,17 @@ public final class TerminalPane implements AutoCloseable, RenderTarget { private RenderStateSnapshot takeSnapshot(boolean full) { synchronized (terminal) { + long version = contentVersion.get(); if (full) { renderState.update(terminal); cachedSnapshot = renderState.snapshot(); renderState.resetDirty(); - snapshotVersion = contentVersion; - } else if (snapshotVersion != contentVersion) { + snapshotVersion = version; + } else if (snapshotVersion != version) { renderState.update(terminal); cachedSnapshot = renderState.snapshotIncremental(); renderState.resetDirty(); - snapshotVersion = contentVersion; + snapshotVersion = version; } return cachedSnapshot; } @@ -192,7 +194,7 @@ public final class TerminalPane implements AutoCloseable, RenderTarget { /** This pane's own content revision, bumped on every change (see {@link #refresh()}). */ public long contentVersion() { - return contentVersion; + return contentVersion.get(); } @Override @@ -276,18 +278,20 @@ public final class TerminalPane implements AutoCloseable, RenderTarget { // 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++; + contentVersion.incrementAndGet(); onContentChange.run(); } /** Paint the whole pane; see {@link TerminalRenderer#paintFull}. */ - public void paintFull(GraphicsContext gc, boolean active) { + public long paintFull(GraphicsContext gc, boolean active) { renderer.paintFull(gc, this, active); + return snapshotVersion; } /** Repaint what changed; see {@link TerminalRenderer#paintIncremental}. */ - public void paintIncremental(GraphicsContext gc, boolean active) { + public long paintIncremental(GraphicsContext gc, boolean active) { renderer.paintIncremental(gc, this, active); + return snapshotVersion; } @Override