Milan Jurkulak vor 3 Monaten
Ursprung
Commit
2e75731d87

+ 29 - 0
.claude/skills/mjdev-user-requests-first/SKILL.md

@@ -0,0 +1,29 @@
+---
+name: mjdev-user-requests-first
+description: User requests always take priority over the assistant's own plans, task lists, or agenda. Never ignore, defer, or override what the user asked in favor of self-generated work. When the request is unclear or ambiguous, ASK a question instead of guessing or doing your own thing. Apply on every turn in this project.
+---
+
+# User requests come first (non-negotiable)
+
+The user's stated request is the priority. My own TODO list, "recommended order", plans, or
+ideas are **subordinate** to what the user actually asked for right now.
+
+## Rules
+1. **Do what the user asked, in the order they asked.** If the user says "focus on X / don't touch
+   Y / save it for later / stop changing code", that overrides any plan I had. Drop my agenda and
+   serve the request.
+2. **Never silently ignore or reinterpret a request.** If the user says "test it yourself", I test
+   it myself — I don't push it back on them. If they say "don't change anything", I change nothing.
+3. **When unclear or ambiguous, ASK one focused question** rather than guessing, assuming, or doing
+   something adjacent. A short question beats unwanted work.
+4. **Don't pile on work.** The user is frustrated when tasks accumulate instead of finishing.
+   Finish the current requested item cleanly before opening new fronts. Don't task-manage the user.
+5. **Keep their workspace clean.** Don't commit / leave my own changes (esp. build-config or
+   scratch) in git without an explicit greenlight.
+
+## Why
+The user has repeatedly had to repeat themselves ("otestuj si to", "neměň nic", "řeším gif") because
+self-directed work drifted from their request. That wastes their limited patience and rebuild time.
+Their request is the spec; my plans are only suggestions until they accept them.
+
+See [[autonomy-only-ask-when-unsure]] and [[feedback-test-it-yourself]].

+ 6 - 13
ai-resource-analysis.md

@@ -18,6 +18,12 @@ Datum: 2026-06-16. Cíl: **non-invazivní** snížení CPU/RAM bez změny vidite
   pollingu á 200 ms (~1 probuzení/s).
 - **#7.1 Shell blokující I/O → IO** — `Shell.init` launch přepnut z `Dispatchers.Default` na `IO`
   (blokující `ProcessBuilder.waitFor` nehladoví CPU-sized Default pool).
+- **#2 GIF perzistentní disk cache** — `GifDecoder` už nedrží snímky v RAM: každý snímek se streamuje
+  na disk do `/var/tmp/mjdev-desktop/gif-cache/<klíč>/f<n>` (raw ARGB) + `info` manifest (klíč =
+  src+velikost+mtime). Při dalším spuštění se GIF **vůbec nedekóduje** (cache hit). Za běhu se snímky
+  čtou z disku líně přes malou in-memory LRU (`MAX_CACHED_BITMAPS=4`) → max 4 živé bitmapy/GPU textury
+  místo všech. Build snímku běží přes `withContext(Dispatchers.Default)` (mimo UI vlákno, bez janku).
+  `gifCacheBaseDir()` expect/actual (desktop `/var/tmp/...`, android temp).
 
 Pozn.: přímý `java.util.HashMap` v commonMain shazuje K2 actualizer (přes `OsRelease`) — používat
 `mutableMapOf`.
