-
Notifications
You must be signed in to change notification settings - Fork 110
Use telephoto for panning and zooming #1422
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -2,25 +2,24 @@ package com.squareup.workflow1.traceviewer | |
|
||
import androidx.compose.foundation.layout.Arrangement | ||
import androidx.compose.foundation.layout.Box | ||
import androidx.compose.foundation.layout.PaddingValues | ||
import androidx.compose.foundation.layout.Row | ||
import androidx.compose.foundation.layout.padding | ||
import androidx.compose.runtime.Composable | ||
import androidx.compose.runtime.LaunchedEffect | ||
import androidx.compose.runtime.getValue | ||
import androidx.compose.runtime.key | ||
import androidx.compose.runtime.mutableIntStateOf | ||
import androidx.compose.runtime.mutableStateListOf | ||
import androidx.compose.runtime.mutableStateMapOf | ||
import androidx.compose.runtime.mutableStateOf | ||
import androidx.compose.runtime.remember | ||
import androidx.compose.runtime.rememberCoroutineScope | ||
import androidx.compose.runtime.setValue | ||
import androidx.compose.runtime.snapshotFlow | ||
import androidx.compose.runtime.snapshots.SnapshotStateMap | ||
import androidx.compose.ui.Alignment | ||
import androidx.compose.ui.Modifier | ||
import androidx.compose.ui.geometry.Offset | ||
import androidx.compose.ui.layout.onSizeChanged | ||
import androidx.compose.ui.unit.IntSize | ||
import androidx.compose.ui.layout.ContentScale | ||
import androidx.compose.ui.unit.dp | ||
import com.squareup.workflow1.traceviewer.model.Node | ||
import com.squareup.workflow1.traceviewer.model.NodeUpdate | ||
|
@@ -31,6 +30,10 @@ import com.squareup.workflow1.traceviewer.ui.control.SearchBox | |
import com.squareup.workflow1.traceviewer.util.SandboxBackground | ||
import com.squareup.workflow1.traceviewer.util.parser.RenderTrace | ||
import io.github.vinceglb.filekit.PlatformFile | ||
import kotlinx.coroutines.launch | ||
import me.saket.telephoto.zoomable.ZoomSpec | ||
import me.saket.telephoto.zoomable.rememberZoomableState | ||
import kotlin.also | ||
|
||
/** | ||
* Main composable that provides the different layers of UI. | ||
|
@@ -40,53 +43,52 @@ internal fun TraceViewerWindow( | |
modifier: Modifier = Modifier, | ||
traceMode: TraceMode, | ||
) { | ||
var appWindowSize by remember { mutableStateOf(IntSize(0, 0)) } | ||
var selectedNode by remember { mutableStateOf<NodeUpdate?>(null) } | ||
var frameSize by remember { mutableIntStateOf(0) } | ||
var rawRenderPass by remember { mutableStateOf("") } | ||
var frameIndex by remember { mutableIntStateOf(if (traceMode is TraceMode.Live) -1 else 0) } | ||
val sandboxState = remember { SandboxState() } | ||
val nodeLocations = remember { mutableStateListOf<SnapshotStateMap<Node, Offset>>() } | ||
|
||
// Default to File mode, and can be toggled to be in Live mode. | ||
var active by remember { mutableStateOf(false) } | ||
// frameIndex is set to -1 when app is in Live Mode, so we increment it by one to avoid off-by-one errors | ||
val frameInd = if (traceMode is TraceMode.Live) frameIndex + 1 else frameIndex | ||
|
||
LaunchedEffect(sandboxState) { | ||
snapshotFlow { frameIndex }.collect { | ||
sandboxState.reset() | ||
} | ||
} | ||
Box(modifier) { | ||
val zoomableState = key(frameInd) { | ||
rememberZoomableState( | ||
zoomSpec = ZoomSpec(maxZoomFactor = 1f) | ||
).also { | ||
it.contentScale = ContentScale.Fit | ||
|
||
Box( | ||
modifier = modifier.onSizeChanged { | ||
appWindowSize = it | ||
// TODO: do we want to draw behind the search bar? | ||
val searchBarHeight = 80.dp | ||
it.contentPadding = PaddingValues(horizontal = 24.dp, vertical = searchBarHeight) | ||
} | ||
} | ||
) { | ||
|
||
// Main content | ||
SandboxBackground( | ||
appWindowSize = appWindowSize, | ||
sandboxState = sandboxState, | ||
) { | ||
// if there is not a file selected and trace mode is live, then don't render anything. | ||
val readyForFileTrace = TraceMode.validateFileMode(traceMode) | ||
val readyForLiveTrace = TraceMode.validateLiveMode(traceMode) | ||
zoomableState = zoomableState, | ||
content = { | ||
// if there is not a file selected and trace mode is live, then don't render anything. | ||
val readyForFileTrace = TraceMode.validateFileMode(traceMode) | ||
val readyForLiveTrace = TraceMode.validateLiveMode(traceMode) | ||
|
||
if (readyForFileTrace || readyForLiveTrace) { | ||
active = true | ||
RenderTrace( | ||
traceSource = traceMode, | ||
frameInd = frameIndex, | ||
onFileParse = { frameSize += it }, | ||
onNodeSelect = { selectedNode = it }, | ||
onNewFrame = { frameIndex += 1 }, | ||
onNewData = { rawRenderPass += "$it," }, | ||
storeNodeLocation = { node, loc -> nodeLocations[frameInd] += (node to loc) } | ||
) | ||
} | ||
} | ||
if (readyForFileTrace || readyForLiveTrace) { | ||
active = true | ||
RenderTrace( | ||
traceSource = traceMode, | ||
frameInd = frameIndex, | ||
onFileParse = { frameSize += it }, | ||
onNodeSelect = { selectedNode = it }, | ||
onNewFrame = { frameIndex += 1 }, | ||
onNewData = { rawRenderPass += "$it," }, | ||
storeNodeLocation = { node, loc -> nodeLocations[frameInd] += (node to loc) } | ||
) | ||
} | ||
}, | ||
) | ||
|
||
Row( | ||
modifier = Modifier | ||
|
@@ -108,15 +110,19 @@ internal fun TraceViewerWindow( | |
} | ||
|
||
val frameNodeLocations = nodeLocations[frameInd] | ||
val scope = rememberCoroutineScope() | ||
SearchBox( | ||
nodes = frameNodeLocations.keys.toList(), | ||
onSearch = { name -> | ||
val node = frameNodeLocations.keys.first { it.name == name } | ||
val newX = (sandboxState.offset.x - frameNodeLocations.getValue(node).x | ||
+ appWindowSize.width / 2) | ||
val newY = (sandboxState.offset.y - frameNodeLocations.getValue(node).y | ||
+ appWindowSize.height / 2) | ||
sandboxState.offset = Offset(x = newX, y = newY) | ||
scope.launch { | ||
// TODO: this doesn't work super well. | ||
// Probably because of https://github.com/saket/telephoto/issues/135? | ||
zoomableState.zoomTo( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. From local testing this isn't adjusting the pan at all when selecting a node. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This should be fixed by saket/telephoto@68fcaef. Do you mind if I update There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nope go for it |
||
zoomFactor = zoomableState.zoomSpec.maximum.factor, | ||
centroid = frameNodeLocations.getValue(node), | ||
) | ||
} | ||
}, | ||
) | ||
|
||
|
@@ -142,14 +148,6 @@ internal fun TraceViewerWindow( | |
} | ||
} | ||
|
||
internal class SandboxState { | ||
var offset by mutableStateOf(Offset.Zero) | ||
|
||
fun reset() { | ||
offset = Offset.Zero | ||
} | ||
} | ||
|
||
internal sealed interface TraceMode { | ||
data class File(val file: PlatformFile?) : TraceMode | ||
data class Live(val device: String? = null) : TraceMode | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,20 +1,22 @@ | ||
package com.squareup.workflow1.traceviewer.util | ||
|
||
import androidx.compose.foundation.gestures.awaitEachGesture | ||
import androidx.compose.foundation.gestures.detectDragGestures | ||
import androidx.compose.foundation.layout.Box | ||
import androidx.compose.foundation.layout.fillMaxSize | ||
import androidx.compose.foundation.layout.wrapContentSize | ||
import androidx.compose.runtime.Composable | ||
import androidx.compose.runtime.LaunchedEffect | ||
import androidx.compose.runtime.remember | ||
import androidx.compose.ui.AbsoluteAlignment | ||
import androidx.compose.ui.Alignment | ||
import androidx.compose.ui.Modifier | ||
import androidx.compose.ui.focus.FocusRequester | ||
import androidx.compose.ui.focus.focusRequester | ||
import androidx.compose.ui.graphics.graphicsLayer | ||
import androidx.compose.ui.input.pointer.PointerEventType | ||
import androidx.compose.ui.input.pointer.pointerInput | ||
import androidx.compose.ui.layout.FixedScale | ||
import androidx.compose.ui.unit.IntSize | ||
import com.squareup.workflow1.traceviewer.SandboxState | ||
import me.saket.telephoto.zoomable.rememberZoomableState | ||
import androidx.compose.ui.layout.ContentScale | ||
import androidx.compose.ui.layout.onSizeChanged | ||
import androidx.compose.ui.unit.toSize | ||
import me.saket.telephoto.zoomable.ZoomableContentLocation | ||
import me.saket.telephoto.zoomable.ZoomableState | ||
import me.saket.telephoto.zoomable.zoomable | ||
|
||
/** | ||
|
@@ -26,51 +28,29 @@ import me.saket.telephoto.zoomable.zoomable | |
*/ | ||
@Composable | ||
internal fun SandboxBackground( | ||
appWindowSize: IntSize, | ||
sandboxState: SandboxState, | ||
zoomableState: ZoomableState, | ||
modifier: Modifier = Modifier, | ||
content: @Composable () -> Unit, | ||
) { | ||
val zoomableState = rememberZoomableState() | ||
|
||
val focusRequester = remember { FocusRequester() } | ||
LaunchedEffect(zoomableState) { | ||
// Request focus to receive keyboard shortcuts. | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is turning out to be more complex than I expected. The content starts in a focused state but loses focus when the search box is clicked. We'll have to restore focus when a search result is selected. Let me try fixing this. |
||
focusRequester.requestFocus() | ||
} | ||
Box( | ||
modifier | ||
.fillMaxSize() | ||
.zoomable(state = zoomableState) | ||
.pointerInput(Unit) { | ||
// Panning capabilities: watches for drag gestures and applies the translation | ||
detectDragGestures { _, translation -> | ||
sandboxState.offset += translation | ||
} | ||
} | ||
.pointerInput(appWindowSize) { | ||
// Zooming capabilities: watches for any scroll events and immediately consumes changes. | ||
// - This is AI generated. | ||
awaitEachGesture { | ||
val event = awaitPointerEvent() | ||
if (event.type == PointerEventType.Scroll) { | ||
val pointerInput = event.changes.first() | ||
// Applies zoom factor based on the actual delta change rather than just the act of scrolling | ||
// This helps to normalize mouse scrolling and touchpad scrolling, since touchpad will | ||
// fire a lot more scroll events. | ||
val factor = 1f + (-pointerInput.scrollDelta.y * 0.1f) | ||
val minWindowSize = 0.3f | ||
val maxWindowSize = 2f | ||
val oldScale = (zoomableState.contentScale as? FixedScale)?.value ?: 1.0f | ||
val newScale = (oldScale * factor).coerceIn(minWindowSize, maxWindowSize) | ||
|
||
zoomableState.contentScale = FixedScale(newScale) | ||
event.changes.forEach { it.consume() } | ||
} | ||
} | ||
} | ||
.focusRequester(focusRequester) | ||
.zoomable(zoomableState) | ||
) { | ||
Box( | ||
modifier = Modifier | ||
.wrapContentSize(unbounded = true, align = Alignment.Center) | ||
.graphicsLayer { | ||
translationX = sandboxState.offset.x | ||
translationY = sandboxState.offset.y | ||
.wrapContentSize(unbounded = true, align = AbsoluteAlignment.TopLeft) | ||
.onSizeChanged { | ||
// TODO(saket): Modifier.zoomable() should automatically use its child's size by default. | ||
zoomableState.setContentLocation( | ||
ZoomableContentLocation.unscaledAndTopLeftAligned(it.toSize()) | ||
) | ||
} | ||
) { | ||
content() | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think drawing behind the search looks correct, which is what it's doing already, if we didn't allow it there would be a jarring cutoff.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In that case, do you want to synchronize the content padding below with the search box's height?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm not sure, I think it was looking good how it was now.