diff --git a/PLAN.md b/PLAN.md index 28519f0..2a698f4 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1044,6 +1044,11 @@ dev-updater (Kotlin 2.4.x, CMP 1.11.x, JDK 21). different composable in a different part of the tree, so the old subtree and its open dialog go. Somebody looking at a screenshot was thrown back to the transcript because the session made another tool call. + - **The image viewer fits against the whole physical display, including the + system-bar regions** (2026-09-12). It hides the status and navigation bars + independently when the fitted, zoomed or panned image reaches them, and + restores either one when it does not. `100%` recenters at one bitmap pixel + per screen pixel; opening still fits the whole image first. - **All transcript text is selectable, from one `SelectionContainer` around the whole list.** Not per row: a transcript is one body of text, so a selection has to run from a reply into the tool output under it — diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionImage.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionImage.kt index 697ac9f..9dc8612 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionImage.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionImage.kt @@ -10,14 +10,20 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -32,12 +38,20 @@ import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalView import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.isSpecified import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties +import androidx.compose.ui.window.DialogWindowProvider +import androidx.core.view.ViewCompat +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -142,10 +156,15 @@ fun SessionImageViewer( onClose: () -> Unit, ) { val (bitmap, failed) = rememberSessionBitmap(settings, sessionId, ref) + val view = LocalView.current + var hiddenBars by remember(ref) { mutableStateOf(ViewerBars()) } + var barInsets by remember(ref) { mutableStateOf(ViewerBarInsets()) } Dialog( onDismissRequest = onClose, - properties = DialogProperties(usePlatformDefaultWidth = false), + properties = + DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false), ) { + ViewerSystemBars(hiddenBars) Box( Modifier.fillMaxSize() .background(Color.Black) @@ -173,7 +192,46 @@ fun SessionImageViewer( // beside it are. CircularProgressIndicator(color = Color.White) } - else -> ZoomableImage(image) + else -> { + var viewport by remember { mutableStateOf(IntSize.Zero) } + var nativeSizeRequest by remember { mutableIntStateOf(0) } + ZoomableImage( + image, + nativeSizeRequest = nativeSizeRequest, + onViewportChanged = { + viewport = it + ViewCompat.getRootWindowInsets(view)?.let { insets -> + barInsets = + ViewerBarInsets( + status = + insets + .getInsetsIgnoringVisibility( + WindowInsetsCompat.Type.statusBars() + ) + .top, + navigation = + insets + .getInsetsIgnoringVisibility( + WindowInsetsCompat.Type.navigationBars() + ) + .bottom, + ) + } + }, + onBarsChanged = { hiddenBars = it }, + barInsets = barInsets, + viewport = viewport, + ) + Button( + onClick = { nativeSizeRequest++ }, + modifier = + Modifier.align(Alignment.BottomEnd) + .navigationBarsPadding() + .padding(16.dp), + ) { + Text("100%") + } + } } } } @@ -233,14 +291,40 @@ private fun enlargingFilter(sourceHeight: Int, drawnHeight: Int): FilterQuality * The image on its own, as large as it fits, with pinch to zoom. * * Inside a dialog rather than a screen -- see [SessionImageViewer] -- so the platform's back - * gesture returns to the transcript instead of leaving the app. It opens fitted, the whole image - * visible. + * gesture returns to the transcript instead of leaving the app. It opens fitted, with the whole + * image visible; the 100% control changes to one bitmap pixel per screen pixel and recenters it. */ @Composable -private fun ZoomableImage(image: ImageBitmap) { +private fun ZoomableImage( + image: ImageBitmap, + nativeSizeRequest: Int, + onViewportChanged: (IntSize) -> Unit, + onBarsChanged: (ViewerBars) -> Unit, + barInsets: ViewerBarInsets, + viewport: IntSize, +) { var scale by remember { mutableFloatStateOf(1f) } var offsetX by remember { mutableFloatStateOf(0f) } var offsetY by remember { mutableFloatStateOf(0f) } + val nativeScale = nativeScale(image.width, image.height, viewport.width, viewport.height) + LaunchedEffect(nativeSizeRequest, nativeScale) { + if (nativeSizeRequest > 0) { + scale = nativeScale + offsetX = 0f + offsetY = 0f + } + } + val bars = + viewerBars( + image.width, + image.height, + viewport.width, + viewport.height, + scale, + Offset(offsetX, offsetY), + barInsets, + ) + SideEffect { onBarsChanged(bars) } Image( bitmap = image, contentDescription = "Attached image", @@ -249,12 +333,13 @@ private fun ZoomableImage(image: ImageBitmap) { filterQuality = FilterQuality.None, modifier = Modifier.fillMaxSize() - .pointerInput(Unit) { + .onSizeChanged(onViewportChanged) + .pointerInput(nativeScale) { detectTransformGestures { centroid, pan, zoom, _ -> - // Floor of 1 so the image cannot be pinched smaller than fitted, which is - // already the whole of it; a ceiling so it cannot be lost off-screen. val oldScale = scale - val newScale = (oldScale * zoom).coerceIn(1f, 8f) + val minimumScale = minOf(1f, nativeScale) + val maximumScale = maxOf(8f, nativeScale) + val newScale = (oldScale * zoom).coerceIn(minimumScale, maximumScale) if (newScale > 1f) { val offset = zoomOffset( @@ -283,6 +368,84 @@ private fun ZoomableImage(image: ImageBitmap) { ) } +/** Lets the picture use the whole display, hiding only the system bars it actually reaches. */ +@Composable +private fun ViewerSystemBars(hidden: ViewerBars) { + val view = LocalView.current + val window = (view.parent as? DialogWindowProvider)?.window + val controller = window?.let { WindowCompat.getInsetsController(it, view) } + SideEffect { + controller?.systemBarsBehavior = + WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + if (hidden.status) { + controller?.hide(WindowInsetsCompat.Type.statusBars()) + } else { + controller?.show(WindowInsetsCompat.Type.statusBars()) + } + if (hidden.navigation) { + controller?.hide(WindowInsetsCompat.Type.navigationBars()) + } else { + controller?.show(WindowInsetsCompat.Type.navigationBars()) + } + } + DisposableEffect(view) { + onDispose { + controller?.show( + WindowInsetsCompat.Type.statusBars() or WindowInsetsCompat.Type.navigationBars() + ) + } + } +} + +internal data class ViewerBars(val status: Boolean = false, val navigation: Boolean = false) + +internal data class ViewerBarInsets(val status: Int = 0, val navigation: Int = 0) + +/** Which full-screen system-bar regions the fitted, zoomed and panned image intersects. */ +internal fun viewerBars( + imageWidth: Int, + imageHeight: Int, + viewportWidth: Int, + viewportHeight: Int, + scale: Float, + offset: Offset, + insets: ViewerBarInsets, +): ViewerBars { + if (imageWidth <= 0 || imageHeight <= 0 || viewportWidth <= 0 || viewportHeight <= 0) { + return ViewerBars() + } + val fittedScale = + minOf(viewportWidth.toFloat() / imageWidth, viewportHeight.toFloat() / imageHeight) + val width = imageWidth * fittedScale * scale + val height = imageHeight * fittedScale * scale + val left = viewportWidth / 2f + offset.x - width / 2f + val right = left + width + val top = viewportHeight / 2f + offset.y - height / 2f + val bottom = top + height + val crossesScreen = right > 0f && left < viewportWidth + return ViewerBars( + status = crossesScreen && insets.status > 0 && bottom > 0f && top < insets.status, + navigation = + crossesScreen && + insets.navigation > 0 && + bottom > viewportHeight - insets.navigation && + top < viewportHeight, + ) +} + +/** Scale relative to [ContentScale.Fit] at which bitmap and screen pixels are one-to-one. */ +internal fun nativeScale( + imageWidth: Int, + imageHeight: Int, + viewportWidth: Int, + viewportHeight: Int, +): Float { + if (imageWidth <= 0 || imageHeight <= 0 || viewportWidth <= 0 || viewportHeight <= 0) return 1f + val fittedScale = + minOf(viewportWidth.toFloat() / imageWidth, viewportHeight.toFloat() / imageHeight) + return 1f / fittedScale +} + /** Keeps the image point beneath [centroid] beneath the fingers as its scale changes. */ internal fun zoomOffset( offset: Offset, diff --git a/app/androidApp/src/test/kotlin/com/example/aiapp/SessionImageTest.kt b/app/androidApp/src/test/kotlin/com/example/aiapp/SessionImageTest.kt index a7fff9c..62c2787 100644 --- a/app/androidApp/src/test/kotlin/com/example/aiapp/SessionImageTest.kt +++ b/app/androidApp/src/test/kotlin/com/example/aiapp/SessionImageTest.kt @@ -5,6 +5,80 @@ import kotlin.test.Test import kotlin.test.assertEquals class SessionImageTest { + @Test + fun `a full-height image hides both bars`() { + assertEquals( + ViewerBars(status = true, navigation = true), + viewerBars( + imageWidth = 500, + imageHeight = 1000, + viewportWidth = 1000, + viewportHeight = 2000, + scale = 1f, + offset = Offset.Zero, + insets = ViewerBarInsets(status = 100, navigation = 100), + ), + ) + } + + @Test + fun `a letterboxed image leaves both bars visible`() { + assertEquals( + ViewerBars(), + viewerBars( + imageWidth = 1000, + imageHeight = 500, + viewportWidth = 1000, + viewportHeight = 2000, + scale = 1f, + offset = Offset.Zero, + insets = ViewerBarInsets(status = 100, navigation = 100), + ), + ) + } + + @Test + fun `panning into the status bar hides only that bar`() { + assertEquals( + ViewerBars(status = true), + viewerBars( + imageWidth = 1000, + imageHeight = 500, + viewportWidth = 1000, + viewportHeight = 2000, + scale = 2f, + offset = Offset(0f, -500f), + insets = ViewerBarInsets(status = 100, navigation = 100), + ), + ) + } + + @Test + fun `native scale reverses fitting a tall image`() { + assertEquals( + 1.25f, + nativeScale( + imageWidth = 1000, + imageHeight = 2000, + viewportWidth = 1000, + viewportHeight = 1600, + ), + ) + } + + @Test + fun `native scale reverses enlarging a small image`() { + assertEquals( + 0.5f, + nativeScale( + imageWidth = 500, + imageHeight = 500, + viewportWidth = 1000, + viewportHeight = 1000, + ), + ) + } + @Test fun `zoom keeps the region panned to in the center`() { assertEquals(