@@ -26,19 +32,6 @@ Pozn.: přímý `java.util.HashMap` v commonMain shazuje K2 actualizer (přes `O
 
 ## ZBÝVÁ K ROZHODNUTÍ
 
-### #2 — GIF snímky v RAM (LRU) — POZOR na návrh
-Naivní LRU paměť **nesníží** (možná zvýší): snímek jako `IntArray` (heap, w·h·4 B) je stejně velký
-jako `ImageBitmap`, takže držet všechny IntArrays + pár přestavěných bitmap = stejně/víc než dnes
-(všechny bitmapy). Reálnou úsporu dá jen:
-- **(a)** ukládat snímky komprimovaně — palette-index **1 B/px** (~4× méně heapu) a kompozitovat
-  on-demand; čisté pro **sekvenční** přehrávání, ale rozbíjí náhodný seek (`prevFrame`/`reset`
-  v `GifViewState`) — nutno dořešit (keyframe replay nebo udržovat poslední kompozici);
-- **(b)** **coil3 animated decoder** pro přehrávání (coil GIF dekodér v projektu už je, používá se
-  pro paletu) — deleguje správu snímků, ruší vlastní `GifDecoder`; mění verified-working playback
-  path → ověřit vizuál 1:1 za běhu.
-Vybrat (a)/(b). Měřit RSS + počet živých bitmap před/po. `imageBitmapFromArgb` (z #1) dělá rebuild
-snímku levný, takže lazy decode je teď reálná cesta.
-
 ### #7.2 — Scope leaky v default argumentech @Composable
 `CoroutineScope(Dispatchers.Default)` jako default arg (`AppsMenu.kt:264,276`, bázová
 `VisibilityState.kt:22`) → nový nezrušený scope při každé rekompozici. Fix = scope z

BIN
fix-it/Snímek obrazovky z 2026-06-16 09-44-51.png


BIN
fix-it/Snímek obrazovky z 2026-06-16 09-49-19.png


BIN
fix-it/Snímek obrazovky z 2026-06-16 09-58-11.png


+ 12 - 0
shared/src/androidMain/kotlin/org/mjdev/desktop/helpers/gif/GifCacheDir.kt

@@ -0,0 +1,12 @@
+package org.mjdev.desktop.helpers.gif
+
+import okio.Path
+import okio.Path.Companion.toPath
+
+// Animated GIF wallpapers are a desktop feature; on Android we just point at the JVM temp dir so
+// the shared code compiles and still works if ever used.
+actual fun gifCacheBaseDir(): Path =
+    (System.getProperty("java.io.tmpdir") ?: "/tmp")
+        .trimEnd('/')
+        .plus("/mjdev-desktop/gif-cache")
+        .toPath()

+ 45 - 17
shared/src/commonMain/kotlin/org/mjdev/desktop/components/image/GifView.kt

@@ -7,8 +7,13 @@ import androidx.compose.ui.Modifier
 import androidx.compose.ui.graphics.ImageBitmap
 import androidx.compose.ui.layout.ContentScale
 import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.async
+import kotlinx.coroutines.coroutineScope
 import kotlinx.coroutines.delay
+import kotlinx.coroutines.isActive
 import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
 import org.jetbrains.compose.ui.tooling.preview.Preview
 import org.mjdev.desktop.components.image.GifViewState.Companion.rememberGifViewState
 import org.mjdev.desktop.context.DesktopContextScope.Companion.withDesktopContext
@@ -43,21 +48,10 @@ fun GifView(
                 contentScale = ContentScale.Crop,
             )
         }
-        LaunchedEffect(state.currentFrame) {
-//            if(state.isLoading) {
-//                onLoading()
-//            }
-            if (state.currentFrame == 0) {
-                onLoaded(state.duration)
-            }
-            if (state.currentFrame > -1) {
-                delay(state.currentFrameDelay)
-            }
-            if (state.hasNextFrame) {
-                state.nextFrame()
-            } else {
-                state.reset()
-            }
+        // single playback loop per gif: decodes the NEXT frame off the UI thread while the current
+        // one is on screen (prefetch pipeline), so swaps are instant and we never spin the CPU.
+        LaunchedEffect(state) {
+            state.play(onLoaded = onLoaded, onAnimationFinish = onAnimationFinish)
         }
     }
 }
@@ -72,8 +66,8 @@ class GifViewState {
         internal set
     var currentFrame by mutableIntStateOf(-1)
         internal set
-    val currentImage: ImageBitmap?
-        get() = gifDecoder?.getFrame(currentFrame)
+    var currentImage by mutableStateOf<ImageBitmap?>(null)
+        internal set
     val currentFrameDelay: Long
         get() = gifDecoder?.getDelay(currentFrame) ?: 0
     val framesCount
@@ -83,6 +77,36 @@ class GifViewState {
     val duration: Long
         get() = gifDecoder?.getDuration() ?: 0
 
+    // Drives playback in a single coroutine: shows frame n while decoding n+1 in the background,
+    // so the visible swap is instant and the CPU is never spun (sub-MIN_FRAME_DELAY_MS gifs are
+    // clamped). Building frames off the UI thread keeps the desktop responsive.
+    suspend fun play(
+        onLoaded: (Long) -> Unit,
+        onAnimationFinish: () -> Unit,
+    ) = coroutineScope {
+        while (isActive && (gifDecoder == null || isLoading || framesCount == 0)) {
+            delay(50L)
+        }
+        val decoder = gifDecoder ?: return@coroutineScope
+        val count = framesCount
+        if (count == 0) return@coroutineScope
+        var n = 0
+        currentFrame = 0
+        currentImage = withContext(Dispatchers.Default) { decoder.getFrame(0) }
+        onLoaded(duration)
+        if (count <= 1) return@coroutineScope
+        while (isActive) {
+            val next = if (n < count - 1) n + 1 else 0
+            val prefetch = async(Dispatchers.Default) { decoder.getFrame(next) }
+            delay(decoder.getDelay(n).coerceAtLeast(MIN_FRAME_DELAY_MS))
+            val bitmap = prefetch.await()
+            n = next
+            currentFrame = n
+            currentImage = bitmap
+            if (n == 0) onAnimationFinish()
+        }
+    }
+
     suspend fun nextFrame() {
         if (isLoading || framesCount == 0) return
         var nextFrame = currentFrame + 1
@@ -114,6 +138,10 @@ class GifViewState {
     }
 
     companion object {
+        // Clamp very short / zero frame delays so a "play as fast as possible" gif can't spin the
+        // CPU (which starved the UI — control center wouldn't open). ~50fps ceiling.
+        const val MIN_FRAME_DELAY_MS = 20L
+
         @Composable
         fun rememberGifViewState(
             src: String,

+ 1 - 1
shared/src/commonMain/kotlin/org/mjdev/desktop/context/DesktopContextScope.kt

@@ -132,7 +132,7 @@ open class DesktopContextScope(
         get() = context.theme.panelHideDelay > 0L
     val panelDividerWidth: Dp
         get() = context.theme.panelDividerWidth
-    val panelLocation : PanelLocation
+    val panelLocation: PanelLocation
         get() = context.theme.panelLocation
 
     val controlPanelHideDelay: Long

+ 10 - 0
shared/src/commonMain/kotlin/org/mjdev/desktop/helpers/gif/GifCacheDir.kt

@@ -0,0 +1,10 @@
+package org.mjdev.desktop.helpers.gif
+
+import okio.Path
+
+/**
+ * Base directory for the persistent on-disk GIF frame cache. Mirrors the project's
+ * `/var/tmp/mjdev-desktop/...` convention (same as corrected-desktop-files): survives across
+ * app restarts so a GIF is decoded only once.
+ */
+expect fun gifCacheBaseDir(): Path

+ 169 - 27
shared/src/commonMain/kotlin/org/mjdev/desktop/helpers/gif/GifDecoder.kt

@@ -17,8 +17,8 @@ import okio.Path.Companion.toPath
 import okio.buffer
 import okio.source
 import org.mjdev.desktop.extensions.imageBitmapFromArgb
-import org.mjdev.desktop.extensions.pixels
 import org.mjdev.desktop.log.Log
+import org.mjdev.desktop.system.Filesystem
 import org.mjdev.desktop.system.Filesystem.source
 
 // todo optimize code, remove unused variables, use better data structures
@@ -56,14 +56,25 @@ class GifDecoder {
     private var globalColorTable: IntArray = intArrayOf()
     private var localColorTable: IntArray = intArrayOf()
     private var activeColorTable: IntArray = intArrayOf()
-    private var image: ImageBitmap? = null
-    private var lastImage: ImageBitmap? = null
     private var input: BufferedSource? = null
     private var block = byteArrayOf()
     private var frameCount: Int = 0
 
     private val frames = mutableListOf<GifFrame>()
 
+    // Compositing history (used only during decode): previous and two-frames-ago composited
+    // canvases needed for the GIF dispose methods — bounded, not the whole movie.
+    private var lastFramePixels: IntArray? = null
+    private var lastLastFramePixels: IntArray? = null
+
+    // Directory holding this gif's on-disk frame cache (raw ARGB per frame).
+    private var cacheDir: Path? = null
+
+    // Small in-memory LRU of frames built from disk — bounds live ImageBitmaps / GPU textures
+    // to MAX_CACHED_BITMAPS instead of holding every frame of the animation in RAM.
+    private val frameBitmapCache = mutableMapOf<Int, ImageBitmap>()
+    private val frameCacheOrder = ArrayDeque<Int>()
+
     fun getWidth(): Int = width
 
     fun getHeight(): Int = height
@@ -85,15 +96,34 @@ class GifDecoder {
 
     fun getFrameCount(): Int = frameCount
 
-    fun getFrame(n: Int): ImageBitmap? =
-        if (n in 0..<frameCount) {
-            frames[n].image
-        } else {
-            null
+    fun getFrame(n: Int): ImageBitmap? {
+        if (n !in 0..<frameCount) return null
+        frameBitmapCache[n]?.let { cached ->
+            frameCacheOrder.remove(n)
+            frameCacheOrder.addLast(n)
+            return cached
+        }
+        val frame = frames.getOrNull(n) ?: return null
+        val bitmap =
+            runCatching { imageBitmapFromArgb(readFramePixels(frame.file), width, height) }
+                .onFailure { e -> Log.e(e) }
+                .getOrNull() ?: return null
+        frameBitmapCache[n] = bitmap
+        frameCacheOrder.addLast(n)
+        while (frameCacheOrder.size > MAX_CACHED_BITMAPS) {
+            frameBitmapCache.remove(frameCacheOrder.removeFirst())
         }
+        return bitmap
+    }
 
     fun fromSource(inp: BufferedSource?): Int {
         init()
+        // Frames are always streamed to disk; when no stable cache dir was set (e.g. a direct
+        // fromSource/fromPath call), use an ephemeral one so we still avoid holding frames in RAM.
+        if (cacheDir == null) {
+            cacheDir = gifCacheBaseDir().resolve("tmp-${hashCode().toUInt().toString(16)}")
+        }
+        runCatching { Filesystem.createDirectories(cacheDir!!) }.onFailure { e -> Log.e(e) }
         if (inp != null) {
             input = inp
             readHeader()
@@ -137,6 +167,15 @@ class GifDecoder {
         }
 
     fun from(pathOrUrl: String): Int {
+        init()
+        val dir = stableCacheDir(pathOrUrl)
+        cacheDir = dir
+        runCatching { Filesystem.createDirectories(dir) }.onFailure { e -> Log.e(e) }
+        // Persistent cache hit: frames already on disk from a previous run — skip decoding entirely.
+        if (loadManifest(dir, pathOrUrl)) {
+            status = STATUS_OK
+            return status
+        }
         status =
             when {
                 pathOrUrl.startsWith("http://") || pathOrUrl.startsWith("https://") -> {
@@ -146,25 +185,27 @@ class GifDecoder {
                     fromFile(pathOrUrl)
                 }
             }
+        if (status == STATUS_OK) {
+            runCatching { saveManifest(dir, pathOrUrl) }.onFailure { e -> Log.e(e) }
+        }
         return status
     }
 
-    private fun setPixels() {
-        // Build the frame into a plain IntArray and create the ImageBitmap in ONE bulk op
-        // (imageBitmapFromArgb) instead of the old per-pixel Canvas.drawRect write.
+    // Composites the current frame onto the right base canvas (per GIF dispose method) and returns
+    // the full-canvas ARGB pixels. Output identical to the old setPixels; it just works on plain
+    // IntArray history buffers instead of reading pixels back out of ImageBitmaps.
+    private fun compositeCurrentFrame(): IntArray {
         val dest = IntArray(width * height)
-        if (lastDispose > 0) {
-            if (lastDispose == 3) {
-                val n = frameCount - 2
-                lastImage = if (n > 0) getFrame(n - 1) else null
-            }
-            if (lastImage != null) {
-                val prev = lastImage?.pixels ?: intArrayOf()
-                System.arraycopy(prev, 0, dest, 0, width * height)
-                // NOTE: the original code called image.fillRect here for lastDispose == 2, but the
-                // result was immediately overwritten by `image.pixels = dest`, so it was a no-op.
-                // Preserved as a no-op to keep frame output identical.
+        // dispose of the PREVIOUS frame decides the base: 3 = restore to two-frames-ago,
+        // 1/2 = keep previous, 0 = none (first frame). (dispose==2's fillRect was a no-op before.)
+        val base =
+            when {
+                lastDispose == 3 -> lastLastFramePixels
+                lastDispose > 0 -> lastFramePixels
+                else -> null
             }
+        if (base != null && base.size >= width * height) {
+            System.arraycopy(base, 0, dest, 0, width * height)
         }
         var pass = 1
         var inc = 8
@@ -206,7 +247,7 @@ class GifDecoder {
                 }
             }
         }
-        image = imageBitmapFromArgb(dest, width, height)
+        return dest
     }
 
     private fun decodeImageData() {
@@ -318,8 +359,102 @@ class GifDecoder {
         frames.clear()
         globalColorTable = intArrayOf()
         localColorTable = intArrayOf()
+        lastFramePixels = null
+        lastLastFramePixels = null
+        frameBitmapCache.clear()
+        frameCacheOrder.clear()
+    }
+
+    // --- on-disk frame cache -------------------------------------------------------------------
+
+    // Cache key from source + file size + mtime so it invalidates when the gif changes.
+    private fun stableCacheDir(src: String): Path {
+        val meta = runCatching { Filesystem.metadataOrNull(src.toPath()) }.getOrNull()
+        val size = meta?.size ?: -1L
+        val mtime = meta?.lastModifiedAtMillis ?: -1L
+        val key = "$src|$size|$mtime".hashCode().toUInt().toString(16)
+        return gifCacheBaseDir().resolve(key)
+    }
+
+    private fun writeFramePixels(
+        file: Path,
+        pixels: IntArray,
+    ) {
+        val bytes = ByteArray(pixels.size * 4)
+        for (i in pixels.indices) {
+            val v = pixels[i]
+            val o = i * 4
+            bytes[o] = (v ushr 24).toByte()
+            bytes[o + 1] = (v ushr 16).toByte()
+            bytes[o + 2] = (v ushr 8).toByte()
+            bytes[o + 3] = v.toByte()
+        }
+        Filesystem.sink(file).buffer().use { sink -> sink.write(bytes) }
+    }
+
+    private fun readFramePixels(file: Path): IntArray {
+        val bytes = Filesystem.source(file).buffer().use { it.readByteArray() }
+        val out = IntArray(bytes.size / 4)
+        for (i in out.indices) {
+            val o = i * 4
+            out[i] =
+                ((bytes[o].toInt() and 0xff) shl 24) or
+                ((bytes[o + 1].toInt() and 0xff) shl 16) or
+                ((bytes[o + 2].toInt() and 0xff) shl 8) or
+                (bytes[o + 3].toInt() and 0xff)
+        }
+        return out
+    }
+
+    private fun saveManifest(
+        dir: Path,
+        src: String,
+    ) {
+        val text =
+            buildString {
+                appendLine("v1")
+                appendLine(src)
+                appendLine(width.toString())
+                appendLine(height.toString())
+                appendLine(loopCount.toString())
+                appendLine(frameCount.toString())
+                appendLine(frames.joinToString(",") { it.delay.toString() })
+            }
+        Filesystem.writeText(dir.resolve("info"), text)
     }
 
+    // Populates frames from a previously written manifest (no decoding). Returns false on any
+    // mismatch / missing file so the caller re-decodes.
+    private fun loadManifest(
+        dir: Path,
+        src: String,
+    ): Boolean =
+        runCatching {
+            val info = dir.resolve("info")
+            if (!Filesystem.fileExists(info)) return false
+            val lines = Filesystem.readLines(info)
+            if (lines.size < 7 || lines[0] != "v1" || lines[1] != src) return false
+            val w = lines[2].toInt()
+            val h = lines[3].toInt()
+            val loop = lines[4].toInt()
+            val count = lines[5].toInt()
+            val delays = lines[6].split(",").mapNotNull { it.toIntOrNull() }
+            if (count <= 0 || delays.size != count) return false
+            val restored = mutableListOf<GifFrame>()
+            for (i in 0 until count) {
+                val f = dir.resolve("f$i")
+                if (!Filesystem.fileExists(f)) return false
+                restored.add(GifFrame(delays[i], f))
+            }
+            width = w
+            height = h
+            loopCount = loop
+            frameCount = count
+            frames.clear()
+            frames.addAll(restored)
+            true
+        }.getOrDefault(false)
+
     private fun readByte(): Int {
         val curByte =
             try {
@@ -464,12 +599,17 @@ class GifDecoder {
         skip()
         if (isErr()) return
         frameCount++
-        // setPixels() builds `image` directly from the decoded pixels (bulk), no empty alloc needed
-        setPixels()
-        frames.add(GifFrame(image, delay))
+        val dest = compositeCurrentFrame()
+        val index = frameCount - 1
+        val frameFile = cacheDir!!.resolve("f$index")
+        runCatching { writeFramePixels(frameFile, dest) }.onFailure { e -> Log.e(e) }
+        frames.add(GifFrame(delay, frameFile))
         if (transparency) {
             activeColorTable[transIndex] = save
         }
+        // advance compositing history for the next frame's dispose handling
+        lastLastFramePixels = lastFramePixels
+        lastFramePixels = dest
         resetFrame()
     }
 
@@ -499,7 +639,6 @@ class GifDecoder {
     private fun resetFrame() {
         lastDispose = dispose
         lastRect = IntRect(ix, iy, iw, ih)
-        lastImage = image
         lastBackgroundColor = backgroundColor
         dispose = 0
         transparency = false
@@ -518,6 +657,9 @@ class GifDecoder {
         const val STATUS_FORMAT_ERROR = 1
         const val STATUS_OPEN_ERROR = 2
 
+        // Max frames kept as live ImageBitmaps in RAM at once (rest stream from disk on demand).
+        const val MAX_CACHED_BITMAPS = 4
+
         fun fromSource(source: BufferedSource?): GifDecoder =
             GifDecoder().apply {
                 fromSource(source)

+ 4 - 2
shared/src/commonMain/kotlin/org/mjdev/desktop/helpers/gif/GifFrame.kt

@@ -1,9 +1,11 @@
 package org.mjdev.desktop.helpers.gif
 
-import androidx.compose.ui.graphics.ImageBitmap
+import okio.Path
 import kotlin.jvm.JvmField
 
+// A decoded frame is NOT kept in RAM — only its on-disk cache file (raw ARGB pixels) + delay.
+// The bitmap is built lazily from [file] on demand (see GifDecoder.getFrame) with a small LRU.
 class GifFrame(
-    @JvmField val image: ImageBitmap?,
     @JvmField val delay: Int,
+    @JvmField val file: Path,
 )

+ 1 - 1
shared/src/desktopMain/kotlin/org/mjdev/desktop/components/dockbar/DockBarWindow.kt

@@ -220,4 +220,4 @@ fun DockBarWindow(
 fun PreviewDockBarWindow() =
     preview {
         DockBarWindow()
-    }
+    }

+ 1 - 1
shared/src/desktopMain/kotlin/org/mjdev/desktop/components/main/MainWindow.kt

@@ -128,7 +128,7 @@ fun MainWindow() =
 //            panelState = panelState,
 //            menuState = menuState,
 //            onFocusChange = { focused ->
-////            Log.d("panel focus : $focused")
+// //            Log.d("panel focus : $focused")
 //                val menuIsVisible = appsMenuState.isVisible || menuState.isVisible
 //                if (panelState.enabled) {
 //                    if (!menuIsVisible && !focused) {

+ 7 - 0
shared/src/desktopMain/kotlin/org/mjdev/desktop/helpers/gif/GifCacheDir.kt

@@ -0,0 +1,7 @@
+package org.mjdev.desktop.helpers.gif
+
+import okio.Path
+import okio.Path.Companion.toPath
+
+// Same convention as DesktopFile.correctDir / Log: /var/tmp/mjdev-desktop/... (persists across reboots).
+actual fun gifCacheBaseDir(): Path = "/var/tmp/mjdev-desktop/gif-cache".toPath()