Make image viewer truly full screen
This commit is contained in:
1 parent
559e6c9226
commit
6d765ff6e4
3 files changed
+251
-9
No files matched your search
@@ -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 —
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user