From a492e2def3dabe5e8de5ef4486b92859850503db Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Tue, 29 Sep 2026 07:12:48 +0000 Subject: Reflow reader text live while pinching, and let a zoomed image pan Pinching in the reader used to scale the whole WebView like a photo and snap back to the real font size on release. It now drives WebSettings.textZoom as the fingers move, so the text itself resizes and reflows during the gesture with nothing to snap back from; images and margins stay put. The reload at the new saved size is what returns textZoom to 100%, so the two never overlap -- and loading reader HTML now goes through ReaderWebView.loadReaderHtml, which does both together, so a future load site can't forget the reset. Reader view for a tapped link now honors the saved font size and the same pinch gesture, instead of always rendering at the 18px default. A zoomed image can be panned again. The viewer computed its pan bounds from values captured when the dialog first composed -- an empty viewport around a not-yet-decoded image -- because a pointerInput(Unit) block keeps the lambda it was created with. Those bounds allowed exactly one offset, 0, so zooming worked and dragging did nothing. The whole pinch/pan step is now one pure function the gesture callback calls with values it reads fresh each time. --- .../java/org/terst/feedreader/ui/ImageZoomTest.kt | 104 +++++++++++++++++++++ 1 file changed, 104 insertions(+) (limited to 'app/src/test/java/org/terst/feedreader/ui/ImageZoomTest.kt') diff --git a/app/src/test/java/org/terst/feedreader/ui/ImageZoomTest.kt b/app/src/test/java/org/terst/feedreader/ui/ImageZoomTest.kt index 81ecc33..3f7078d 100644 --- a/app/src/test/java/org/terst/feedreader/ui/ImageZoomTest.kt +++ b/app/src/test/java/org/terst/feedreader/ui/ImageZoomTest.kt @@ -168,6 +168,110 @@ class ImageZoomTest { assertEquals(500f to 1000f, fittedImageSize(1000f, 1000f, 1000f, 2000f)) } + @Test + fun `a drag on a zoomed image pans it`() { + // The regression this function exists for: the viewer computed its pan bounds from + // values captured once at composition (viewport still zero, image not yet decoded), + // which pinned every offset to 0 -- a zoomed image simply refused to pan. + val panned = imageZoomTransform( + ImageZoomState(scale = 2f), + viewportSize = 1000f to 1000f, + intrinsicSize = 1000f to 1000f, + pan = -100f to -50f, + gestureZoom = 1f + ) + assertEquals(2f, panned.scale, 0.001f) + assertEquals(-100f, panned.offsetX, 0.001f) + assertEquals(-50f, panned.offsetY, 0.001f) + } + + @Test + fun `a drag on an unzoomed image leaves it where it is`() { + val panned = imageZoomTransform( + ImageZoomState(scale = 1f), + viewportSize = 1000f to 1000f, + intrinsicSize = 1000f to 1000f, + pan = 100f to 100f, + gestureZoom = 1f + ) + assertEquals(0f, panned.offsetX, 0.001f) + assertEquals(0f, panned.offsetY, 0.001f) + } + + @Test + fun `a pan stops at the edge of the scaled image`() { + val panned = imageZoomTransform( + ImageZoomState(scale = 2f, offsetX = 400f), + viewportSize = 1000f to 1000f, + intrinsicSize = 1000f to 1000f, + pan = 300f to 0f, + gestureZoom = 1f + ) + assertEquals(500f, panned.offsetX, 0.001f) + } + + @Test + fun `a pinch zooms and clamps the existing pan to the new scale`() { + val zoomedOut = imageZoomTransform( + ImageZoomState(scale = 2f, offsetX = 500f, offsetY = 500f), + viewportSize = 1000f to 1000f, + intrinsicSize = 1000f to 1000f, + pan = 0f to 0f, + gestureZoom = 0.1f + ) + assertEquals(1f, zoomedOut.scale, 0.001f) + assertEquals(0f, zoomedOut.offsetX, 0.001f) + assertEquals(0f, zoomedOut.offsetY, 0.001f) + } + + @Test + fun `a letterboxed image pans along its long axis only`() { + // A 2000x1000 photo fitted into a 1000x1000 viewport is 1000x500 on screen, so at 2x + // it fills the viewport exactly -- there's nothing above or below it to pan to, but + // dragging sideways still works. + val panned = imageZoomTransform( + ImageZoomState(scale = 2f), + viewportSize = 1000f to 1000f, + intrinsicSize = 2000f to 1000f, + pan = -200f to 300f, + gestureZoom = 1f + ) + assertEquals(-200f, panned.offsetX, 0.001f) + assertEquals(0f, panned.offsetY, 0.001f) + } + + @Test + fun `an image that hasn't reported its size yet can still be panned`() { + // Coil reports intrinsic size only once the image decodes; a pinch-and-drag before + // then is bounded by the viewport rather than refusing to move. + val panned = imageZoomTransform( + ImageZoomState(scale = 2f), + viewportSize = 1000f to 1000f, + intrinsicSize = 0f to 0f, + pan = -300f to 0f, + gestureZoom = 1f + ) + assertEquals(-300f, panned.offsetX, 0.001f) + } + + @Test + fun `double-tapping zooms in from the fitted size`() { + val zoomed = imageZoomDoubleTap(ImageZoomState(), 1000f to 1000f, 1000f to 1000f) + assertTrue(zoomed.scale > 1f) + } + + @Test + fun `double-tapping a panned, zoomed image returns it to fitted and centered`() { + val reset = imageZoomDoubleTap( + ImageZoomState(scale = 3f, offsetX = 300f, offsetY = -200f), + 1000f to 1000f, + 1000f to 1000f + ) + assertEquals(1f, reset.scale, 0.001f) + assertEquals(0f, reset.offsetX, 0.001f) + assertEquals(0f, reset.offsetY, 0.001f) + } + @Test fun `an image whose size isn't known yet is assumed to fill the viewport`() { // Coil only reports intrinsic size once the image has actually decoded; until then -- cgit v1.2.3