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
|
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
|
and its open dialog go. Somebody looking at a screenshot was thrown back
|
||||||
to the transcript because the session made another tool call.
|
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`
|
- **All transcript text is selectable, from one `SelectionContainer`
|
||||||
around the whole list.** Not per row: a transcript is one body of text,
|
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 —
|
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.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
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.foundation.layout.size
|
||||||
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.SideEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableFloatStateOf
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
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.graphics.graphicsLayer
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.layout.onSizeChanged
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.platform.LocalView
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.IntSize
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.isSpecified
|
import androidx.compose.ui.unit.isSpecified
|
||||||
import androidx.compose.ui.window.Dialog
|
import androidx.compose.ui.window.Dialog
|
||||||
import androidx.compose.ui.window.DialogProperties
|
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.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
@@ -142,10 +156,15 @@ fun SessionImageViewer(
|
|||||||
onClose: () -> Unit,
|
onClose: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val (bitmap, failed) = rememberSessionBitmap(settings, sessionId, ref)
|
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(
|
Dialog(
|
||||||
onDismissRequest = onClose,
|
onDismissRequest = onClose,
|
||||||
properties = DialogProperties(usePlatformDefaultWidth = false),
|
properties =
|
||||||
|
DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false),
|
||||||
) {
|
) {
|
||||||
|
ViewerSystemBars(hiddenBars)
|
||||||
Box(
|
Box(
|
||||||
Modifier.fillMaxSize()
|
Modifier.fillMaxSize()
|
||||||
.background(Color.Black)
|
.background(Color.Black)
|
||||||
@@ -173,7 +192,46 @@ fun SessionImageViewer(
|
|||||||
// beside it are.
|
// beside it are.
|
||||||
CircularProgressIndicator(color = Color.White)
|
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.
|
* 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
|
* 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
|
* gesture returns to the transcript instead of leaving the app. It opens fitted, with the whole
|
||||||
* visible.
|
* image visible; the 100% control changes to one bitmap pixel per screen pixel and recenters it.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@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 scale by remember { mutableFloatStateOf(1f) }
|
||||||
var offsetX by remember { mutableFloatStateOf(0f) }
|
var offsetX by remember { mutableFloatStateOf(0f) }
|
||||||
var offsetY 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(
|
Image(
|
||||||
bitmap = image,
|
bitmap = image,
|
||||||
contentDescription = "Attached image",
|
contentDescription = "Attached image",
|
||||||
@@ -249,12 +333,13 @@ private fun ZoomableImage(image: ImageBitmap) {
|
|||||||
filterQuality = FilterQuality.None,
|
filterQuality = FilterQuality.None,
|
||||||
modifier =
|
modifier =
|
||||||
Modifier.fillMaxSize()
|
Modifier.fillMaxSize()
|
||||||
.pointerInput(Unit) {
|
.onSizeChanged(onViewportChanged)
|
||||||
|
.pointerInput(nativeScale) {
|
||||||
detectTransformGestures { centroid, pan, zoom, _ ->
|
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 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) {
|
if (newScale > 1f) {
|
||||||
val offset =
|
val offset =
|
||||||
zoomOffset(
|
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. */
|
/** Keeps the image point beneath [centroid] beneath the fingers as its scale changes. */
|
||||||
internal fun zoomOffset(
|
internal fun zoomOffset(
|
||||||
offset: Offset,
|
offset: Offset,
|
||||||
|
|||||||
@@ -5,6 +5,80 @@ import kotlin.test.Test
|
|||||||
import kotlin.test.assertEquals
|
import kotlin.test.assertEquals
|
||||||
|
|
||||||
class SessionImageTest {
|
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
|
@Test
|
||||||
fun `zoom keeps the region panned to in the center`() {
|
fun `zoom keeps the region panned to in the center`() {
|
||||||
assertEquals(
|
assertEquals(
|
||||||
|
|||||||
Reference in new issue
Block a user