From f16177ad4703ff76a8a7341f2b377f9e2ad7d42e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 21:16:43 +0000 Subject: [PATCH 1/3] fix: clip zoom dialog grow/shrink animation to the tapped thumbnail's crop Gallery cells show images with ContentScale.Crop (e.g. a 4:3 photo in a square cell), but the dialog's enter/exit transform only scaled the whole image to cover the thumbnail rect, so it opened from and closed into the full uncropped rectangle. Clip the transformed layer to a window that morphs from the thumbnail's rect into the image's (zoom-aware) bounds while the animation runs; the clip is dropped at full progress so pager neighbours aren't cut off while swiping. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XRaVL4V96t595KjuGXx2Ay --- .../ui/components/ZoomableContentDialog.kt | 90 +++++++++++++------ 1 file changed, 64 insertions(+), 26 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt index fbb54dbea1..0e8665e981 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt @@ -53,8 +53,10 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.graphics.drawscope.clipRect import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.boundsInWindow @@ -267,40 +269,55 @@ private fun DialogContent( .graphicsLayer { val src = sourceBounds val img = imageBounds - if (src != null && img != null && - src.width > 0f && src.height > 0f && - img.width > 0f && img.height > 0f - ) { - // Account for user-applied zoom: the image is rendered at - // imageBounds transformed by the inner zoomable (uniform scale - // around the layout center, then offset). The exit animation - // must start from those visible bounds, not the unzoomed layout - // bounds — otherwise dismissing a zoomed-in image jumps. - val zoom = currentZoomState - val zScale = zoom?.scale ?: 1f - val zOffX = zoom?.offsetX ?: 0f - val zOffY = zoom?.offsetY ?: 0f - val imgCenter = img.center - val zoomedWidth = img.width * zScale - val zoomedHeight = img.height * zScale - val zoomedCenterX = imgCenter.x + zOffX - val zoomedCenterY = imgCenter.y + zOffY + if (src != null && img != null && src.hasArea() && img.hasArea()) { + // Account for user-applied zoom: the exit animation must start from + // the visible bounds, not the unzoomed layout bounds — otherwise + // dismissing a zoomed-in image jumps. + val zoomed = img.zoomedBy(currentZoomState) + // Uniform scale so non-square images keep their aspect ratio during + // the grow animation. The image covers the source rect; the overflow + // is clipped below. + val startScale = coverScale(src, zoomed) + val p = progress() transformOrigin = TransformOrigin(0f, 0f) - // Uniform scale so non-square images keep their aspect ratio during - // the grow animation. max() so the image covers the source rect in - // at least one dimension; the other overflows centered on the tap. - val startScale = maxOf(src.width / zoomedWidth, src.height / zoomedHeight) - val srcCenter = src.center - val p = progress() scaleX = lerp(startScale, 1f, p) scaleY = lerp(startScale, 1f, p) - translationX = lerp(srcCenter.x - startScale * zoomedCenterX, 0f, p) - translationY = lerp(srcCenter.y - startScale * zoomedCenterY, 0f, p) + translationX = lerp(src.center.x - startScale * zoomed.center.x, 0f, p) + translationY = lerp(src.center.y - startScale * zoomed.center.y, 0f, p) } else { // No source bounds: fall back to a plain fade. alpha = progress() } + }.drawWithContent { + val src = sourceBounds + val img = imageBounds + val p = progress() + if (p < 1f && src != null && img != null && src.hasArea() && img.hasArea()) { + // The thumbnail may be a crop of the image (e.g. a square gallery + // cell showing a 4:3 photo). Clip to a window that morphs from the + // thumbnail's rect into the image's own bounds, so the transition + // opens from and closes into exactly what was on screen. Skipped at + // p = 1 so pager neighbours aren't clipped while swiping. + val zoomed = img.zoomedBy(currentZoomState) + val startScale = coverScale(src, zoomed) + val scale = lerp(startScale, 1f, p) + val tx = lerp(src.center.x - startScale * zoomed.center.x, 0f, p) + val ty = lerp(src.center.y - startScale * zoomed.center.y, 0f, p) + + // The visible window in window coordinates, mapped back into this + // layer's pre-transform coordinates. + clipRect( + left = (lerp(src.left, zoomed.left, p) - tx) / scale, + top = (lerp(src.top, zoomed.top, p) - ty) / scale, + right = (lerp(src.right, zoomed.right, p) - tx) / scale, + bottom = (lerp(src.bottom, zoomed.bottom, p) - ty) / scale, + ) { + this@drawWithContent.drawContent() + } + } else { + drawContent() + } }, ) { if (allImages.size > 1) { @@ -441,6 +458,27 @@ internal suspend fun saveMediaToGallery( } } +private fun Rect.hasArea() = width > 0f && height > 0f + +/** + * The image's on-screen bounds after the user's pinch zoom: the zoomable scales + * uniformly around the layout center, then offsets. + */ +private fun Rect.zoomedBy(zoom: ZoomState?): Rect { + val zScale = zoom?.scale ?: 1f + val halfWidth = width * zScale / 2f + val halfHeight = height * zScale / 2f + val centerX = center.x + (zoom?.offsetX ?: 0f) + val centerY = center.y + (zoom?.offsetY ?: 0f) + return Rect(centerX - halfWidth, centerY - halfHeight, centerX + halfWidth, centerY + halfHeight) +} + +/** Uniform scale at which [image] covers [source] in both dimensions. */ +private fun coverScale( + source: Rect, + image: Rect, +): Float = maxOf(source.width / image.width, source.height / image.height) + @Composable private fun RenderImageOrVideo( content: BaseMediaContent, From 58157fb4f78ee39fd2aa0481d2b2af9a72c227fb Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 21:57:10 +0000 Subject: [PATCH 2/3] test(concord): build the fragmented List from fewer, fatter joins ConcordChannelListFragmentationTest built its multi-fragment List from 250 joins, each re-reading, re-packing and re-encrypting the whole List, so the setup grows with joins x List size. On the iOS simulator every test ran past runTest's 60s timeout (UncompletedCoroutinesError). Memberships now carry 30 private channels (~6 KB each), so 30 joins span the same several fragments with a partly filled last one. Same assertions; JVM time per test drops from ~5.5s to ~0.35s. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PcGLecmnkdzza3VntSPeCm --- .../ConcordChannelListFragmentationTest.kt | 46 ++++++++++++------- 1 file changed, 30 insertions(+), 16 deletions(-) diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordChannelListFragmentationTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordChannelListFragmentationTest.kt index 8d401335be..21df7c3efa 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordChannelListFragmentationTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordChannelListFragmentationTest.kt @@ -62,7 +62,13 @@ class ConcordChannelListFragmentationTest { salt: Int, ): HexKey = (seed * 7919 + salt).toString(16).padStart(8, '0').repeat(8) - /** A realistically heavy membership: staff secrets, two held roots, three private channels. */ + /** + * A heavy membership: staff secrets, two held roots and a staff member's worth of private + * channels. Each join re-reads and re-packs the whole List, so the cost of building one grows + * with (joins x List size); fat entries span several fragments in a few dozen joins instead of + * hundreds, which keeps the suite inside `runTest`'s timeout on slow targets (the iOS + * simulator ran 250 lean joins past it). + */ private fun entry(n: Int) = ConcordCommunityListEntry( id = hex(n, 1), @@ -73,7 +79,7 @@ class ConcordChannelListFragmentationTest { controlPk = hex(n, 5), controlRoot = hex(n, 6), heldRoots = listOf(HeldRoot(0, hex(n, 7)), HeldRoot(1, hex(n, 8), hex(n, 9))), - privateChannels = (0 until 3).map { PrivateChannelKey(hex(n, 20 + it), hex(n, 30 + it), 1, "private-$it") }, + privateChannels = (0 until PRIVATE_CHANNELS).map { PrivateChannelKey(hex(n, 100 + it), hex(n, 200 + it), 1, "private-$it") }, relays = listOf("wss://nos.lol/", "wss://nostr.mom/"), name = "Community number $n", addedAt = 1_000L + n, @@ -140,28 +146,28 @@ class ConcordChannelListFragmentationTest { fun aLargeListSplitsIntoFragmentsThatEachFitAnEvent() = runTest { val (list, repo) = device() - val published = joinAll(list, 250) + val published = joinAll(list, COUNT) val wire = repo.fragments - assertTrue(wire.size >= 2, "250 heavy memberships must not fit one fragment; got ${wire.size}") + assertTrue(wire.size >= 2, "$COUNT heavy memberships must not fit one fragment; got ${wire.size}") for (fragment in published) { val bytes = fragment.toJson().encodeToByteArray().size assertTrue(bytes <= ConcordListFragments.EVENT_CEILING_BYTES, "fragment ${fragment.index()} is $bytes bytes, over the ${ConcordListFragments.EVENT_CEILING_BYTES} ceiling") } - assertEquals(250, list.entries().size) + assertEquals(COUNT, list.entries().size) } @Test fun anotherDeviceReadsEveryMembershipFromThePublishedFragments() = runTest { val (first, repo) = device() - joinAll(first, 250) + joinAll(first, COUNT) val (second, _) = device(repo.fragments) val read = second.entries().associateBy { it.id } - assertEquals(250, read.size) - for (n in listOf(0, 1, 124, 248, 249)) { + assertEquals(COUNT, read.size) + for (n in listOf(0, 1, COUNT / 2, COUNT - 2, COUNT - 1)) { val want = entry(n) val got = read.getValue(want.id) assertEquals(want.root, got.root) @@ -176,27 +182,27 @@ class ConcordChannelListFragmentationTest { fun editsOnAnotherDeviceSurviveTheRoundTripAcrossFragments() = runTest { val (first, repo) = device() - joinAll(first, 250) + joinAll(first, COUNT) // The second device leaves a membership that lives in a later fragment and joins a new one. val (second, secondRepo) = device(repo.fragments) - second.unfollow(entry(240).id) + second.unfollow(entry(COUNT - 3).id) second.follow(entry(900)) // The first device, fed what the second published, converges on the same List. val (third, _) = device(secondRepo.fragments) val ids = third.entries().map { it.id }.toSet() - assertEquals(250, ids.size) - assertTrue(entry(240).id !in ids, "the leave must hold across fragments") + assertEquals(COUNT, ids.size) + assertTrue(entry(COUNT - 3).id !in ids, "the leave must hold across fragments") assertTrue(entry(900).id in ids) - assertTrue(entry(0).id in ids && entry(249).id in ids) + assertTrue(entry(0).id in ids && entry(COUNT - 1).id in ids) } @Test fun aDeviceMissingFragmentsJoinsIntoAHeldFragmentWithRoom() = runTest { val (first, repo) = device() - joinAll(first, 250) + joinAll(first, COUNT) val wire = repo.fragments.sortedBy { it.index() } assertTrue(wire.size >= 3) @@ -210,7 +216,7 @@ class ConcordChannelListFragmentationTest { val merged = (partialRepo.fragments + wire).groupBy { it.index() }.map { (_, copies) -> copies.maxBy { it.createdAt } } val (reader, _) = device(merged) val ids = reader.entries().map { it.id }.toSet() - assertEquals(251, ids.size, "every membership from every fragment plus the new join") + assertEquals(COUNT + 1, ids.size, "every membership from every fragment plus the new join") assertTrue(entry(901).id in ids) } @@ -218,7 +224,7 @@ class ConcordChannelListFragmentationTest { fun aDeviceHoldingOnlyFullFragmentsRefusesTheJoinRatherThanOverflowing() = runTest { val (first, repo) = device() - joinAll(first, 250) + joinAll(first, COUNT) val fragmentZero = repo.fragments.first { it.index() == 0 } // Opening a new fragment is a repack, which needs the complete List (CORD-02 §8); an @@ -228,4 +234,12 @@ class ConcordChannelListFragmentationTest { assertFailsWith { partial.follow(entry(902)) } assertEquals(listOf(fragmentZero), partialRepo.fragments, "a refused write publishes nothing") } + + companion object { + /** Private channels per membership: ~6 KB entries, a handful per fragment. */ + private const val PRIVATE_CHANNELS = 30 + + /** Joins per test: enough for several fragments with the last one only partly filled. */ + private const val COUNT = 30 + } } From ab4304a97b467f43fef560357dd65841547a6989 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 22:00:59 +0000 Subject: [PATCH 3/3] perf: clip the zoom transition via the layer outline, growing to the viewport Audit follow-ups on the thumbnail-crop clip: - Move the clip from a draw-phase clipRect into the existing graphicsLayer (clip + outline shape). Reading progress in draw re-recorded the pager's display list every animation frame; a layer outline animates without it. This also drops the duplicated transform math. - Morph the clip window into the whole viewport instead of the measured image rect. Turning the clip off at p = 1 no longer pops anything drawn outside the measured row (pager neighbours mid-swipe, video overlays) on the first exit frame, and bounds frozen mid-animation can no longer crop the image and then jump at the end. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XRaVL4V96t595KjuGXx2Ay --- .../ui/components/ZoomableContentDialog.kt | 78 +++++++++++-------- 1 file changed, 44 insertions(+), 34 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt index 0e8665e981..53ecdf75f5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt @@ -53,15 +53,18 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Outline +import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.TransformOrigin -import androidx.compose.ui.graphics.drawscope.clipRect import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.boundsInWindow import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.util.lerp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties @@ -279,44 +282,40 @@ private fun DialogContent( // is clipped below. val startScale = coverScale(src, zoomed) val p = progress() - - transformOrigin = TransformOrigin(0f, 0f) - scaleX = lerp(startScale, 1f, p) - scaleY = lerp(startScale, 1f, p) - translationX = lerp(src.center.x - startScale * zoomed.center.x, 0f, p) - translationY = lerp(src.center.y - startScale * zoomed.center.y, 0f, p) - } else { - // No source bounds: fall back to a plain fade. - alpha = progress() - } - }.drawWithContent { - val src = sourceBounds - val img = imageBounds - val p = progress() - if (p < 1f && src != null && img != null && src.hasArea() && img.hasArea()) { - // The thumbnail may be a crop of the image (e.g. a square gallery - // cell showing a 4:3 photo). Clip to a window that morphs from the - // thumbnail's rect into the image's own bounds, so the transition - // opens from and closes into exactly what was on screen. Skipped at - // p = 1 so pager neighbours aren't clipped while swiping. - val zoomed = img.zoomedBy(currentZoomState) - val startScale = coverScale(src, zoomed) val scale = lerp(startScale, 1f, p) val tx = lerp(src.center.x - startScale * zoomed.center.x, 0f, p) val ty = lerp(src.center.y - startScale * zoomed.center.y, 0f, p) - // The visible window in window coordinates, mapped back into this - // layer's pre-transform coordinates. - clipRect( - left = (lerp(src.left, zoomed.left, p) - tx) / scale, - top = (lerp(src.top, zoomed.top, p) - ty) / scale, - right = (lerp(src.right, zoomed.right, p) - tx) / scale, - bottom = (lerp(src.bottom, zoomed.bottom, p) - ty) / scale, - ) { - this@drawWithContent.drawContent() + transformOrigin = TransformOrigin(0f, 0f) + scaleX = scale + scaleY = scale + translationX = tx + translationY = ty + + if (p < 1f) { + // The thumbnail may be a crop of the image (e.g. a square gallery + // cell showing a 4:3 photo). Clip to a window that morphs from the + // thumbnail's rect into the whole viewport, so the transition opens + // from and closes into exactly what was on screen. The viewport (not + // the measured image) is the end state so there is nothing to snap + // when the clip turns off at p = 1, where pager neighbours must show. + // Window coordinates, mapped back into this layer's pre-transform + // space. A layer outline, not a draw-phase clip, so animating it + // doesn't re-record the pager's display list every frame. + shape = + RectClipShape( + Rect( + left = (lerp(src.left, 0f, p) - tx) / scale, + top = (lerp(src.top, 0f, p) - ty) / scale, + right = (lerp(src.right, size.width, p) - tx) / scale, + bottom = (lerp(src.bottom, size.height, p) - ty) / scale, + ), + ) + clip = true } } else { - drawContent() + // No source bounds: fall back to a plain fade. + alpha = progress() } }, ) { @@ -479,6 +478,17 @@ private fun coverScale( image: Rect, ): Float = maxOf(source.width / image.width, source.height / image.height) +/** Clips a layer to a fixed [rect] in its own coordinates, regardless of the layer's size. */ +private class RectClipShape( + private val rect: Rect, +) : Shape { + override fun createOutline( + size: Size, + layoutDirection: LayoutDirection, + density: Density, + ): Outline = Outline.Rectangle(rect) +} + @Composable private fun RenderImageOrVideo( content: BaseMediaContent,