From 1665dcfaaec4c5a69e8aaab0ebf7d721f65dc9cc Mon Sep 17 00:00:00 2001 From: Gregor Lohaus Date: Thu, 28 May 2026 13:37:18 +0200 Subject: [PATCH] images work --- .gradle/9.4.1/checksums/checksums.lock | Bin 39 -> 39 bytes .gradle/9.4.1/checksums/md5-checksums.bin | Bin 22097 -> 22147 bytes .gradle/9.4.1/checksums/sha1-checksums.bin | Bin 30971 -> 31025 bytes .../executionHistory/executionHistory.bin | Bin 49514 -> 49514 bytes .../executionHistory/executionHistory.lock | Bin 39 -> 39 bytes .gradle/9.4.1/fileHashes/fileHashes.bin | Bin 27897 -> 28197 bytes .gradle/9.4.1/fileHashes/fileHashes.lock | Bin 39 -> 39 bytes .../9.4.1/fileHashes/resourceHashesCache.bin | Bin 21251 -> 21523 bytes .../buildOutputCleanup.lock | Bin 39 -> 39 bytes .../gregor/jprototerm/TerminalCanvasView.java | 440 +++++++++++++++--- .../com/gregor/jprototerm/TerminalPane.java | 6 + .../gregor/jprototerm/TerminalWorkspace.java | 19 +- 12 files changed, 407 insertions(+), 58 deletions(-) diff --git a/.gradle/9.4.1/checksums/checksums.lock b/.gradle/9.4.1/checksums/checksums.lock index f74a924e8cd944af8b3608144e38f2bf2aa3579a..4103b66359d3493c90ba7c303797587559c52f0b 100644 GIT binary patch literal 39 rcmZSnqqyGv@x_|y3}C?6$jrddu?@t|#UttCSn`0I! delta 36 ucmV+<0Neku@&Wtt0kAX}0q3(d81M|UJte~k0lBdu_!qG-U;?v2NV+gE4G&fT diff --git a/.gradle/9.4.1/executionHistory/executionHistory.bin b/.gradle/9.4.1/executionHistory/executionHistory.bin index f5d8b1dae0770daa096526bc508f5f3ea1b99f3d..098edc28e33998eda185b7260bc33c3cd0f06837 100644 GIT binary patch delta 1046 zcmaFW#Qds>d4pS~i+mRYgM8O)ldOu;QsdO@%B-Yp^Q5ZO^h8sm9Mj~S;!1%NvcWqN zgnvEfyA_%@MNCb+3`1n{g-qAY=QF>tDP$WMq?i_Gn3ks(Rpq4_7Zq3}Rp#Vo`5_ZioMWGA1A zwGa^ZUAHF2pLe!nue$rOonSuQPn`f2jHW+e#YOS2Zs{>|4uO;&1A61j4x z+n`r=9&2Vurqf{#E9*lbr4cPU^(wnBH%1Fz*mALJ?#L}&y0`qF)j~DEy~Tz zOU!Xj%qvSQ4$Dj}SMkm)DXH|#O-xVqPOa2S&PgmTW)NuE7uzU1Cw#&RIkTYHvu6S! zCOnj2+bnr~J~qlwz1!-d_x>8|IwYuDtAebmYOC$025&=3`t- zgjq;#`c<+e@s4j-wafgbHm38>J0a#g=4D)pX3pfbE#mdOJ-o+1wnP-3ICOH0NOMdG zMD;R$#+7KQL17S(n3o!qng?{db7FEvD$M1{Oy=!M2g)8yS78*F;n$e~F{6i-aS0YP zg7Zs@l7Xfqmw>d(fkLr!`Mjmv(T{dK;_#bYvUAUGi2iPV#+j2B#t17w!&>yDhyLWQ zRhj%+Q@HkgSHE}+99vCIOp^s$#U$3-glMTL9{kW~-+k0L`_=O8z}SCge9C;XeXFcU zl9T=>x9!Pmub3KcShVk)(`Hc8C}@>WDcc)IEufIOlG4@bSF3Mq|FElXf%$Ydpb%?!UzBh)U*c}{#~&;8n{0tX zB@@@4b3Y{TaOTt%%1Rzze{61ylVhBmniwSiBF#`-R_$E_7lR95S5jfrEwB%szhGox a1ZMi_lNaoan4HkfF}bfxc(ZS(tOWqo&d&P) delta 783 zcmaFW#Qds>d4pS~i(CZ*gPhUqQlpBT@(d%RyrRU^jDmth^R)C#^PJ>LlT-n_&^c)< z&)RcH@@JVR&RX3ThaocgLZ<8H^O@h+6f%kp3z93UOe`x6(u#_5Gcz&`s&Y&!O^YiF zO*5(r(kH+4&lNZ-ZollxqlY)A=<3H^v;OsGvSX>;<^_eFtc10DmgfjmEdHc?V%uux zy9%%F>n{-Mo&2!WWb&D6&dCc3{Wk|zAxa+wjLGhehFV+AS=wcV)DcWxk<5{le-%ugf=e?pZej&6|I|gC%W!^p0|K;El76q znOF;fzR;|{MZ!5gJ~?Y2u(J2KZjNrsXJl#pa{S}ubInQ+#+PO-mh02^ew?h-q9noK zxYlx?x5oOz2L)QC4;-KS_w(_O&y1I`O)hK|om|)=Ea842_S?Fhnl+}8ncv=at$zto zaFh?IKtt;DUujcq(Ye#VvpF7oS+##QR5w54%*hL5g#D6p5{ru&1p4@D6ZVF`bl){G zXI^MKdoe^+%MwP$$swu0fX_?J2}sOK)q^TGDsQ^~R$|_AwqHRl{}cLoF9Y5B%=o(5 z+AQb;9FCc7{5uJIcIZYoE+oi%#I*c!Dr&^b}yo8x-WH=?iN_? z-3bigXU5l_F|sfMlc+tIbe()5UO_=1Z>p^LgqJcBA9;Qx^siRSyma#T9o-K+1pRef5>H(C2aHCT~3V-!S@TG-@F&oYI5E6$Y0 zrXKW_n8`Fx>f~Z^up+bbNf1S%6DI!$DiS^Ep+C86RVKgI6s|qr)h~h-MFt#$C~9Py zJk=|bIWpkVUDU)XZ7YwsrAru(m;st!$7&8d^Pa;Mv%S9Tt2W=N*fVGb*6xa95}se0ZX-s2xz zA_`9&I=MxpIVJ=q2{E628IVSEQ8JTxyV8NO2h&v;#bx+)W}xa|>cP~}vM;t#c24+& z6>?@lv1iW&BI(Eks^&?=)WY?4zn!b_m6nt@POp4RxBdjG00QP0V2X1R8<0kGR_F40 zOSz*T?RdoDH@jr#p5I8802Q-$Pd?}^9Qwtux?#?V?aIrpM@JsKc^plRH>Rn-O1324 W@$IU1ncvjLbpClKst&ftARPex)DbrT delta 489 zcmZ2_hw zqH4vPL=P}J)_w&m(*3UhQKZT*SAKbqdN2ckXXH zr}O&LzddhKclO-{E7tcu3{k9eYjVGLB(uKv^~tZj6TzDNeS8#*%A4-Lm6*4j?N?CC z|Ac;CsHV#hP150$cl)>mO^8iBqVFABKm8QDvnHb)(8oZZ!%b%P1S-}u1`?V;tO&va zg3rX+?OsIHbYJQ!-7T=(8{+U^oIpt+C=S~k=(|Tw!u>$(w{<%;YfK|EzrF2R{}Q6= z=ikYyIdu{Yj%zLVd26gcd{CfO`oQtIaLL^{QYxSSN}Fnn&Yk|9&GF#Ns{ONJk`VLR ImjP)#0JmJPO8@`> diff --git a/.gradle/9.4.1/fileHashes/fileHashes.lock b/.gradle/9.4.1/fileHashes/fileHashes.lock index 682cde668175771448520870f0839e7559944c62..9266c10a9eeda6a0a05fea93d5be0d85077ea49e 100644 GIT binary patch literal 39 scmZQJN)8L&J8{xS1~6d0&&8XB1!8!|8e0Qaa1q5uE@ literal 39 rcmZQJN)8L&J8{xS1~6c*XJ%k%SSS1;+`hKRY&rw0p^>?ma45{-=fCl7z*67svw00zO`lh;Zr2yD1?W=p4U(<-Q->gJ=8 z3d{n^Pu_ja?hh%0in?#UE91Z@U~e4O68X&JK2-GMWKlT_0msH<&G}oVazX`}C%Xa# zy^c8YE|T+G4;4H=xm50fMwI*&v1za3455OV|Dhn>VaEgI{+H2E@wJnE<-3?TGB+yz z5Z`F9fq%23$04qIW1y@g5DS8^z|k6!{+CMKcUvUhiLt76-2@8Cp{mG;d;iDhtZbO! zn-{Fh&whT0sbcR&wm%Hr9TRUcYX$$vehKmwnib!FbY&B9a><)huh{PpL&4DUnQCv8^? aV5-7{GwZi7{Gu@jhTU=VI8B!)3>%9>kAlI4UNo=4H*~!0yql} diff --git a/src/main/java/com/gregor/jprototerm/TerminalCanvasView.java b/src/main/java/com/gregor/jprototerm/TerminalCanvasView.java index 80d84cb..3e0f0cb 100644 --- a/src/main/java/com/gregor/jprototerm/TerminalCanvasView.java +++ b/src/main/java/com/gregor/jprototerm/TerminalCanvasView.java @@ -4,6 +4,8 @@ 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; @@ -30,6 +32,7 @@ import javafx.scene.text.Text; import java.io.ByteArrayInputStream; import java.util.HashMap; +import java.util.List; import java.util.Map; public final class TerminalCanvasView { @@ -39,9 +42,15 @@ public final class TerminalCanvasView { private final Canvas canvas = new Canvas(); private final TerminalWorkspace workspace; private final AppConfig config; - private final Map kittyImageCache = new HashMap<>(); + private final Map kittyImageCache = new HashMap<>(); + private final Map paneRenderCache = new HashMap<>(); private String fontFamily; private double fontSize; + private Font cachedFont; + private FontMetrics cachedMetrics; + private String cachedFontFamily; + private double cachedFontSize; + private String lastRenderKey; private boolean mouseButtonPressed; private MouseButton pressedButton = MouseButton.UNKNOWN; @@ -65,53 +74,111 @@ public final class TerminalCanvasView { public void setFont(String family, double size) { this.fontFamily = family; this.fontSize = size; + cachedFont = null; + cachedMetrics = null; + paneRenderCache.clear(); + lastRenderKey = null; } public void render() { double width = canvas.getWidth(); double height = canvas.getHeight(); workspace.layout(width, height); + Font font = currentFont(); + FontMetrics metrics = currentFontMetrics(); + List panes = workspace.panes(); + + String renderKey = renderKey(width, height, metrics, panes); + if (renderKey.equals(lastRenderKey)) { + return; + } + lastRenderKey = renderKey; GraphicsContext gc = canvas.getGraphicsContext2D(); gc.setFill(Color.rgb(16, 16, 18)); gc.fillRect(0, 0, width, height); gc.setFontSmoothingType(FontSmoothingType.LCD); - for (TerminalPane pane : workspace.panes()) { - drawPane(gc, pane); + paneRenderCache.keySet().removeIf(pane -> !panes.contains(pane)); + for (TerminalPane pane : panes) { + drawPane(gc, pane, font, metrics); } } - private void drawPane(GraphicsContext gc, TerminalPane pane) { - gc.save(); - gc.beginPath(); - gc.rect(pane.x(), pane.y(), pane.width(), pane.height()); - gc.clip(); + private void drawPane(GraphicsContext gc, TerminalPane pane, Font font, FontMetrics metrics) { + if (config.kittyGraphics() && paneHasKittyGraphics(pane)) { + paneRenderCache.remove(pane); + gc.save(); + gc.beginPath(); + gc.rect(pane.x(), pane.y(), pane.width(), pane.height()); + gc.clip(); + drawPaneContent(gc, pane, font, metrics, pane.x(), pane.y(), pane.width(), pane.height(), false); + gc.restore(); + return; + } + PaneRenderCache cache = paneRenderCache.computeIfAbsent(pane, ignored -> new PaneRenderCache()); + String cacheKey = paneCacheKey(pane, metrics); + int imageWidth = Math.max(1, (int) Math.ceil(pane.width())); + int imageHeight = Math.max(1, (int) Math.ceil(pane.height())); + if (cache.image == null || cache.canvas == null || cache.imageWidth != imageWidth || cache.imageHeight != imageHeight || !cacheKey.equals(cache.key)) { + cache.canvas = new Canvas(imageWidth, imageHeight); + drawPaneContent(cache.canvas.getGraphicsContext2D(), pane, font, metrics, 0.0, 0.0, imageWidth, imageHeight, true); + cache.image = new WritableImage(imageWidth, imageHeight); + cache.canvas.snapshot(null, cache.image); + cache.imageWidth = imageWidth; + cache.imageHeight = imageHeight; + cache.key = cacheKey; + } + + gc.drawImage(cache.image, pane.x(), pane.y()); + } + + private void drawPaneContent( + GraphicsContext gc, + TerminalPane pane, + Font font, + FontMetrics metrics, + double x, + double y, + double width, + double height, + boolean clear + ) { + if (clear) { + gc.clearRect(x, y, width, height); + } + gc.setFontSmoothingType(FontSmoothingType.LCD); if (pane.floating()) { gc.setGlobalAlpha(0.96); } gc.setFill(Color.rgb(9, 10, 12)); - gc.fillRect(pane.x(), pane.y(), pane.width(), pane.height()); + gc.fillRect(x, y, width, height); gc.setGlobalAlpha(1.0); 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(pane.x() + 0.5, pane.y() + 0.5, pane.width() - 1.0, pane.height() - 1.0); + gc.strokeRect(x + 0.5, y + 0.5, width - 1.0, height - 1.0); - Font font = Font.font(fontFamily, fontSize); gc.setFont(font); - FontMetrics metrics = measureFontMetrics(font); - int columns = Math.max(1, (int) ((pane.width() - 24.0) / metrics.cellWidth)); - int rows = Math.max(1, (int) ((pane.height() - 24.0) / metrics.lineHeight)); + int columns = Math.max(1, (int) ((width - 24.0) / metrics.cellWidth)); + int rows = Math.max(1, (int) ((height - 24.0) / metrics.lineHeight)); pane.resize(columns, rows, (int) Math.round(metrics.cellWidth), (int) Math.round(metrics.lineHeight)); - double left = pane.x() + 12.0; - double top = pane.y() + 12.0; + double left = x + 12.0; + double top = y + 12.0; double baseline = top + metrics.baselineOffset; RenderStateSnapshot snapshot = pane.renderSnapshot(); + Map placeholderBounds = config.kittyGraphics() + ? kittyPlaceholderBounds(snapshot) + : Map.of(); + + if (config.kittyGraphics()) { + drawKittyGraphics(gc, pane, KittyPlacementLayer.BELOW_TEXT, placeholderBounds, left, top, metrics.cellWidth, metrics.lineHeight); + } + if (snapshot != null) { for (RenderRow row : snapshot.renderRows()) { drawRow(gc, row, left, top, baseline, metrics.cellWidth, metrics.lineHeight); @@ -123,9 +190,8 @@ public final class TerminalCanvasView { } if (config.kittyGraphics()) { - drawKittyGraphics(gc, pane, left, top, metrics.cellWidth, metrics.lineHeight); + drawKittyGraphics(gc, pane, KittyPlacementLayer.ABOVE_TEXT, placeholderBounds, left, top, metrics.cellWidth, metrics.lineHeight); } - gc.restore(); } private static FontMetrics measureFontMetrics(Font font) { @@ -134,13 +200,80 @@ public final class TerminalCanvasView { double lineHeight = Math.max(1.0, text.getLayoutBounds().getHeight()); double baselineOffset = -text.getLayoutBounds().getMinY(); - String sample = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; - Text cell = new Text(sample); + Text cell = new Text("M"); cell.setFont(font); - double cellWidth = Math.max(1.0, cell.getLayoutBounds().getWidth() / sample.length()); + double cellWidth = Math.max(1.0, 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; + } + + private String renderKey(double width, double height, FontMetrics metrics, List panes) { + StringBuilder builder = new StringBuilder(); + builder.append(width).append(':') + .append(height).append(':') + .append(workspace.version()).append(':') + .append(fontFamily).append(':') + .append(fontSize).append(':') + .append(metrics.cellWidth).append(':') + .append(metrics.lineHeight); + for (TerminalPane pane : panes) { + builder.append('|') + .append(System.identityHashCode(pane)).append(',') + .append(pane.renderVersion()).append(',') + .append(workspace.isActive(pane)).append(',') + .append(pane.x()).append(',') + .append(pane.y()).append(',') + .append(pane.width()).append(',') + .append(pane.height()); + } + return builder.toString(); + } + + private String paneCacheKey(TerminalPane pane, FontMetrics metrics) { + return pane.renderVersion() + + ":" + workspace.isActive(pane) + + ":" + pane.width() + + ":" + pane.height() + + ":" + fontFamily + + ":" + fontSize + + ":" + metrics.cellWidth + + ":" + metrics.lineHeight + + ":" + config.kittyGraphics(); + } + + 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()); @@ -244,7 +377,7 @@ public final class TerminalCanvasView { return null; } - FontMetrics metrics = measureFontMetrics(Font.font(fontFamily, fontSize)); + 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)); @@ -325,15 +458,19 @@ public final class TerminalCanvasView { double lineHeight ) { for (RenderCell cell : row.cells()) { + if (cell.kittyPlaceholder().isPresent()) { + continue; + } + double x = left + (cell.column() * cellWidth); double cellTop = top + (row.row() * lineHeight); cell.background().ifPresent(background -> { gc.setFill(toFxColor(background)); - fillCellRect(gc, x, cellTop, cellWidth, lineHeight); + gc.fillRect(x, cellTop, cellWidth, lineHeight); }); if (cell.selected()) { gc.setFill(SELECTED_BACKGROUND); - fillCellRect(gc, x, cellTop, cellWidth, lineHeight); + gc.fillRect(x, cellTop, cellWidth, lineHeight); } if (cell.codepoints().length == 0) { continue; @@ -346,14 +483,6 @@ public final class TerminalCanvasView { } } - private static void fillCellRect(GraphicsContext gc, double x, double y, double width, double height) { - double x1 = Math.floor(x); - double y1 = Math.floor(y); - double x2 = Math.ceil(x + width); - double y2 = Math.ceil(y + height); - gc.fillRect(x1, y1, Math.max(1.0, x2 - x1), Math.max(1.0, y2 - y1)); - } - private static Color toFxColor(RenderColor color) { return Color.rgb(color.red(), color.green(), color.blue()); } @@ -381,54 +510,174 @@ public final class TerminalCanvasView { } } - private void drawKittyGraphics(GraphicsContext gc, TerminalPane pane, double originX, double originY, double cellWidth, double lineHeight) { + 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()) { + for (KittyPlacement placement : graphics.placements(layer)) { Image image = imageFor(placement); if (image == null) { continue; } - KittyRenderInfo renderInfo = placement.renderInfo().orElse(null); - double x = originX; - double y = originY; - double width = image.getWidth(); - double height = image.getHeight(); - - if (renderInfo != null) { - x += renderInfo.viewportColumn() * cellWidth; - y += renderInfo.viewportRow() * lineHeight; - width = renderInfo.gridColumns() > 0 ? renderInfo.gridColumns() * cellWidth : renderInfo.pixelWidth(); - height = renderInfo.gridRows() > 0 ? renderInfo.gridRows() * lineHeight : renderInfo.pixelHeight(); + if (placement.virtual()) { + drawVirtualKittyPlacement(gc, placement, image, placeholderBounds, originX, originY, cellWidth, lineHeight); } else { - width = placement.columns() > 0 ? placement.columns() * cellWidth : width; - height = placement.rows() > 0 ? placement.rows() * lineHeight : height; + drawPinnedKittyPlacement(gc, placement, image, originX, originY, cellWidth, lineHeight); } - - gc.drawImage(image, x + placement.xOffset(), y + placement.yOffset(), width, height); } }); } - private Image imageFor(KittyPlacement placement) { - return placement.image() - .map(snapshot -> kittyImageCache.computeIfAbsent(snapshot.id(), ignored -> decodeImage(snapshot))) - .orElse(null); + 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 Image decodeImage(KittyImageSnapshot snapshot) { + 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 width = bounds.columns() * cellWidth; + double height = bounds.rows() * lineHeight; + + if (sourceWidth <= 0.0 || sourceHeight <= 0.0 || width <= 0.0 || height <= 0.0) { + return; + } + + 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(snapshot.data())); + return new Image(new ByteArrayInputStream(data)); } int width = Math.toIntExact(snapshot.width()); int height = Math.toIntExact(snapshot.height()); WritableImage image = new WritableImage(width, height); - byte[] data = snapshot.data(); if (snapshot.format() == KittyImageFormat.RGBA) { image.getPixelWriter().setPixels(0, 0, width, height, PixelFormat.getByteBgraInstance(), rgbaToBgra(data), 0, width * 4); @@ -454,4 +703,83 @@ public final class TerminalCanvasView { private record MouseTarget(MouseEncoderSize size, long screenWidth, long screenHeight) { } + + private record KittyImageKey(long id, long number, long width, long height, KittyImageFormat format, int dataLength, long fingerprint) { + private static KittyImageKey of(KittyImageSnapshot snapshot, byte[] data) { + return new KittyImageKey( + snapshot.id(), + snapshot.number(), + snapshot.width(), + snapshot.height(), + snapshot.format(), + data.length, + fingerprint(data) + ); + } + + private static long fingerprint(byte[] data) { + long hash = 0xcbf29ce484222325L; + for (byte value : data) { + hash ^= Byte.toUnsignedInt(value); + hash *= 0x100000001b3L; + } + return hash; + } + } + + 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; + } + } + + private static final class PaneRenderCache { + private Canvas canvas; + private WritableImage image; + private int imageWidth; + private int imageHeight; + private String key; + } } diff --git a/src/main/java/com/gregor/jprototerm/TerminalPane.java b/src/main/java/com/gregor/jprototerm/TerminalPane.java index f6e179d..3166f10 100644 --- a/src/main/java/com/gregor/jprototerm/TerminalPane.java +++ b/src/main/java/com/gregor/jprototerm/TerminalPane.java @@ -30,6 +30,7 @@ public final class TerminalPane implements AutoCloseable { private int rows; private int pixelWidth; private int pixelHeight; + private long renderVersion; private TerminalPane(Terminal terminal, int columns, int rows) { this.terminal = terminal; @@ -114,6 +115,10 @@ public final class TerminalPane implements AutoCloseable { return renderSnapshot.get(); } + public long renderVersion() { + return renderVersion; + } + public Optional kittyGraphics() { synchronized (terminal) { return terminal.kittyGraphics(); @@ -182,6 +187,7 @@ public final class TerminalPane implements AutoCloseable { private void refresh() { renderSnapshot.set(terminal.renderSnapshot()); + renderVersion++; } @Override diff --git a/src/main/java/com/gregor/jprototerm/TerminalWorkspace.java b/src/main/java/com/gregor/jprototerm/TerminalWorkspace.java index e0ad305..072fc6c 100644 --- a/src/main/java/com/gregor/jprototerm/TerminalWorkspace.java +++ b/src/main/java/com/gregor/jprototerm/TerminalWorkspace.java @@ -9,6 +9,7 @@ public final class TerminalWorkspace implements AutoCloseable { private final List panes = new ArrayList<>(); private int activeIndex; private int hiddenFloatingFocusIndex = -1; + private long version; public TerminalWorkspace(AppConfig config) { this.config = config; @@ -38,10 +39,15 @@ public final class TerminalWorkspace implements AutoCloseable { return activePane() == pane; } + public long version() { + return version; + } + public void focus(TerminalPane pane) { int index = panes.indexOf(pane); - if (index >= 0 && pane.visible()) { + if (index >= 0 && pane.visible() && activeIndex != index) { activeIndex = index; + version++; } } @@ -79,6 +85,7 @@ public final class TerminalWorkspace implements AutoCloseable { public void navigate(Direction direction) { TerminalPane current = activePane(); if (current.floating() && navigateFloatingStack(direction)) { + version++; return; } @@ -87,7 +94,10 @@ public final class TerminalWorkspace implements AutoCloseable { .filter(pane -> pane != current) .filter(pane -> directionFilter(direction, current, pane)) .min(Comparator.comparingDouble(pane -> distance(current, pane))) - .ifPresent(pane -> activeIndex = panes.indexOf(pane)); + .ifPresent(pane -> { + activeIndex = panes.indexOf(pane); + version++; + }); } public void toggleFloating() { @@ -105,10 +115,12 @@ public final class TerminalWorkspace implements AutoCloseable { hiddenFloatingFocusIndex = active.floating() ? activeIndex : firstVisibleFloatingIndex(); floating.forEach(pane -> pane.setVisible(false)); activeIndex = firstVisibleNonFloatingIndex(); + version++; } else { floating.forEach(pane -> pane.setVisible(true)); activeIndex = visibleIndexOrFallback(hiddenFloatingFocusIndex, panes.indexOf(floating.get(floating.size() - 1))); hiddenFloatingFocusIndex = -1; + version++; } } @@ -116,12 +128,14 @@ public final class TerminalWorkspace implements AutoCloseable { TerminalPane pane = openPane(true); panes.add(pane); activeIndex = panes.size() - 1; + version++; } public void nextFloatingPane() { TerminalPane next = nextFloatingAfter(activeIndex); next.setVisible(true); activeIndex = panes.indexOf(next); + version++; } public void closeActivePane() { @@ -136,6 +150,7 @@ public final class TerminalWorkspace implements AutoCloseable { active.close(); activeIndex = adjustIndexAfterRemoval(previous, removed); hiddenFloatingFocusIndex = adjustHiddenFocusAfterRemoval(hiddenFloatingFocusIndex, removed); + version++; } private TerminalPane nextFloatingAfter(int index) {