From 84624cf6940b099ed0c59aea4c3fad0a79bedfee Mon Sep 17 00:00:00 2001
From: Leland Takamine <leland.takamine@gmail.com>
Date: Tue, 14 Jan 2025 06:08:58 -0800
Subject: [PATCH] Scroll local video rendering to follow currently executing
 command (#2232)

---
 .../maestro/cli/graphics/SkiaFrameRenderer.kt | 38 +++++------
 .../maestro/cli/graphics/SkiaTextClipper.kt   | 64 +++++++++++++++++++
 2 files changed, 84 insertions(+), 18 deletions(-)
 create mode 100644 maestro-cli/src/main/java/maestro/cli/graphics/SkiaTextClipper.kt

diff --git a/maestro-cli/src/main/java/maestro/cli/graphics/SkiaFrameRenderer.kt b/maestro-cli/src/main/java/maestro/cli/graphics/SkiaFrameRenderer.kt
index 2a150aa50a..b358a3a5e0 100644
--- a/maestro-cli/src/main/java/maestro/cli/graphics/SkiaFrameRenderer.kt
+++ b/maestro-cli/src/main/java/maestro/cli/graphics/SkiaFrameRenderer.kt
@@ -3,14 +3,9 @@ package maestro.cli.graphics
 import org.jetbrains.skia.Canvas
 import org.jetbrains.skia.Color
 import org.jetbrains.skia.Font
-import org.jetbrains.skia.FontMgr
 import org.jetbrains.skia.Paint
 import org.jetbrains.skia.Rect
 import org.jetbrains.skia.Surface
-import org.jetbrains.skia.paragraph.FontCollection
-import org.jetbrains.skia.paragraph.ParagraphBuilder
-import org.jetbrains.skia.paragraph.ParagraphStyle
-import org.jetbrains.skia.paragraph.TextStyle
 import org.jetbrains.skiko.toImage
 import java.awt.image.BufferedImage
 import javax.imageio.ImageIO
@@ -42,13 +37,10 @@ class SkiaFrameRenderer : FrameRenderer {
     private val footerText = "maestro.mobile.dev"
 
     private val terminalBgColor = Color.makeARGB(220, 0, 0, 0)
-    private val terminalTextStyle = TextStyle().apply {
-        fontFamilies = SkiaFonts.MONOSPACE_FONT_FAMILIES.toTypedArray()
-        fontSize = 24f
-        color = Color.WHITE
-    }
     private val terminalContentPadding = 40f
 
+    private val textClipper = SkiaTextClipper()
+
     override fun render(
         outputWidthPx: Int,
         outputHeightPx: Int,
@@ -154,14 +146,24 @@ class SkiaFrameRenderer : FrameRenderer {
         val contentRect = Rect.makeLTRB(terminalRect.left, headerRect.bottom, terminalRect.right, footerRect.top)
         canvas.drawRect(contentRect, Paint().apply { color = terminalBgColor })
 
-        val paddedContentRect = contentRect.inflate(-terminalContentPadding)
+        val paddedContentRect = Rect.makeLTRB(
+            l = contentRect.left + terminalContentPadding,
+            t = contentRect.top + terminalContentPadding,
+            r = contentRect.right - terminalContentPadding,
+            b = contentRect.bottom - terminalContentPadding / 4f,
+        )
+
+        val focusedLineIndex = getFocusedLineIndex(string)
+        val focusedLinePadding = 5
+        textClipper.renderClippedText(canvas, paddedContentRect, string, focusedLineIndex + focusedLinePadding)
+    }
 
-        val fontCollection = FontCollection().setDefaultFontManager(FontMgr.default)
-        val p = ParagraphBuilder(ParagraphStyle(), fontCollection)
-            .pushStyle(terminalTextStyle)
-            .addText(string)
-            .build()
-        p.layout(paddedContentRect.width)
-        p.paint(canvas, paddedContentRect.left, paddedContentRect.top)
+    private fun getFocusedLineIndex(text: String): Int {
+        val lines = text.lines()
+        val indexOfFirstPendingLine = lines.indexOfFirst { it.contains("\uD83D\uDD32") }
+        if (indexOfFirstPendingLine != -1) return indexOfFirstPendingLine
+        val indexOfLastCheck = lines.indexOfLast { it.contains("✅") }
+        if (indexOfLastCheck != -1) return indexOfLastCheck
+        return 0
     }
 }
diff --git a/maestro-cli/src/main/java/maestro/cli/graphics/SkiaTextClipper.kt b/maestro-cli/src/main/java/maestro/cli/graphics/SkiaTextClipper.kt
new file mode 100644
index 0000000000..e23795a9b3
--- /dev/null
+++ b/maestro-cli/src/main/java/maestro/cli/graphics/SkiaTextClipper.kt
@@ -0,0 +1,64 @@
+package maestro.cli.graphics
+
+import org.jetbrains.skia.Canvas
+import org.jetbrains.skia.Color
+import org.jetbrains.skia.FontMgr
+import org.jetbrains.skia.Rect
+import org.jetbrains.skia.paragraph.FontCollection
+import org.jetbrains.skia.paragraph.Paragraph
+import org.jetbrains.skia.paragraph.ParagraphBuilder
+import org.jetbrains.skia.paragraph.ParagraphStyle
+import org.jetbrains.skia.paragraph.RectHeightMode
+import org.jetbrains.skia.paragraph.RectWidthMode
+import org.jetbrains.skia.paragraph.TextStyle
+import kotlin.math.min
+
+class SkiaTextClipper {
+
+    private val terminalTextStyle = TextStyle().apply {
+        fontFamilies = SkiaFonts.MONOSPACE_FONT_FAMILIES.toTypedArray()
+        fontSize = 24f
+        color = Color.WHITE
+    }
+
+    fun renderClippedText(canvas: Canvas, rect: Rect, text: String, focusedLine: Int) {
+        val p = createParagraph(text, rect.width)
+        val focusedLineRange = getRangeForLine(text, focusedLine)
+        val focusedLineBottom = p.getRectsForRange(
+            start = focusedLineRange.first,
+            end = focusedLineRange.second,
+            rectHeightMode = RectHeightMode.MAX,
+            rectWidthMode = RectWidthMode.MAX
+        ).maxOf { it.rect.bottom }
+        val offsetY = min(0f, rect.height - focusedLineBottom)
+        canvas.save()
+        canvas.clipRect(rect)
+        p.paint(canvas, rect.left, rect.top + offsetY)
+        canvas.restore()
+    }
+
+    private fun getRangeForLine(text: String, lineIndex: Int): Pair<Int, Int> {
+        var start = 0
+        var end = 0
+        var currentLine = 0
+        while (currentLine <= lineIndex) {
+            start = end
+            end = text.indexOf('\n', start + 1)
+            if (end == -1) {
+                end = text.length
+                break
+            }
+            currentLine++
+        }
+        return Pair(start, end)
+    }
+
+    private fun createParagraph(text: String, width: Float): Paragraph {
+        val fontCollection = FontCollection().setDefaultFontManager(FontMgr.default)
+        return ParagraphBuilder(ParagraphStyle(), fontCollection)
+            .pushStyle(terminalTextStyle)
+            .addText(text)
+            .build()
+            .apply { layout(width) }
+    }
+}