From 73a61160b0e6fb097a3b069f91ddabc2377a0117 Mon Sep 17 00:00:00 2001 From: Maxwell Metzger Mitchell <60010512+maxmmitchell@users.noreply.github.com> Date: Mon, 29 Jun 2026 16:02:42 +0000 Subject: [PATCH 1/7] Fix color picker --- .../developer/brushgraph/ui/TestCanvas.kt | 96 +++++++++++++++++-- 1 file changed, 86 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/TestCanvas.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/TestCanvas.kt index 9f863788..f4e35a79 100644 --- a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/TestCanvas.kt +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/TestCanvas.kt @@ -35,15 +35,18 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Error import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.KeyboardArrowUp import androidx.compose.material.icons.filled.Warning import androidx.compose.material3.Checkbox +import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface @@ -55,8 +58,11 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance import androidx.compose.ui.layout.Layout +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow @@ -67,6 +73,11 @@ import androidx.ink.rendering.android.canvas.CanvasStrokeRenderer import androidx.ink.strokes.Stroke import com.example.cahier.R import com.example.cahier.core.ui.DrawingSurface +import com.example.cahier.core.ui.theme.BrushBlack +import com.example.cahier.core.ui.theme.BrushBlue +import com.example.cahier.core.ui.theme.BrushGreen +import com.example.cahier.core.ui.theme.BrushRed +import com.example.cahier.core.ui.theme.BrushYellow import com.example.cahier.core.ui.theme.extendedColorScheme import com.example.cahier.developer.brushgraph.data.GraphValidationException import com.example.cahier.developer.brushgraph.data.ValidationSeverity @@ -228,17 +239,82 @@ fun CollapsiblePreviewPane( Spacer(Modifier.width(16.dp)) // Color picker - Box( - modifier = Modifier - .size(20.dp) - .background(brushColor) - .border(1.dp, MaterialTheme.colorScheme.outline) - .clickable { - onChooseColor(brushColor) { newColor -> - onUpdateTestBrushColor(newColor) - } + var colorMenuExpanded by remember { mutableStateOf(false) } + Box { + val iconTint = + if (brushColor.luminance() < 0.5f) Color.White else Color.Black + + Box( + modifier = Modifier + .size(32.dp) + .background(brushColor, shape = CircleShape) + .border( + 1.dp, + MaterialTheme.colorScheme.outline, + shape = CircleShape + ) + .clip(CircleShape) + .clickable { colorMenuExpanded = true }, + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(R.drawable.palette_24px), + contentDescription = stringResource(R.string.color), + tint = iconTint, + modifier = Modifier.size(20.dp) + ) + } + + DropdownMenu( + expanded = colorMenuExpanded, + onDismissRequest = { colorMenuExpanded = false } + ) { + val colors = remember { + listOf( + R.string.brush_designer_color_black to BrushBlack, + R.string.brush_designer_color_red to BrushRed, + R.string.brush_designer_color_blue to BrushBlue, + R.string.brush_designer_color_green to BrushGreen, + R.string.brush_designer_color_yellow to BrushYellow + ) } - ) + colors.forEach { (nameRes, color) -> + val name = stringResource(nameRes) + DropdownMenuItem( + text = { Text(name) }, + leadingIcon = { + Icon( + painter = painterResource(R.drawable.circle_24px), + contentDescription = name, + tint = color + ) + }, + onClick = { + onUpdateTestBrushColor(color) + colorMenuExpanded = false + } + ) + } + HorizontalDivider(modifier = Modifier.padding(horizontal = 8.dp)) + + DropdownMenuItem( + text = { Text(stringResource(R.string.brush_designer_custom_color)) }, + leadingIcon = { + Icon( + painter = painterResource(R.drawable.palette_24px), + contentDescription = stringResource(R.string.brush_designer_custom_color), + tint = MaterialTheme.colorScheme.onSurface + ) + }, + onClick = { + colorMenuExpanded = false + onChooseColor(brushColor) { newColor -> + onUpdateTestBrushColor(newColor) + } + } + ) + } + } Spacer(Modifier.width(16.dp)) // Size selector From ce29fb64db64e7e7655d7fff225ca599370a7eb3 Mon Sep 17 00:00:00 2001 From: Maxwell Metzger Mitchell <60010512+maxmmitchell@users.noreply.github.com> Date: Tue, 30 Jun 2026 19:53:11 +0000 Subject: [PATCH 2/7] Adjust test canvas size + mask drawing out of test canvas and on sidepane --- .../java/com/example/cahier/MainActivity.kt | 9 +- .../example/cahier/core/ui/DrawingSurface.kt | 4 +- .../brushgraph/ui/BrushGraphContent.kt | 21 +- .../brushgraph/ui/BrushGraphMenus.kt | 13 +- .../brushgraph/ui/BrushGraphScreen.kt | 104 +++++-- .../brushgraph/ui/GraphCameraController.kt | 9 +- .../developer/brushgraph/ui/TestCanvas.kt | 274 +++++++++++++++--- .../brushgraph/ui/TutorialOverlayHost.kt | 85 +++--- 8 files changed, 385 insertions(+), 134 deletions(-) diff --git a/app/src/main/java/com/example/cahier/MainActivity.kt b/app/src/main/java/com/example/cahier/MainActivity.kt index ce2aa7b7..0efa18b3 100644 --- a/app/src/main/java/com/example/cahier/MainActivity.kt +++ b/app/src/main/java/com/example/cahier/MainActivity.kt @@ -69,10 +69,11 @@ class MainActivity : ComponentActivity() { androidx.compose.runtime.CompositionLocalProvider(LocalTextureStore provides textureStore) { Surface( modifier = Modifier - .fillMaxSize(), - color = MaterialTheme.colorScheme.background - ) { - CahierApp(noteId = noteId, + .fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + CahierApp( + noteId = noteId, noteType = noteType, navigateToBrushGraph = navigateToBrushGraph, onNavigateToBrushGraphHandled = { diff --git a/app/src/main/java/com/example/cahier/core/ui/DrawingSurface.kt b/app/src/main/java/com/example/cahier/core/ui/DrawingSurface.kt index 6c32eca2..234ad466 100644 --- a/app/src/main/java/com/example/cahier/core/ui/DrawingSurface.kt +++ b/app/src/main/java/com/example/cahier/core/ui/DrawingSurface.kt @@ -65,6 +65,7 @@ fun DrawingSurface( backgroundImageUri: String?, onStartDrag: () -> Unit, modifier: Modifier = Modifier, + maskPath: androidx.compose.ui.graphics.Path? = null, ) { val textureStore = LocalTextureStore.current Box(modifier = modifier) { @@ -139,7 +140,8 @@ fun DrawingSurface( defaultBrush = currentBrush, nextBrush = onGetNextBrush, onStrokesFinished = onStrokesFinished, - textureBitmapStore = textureStore + textureBitmapStore = textureStore, + maskPath = maskPath ) } } diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/BrushGraphContent.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/BrushGraphContent.kt index 2ec485b2..48fa4831 100644 --- a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/BrushGraphContent.kt +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/BrushGraphContent.kt @@ -37,7 +37,7 @@ fun BrushGraphContent( isNodeSelected: Boolean, isEdgeSelected: Boolean, isErrorPaneOpen: Boolean, - isPreviewExpanded: Boolean, + previewHeight: Dp, viewportSize: Size, onViewportSizeChange: (Size) -> Unit, canvasSlot: @Composable (trashPaddingBottom: Dp) -> Unit, @@ -61,19 +61,14 @@ fun BrushGraphContent( targetValue = if (isSidePaneOpen) (INSPECTOR_WIDTH_LANDSCAPE + 16).dp else 16.dp, label = "indicatorPaddingEnd", ) - val previewHeight = if (isPreviewExpanded) { - PREVIEW_HEIGHT_EXPANDED - } else { - PREVIEW_HEIGHT_COLLAPSED - } val isAnySidePaneOpen = isNodeSelected || isEdgeSelected || isErrorPaneOpen val trashPaddingBottom by animateDpAsState( targetValue = if (!isWideScreen && isAnySidePaneOpen) { - (maxOf(previewHeight, INSPECTOR_HEIGHT_PORTRAIT) + 16).dp + (maxOf(previewHeight.value, INSPECTOR_HEIGHT_PORTRAIT.toFloat()) + 16).dp } else { - (previewHeight + 16).dp + previewHeight + 16.dp }, label = "trashPaddingBottom", ) @@ -82,11 +77,11 @@ fun BrushGraphContent( Box( modifier = Modifier - .fillMaxSize() - .padding(paddingValues) - .onGloballyPositioned { coordinates -> - onViewportSizeChange(coordinates.size.toSize()) - } + .fillMaxSize() + .padding(paddingValues) + .onGloballyPositioned { coordinates -> + onViewportSizeChange(coordinates.size.toSize()) + } ) { canvasSlot(trashPaddingBottom) inspectorSlot() diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/BrushGraphMenus.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/BrushGraphMenus.kt index c4b4daf9..cce89649 100644 --- a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/BrushGraphMenus.kt +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/BrushGraphMenus.kt @@ -67,6 +67,7 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp @@ -305,26 +306,20 @@ fun PaletteMenu( fun CreateNodeSpeedDial( isWideScreen: Boolean, isAnySidePaneOpen: Boolean, - isPreviewExpanded: Boolean, + previewHeight: Dp, viewportSize: androidx.compose.ui.geometry.Size, modifier: Modifier = Modifier, menuContent: @Composable (onClose: () -> Unit) -> Unit, ) { var expanded by rememberSaveable { mutableStateOf(false) } - val previewHeight = if (isPreviewExpanded) { - PREVIEW_HEIGHT_EXPANDED - } else { - PREVIEW_HEIGHT_COLLAPSED - } - val fabPaddingBottom by animateDpAsState( targetValue = if (!isWideScreen && isAnySidePaneOpen) { - (maxOf(previewHeight, INSPECTOR_HEIGHT_PORTRAIT) + 16).dp + maxOf(previewHeight, INSPECTOR_HEIGHT_PORTRAIT.dp) + 16.dp } else { - (previewHeight + 16).dp + previewHeight + 16.dp }, label = "fabPaddingBottom", ) diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/BrushGraphScreen.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/BrushGraphScreen.kt index 9be9a365..484b9e13 100644 --- a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/BrushGraphScreen.kt +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/BrushGraphScreen.kt @@ -22,6 +22,8 @@ import android.net.Uri import android.util.Log import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.VectorConverter import androidx.compose.animation.core.animateDpAsState import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints @@ -50,10 +52,14 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInWindow import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.toSize import androidx.compose.ui.zIndex import androidx.hilt.navigation.compose.hiltViewModel import androidx.ink.brush.BrushFamily @@ -118,6 +124,13 @@ fun BrushGraphScreen( ) } + var inspectorBounds by remember { mutableStateOf(null) } + var notificationPaneBounds by remember { mutableStateOf(null) } + + val exclusionRects = remember(inspectorBounds, notificationPaneBounds) { + listOfNotNull(inspectorBounds, notificationPaneBounds) + } + // Texture picking logic var showTextureNameDialog by remember { mutableStateOf(false) } var pendingTextureUri by remember { mutableStateOf(null) } @@ -257,29 +270,37 @@ fun BrushGraphScreen( targetValue = if (isSidePaneOpen) (INSPECTOR_WIDTH_LANDSCAPE + 16).dp else 16.dp, label = "indicatorPaddingEnd", ) - val previewHeight = if (uiState.isPreviewExpanded) { - PREVIEW_HEIGHT_EXPANDED + var previewExpandedHeightDp by remember { mutableStateOf(200.dp) } + var isDraggingPreview by remember { mutableStateOf(false) } + val currentPreviewHeightDp = if (uiState.isPreviewExpanded) { + previewExpandedHeightDp } else { - PREVIEW_HEIGHT_COLLAPSED + PREVIEW_HEIGHT_COLLAPSED.dp } - val animatedPreviewHeight by animateDpAsState( - targetValue = previewHeight.dp, - label = "animatedPreviewHeight" - ) + val animatedPreviewHeight = + remember { Animatable(currentPreviewHeightDp, Dp.VectorConverter) } + LaunchedEffect(uiState.isPreviewExpanded, previewExpandedHeightDp) { + if (!isDraggingPreview) { + val target = + if (uiState.isPreviewExpanded) previewExpandedHeightDp else PREVIEW_HEIGHT_COLLAPSED.dp + animatedPreviewHeight.animateTo(target) + } + } + + val isNodeSelected = uiState.selectedNodeId != null val isEdgeSelected = uiState.selectedEdge != null val isErrorPaneOpen = uiState.isErrorPaneOpen val isAnySidePaneOpen = isNodeSelected || isEdgeSelected || isErrorPaneOpen - val trashPaddingBottom by animateDpAsState( - targetValue = - if (!isWideScreen && isAnySidePaneOpen) { - (maxOf(previewHeight, INSPECTOR_HEIGHT_PORTRAIT) + 16).dp - } else { - (previewHeight + 16).dp - }, - label = "trashPaddingBottom", - ) + val trashPaddingBottom = if (!isWideScreen && isAnySidePaneOpen) { + (maxOf( + animatedPreviewHeight.value.value, + INSPECTOR_HEIGHT_PORTRAIT.toFloat() + ) + 16).dp + } else { + animatedPreviewHeight.value + 16.dp + } val nodeRegistry = remember { NodeRegistry() } val issues = uiState.graphIssues @@ -305,7 +326,7 @@ fun BrushGraphScreen( isNodeSelected = isNodeSelected, isEdgeSelected = isEdgeSelected, isErrorPaneOpen = isErrorPaneOpen, - isPreviewExpanded = uiState.isPreviewExpanded, + previewHeight = animatedPreviewHeight.value, viewportSize = viewportSize, onViewportSizeChange = { viewportSize = it }, canvasSlot = { padding -> @@ -414,10 +435,23 @@ fun BrushGraphScreen( .align(if (isWideScreen) Alignment.CenterEnd else Alignment.BottomCenter) .let { if (isTallAndWide) { - it.padding(bottom = animatedPreviewHeight) + it.padding(bottom = animatedPreviewHeight.value) } else { it } + } + .onGloballyPositioned { coordinates: androidx.compose.ui.layout.LayoutCoordinates -> + val isInspectorOpen = + selectedNode != null || selectedEdge != null + inspectorBounds = + if (isInspectorOpen && coordinates.size.width > 0 && coordinates.size.height > 0) { + androidx.compose.ui.geometry.Rect( + coordinates.positionInWindow(), + coordinates.size.toSize() + ) + } else { + null + } }, ) { if (selectedNode != null) { @@ -499,10 +533,21 @@ fun BrushGraphScreen( .align(if (isWideScreen) Alignment.CenterEnd else Alignment.BottomCenter) .let { if (isTallAndWide) { - it.padding(bottom = animatedPreviewHeight) + it.padding(bottom = animatedPreviewHeight.value) } else { it } + } + .onGloballyPositioned { coordinates: androidx.compose.ui.layout.LayoutCoordinates -> + notificationPaneBounds = + if (uiState.isErrorPaneOpen && coordinates.size.width > 0 && coordinates.size.height > 0) { + androidx.compose.ui.geometry.Rect( + coordinates.positionInWindow(), + coordinates.size.toSize() + ) + } else { + null + } }, ) }, @@ -526,6 +571,7 @@ fun BrushGraphScreen( CollapsiblePreviewPane( isPreviewExpanded = uiState.isPreviewExpanded, isInvertedCanvas = uiState.isDarkCanvas, + exclusionRects = exclusionRects, testAutoUpdateStrokes = uiState.testAutoUpdateStrokes, brushColor = uiState.testBrushColor ?: primaryColor, brushSize = uiState.testBrushSize, @@ -533,6 +579,14 @@ fun BrushGraphScreen( strokeList = viewModel.strokeList, strokeRenderer = renderer, topIssue = topIssue, + currentHeight = animatedPreviewHeight.value, + onDragStateChanged = { isDraggingPreview = it }, + onHeightDrag = { newHeight -> + previewExpandedHeightDp = newHeight + scope.launch { + animatedPreviewHeight.snapTo(newHeight) + } + }, onGetNextBrush = { viewModel.brush.value }, onTogglePreviewExpanded = { viewModel.togglePreviewExpanded() }, onClearStrokes = { viewModel.clearStrokes() }, @@ -592,8 +646,7 @@ fun BrushGraphScreen( }, fabSlot = { vSize -> val density = LocalDensity.current.density - val previewHeight = - if (uiState.isPreviewExpanded) PREVIEW_HEIGHT_EXPANDED else PREVIEW_HEIGHT_COLLAPSED + val previewHeight = animatedPreviewHeight.value val isInspectorOpen = (uiState.selectedNodeId != null || uiState.selectedEdge != null) val isErrorPaneOpen = uiState.isErrorPaneOpen @@ -601,7 +654,7 @@ fun BrushGraphScreen( val inspectorWidthPx = INSPECTOR_WIDTH_LANDSCAPE * density val inspectorHeightPx = INSPECTOR_HEIGHT_PORTRAIT * density - val previewHeightPx = previewHeight * density + val previewHeightPx = previewHeight.value * density val (visibleWidth, visibleHeight) = if (isWideScreen) { @@ -627,7 +680,7 @@ fun BrushGraphScreen( CreateNodeSpeedDial( isWideScreen = isWideScreen, isAnySidePaneOpen = isAnySidePaneOpen, - isPreviewExpanded = uiState.isPreviewExpanded, + previewHeight = animatedPreviewHeight.value, viewportSize = vSize, modifier = Modifier.align(Alignment.BottomEnd), menuContent = { onClose -> @@ -703,8 +756,7 @@ fun BrushGraphScreen( selectedEdge = uiState.selectedEdge, currentStepIndex = viewModel.currentStepIndex, isWideScreen = isWideScreen, - viewportSize = vSize, - isPreviewExpanded = uiState.isPreviewExpanded, + previewHeight = animatedPreviewHeight.value, onAdvanceTutorial = { viewModel.advanceTutorial(it) }, onRegressTutorial = { viewModel.regressTutorial() }, onCloseTutorial = { showTutorialFinishDialog = true }, @@ -734,7 +786,7 @@ fun BrushGraphScreen( focusTrigger = uiState.focusTrigger, graph = uiState.graph, zoom = uiState.zoom, - isPreviewExpanded = uiState.isPreviewExpanded, + previewHeight = animatedPreviewHeight.value, selectedNodeId = uiState.selectedNodeId, updateOffset = { viewModel.updateOffset(GraphPoint(it.x, it.y)) }, viewportSize = viewportSize, diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/GraphCameraController.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/GraphCameraController.kt index d3c52b03..405d6d5f 100644 --- a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/GraphCameraController.kt +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/GraphCameraController.kt @@ -41,7 +41,7 @@ fun GraphCameraController( focusTrigger: Int, graph: BrushGraph, zoom: Float, - isPreviewExpanded: Boolean, + previewHeight: Dp, selectedNodeId: String?, updateOffset: (Offset) -> Unit, viewportSize: Size, @@ -91,7 +91,7 @@ fun GraphCameraController( viewportSize = viewportSize, density = density, isWideScreen = isWideScreen, - isPreviewExpanded = isPreviewExpanded + previewHeight = previewHeight ) animatableOffset.snapTo(offset) animatableOffset.animateTo(newOffset, animationSpec = tween(500)) { @@ -110,7 +110,7 @@ private fun calculateFocusOffset( viewportSize: Size = Size.Zero, density: Float = 1f, isWideScreen: Boolean = false, - isPreviewExpanded: Boolean = false, + previewHeight: Dp = Dp.Unspecified, targetScreenPos: Offset? = null, ): Offset { val nodeCenterX = position.x + node.data.width() / 2f @@ -119,8 +119,7 @@ private fun calculateFocusOffset( val targetPos = if (targetScreenPos != null) { Pair(targetScreenPos.x, targetScreenPos.y) } else { - val previewHeightPx = - (if (isPreviewExpanded) PREVIEW_HEIGHT_EXPANDED else PREVIEW_HEIGHT_COLLAPSED) * density + val previewHeightPx = previewHeight.value * density val safeSize = if (isWideScreen) { val inspectorWidthPx = INSPECTOR_WIDTH_LANDSCAPE * density Pair(viewportSize.width - inspectorWidthPx, viewportSize.height - previewHeightPx) diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/TestCanvas.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/TestCanvas.kt index f4e35a79..bf4b53b5 100644 --- a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/TestCanvas.kt +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/TestCanvas.kt @@ -16,12 +16,12 @@ package com.example.cahier.developer.brushgraph.ui -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.expandVertically -import androidx.compose.animation.shrinkVertically +import android.view.ViewGroup +import android.widget.FrameLayout import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -32,6 +32,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredHeight import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState @@ -55,19 +56,27 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.Layout +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInWindow +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView import androidx.ink.brush.Brush import androidx.ink.rendering.android.canvas.CanvasStrokeRenderer import androidx.ink.strokes.Stroke @@ -89,10 +98,11 @@ fun TestCanvas( strokeRenderer: CanvasStrokeRenderer, brush: Brush, modifier: Modifier = Modifier, + maskPath: androidx.compose.ui.graphics.Path? = null, onGetNextBrush: () -> Brush, onStrokesAdded: (List) -> Unit, ) { - Box(modifier = modifier.fillMaxSize()) { + Box(modifier = modifier) { Text( stringResource(R.string.bg_test_canvas_draw_prompt), modifier = Modifier.align(Alignment.Center), @@ -116,10 +126,55 @@ fun TestCanvas( isEraserMode = false, backgroundImageUri = null, onStartDrag = {}, + modifier = Modifier.fillMaxSize(), + maskPath = maskPath, ) } } +/** + * A container that uses a native Android FrameLayout with clipChildren = true + * to physically clip the touch bounds and rendering of its child AndroidViews (like InProgressStrokesView). + * This prevents touch events and low-latency wet strokes from leaking outside the visible bounds. + */ +@Composable +fun ClippedCanvasContainer( + currentHeight: Dp, + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { + val density = LocalDensity.current + AndroidView( + factory = { context -> + val frameLayout = FrameLayout(context).apply { + clipChildren = true + } + val composeView = ComposeView(context).apply { + setContent { + content() + } + } + frameLayout.addView( + composeView, + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + frameLayout + }, + update = { frameLayout -> + val heightPx = with(density) { currentHeight.roundToPx() } + val lp = frameLayout.layoutParams + ?: ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, heightPx) + if (lp.height != heightPx) { + lp.height = heightPx + frameLayout.layoutParams = lp + } + }, + modifier = modifier + ) +} + + @Composable @OptIn(ExperimentalMaterial3Api::class) fun CollapsiblePreviewPane( @@ -129,9 +184,13 @@ fun CollapsiblePreviewPane( brushColor: Color, brushSize: Float, brush: Brush, + exclusionRects: List = emptyList(), strokeList: List, strokeRenderer: CanvasStrokeRenderer, topIssue: GraphValidationException?, + currentHeight: Dp, + onDragStateChanged: (Boolean) -> Unit, + onHeightDrag: (Dp) -> Unit, modifier: Modifier = Modifier, onGetNextBrush: () -> Brush, onTogglePreviewExpanded: () -> Unit, @@ -144,14 +203,52 @@ fun CollapsiblePreviewPane( onChooseColor: (Color, (Color) -> Unit) -> Unit, onToggleNotificationPane: () -> Unit, ) { + val density = androidx.compose.ui.platform.LocalDensity.current + val onHeightDragState = rememberUpdatedState(onHeightDrag) + val currentHeightState = rememberUpdatedState(currentHeight) + var isDragging by remember { mutableStateOf(false) } + + // Mutable state to synchronously track height during a drag gesture + val dragHeightState = remember { mutableStateOf(currentHeight) } + Column(modifier = modifier.fillMaxWidth()) { - // Toggle Tab (always visible) + // Toggle Tab (always visible, draggable only when expanded) Surface( - modifier = - Modifier - .fillMaxWidth() - .height(40.dp) - .clickable { onTogglePreviewExpanded() }, + modifier = Modifier + .fillMaxWidth() + .height(40.dp) + .then( + if (isPreviewExpanded) { + Modifier.pointerInput(Unit) { + detectDragGestures( + onDragStart = { + isDragging = true + dragHeightState.value = currentHeightState.value + onDragStateChanged(true) + }, + onDragEnd = { + isDragging = false + onDragStateChanged(false) + }, + onDragCancel = { + isDragging = false + onDragStateChanged(false) + }, + onDrag = { change, dragAmount -> + change.consume() + val deltaDp = with(density) { (-dragAmount.y).toDp() } + val newHeight = + (dragHeightState.value + deltaDp).coerceIn(120.dp, 500.dp) + dragHeightState.value = newHeight + onHeightDragState.value(newHeight) + } + ) + } + } else { + Modifier + } + ) + .clickable { onTogglePreviewExpanded() }, color = MaterialTheme.colorScheme.surfaceVariant, tonalElevation = 4.dp, shadowElevation = 8.dp, @@ -160,7 +257,6 @@ fun CollapsiblePreviewPane( modifier = Modifier .fillMaxSize() .padding(horizontal = 16.dp), - contentAlignment = Alignment.Center, ) { Row( modifier = Modifier @@ -434,36 +530,142 @@ fun CollapsiblePreviewPane( } } } + + // Drag handle pill centered at the very top of the bar (only visible when expanded) + if (isPreviewExpanded) { + val handleScale by androidx.compose.animation.core.animateFloatAsState( + targetValue = if (isDragging) 1.2f else 1.0f, + label = "handleScale" + ) + val handleAlpha by androidx.compose.animation.core.animateFloatAsState( + targetValue = if (isDragging) 0.8f else 0.4f, + label = "handleAlpha" + ) + Box( + modifier = Modifier + .align(Alignment.TopCenter) + .padding(top = 4.dp) + .graphicsLayer(scaleX = handleScale, scaleY = handleScale) + .size(width = 36.dp, height = 4.dp) + .background( + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = handleAlpha), + shape = CircleShape + ) + ) + } } } - // Expanding Drawer Content - AnimatedVisibility( - visible = isPreviewExpanded, - enter = expandVertically(), - exit = shrinkVertically(), + Surface( + modifier = Modifier + .fillMaxWidth() + // The drawer height is smoothly driven by currentHeight (animatedPreviewHeight) + // minus the 40.dp tab header. We coerce it to >= 0.dp. + .height((currentHeight - 40.dp).coerceAtLeast(0.dp)), + tonalElevation = 8.dp, + color = if (isInvertedCanvas) { + MaterialTheme.colorScheme.inverseSurface + } else { + MaterialTheme.colorScheme.surface + }, ) { - Surface( - modifier = - Modifier - .fillMaxWidth() - .height((PREVIEW_HEIGHT_EXPANDED - PREVIEW_HEIGHT_COLLAPSED).dp), - tonalElevation = 8.dp, - color = - if (isInvertedCanvas) { - MaterialTheme.colorScheme.inverseSurface - } else { - MaterialTheme.colorScheme.surface - }, + val contentHeight = (currentHeight - 40.dp).coerceAtLeast(0.dp) + var containerPositionInWindow by remember { mutableStateOf(androidx.compose.ui.geometry.Offset.Zero) } + var containerSize by remember { mutableStateOf(androidx.compose.ui.unit.IntSize.Zero) } + var canvasPositionInWindow by remember { mutableStateOf(androidx.compose.ui.geometry.Offset.Zero) } + var canvasSize by remember { mutableStateOf(androidx.compose.ui.unit.IntSize.Zero) } + + val maskPath = remember( + containerPositionInWindow, + containerSize, + canvasPositionInWindow, + canvasSize, + exclusionRects ) { - TestCanvas( - strokeList = strokeList, - strokeRenderer = strokeRenderer, - brush = brush, - isInvertedCanvas = isInvertedCanvas, - onGetNextBrush = onGetNextBrush, - onStrokesAdded = onStrokesAdded, - ) + if (containerSize == androidx.compose.ui.unit.IntSize.Zero || canvasSize == androidx.compose.ui.unit.IntSize.Zero) null else { + val yContainerTop = containerPositionInWindow.y + val yCanvasTop = canvasPositionInWindow.y + val yContainerBottom = yContainerTop + containerSize.height + + val yVisibleTopLocal = (yContainerTop - yCanvasTop).coerceAtLeast(0f) + val yVisibleBottomLocal = + (yContainerBottom - yCanvasTop).coerceAtMost(canvasSize.height.toFloat()) + + androidx.compose.ui.graphics.Path().apply { + if (yVisibleTopLocal > 0f) { + addRect( + androidx.compose.ui.geometry.Rect( + left = 0f, + top = 0f, + right = canvasSize.width.toFloat(), + bottom = yVisibleTopLocal + ) + ) + } + if (yVisibleBottomLocal < canvasSize.height.toFloat()) { + addRect( + androidx.compose.ui.geometry.Rect( + left = 0f, + top = yVisibleBottomLocal, + right = canvasSize.width.toFloat(), + bottom = canvasSize.height.toFloat() + ) + ) + } + val xCanvasLeft = canvasPositionInWindow.x + exclusionRects.forEach { rect -> + val localLeft = (rect.left - xCanvasLeft).coerceAtLeast(0f) + val localTop = (rect.top - yCanvasTop).coerceAtLeast(0f) + val localRight = + (rect.right - xCanvasLeft).coerceAtMost(canvasSize.width.toFloat()) + val localBottom = + (rect.bottom - yCanvasTop).coerceAtMost(canvasSize.height.toFloat()) + + if (localLeft < localRight && localTop < localBottom) { + addRect( + androidx.compose.ui.geometry.Rect( + left = localLeft, + top = localTop, + right = localRight, + bottom = localBottom + ) + ) + } + } + } + } + } + + ClippedCanvasContainer( + currentHeight = contentHeight, + modifier = Modifier + .fillMaxSize() + .onGloballyPositioned { coordinates -> + containerPositionInWindow = coordinates.positionInWindow() + containerSize = coordinates.size + } + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.BottomCenter + ) { + TestCanvas( + strokeList = strokeList, + strokeRenderer = strokeRenderer, + brush = brush, + isInvertedCanvas = isInvertedCanvas, + maskPath = maskPath, + onGetNextBrush = onGetNextBrush, + onStrokesAdded = onStrokesAdded, + modifier = Modifier + .fillMaxWidth() + .requiredHeight(500.dp) + .onGloballyPositioned { coordinates -> + canvasPositionInWindow = coordinates.positionInWindow() + canvasSize = coordinates.size + } + ) + } } } } diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/TutorialOverlayHost.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/TutorialOverlayHost.kt index 6de1470a..ff9a1c3b 100644 --- a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/TutorialOverlayHost.kt +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/TutorialOverlayHost.kt @@ -26,8 +26,10 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp @@ -41,21 +43,20 @@ import com.example.cahier.developer.brushgraph.ui.node.NodeRegistry @Composable fun BoxScope.TutorialOverlayHost( - tutorialStep: TutorialStep?, - graph: BrushGraph, - zoom: Float, - offset: androidx.compose.ui.geometry.Offset, - selectedNodeId: String?, - selectedEdge: GraphEdge?, - currentStepIndex: Int, - isWideScreen: Boolean, - viewportSize: androidx.compose.ui.geometry.Size, - isPreviewExpanded: Boolean, - onAdvanceTutorial: (TutorialAction) -> Unit, - onRegressTutorial: () -> Unit, - onCloseTutorial: () -> Unit, - nodeRegistry: NodeRegistry, - modifier: Modifier = Modifier, + tutorialStep: TutorialStep?, + graph: BrushGraph, + zoom: Float, + offset: Offset, + selectedNodeId: String?, + selectedEdge: GraphEdge?, + currentStepIndex: Int, + isWideScreen: Boolean, + previewHeight: Dp, + onAdvanceTutorial: (TutorialAction) -> Unit, + onRegressTutorial: () -> Unit, + onCloseTutorial: () -> Unit, + nodeRegistry: NodeRegistry, + modifier: Modifier = Modifier, ) { tutorialStep?.let { step -> val density = LocalDensity.current @@ -69,17 +70,17 @@ fun BoxScope.TutorialOverlayHost( if (isInspectorOpen) { if (isWideScreen) { Modifier - .align(Alignment.BottomEnd) - .padding(bottom = 80.dp, end = (INSPECTOR_WIDTH_LANDSCAPE + 80).dp) + .align(Alignment.BottomEnd) + .padding(bottom = 80.dp, end = (INSPECTOR_WIDTH_LANDSCAPE + 80).dp) } else { Modifier - .align(Alignment.BottomEnd) - .padding(bottom = (INSPECTOR_HEIGHT_PORTRAIT + 16).dp, end = 80.dp) + .align(Alignment.BottomEnd) + .padding(bottom = (INSPECTOR_HEIGHT_PORTRAIT + 16).dp, end = 80.dp) } } else { Modifier - .align(Alignment.BottomEnd) - .padding(bottom = 80.dp, end = 80.dp) + .align(Alignment.BottomEnd) + .padding(bottom = 80.dp, end = 80.dp) } } @@ -87,7 +88,7 @@ fun BoxScope.TutorialOverlayHost( val node = step.getTargetNode(graph) if (node != null) { val nodePos = nodeRegistry.getNodePosition(node.id) - ?: androidx.compose.ui.geometry.Offset.Zero + ?: Offset.Zero val nodeCenterX = nodePos.x + node.data.width() / 2f val nodeTopY = nodePos.y @@ -109,39 +110,43 @@ fun BoxScope.TutorialOverlayHost( TutorialAnchor.INSPECTOR -> { if (isWideScreen) { Modifier - .align(Alignment.CenterEnd) - .padding(end = (INSPECTOR_WIDTH_LANDSCAPE + 16).dp) + .align(Alignment.CenterEnd) + .padding(end = (INSPECTOR_WIDTH_LANDSCAPE + 16).dp) } else { Modifier - .align(Alignment.BottomCenter) - .padding(bottom = (INSPECTOR_HEIGHT_PORTRAIT + 16).dp) + .align(Alignment.BottomCenter) + .padding(bottom = (INSPECTOR_HEIGHT_PORTRAIT + 16).dp) } } TutorialAnchor.TEST_CANVAS -> { - val basePadding = - if (isPreviewExpanded) PREVIEW_HEIGHT_EXPANDED else PREVIEW_HEIGHT_COLLAPSED + val basePadding = previewHeight if (isInspectorOpen && !isWideScreen) { Modifier - .align(Alignment.BottomCenter) - .padding(bottom = (maxOf(INSPECTOR_HEIGHT_PORTRAIT, basePadding) + 16).dp) + .align(Alignment.BottomCenter) + .padding( + bottom = (maxOf( + INSPECTOR_HEIGHT_PORTRAIT.toFloat(), + basePadding.value + ) + 16).dp + ) } else { Modifier - .align(Alignment.BottomCenter) - .padding(bottom = (basePadding + 16).dp) + .align(Alignment.BottomCenter) + .padding(bottom = basePadding + 16.dp) } } TutorialAnchor.ACTION_BAR -> Modifier - .align(Alignment.TopStart) - .padding(top = 80.dp, start = 16.dp) + .align(Alignment.TopStart) + .padding(top = 80.dp, start = 16.dp) TutorialAnchor.NOTIFICATION_ICON -> { val indicatorPaddingEnd = if (isWideScreen && isInspectorOpen) (INSPECTOR_WIDTH_LANDSCAPE + 16).dp else 16.dp Modifier - .align(Alignment.TopEnd) - .padding(top = 80.dp, end = indicatorPaddingEnd) + .align(Alignment.TopEnd) + .padding(top = 80.dp, end = indicatorPaddingEnd) } }.zIndex(20f) @@ -153,10 +158,10 @@ fun BoxScope.TutorialOverlayHost( } else null, onClose = onCloseTutorial, modifier = modifier - .then(tutorialModifier) - .onGloballyPositioned { coordinates -> - overlaySize = coordinates.size - } + .then(tutorialModifier) + .onGloballyPositioned { coordinates -> + overlaySize = coordinates.size + } ) } } From 57c56d74cf79b91b6b3d4367b65cab4edd93ef72 Mon Sep 17 00:00:00 2001 From: Maxwell Metzger Mitchell <60010512+maxmmitchell@users.noreply.github.com> Date: Tue, 30 Jun 2026 19:53:11 +0000 Subject: [PATCH 3/7] Adjust test canvas size + mask drawing out of test canvas and on sidepane --- .../java/com/example/cahier/MainActivity.kt | 9 +- .../example/cahier/core/ui/DrawingSurface.kt | 4 +- .../brushgraph/ui/BrushGraphContent.kt | 21 +- .../brushgraph/ui/BrushGraphMenus.kt | 13 +- .../brushgraph/ui/BrushGraphScreen.kt | 104 +++++-- .../brushgraph/ui/GraphCameraController.kt | 9 +- .../developer/brushgraph/ui/TestCanvas.kt | 274 +++++++++++++++--- .../brushgraph/ui/TutorialOverlayHost.kt | 85 +++--- 8 files changed, 385 insertions(+), 134 deletions(-) diff --git a/app/src/main/java/com/example/cahier/MainActivity.kt b/app/src/main/java/com/example/cahier/MainActivity.kt index ce2aa7b7..0efa18b3 100644 --- a/app/src/main/java/com/example/cahier/MainActivity.kt +++ b/app/src/main/java/com/example/cahier/MainActivity.kt @@ -69,10 +69,11 @@ class MainActivity : ComponentActivity() { androidx.compose.runtime.CompositionLocalProvider(LocalTextureStore provides textureStore) { Surface( modifier = Modifier - .fillMaxSize(), - color = MaterialTheme.colorScheme.background - ) { - CahierApp(noteId = noteId, + .fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + CahierApp( + noteId = noteId, noteType = noteType, navigateToBrushGraph = navigateToBrushGraph, onNavigateToBrushGraphHandled = { diff --git a/app/src/main/java/com/example/cahier/core/ui/DrawingSurface.kt b/app/src/main/java/com/example/cahier/core/ui/DrawingSurface.kt index 6c32eca2..234ad466 100644 --- a/app/src/main/java/com/example/cahier/core/ui/DrawingSurface.kt +++ b/app/src/main/java/com/example/cahier/core/ui/DrawingSurface.kt @@ -65,6 +65,7 @@ fun DrawingSurface( backgroundImageUri: String?, onStartDrag: () -> Unit, modifier: Modifier = Modifier, + maskPath: androidx.compose.ui.graphics.Path? = null, ) { val textureStore = LocalTextureStore.current Box(modifier = modifier) { @@ -139,7 +140,8 @@ fun DrawingSurface( defaultBrush = currentBrush, nextBrush = onGetNextBrush, onStrokesFinished = onStrokesFinished, - textureBitmapStore = textureStore + textureBitmapStore = textureStore, + maskPath = maskPath ) } } diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/BrushGraphContent.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/BrushGraphContent.kt index 2ec485b2..48fa4831 100644 --- a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/BrushGraphContent.kt +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/BrushGraphContent.kt @@ -37,7 +37,7 @@ fun BrushGraphContent( isNodeSelected: Boolean, isEdgeSelected: Boolean, isErrorPaneOpen: Boolean, - isPreviewExpanded: Boolean, + previewHeight: Dp, viewportSize: Size, onViewportSizeChange: (Size) -> Unit, canvasSlot: @Composable (trashPaddingBottom: Dp) -> Unit, @@ -61,19 +61,14 @@ fun BrushGraphContent( targetValue = if (isSidePaneOpen) (INSPECTOR_WIDTH_LANDSCAPE + 16).dp else 16.dp, label = "indicatorPaddingEnd", ) - val previewHeight = if (isPreviewExpanded) { - PREVIEW_HEIGHT_EXPANDED - } else { - PREVIEW_HEIGHT_COLLAPSED - } val isAnySidePaneOpen = isNodeSelected || isEdgeSelected || isErrorPaneOpen val trashPaddingBottom by animateDpAsState( targetValue = if (!isWideScreen && isAnySidePaneOpen) { - (maxOf(previewHeight, INSPECTOR_HEIGHT_PORTRAIT) + 16).dp + (maxOf(previewHeight.value, INSPECTOR_HEIGHT_PORTRAIT.toFloat()) + 16).dp } else { - (previewHeight + 16).dp + previewHeight + 16.dp }, label = "trashPaddingBottom", ) @@ -82,11 +77,11 @@ fun BrushGraphContent( Box( modifier = Modifier - .fillMaxSize() - .padding(paddingValues) - .onGloballyPositioned { coordinates -> - onViewportSizeChange(coordinates.size.toSize()) - } + .fillMaxSize() + .padding(paddingValues) + .onGloballyPositioned { coordinates -> + onViewportSizeChange(coordinates.size.toSize()) + } ) { canvasSlot(trashPaddingBottom) inspectorSlot() diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/BrushGraphMenus.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/BrushGraphMenus.kt index c4b4daf9..cce89649 100644 --- a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/BrushGraphMenus.kt +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/BrushGraphMenus.kt @@ -67,6 +67,7 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp @@ -305,26 +306,20 @@ fun PaletteMenu( fun CreateNodeSpeedDial( isWideScreen: Boolean, isAnySidePaneOpen: Boolean, - isPreviewExpanded: Boolean, + previewHeight: Dp, viewportSize: androidx.compose.ui.geometry.Size, modifier: Modifier = Modifier, menuContent: @Composable (onClose: () -> Unit) -> Unit, ) { var expanded by rememberSaveable { mutableStateOf(false) } - val previewHeight = if (isPreviewExpanded) { - PREVIEW_HEIGHT_EXPANDED - } else { - PREVIEW_HEIGHT_COLLAPSED - } - val fabPaddingBottom by animateDpAsState( targetValue = if (!isWideScreen && isAnySidePaneOpen) { - (maxOf(previewHeight, INSPECTOR_HEIGHT_PORTRAIT) + 16).dp + maxOf(previewHeight, INSPECTOR_HEIGHT_PORTRAIT.dp) + 16.dp } else { - (previewHeight + 16).dp + previewHeight + 16.dp }, label = "fabPaddingBottom", ) diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/BrushGraphScreen.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/BrushGraphScreen.kt index 9be9a365..484b9e13 100644 --- a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/BrushGraphScreen.kt +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/BrushGraphScreen.kt @@ -22,6 +22,8 @@ import android.net.Uri import android.util.Log import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.VectorConverter import androidx.compose.animation.core.animateDpAsState import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints @@ -50,10 +52,14 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInWindow import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.toSize import androidx.compose.ui.zIndex import androidx.hilt.navigation.compose.hiltViewModel import androidx.ink.brush.BrushFamily @@ -118,6 +124,13 @@ fun BrushGraphScreen( ) } + var inspectorBounds by remember { mutableStateOf(null) } + var notificationPaneBounds by remember { mutableStateOf(null) } + + val exclusionRects = remember(inspectorBounds, notificationPaneBounds) { + listOfNotNull(inspectorBounds, notificationPaneBounds) + } + // Texture picking logic var showTextureNameDialog by remember { mutableStateOf(false) } var pendingTextureUri by remember { mutableStateOf(null) } @@ -257,29 +270,37 @@ fun BrushGraphScreen( targetValue = if (isSidePaneOpen) (INSPECTOR_WIDTH_LANDSCAPE + 16).dp else 16.dp, label = "indicatorPaddingEnd", ) - val previewHeight = if (uiState.isPreviewExpanded) { - PREVIEW_HEIGHT_EXPANDED + var previewExpandedHeightDp by remember { mutableStateOf(200.dp) } + var isDraggingPreview by remember { mutableStateOf(false) } + val currentPreviewHeightDp = if (uiState.isPreviewExpanded) { + previewExpandedHeightDp } else { - PREVIEW_HEIGHT_COLLAPSED + PREVIEW_HEIGHT_COLLAPSED.dp } - val animatedPreviewHeight by animateDpAsState( - targetValue = previewHeight.dp, - label = "animatedPreviewHeight" - ) + val animatedPreviewHeight = + remember { Animatable(currentPreviewHeightDp, Dp.VectorConverter) } + LaunchedEffect(uiState.isPreviewExpanded, previewExpandedHeightDp) { + if (!isDraggingPreview) { + val target = + if (uiState.isPreviewExpanded) previewExpandedHeightDp else PREVIEW_HEIGHT_COLLAPSED.dp + animatedPreviewHeight.animateTo(target) + } + } + + val isNodeSelected = uiState.selectedNodeId != null val isEdgeSelected = uiState.selectedEdge != null val isErrorPaneOpen = uiState.isErrorPaneOpen val isAnySidePaneOpen = isNodeSelected || isEdgeSelected || isErrorPaneOpen - val trashPaddingBottom by animateDpAsState( - targetValue = - if (!isWideScreen && isAnySidePaneOpen) { - (maxOf(previewHeight, INSPECTOR_HEIGHT_PORTRAIT) + 16).dp - } else { - (previewHeight + 16).dp - }, - label = "trashPaddingBottom", - ) + val trashPaddingBottom = if (!isWideScreen && isAnySidePaneOpen) { + (maxOf( + animatedPreviewHeight.value.value, + INSPECTOR_HEIGHT_PORTRAIT.toFloat() + ) + 16).dp + } else { + animatedPreviewHeight.value + 16.dp + } val nodeRegistry = remember { NodeRegistry() } val issues = uiState.graphIssues @@ -305,7 +326,7 @@ fun BrushGraphScreen( isNodeSelected = isNodeSelected, isEdgeSelected = isEdgeSelected, isErrorPaneOpen = isErrorPaneOpen, - isPreviewExpanded = uiState.isPreviewExpanded, + previewHeight = animatedPreviewHeight.value, viewportSize = viewportSize, onViewportSizeChange = { viewportSize = it }, canvasSlot = { padding -> @@ -414,10 +435,23 @@ fun BrushGraphScreen( .align(if (isWideScreen) Alignment.CenterEnd else Alignment.BottomCenter) .let { if (isTallAndWide) { - it.padding(bottom = animatedPreviewHeight) + it.padding(bottom = animatedPreviewHeight.value) } else { it } + } + .onGloballyPositioned { coordinates: androidx.compose.ui.layout.LayoutCoordinates -> + val isInspectorOpen = + selectedNode != null || selectedEdge != null + inspectorBounds = + if (isInspectorOpen && coordinates.size.width > 0 && coordinates.size.height > 0) { + androidx.compose.ui.geometry.Rect( + coordinates.positionInWindow(), + coordinates.size.toSize() + ) + } else { + null + } }, ) { if (selectedNode != null) { @@ -499,10 +533,21 @@ fun BrushGraphScreen( .align(if (isWideScreen) Alignment.CenterEnd else Alignment.BottomCenter) .let { if (isTallAndWide) { - it.padding(bottom = animatedPreviewHeight) + it.padding(bottom = animatedPreviewHeight.value) } else { it } + } + .onGloballyPositioned { coordinates: androidx.compose.ui.layout.LayoutCoordinates -> + notificationPaneBounds = + if (uiState.isErrorPaneOpen && coordinates.size.width > 0 && coordinates.size.height > 0) { + androidx.compose.ui.geometry.Rect( + coordinates.positionInWindow(), + coordinates.size.toSize() + ) + } else { + null + } }, ) }, @@ -526,6 +571,7 @@ fun BrushGraphScreen( CollapsiblePreviewPane( isPreviewExpanded = uiState.isPreviewExpanded, isInvertedCanvas = uiState.isDarkCanvas, + exclusionRects = exclusionRects, testAutoUpdateStrokes = uiState.testAutoUpdateStrokes, brushColor = uiState.testBrushColor ?: primaryColor, brushSize = uiState.testBrushSize, @@ -533,6 +579,14 @@ fun BrushGraphScreen( strokeList = viewModel.strokeList, strokeRenderer = renderer, topIssue = topIssue, + currentHeight = animatedPreviewHeight.value, + onDragStateChanged = { isDraggingPreview = it }, + onHeightDrag = { newHeight -> + previewExpandedHeightDp = newHeight + scope.launch { + animatedPreviewHeight.snapTo(newHeight) + } + }, onGetNextBrush = { viewModel.brush.value }, onTogglePreviewExpanded = { viewModel.togglePreviewExpanded() }, onClearStrokes = { viewModel.clearStrokes() }, @@ -592,8 +646,7 @@ fun BrushGraphScreen( }, fabSlot = { vSize -> val density = LocalDensity.current.density - val previewHeight = - if (uiState.isPreviewExpanded) PREVIEW_HEIGHT_EXPANDED else PREVIEW_HEIGHT_COLLAPSED + val previewHeight = animatedPreviewHeight.value val isInspectorOpen = (uiState.selectedNodeId != null || uiState.selectedEdge != null) val isErrorPaneOpen = uiState.isErrorPaneOpen @@ -601,7 +654,7 @@ fun BrushGraphScreen( val inspectorWidthPx = INSPECTOR_WIDTH_LANDSCAPE * density val inspectorHeightPx = INSPECTOR_HEIGHT_PORTRAIT * density - val previewHeightPx = previewHeight * density + val previewHeightPx = previewHeight.value * density val (visibleWidth, visibleHeight) = if (isWideScreen) { @@ -627,7 +680,7 @@ fun BrushGraphScreen( CreateNodeSpeedDial( isWideScreen = isWideScreen, isAnySidePaneOpen = isAnySidePaneOpen, - isPreviewExpanded = uiState.isPreviewExpanded, + previewHeight = animatedPreviewHeight.value, viewportSize = vSize, modifier = Modifier.align(Alignment.BottomEnd), menuContent = { onClose -> @@ -703,8 +756,7 @@ fun BrushGraphScreen( selectedEdge = uiState.selectedEdge, currentStepIndex = viewModel.currentStepIndex, isWideScreen = isWideScreen, - viewportSize = vSize, - isPreviewExpanded = uiState.isPreviewExpanded, + previewHeight = animatedPreviewHeight.value, onAdvanceTutorial = { viewModel.advanceTutorial(it) }, onRegressTutorial = { viewModel.regressTutorial() }, onCloseTutorial = { showTutorialFinishDialog = true }, @@ -734,7 +786,7 @@ fun BrushGraphScreen( focusTrigger = uiState.focusTrigger, graph = uiState.graph, zoom = uiState.zoom, - isPreviewExpanded = uiState.isPreviewExpanded, + previewHeight = animatedPreviewHeight.value, selectedNodeId = uiState.selectedNodeId, updateOffset = { viewModel.updateOffset(GraphPoint(it.x, it.y)) }, viewportSize = viewportSize, diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/GraphCameraController.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/GraphCameraController.kt index d3c52b03..405d6d5f 100644 --- a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/GraphCameraController.kt +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/GraphCameraController.kt @@ -41,7 +41,7 @@ fun GraphCameraController( focusTrigger: Int, graph: BrushGraph, zoom: Float, - isPreviewExpanded: Boolean, + previewHeight: Dp, selectedNodeId: String?, updateOffset: (Offset) -> Unit, viewportSize: Size, @@ -91,7 +91,7 @@ fun GraphCameraController( viewportSize = viewportSize, density = density, isWideScreen = isWideScreen, - isPreviewExpanded = isPreviewExpanded + previewHeight = previewHeight ) animatableOffset.snapTo(offset) animatableOffset.animateTo(newOffset, animationSpec = tween(500)) { @@ -110,7 +110,7 @@ private fun calculateFocusOffset( viewportSize: Size = Size.Zero, density: Float = 1f, isWideScreen: Boolean = false, - isPreviewExpanded: Boolean = false, + previewHeight: Dp = Dp.Unspecified, targetScreenPos: Offset? = null, ): Offset { val nodeCenterX = position.x + node.data.width() / 2f @@ -119,8 +119,7 @@ private fun calculateFocusOffset( val targetPos = if (targetScreenPos != null) { Pair(targetScreenPos.x, targetScreenPos.y) } else { - val previewHeightPx = - (if (isPreviewExpanded) PREVIEW_HEIGHT_EXPANDED else PREVIEW_HEIGHT_COLLAPSED) * density + val previewHeightPx = previewHeight.value * density val safeSize = if (isWideScreen) { val inspectorWidthPx = INSPECTOR_WIDTH_LANDSCAPE * density Pair(viewportSize.width - inspectorWidthPx, viewportSize.height - previewHeightPx) diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/TestCanvas.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/TestCanvas.kt index f4e35a79..bf4b53b5 100644 --- a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/TestCanvas.kt +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/TestCanvas.kt @@ -16,12 +16,12 @@ package com.example.cahier.developer.brushgraph.ui -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.expandVertically -import androidx.compose.animation.shrinkVertically +import android.view.ViewGroup +import android.widget.FrameLayout import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -32,6 +32,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredHeight import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState @@ -55,19 +56,27 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.Layout +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInWindow +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView import androidx.ink.brush.Brush import androidx.ink.rendering.android.canvas.CanvasStrokeRenderer import androidx.ink.strokes.Stroke @@ -89,10 +98,11 @@ fun TestCanvas( strokeRenderer: CanvasStrokeRenderer, brush: Brush, modifier: Modifier = Modifier, + maskPath: androidx.compose.ui.graphics.Path? = null, onGetNextBrush: () -> Brush, onStrokesAdded: (List) -> Unit, ) { - Box(modifier = modifier.fillMaxSize()) { + Box(modifier = modifier) { Text( stringResource(R.string.bg_test_canvas_draw_prompt), modifier = Modifier.align(Alignment.Center), @@ -116,10 +126,55 @@ fun TestCanvas( isEraserMode = false, backgroundImageUri = null, onStartDrag = {}, + modifier = Modifier.fillMaxSize(), + maskPath = maskPath, ) } } +/** + * A container that uses a native Android FrameLayout with clipChildren = true + * to physically clip the touch bounds and rendering of its child AndroidViews (like InProgressStrokesView). + * This prevents touch events and low-latency wet strokes from leaking outside the visible bounds. + */ +@Composable +fun ClippedCanvasContainer( + currentHeight: Dp, + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { + val density = LocalDensity.current + AndroidView( + factory = { context -> + val frameLayout = FrameLayout(context).apply { + clipChildren = true + } + val composeView = ComposeView(context).apply { + setContent { + content() + } + } + frameLayout.addView( + composeView, + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + frameLayout + }, + update = { frameLayout -> + val heightPx = with(density) { currentHeight.roundToPx() } + val lp = frameLayout.layoutParams + ?: ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, heightPx) + if (lp.height != heightPx) { + lp.height = heightPx + frameLayout.layoutParams = lp + } + }, + modifier = modifier + ) +} + + @Composable @OptIn(ExperimentalMaterial3Api::class) fun CollapsiblePreviewPane( @@ -129,9 +184,13 @@ fun CollapsiblePreviewPane( brushColor: Color, brushSize: Float, brush: Brush, + exclusionRects: List = emptyList(), strokeList: List, strokeRenderer: CanvasStrokeRenderer, topIssue: GraphValidationException?, + currentHeight: Dp, + onDragStateChanged: (Boolean) -> Unit, + onHeightDrag: (Dp) -> Unit, modifier: Modifier = Modifier, onGetNextBrush: () -> Brush, onTogglePreviewExpanded: () -> Unit, @@ -144,14 +203,52 @@ fun CollapsiblePreviewPane( onChooseColor: (Color, (Color) -> Unit) -> Unit, onToggleNotificationPane: () -> Unit, ) { + val density = androidx.compose.ui.platform.LocalDensity.current + val onHeightDragState = rememberUpdatedState(onHeightDrag) + val currentHeightState = rememberUpdatedState(currentHeight) + var isDragging by remember { mutableStateOf(false) } + + // Mutable state to synchronously track height during a drag gesture + val dragHeightState = remember { mutableStateOf(currentHeight) } + Column(modifier = modifier.fillMaxWidth()) { - // Toggle Tab (always visible) + // Toggle Tab (always visible, draggable only when expanded) Surface( - modifier = - Modifier - .fillMaxWidth() - .height(40.dp) - .clickable { onTogglePreviewExpanded() }, + modifier = Modifier + .fillMaxWidth() + .height(40.dp) + .then( + if (isPreviewExpanded) { + Modifier.pointerInput(Unit) { + detectDragGestures( + onDragStart = { + isDragging = true + dragHeightState.value = currentHeightState.value + onDragStateChanged(true) + }, + onDragEnd = { + isDragging = false + onDragStateChanged(false) + }, + onDragCancel = { + isDragging = false + onDragStateChanged(false) + }, + onDrag = { change, dragAmount -> + change.consume() + val deltaDp = with(density) { (-dragAmount.y).toDp() } + val newHeight = + (dragHeightState.value + deltaDp).coerceIn(120.dp, 500.dp) + dragHeightState.value = newHeight + onHeightDragState.value(newHeight) + } + ) + } + } else { + Modifier + } + ) + .clickable { onTogglePreviewExpanded() }, color = MaterialTheme.colorScheme.surfaceVariant, tonalElevation = 4.dp, shadowElevation = 8.dp, @@ -160,7 +257,6 @@ fun CollapsiblePreviewPane( modifier = Modifier .fillMaxSize() .padding(horizontal = 16.dp), - contentAlignment = Alignment.Center, ) { Row( modifier = Modifier @@ -434,36 +530,142 @@ fun CollapsiblePreviewPane( } } } + + // Drag handle pill centered at the very top of the bar (only visible when expanded) + if (isPreviewExpanded) { + val handleScale by androidx.compose.animation.core.animateFloatAsState( + targetValue = if (isDragging) 1.2f else 1.0f, + label = "handleScale" + ) + val handleAlpha by androidx.compose.animation.core.animateFloatAsState( + targetValue = if (isDragging) 0.8f else 0.4f, + label = "handleAlpha" + ) + Box( + modifier = Modifier + .align(Alignment.TopCenter) + .padding(top = 4.dp) + .graphicsLayer(scaleX = handleScale, scaleY = handleScale) + .size(width = 36.dp, height = 4.dp) + .background( + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = handleAlpha), + shape = CircleShape + ) + ) + } } } - // Expanding Drawer Content - AnimatedVisibility( - visible = isPreviewExpanded, - enter = expandVertically(), - exit = shrinkVertically(), + Surface( + modifier = Modifier + .fillMaxWidth() + // The drawer height is smoothly driven by currentHeight (animatedPreviewHeight) + // minus the 40.dp tab header. We coerce it to >= 0.dp. + .height((currentHeight - 40.dp).coerceAtLeast(0.dp)), + tonalElevation = 8.dp, + color = if (isInvertedCanvas) { + MaterialTheme.colorScheme.inverseSurface + } else { + MaterialTheme.colorScheme.surface + }, ) { - Surface( - modifier = - Modifier - .fillMaxWidth() - .height((PREVIEW_HEIGHT_EXPANDED - PREVIEW_HEIGHT_COLLAPSED).dp), - tonalElevation = 8.dp, - color = - if (isInvertedCanvas) { - MaterialTheme.colorScheme.inverseSurface - } else { - MaterialTheme.colorScheme.surface - }, + val contentHeight = (currentHeight - 40.dp).coerceAtLeast(0.dp) + var containerPositionInWindow by remember { mutableStateOf(androidx.compose.ui.geometry.Offset.Zero) } + var containerSize by remember { mutableStateOf(androidx.compose.ui.unit.IntSize.Zero) } + var canvasPositionInWindow by remember { mutableStateOf(androidx.compose.ui.geometry.Offset.Zero) } + var canvasSize by remember { mutableStateOf(androidx.compose.ui.unit.IntSize.Zero) } + + val maskPath = remember( + containerPositionInWindow, + containerSize, + canvasPositionInWindow, + canvasSize, + exclusionRects ) { - TestCanvas( - strokeList = strokeList, - strokeRenderer = strokeRenderer, - brush = brush, - isInvertedCanvas = isInvertedCanvas, - onGetNextBrush = onGetNextBrush, - onStrokesAdded = onStrokesAdded, - ) + if (containerSize == androidx.compose.ui.unit.IntSize.Zero || canvasSize == androidx.compose.ui.unit.IntSize.Zero) null else { + val yContainerTop = containerPositionInWindow.y + val yCanvasTop = canvasPositionInWindow.y + val yContainerBottom = yContainerTop + containerSize.height + + val yVisibleTopLocal = (yContainerTop - yCanvasTop).coerceAtLeast(0f) + val yVisibleBottomLocal = + (yContainerBottom - yCanvasTop).coerceAtMost(canvasSize.height.toFloat()) + + androidx.compose.ui.graphics.Path().apply { + if (yVisibleTopLocal > 0f) { + addRect( + androidx.compose.ui.geometry.Rect( + left = 0f, + top = 0f, + right = canvasSize.width.toFloat(), + bottom = yVisibleTopLocal + ) + ) + } + if (yVisibleBottomLocal < canvasSize.height.toFloat()) { + addRect( + androidx.compose.ui.geometry.Rect( + left = 0f, + top = yVisibleBottomLocal, + right = canvasSize.width.toFloat(), + bottom = canvasSize.height.toFloat() + ) + ) + } + val xCanvasLeft = canvasPositionInWindow.x + exclusionRects.forEach { rect -> + val localLeft = (rect.left - xCanvasLeft).coerceAtLeast(0f) + val localTop = (rect.top - yCanvasTop).coerceAtLeast(0f) + val localRight = + (rect.right - xCanvasLeft).coerceAtMost(canvasSize.width.toFloat()) + val localBottom = + (rect.bottom - yCanvasTop).coerceAtMost(canvasSize.height.toFloat()) + + if (localLeft < localRight && localTop < localBottom) { + addRect( + androidx.compose.ui.geometry.Rect( + left = localLeft, + top = localTop, + right = localRight, + bottom = localBottom + ) + ) + } + } + } + } + } + + ClippedCanvasContainer( + currentHeight = contentHeight, + modifier = Modifier + .fillMaxSize() + .onGloballyPositioned { coordinates -> + containerPositionInWindow = coordinates.positionInWindow() + containerSize = coordinates.size + } + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.BottomCenter + ) { + TestCanvas( + strokeList = strokeList, + strokeRenderer = strokeRenderer, + brush = brush, + isInvertedCanvas = isInvertedCanvas, + maskPath = maskPath, + onGetNextBrush = onGetNextBrush, + onStrokesAdded = onStrokesAdded, + modifier = Modifier + .fillMaxWidth() + .requiredHeight(500.dp) + .onGloballyPositioned { coordinates -> + canvasPositionInWindow = coordinates.positionInWindow() + canvasSize = coordinates.size + } + ) + } } } } diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/TutorialOverlayHost.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/TutorialOverlayHost.kt index 6de1470a..ff9a1c3b 100644 --- a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/TutorialOverlayHost.kt +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/TutorialOverlayHost.kt @@ -26,8 +26,10 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp @@ -41,21 +43,20 @@ import com.example.cahier.developer.brushgraph.ui.node.NodeRegistry @Composable fun BoxScope.TutorialOverlayHost( - tutorialStep: TutorialStep?, - graph: BrushGraph, - zoom: Float, - offset: androidx.compose.ui.geometry.Offset, - selectedNodeId: String?, - selectedEdge: GraphEdge?, - currentStepIndex: Int, - isWideScreen: Boolean, - viewportSize: androidx.compose.ui.geometry.Size, - isPreviewExpanded: Boolean, - onAdvanceTutorial: (TutorialAction) -> Unit, - onRegressTutorial: () -> Unit, - onCloseTutorial: () -> Unit, - nodeRegistry: NodeRegistry, - modifier: Modifier = Modifier, + tutorialStep: TutorialStep?, + graph: BrushGraph, + zoom: Float, + offset: Offset, + selectedNodeId: String?, + selectedEdge: GraphEdge?, + currentStepIndex: Int, + isWideScreen: Boolean, + previewHeight: Dp, + onAdvanceTutorial: (TutorialAction) -> Unit, + onRegressTutorial: () -> Unit, + onCloseTutorial: () -> Unit, + nodeRegistry: NodeRegistry, + modifier: Modifier = Modifier, ) { tutorialStep?.let { step -> val density = LocalDensity.current @@ -69,17 +70,17 @@ fun BoxScope.TutorialOverlayHost( if (isInspectorOpen) { if (isWideScreen) { Modifier - .align(Alignment.BottomEnd) - .padding(bottom = 80.dp, end = (INSPECTOR_WIDTH_LANDSCAPE + 80).dp) + .align(Alignment.BottomEnd) + .padding(bottom = 80.dp, end = (INSPECTOR_WIDTH_LANDSCAPE + 80).dp) } else { Modifier - .align(Alignment.BottomEnd) - .padding(bottom = (INSPECTOR_HEIGHT_PORTRAIT + 16).dp, end = 80.dp) + .align(Alignment.BottomEnd) + .padding(bottom = (INSPECTOR_HEIGHT_PORTRAIT + 16).dp, end = 80.dp) } } else { Modifier - .align(Alignment.BottomEnd) - .padding(bottom = 80.dp, end = 80.dp) + .align(Alignment.BottomEnd) + .padding(bottom = 80.dp, end = 80.dp) } } @@ -87,7 +88,7 @@ fun BoxScope.TutorialOverlayHost( val node = step.getTargetNode(graph) if (node != null) { val nodePos = nodeRegistry.getNodePosition(node.id) - ?: androidx.compose.ui.geometry.Offset.Zero + ?: Offset.Zero val nodeCenterX = nodePos.x + node.data.width() / 2f val nodeTopY = nodePos.y @@ -109,39 +110,43 @@ fun BoxScope.TutorialOverlayHost( TutorialAnchor.INSPECTOR -> { if (isWideScreen) { Modifier - .align(Alignment.CenterEnd) - .padding(end = (INSPECTOR_WIDTH_LANDSCAPE + 16).dp) + .align(Alignment.CenterEnd) + .padding(end = (INSPECTOR_WIDTH_LANDSCAPE + 16).dp) } else { Modifier - .align(Alignment.BottomCenter) - .padding(bottom = (INSPECTOR_HEIGHT_PORTRAIT + 16).dp) + .align(Alignment.BottomCenter) + .padding(bottom = (INSPECTOR_HEIGHT_PORTRAIT + 16).dp) } } TutorialAnchor.TEST_CANVAS -> { - val basePadding = - if (isPreviewExpanded) PREVIEW_HEIGHT_EXPANDED else PREVIEW_HEIGHT_COLLAPSED + val basePadding = previewHeight if (isInspectorOpen && !isWideScreen) { Modifier - .align(Alignment.BottomCenter) - .padding(bottom = (maxOf(INSPECTOR_HEIGHT_PORTRAIT, basePadding) + 16).dp) + .align(Alignment.BottomCenter) + .padding( + bottom = (maxOf( + INSPECTOR_HEIGHT_PORTRAIT.toFloat(), + basePadding.value + ) + 16).dp + ) } else { Modifier - .align(Alignment.BottomCenter) - .padding(bottom = (basePadding + 16).dp) + .align(Alignment.BottomCenter) + .padding(bottom = basePadding + 16.dp) } } TutorialAnchor.ACTION_BAR -> Modifier - .align(Alignment.TopStart) - .padding(top = 80.dp, start = 16.dp) + .align(Alignment.TopStart) + .padding(top = 80.dp, start = 16.dp) TutorialAnchor.NOTIFICATION_ICON -> { val indicatorPaddingEnd = if (isWideScreen && isInspectorOpen) (INSPECTOR_WIDTH_LANDSCAPE + 16).dp else 16.dp Modifier - .align(Alignment.TopEnd) - .padding(top = 80.dp, end = indicatorPaddingEnd) + .align(Alignment.TopEnd) + .padding(top = 80.dp, end = indicatorPaddingEnd) } }.zIndex(20f) @@ -153,10 +158,10 @@ fun BoxScope.TutorialOverlayHost( } else null, onClose = onCloseTutorial, modifier = modifier - .then(tutorialModifier) - .onGloballyPositioned { coordinates -> - overlaySize = coordinates.size - } + .then(tutorialModifier) + .onGloballyPositioned { coordinates -> + overlaySize = coordinates.size + } ) } } From e6130232cde9c3a15fbc8219ccd13606fec8b2ca Mon Sep 17 00:00:00 2001 From: Maxwell Metzger Mitchell <60010512+maxmmitchell@users.noreply.github.com> Date: Wed, 1 Jul 2026 14:43:50 +0000 Subject: [PATCH 4/7] Cleanup --- .../brushgraph/ui/BrushGraphContent.kt | 2 +- .../brushgraph/ui/BrushGraphScreen.kt | 9 -- .../developer/brushgraph/ui/TestCanvas.kt | 140 ++++++++++-------- .../brushgraph/ui/TutorialOverlayHost.kt | 8 +- 4 files changed, 83 insertions(+), 76 deletions(-) diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/BrushGraphContent.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/BrushGraphContent.kt index 48fa4831..dc254bce 100644 --- a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/BrushGraphContent.kt +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/BrushGraphContent.kt @@ -66,7 +66,7 @@ fun BrushGraphContent( val trashPaddingBottom by animateDpAsState( targetValue = if (!isWideScreen && isAnySidePaneOpen) { - (maxOf(previewHeight.value, INSPECTOR_HEIGHT_PORTRAIT.toFloat()) + 16).dp + maxOf(previewHeight, INSPECTOR_HEIGHT_PORTRAIT.dp) + 16.dp } else { previewHeight + 16.dp }, diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/BrushGraphScreen.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/BrushGraphScreen.kt index 484b9e13..ba37bf2e 100644 --- a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/BrushGraphScreen.kt +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/BrushGraphScreen.kt @@ -293,15 +293,6 @@ fun BrushGraphScreen( val isErrorPaneOpen = uiState.isErrorPaneOpen val isAnySidePaneOpen = isNodeSelected || isEdgeSelected || isErrorPaneOpen - val trashPaddingBottom = if (!isWideScreen && isAnySidePaneOpen) { - (maxOf( - animatedPreviewHeight.value.value, - INSPECTOR_HEIGHT_PORTRAIT.toFloat() - ) + 16).dp - } else { - animatedPreviewHeight.value + 16.dp - } - val nodeRegistry = remember { NodeRegistry() } val issues = uiState.graphIssues diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/TestCanvas.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/TestCanvas.kt index bf4b53b5..b2639042 100644 --- a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/TestCanvas.kt +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/TestCanvas.kt @@ -46,6 +46,7 @@ import androidx.compose.material3.Checkbox import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuAnchorType import androidx.compose.material3.ExposedDropdownMenuBox import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon @@ -61,7 +62,10 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.luminance import androidx.compose.ui.input.pointer.pointerInput @@ -75,6 +79,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import androidx.ink.brush.Brush @@ -92,7 +97,7 @@ import com.example.cahier.developer.brushgraph.data.GraphValidationException import com.example.cahier.developer.brushgraph.data.ValidationSeverity @Composable -fun TestCanvas( +private fun TestCanvas( isInvertedCanvas: Boolean, strokeList: List, strokeRenderer: CanvasStrokeRenderer, @@ -132,13 +137,8 @@ fun TestCanvas( } } -/** - * A container that uses a native Android FrameLayout with clipChildren = true - * to physically clip the touch bounds and rendering of its child AndroidViews (like InProgressStrokesView). - * This prevents touch events and low-latency wet strokes from leaking outside the visible bounds. - */ @Composable -fun ClippedCanvasContainer( +private fun ClippedCanvasContainer( currentHeight: Dp, modifier: Modifier = Modifier, content: @Composable () -> Unit, @@ -174,7 +174,6 @@ fun ClippedCanvasContainer( ) } - @Composable @OptIn(ExperimentalMaterial3Api::class) fun CollapsiblePreviewPane( @@ -423,7 +422,10 @@ fun CollapsiblePreviewPane( Text( text = "${brushSize.toInt()}px", modifier = Modifier - .menuAnchor() + .menuAnchor( + ExposedDropdownMenuAnchorType.PrimaryNotEditable, + enabled = true + ) .clickable { sizeExpanded = true }, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary, @@ -582,58 +584,13 @@ fun CollapsiblePreviewPane( canvasSize, exclusionRects ) { - if (containerSize == androidx.compose.ui.unit.IntSize.Zero || canvasSize == androidx.compose.ui.unit.IntSize.Zero) null else { - val yContainerTop = containerPositionInWindow.y - val yCanvasTop = canvasPositionInWindow.y - val yContainerBottom = yContainerTop + containerSize.height - - val yVisibleTopLocal = (yContainerTop - yCanvasTop).coerceAtLeast(0f) - val yVisibleBottomLocal = - (yContainerBottom - yCanvasTop).coerceAtMost(canvasSize.height.toFloat()) - - androidx.compose.ui.graphics.Path().apply { - if (yVisibleTopLocal > 0f) { - addRect( - androidx.compose.ui.geometry.Rect( - left = 0f, - top = 0f, - right = canvasSize.width.toFloat(), - bottom = yVisibleTopLocal - ) - ) - } - if (yVisibleBottomLocal < canvasSize.height.toFloat()) { - addRect( - androidx.compose.ui.geometry.Rect( - left = 0f, - top = yVisibleBottomLocal, - right = canvasSize.width.toFloat(), - bottom = canvasSize.height.toFloat() - ) - ) - } - val xCanvasLeft = canvasPositionInWindow.x - exclusionRects.forEach { rect -> - val localLeft = (rect.left - xCanvasLeft).coerceAtLeast(0f) - val localTop = (rect.top - yCanvasTop).coerceAtLeast(0f) - val localRight = - (rect.right - xCanvasLeft).coerceAtMost(canvasSize.width.toFloat()) - val localBottom = - (rect.bottom - yCanvasTop).coerceAtMost(canvasSize.height.toFloat()) - - if (localLeft < localRight && localTop < localBottom) { - addRect( - androidx.compose.ui.geometry.Rect( - left = localLeft, - top = localTop, - right = localRight, - bottom = localBottom - ) - ) - } - } - } - } + createMaskPath( + containerPositionInWindow, + containerSize, + canvasPositionInWindow, + canvasSize, + exclusionRects + ) } ClippedCanvasContainer( @@ -672,7 +629,7 @@ fun CollapsiblePreviewPane( } @Composable -fun AdaptivePreviewControlsLayout( +private fun AdaptivePreviewControlsLayout( badgeMinWidth: Dp, modifier: Modifier = Modifier, content: @Composable () -> Unit, @@ -720,3 +677,62 @@ fun AdaptivePreviewControlsLayout( } } } + +private fun createMaskPath( + containerPositionInWindow: Offset, + containerSize: IntSize, + canvasPositionInWindow: Offset, + canvasSize: IntSize, + exclusionRects: List, +): Path? { + if (containerSize == IntSize.Zero || canvasSize == IntSize.Zero) return null + + val yContainerTop = containerPositionInWindow.y + val yCanvasTop = canvasPositionInWindow.y + val yContainerBottom = yContainerTop + containerSize.height + + val yVisibleTopLocal = (yContainerTop - yCanvasTop).coerceAtLeast(0f) + val yVisibleBottomLocal = + (yContainerBottom - yCanvasTop).coerceAtMost(canvasSize.height.toFloat()) + + return Path().apply { + if (yVisibleTopLocal > 0f) { + addRect( + Rect( + left = 0f, + top = 0f, + right = canvasSize.width.toFloat(), + bottom = yVisibleTopLocal + ) + ) + } + if (yVisibleBottomLocal < canvasSize.height.toFloat()) { + addRect( + Rect( + left = 0f, + top = yVisibleBottomLocal, + right = canvasSize.width.toFloat(), + bottom = canvasSize.height.toFloat() + ) + ) + } + val xCanvasLeft = canvasPositionInWindow.x + exclusionRects.forEach { rect -> + val localLeft = (rect.left - xCanvasLeft).coerceAtLeast(0f) + val localTop = (rect.top - yCanvasTop).coerceAtLeast(0f) + val localRight = (rect.right - xCanvasLeft).coerceAtMost(canvasSize.width.toFloat()) + val localBottom = (rect.bottom - yCanvasTop).coerceAtMost(canvasSize.height.toFloat()) + + if (localLeft < localRight && localTop < localBottom) { + addRect( + Rect( + left = localLeft, + top = localTop, + right = localRight, + bottom = localBottom + ) + ) + } + } + } +} diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/TutorialOverlayHost.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/TutorialOverlayHost.kt index ff9a1c3b..b6e9b247 100644 --- a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/TutorialOverlayHost.kt +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/TutorialOverlayHost.kt @@ -125,10 +125,10 @@ fun BoxScope.TutorialOverlayHost( Modifier .align(Alignment.BottomCenter) .padding( - bottom = (maxOf( - INSPECTOR_HEIGHT_PORTRAIT.toFloat(), - basePadding.value - ) + 16).dp + bottom = maxOf( + INSPECTOR_HEIGHT_PORTRAIT.dp, + basePadding + ) + 16.dp ) } else { Modifier From 14c2f5c73798b9ad2dc71d0ca8594d25ea5ac151 Mon Sep 17 00:00:00 2001 From: Maxwell Metzger Mitchell <60010512+maxmmitchell@users.noreply.github.com> Date: Wed, 1 Jul 2026 15:08:31 +0000 Subject: [PATCH 5/7] Cleanup --- .../example/cahier/core/ui/DrawingSurface.kt | 3 +- .../brushgraph/ui/BrushGraphContent.kt | 4 +-- .../brushgraph/ui/BrushGraphMenus.kt | 1 - .../brushgraph/ui/BrushGraphScreen.kt | 29 ++++++++----------- .../brushgraph/ui/GraphCameraController.kt | 5 ++-- .../developer/brushgraph/ui/TestCanvas.kt | 14 ++++----- .../brushgraph/ui/TutorialOverlayHost.kt | 5 ++-- 7 files changed, 27 insertions(+), 34 deletions(-) diff --git a/app/src/main/java/com/example/cahier/core/ui/DrawingSurface.kt b/app/src/main/java/com/example/cahier/core/ui/DrawingSurface.kt index 234ad466..3063a16e 100644 --- a/app/src/main/java/com/example/cahier/core/ui/DrawingSurface.kt +++ b/app/src/main/java/com/example/cahier/core/ui/DrawingSurface.kt @@ -35,6 +35,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.toRect import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.nativeCanvas import androidx.compose.ui.graphics.withSaveLayer import androidx.compose.ui.input.pointer.pointerInput @@ -65,7 +66,7 @@ fun DrawingSurface( backgroundImageUri: String?, onStartDrag: () -> Unit, modifier: Modifier = Modifier, - maskPath: androidx.compose.ui.graphics.Path? = null, + maskPath: Path? = null, ) { val textureStore = LocalTextureStore.current Box(modifier = modifier) { diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/BrushGraphContent.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/BrushGraphContent.kt index dc254bce..ced6292c 100644 --- a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/BrushGraphContent.kt +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/BrushGraphContent.kt @@ -47,7 +47,7 @@ fun BrushGraphContent( previewSlot: @Composable () -> Unit, menuSlot: @Composable () -> Unit, fabSlot: @Composable (viewportSize: Size) -> Unit, - tutorialSlot: @Composable (viewportSize: Size) -> Unit, + tutorialSlot: @Composable () -> Unit, dialogSlot: @Composable () -> Unit, modifier: Modifier = Modifier, ) { @@ -90,7 +90,7 @@ fun BrushGraphContent( previewSlot() menuSlot() fabSlot(viewportSize) - tutorialSlot(viewportSize) + tutorialSlot() } } } diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/BrushGraphMenus.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/BrushGraphMenus.kt index cce89649..cc1705c8 100644 --- a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/BrushGraphMenus.kt +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/BrushGraphMenus.kt @@ -307,7 +307,6 @@ fun CreateNodeSpeedDial( isWideScreen: Boolean, isAnySidePaneOpen: Boolean, previewHeight: Dp, - viewportSize: androidx.compose.ui.geometry.Size, modifier: Modifier = Modifier, menuContent: @Composable (onClose: () -> Unit) -> Unit, ) { diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/BrushGraphScreen.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/BrushGraphScreen.kt index ba37bf2e..a5fbeb12 100644 --- a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/BrushGraphScreen.kt +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/BrushGraphScreen.kt @@ -24,7 +24,6 @@ import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.VectorConverter -import androidx.compose.animation.core.animateDpAsState import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.fillMaxSize @@ -51,6 +50,8 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.positionInWindow @@ -107,7 +108,7 @@ fun BrushGraphScreen( val onSurfaceColor = MaterialTheme.colorScheme.onSurface LaunchedEffect(primaryColor) { // Only null when we first open the screen, but on rotations this runs again and - // testBrushColor will not be null and we don't want to override it. + // testBrushColor will not be null, and we don't want to override it. if (uiState.testBrushColor == null) viewModel.updateTestBrushColor(primaryColor) } @@ -124,8 +125,8 @@ fun BrushGraphScreen( ) } - var inspectorBounds by remember { mutableStateOf(null) } - var notificationPaneBounds by remember { mutableStateOf(null) } + var inspectorBounds by remember { mutableStateOf(null) } + var notificationPaneBounds by remember { mutableStateOf(null) } val exclusionRects = remember(inspectorBounds, notificationPaneBounds) { listOfNotNull(inspectorBounds, notificationPaneBounds) @@ -183,7 +184,7 @@ fun BrushGraphScreen( stream, maxVersion = Version.DEVELOPMENT ) { id: String, bitmap: Bitmap? -> - bitmap?.let { viewModel.loadTexture(id, it) } + bitmap?.let { bitmap -> viewModel.loadTexture(id, bitmap) } id } } catch (e: Exception) { @@ -203,7 +204,7 @@ fun BrushGraphScreen( viewModel.postDebug(DisplayText.Resource(R.string.bg_msg_brush_loaded_success)) } } catch (e: Exception) { - android.util.Log.e("BrushGraphWidget", "Failed to load brush", e) + Log.e("BrushGraphWidget", "Failed to load brush", e) viewModel.postDebug( DisplayText.Resource( R.string.bg_err_load_brush_failed_with_msg, @@ -229,7 +230,7 @@ fun BrushGraphScreen( } viewModel.postDebug(DisplayText.Resource(R.string.bg_msg_brush_exported_success)) } catch (e: Exception) { - android.util.Log.e("BrushGraphWidget", "Failed to export brush", e) + Log.e("BrushGraphWidget", "Failed to export brush", e) viewModel.postDebug( DisplayText.Resource( R.string.bg_err_export_brush_failed_with_msg, @@ -261,15 +262,11 @@ fun BrushGraphScreen( CahierAppTheme { BoxWithConstraints(modifier = modifier.fillMaxSize()) { - var viewportSize by remember { mutableStateOf(androidx.compose.ui.geometry.Size.Zero) } + var viewportSize by remember { mutableStateOf(Size.Zero) } var showTutorialFinishDialog by remember { mutableStateOf(false) } val isSidePaneOpen = isWideScreen && (uiState.selectedNodeId != null || uiState.isErrorPaneOpen) - val indicatorPaddingEnd by animateDpAsState( - targetValue = if (isSidePaneOpen) (INSPECTOR_WIDTH_LANDSCAPE + 16).dp else 16.dp, - label = "indicatorPaddingEnd", - ) var previewExpandedHeightDp by remember { mutableStateOf(200.dp) } var isDraggingPreview by remember { mutableStateOf(false) } val currentPreviewHeightDp = if (uiState.isPreviewExpanded) { @@ -291,7 +288,6 @@ fun BrushGraphScreen( val isNodeSelected = uiState.selectedNodeId != null val isEdgeSelected = uiState.selectedEdge != null val isErrorPaneOpen = uiState.isErrorPaneOpen - val isAnySidePaneOpen = isNodeSelected || isEdgeSelected || isErrorPaneOpen val nodeRegistry = remember { NodeRegistry() } val issues = uiState.graphIssues @@ -436,7 +432,7 @@ fun BrushGraphScreen( selectedNode != null || selectedEdge != null inspectorBounds = if (isInspectorOpen && coordinates.size.width > 0 && coordinates.size.height > 0) { - androidx.compose.ui.geometry.Rect( + Rect( coordinates.positionInWindow(), coordinates.size.toSize() ) @@ -532,7 +528,7 @@ fun BrushGraphScreen( .onGloballyPositioned { coordinates: androidx.compose.ui.layout.LayoutCoordinates -> notificationPaneBounds = if (uiState.isErrorPaneOpen && coordinates.size.width > 0 && coordinates.size.height > 0) { - androidx.compose.ui.geometry.Rect( + Rect( coordinates.positionInWindow(), coordinates.size.toSize() ) @@ -672,7 +668,6 @@ fun BrushGraphScreen( isWideScreen = isWideScreen, isAnySidePaneOpen = isAnySidePaneOpen, previewHeight = animatedPreviewHeight.value, - viewportSize = vSize, modifier = Modifier.align(Alignment.BottomEnd), menuContent = { onClose -> data class SpeedDialAction( @@ -737,7 +732,7 @@ fun BrushGraphScreen( } ) }, - tutorialSlot = { vSize -> + tutorialSlot = { TutorialOverlayHost( tutorialStep = viewModel.tutorialStep, graph = uiState.graph, diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/GraphCameraController.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/GraphCameraController.kt index 405d6d5f..7f391942 100644 --- a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/GraphCameraController.kt +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/GraphCameraController.kt @@ -54,9 +54,8 @@ fun GraphCameraController( // Auto-pan to node in tutorial LaunchedEffect(tutorialStep) { - val step = tutorialStep - if (step != null && step.anchor == TutorialAnchor.NODE_CANVAS) { - val node = step.getTargetNode(graph) + if (tutorialStep != null && tutorialStep.anchor == TutorialAnchor.NODE_CANVAS) { + val node = tutorialStep.getTargetNode(graph) if (node != null) { val density = context.resources.displayMetrics.density val targetY = TUTORIAL_TARGET_Y * density diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/TestCanvas.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/TestCanvas.kt index b2639042..23a15f40 100644 --- a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/TestCanvas.kt +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/TestCanvas.kt @@ -103,7 +103,7 @@ private fun TestCanvas( strokeRenderer: CanvasStrokeRenderer, brush: Brush, modifier: Modifier = Modifier, - maskPath: androidx.compose.ui.graphics.Path? = null, + maskPath: Path? = null, onGetNextBrush: () -> Brush, onStrokesAdded: (List) -> Unit, ) { @@ -183,7 +183,6 @@ fun CollapsiblePreviewPane( brushColor: Color, brushSize: Float, brush: Brush, - exclusionRects: List = emptyList(), strokeList: List, strokeRenderer: CanvasStrokeRenderer, topIssue: GraphValidationException?, @@ -191,6 +190,7 @@ fun CollapsiblePreviewPane( onDragStateChanged: (Boolean) -> Unit, onHeightDrag: (Dp) -> Unit, modifier: Modifier = Modifier, + exclusionRects: List = emptyList(), onGetNextBrush: () -> Brush, onTogglePreviewExpanded: () -> Unit, onClearStrokes: () -> Unit, @@ -202,7 +202,7 @@ fun CollapsiblePreviewPane( onChooseColor: (Color, (Color) -> Unit) -> Unit, onToggleNotificationPane: () -> Unit, ) { - val density = androidx.compose.ui.platform.LocalDensity.current + val density = LocalDensity.current val onHeightDragState = rememberUpdatedState(onHeightDrag) val currentHeightState = rememberUpdatedState(currentHeight) var isDragging by remember { mutableStateOf(false) } @@ -572,10 +572,10 @@ fun CollapsiblePreviewPane( }, ) { val contentHeight = (currentHeight - 40.dp).coerceAtLeast(0.dp) - var containerPositionInWindow by remember { mutableStateOf(androidx.compose.ui.geometry.Offset.Zero) } - var containerSize by remember { mutableStateOf(androidx.compose.ui.unit.IntSize.Zero) } - var canvasPositionInWindow by remember { mutableStateOf(androidx.compose.ui.geometry.Offset.Zero) } - var canvasSize by remember { mutableStateOf(androidx.compose.ui.unit.IntSize.Zero) } + var containerPositionInWindow by remember { mutableStateOf(Offset.Zero) } + var containerSize by remember { mutableStateOf(IntSize.Zero) } + var canvasPositionInWindow by remember { mutableStateOf(Offset.Zero) } + var canvasSize by remember { mutableStateOf(IntSize.Zero) } val maskPath = remember( containerPositionInWindow, diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/TutorialOverlayHost.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/TutorialOverlayHost.kt index b6e9b247..f55634da 100644 --- a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/TutorialOverlayHost.kt +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/TutorialOverlayHost.kt @@ -120,20 +120,19 @@ fun BoxScope.TutorialOverlayHost( } TutorialAnchor.TEST_CANVAS -> { - val basePadding = previewHeight if (isInspectorOpen && !isWideScreen) { Modifier .align(Alignment.BottomCenter) .padding( bottom = maxOf( INSPECTOR_HEIGHT_PORTRAIT.dp, - basePadding + previewHeight ) + 16.dp ) } else { Modifier .align(Alignment.BottomCenter) - .padding(bottom = basePadding + 16.dp) + .padding(bottom = previewHeight + 16.dp) } } From 1414ef143be18e4e56a9dadf6c5a1c8ea5f23a14 Mon Sep 17 00:00:00 2001 From: Maxwell Metzger Mitchell <60010512+maxmmitchell@users.noreply.github.com> Date: Mon, 6 Jul 2026 17:31:38 +0000 Subject: [PATCH 6/7] Cleanup arch --- .../brushgraph/ui/BrushGraphScreen.kt | 1 - .../brushgraph/ui/GraphCameraController.kt | 6 +- .../developer/brushgraph/ui/TestCanvas.kt | 87 +++++-------------- 3 files changed, 24 insertions(+), 70 deletions(-) diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/BrushGraphScreen.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/BrushGraphScreen.kt index a5fbeb12..4d0a8f91 100644 --- a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/BrushGraphScreen.kt +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/BrushGraphScreen.kt @@ -776,7 +776,6 @@ fun BrushGraphScreen( selectedNodeId = uiState.selectedNodeId, updateOffset = { viewModel.updateOffset(GraphPoint(it.x, it.y)) }, viewportSize = viewportSize, - context = context, isWideScreen = isWideScreen, maxWidthDp = maxWidth, nodeRegistry = nodeRegistry diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/GraphCameraController.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/GraphCameraController.kt index 7f391942..d3f7d40b 100644 --- a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/GraphCameraController.kt +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/GraphCameraController.kt @@ -16,7 +16,6 @@ package com.example.cahier.developer.brushgraph.ui -import android.content.Context import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.VectorConverter import androidx.compose.animation.core.tween @@ -25,6 +24,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Dp import com.example.cahier.developer.brushgraph.data.BrushGraph import com.example.cahier.developer.brushgraph.data.GraphNode @@ -45,19 +45,18 @@ fun GraphCameraController( selectedNodeId: String?, updateOffset: (Offset) -> Unit, viewportSize: Size, - context: Context, isWideScreen: Boolean, maxWidthDp: Dp, nodeRegistry: NodeRegistry, ) { val animatableOffset = remember { Animatable(offset, Offset.VectorConverter) } + val density = LocalDensity.current.density // Auto-pan to node in tutorial LaunchedEffect(tutorialStep) { if (tutorialStep != null && tutorialStep.anchor == TutorialAnchor.NODE_CANVAS) { val node = tutorialStep.getTargetNode(graph) if (node != null) { - val density = context.resources.displayMetrics.density val targetY = TUTORIAL_TARGET_Y * density val targetX = maxWidthDp.value * density / 2f @@ -82,7 +81,6 @@ fun GraphCameraController( selectedNodeId?.let { nodeId -> val node = graph.nodes.find { it.id == nodeId } if (node != null) { - val density = context.resources.displayMetrics.density val newOffset = calculateFocusOffset( node = node, position = nodeRegistry.getNodePosition(node.id) ?: Offset.Zero, diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/TestCanvas.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/TestCanvas.kt index 23a15f40..116913ee 100644 --- a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/TestCanvas.kt +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/TestCanvas.kt @@ -16,8 +16,6 @@ package com.example.cahier.developer.brushgraph.ui -import android.view.ViewGroup -import android.widget.FrameLayout import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable @@ -62,6 +60,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.Color @@ -72,7 +71,6 @@ import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.Layout import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.positionInWindow -import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -81,7 +79,6 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp -import androidx.compose.ui.viewinterop.AndroidView import androidx.ink.brush.Brush import androidx.ink.rendering.android.canvas.CanvasStrokeRenderer import androidx.ink.strokes.Stroke @@ -137,42 +134,6 @@ private fun TestCanvas( } } -@Composable -private fun ClippedCanvasContainer( - currentHeight: Dp, - modifier: Modifier = Modifier, - content: @Composable () -> Unit, -) { - val density = LocalDensity.current - AndroidView( - factory = { context -> - val frameLayout = FrameLayout(context).apply { - clipChildren = true - } - val composeView = ComposeView(context).apply { - setContent { - content() - } - } - frameLayout.addView( - composeView, - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT - ) - frameLayout - }, - update = { frameLayout -> - val heightPx = with(density) { currentHeight.roundToPx() } - val lp = frameLayout.layoutParams - ?: ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, heightPx) - if (lp.height != heightPx) { - lp.height = heightPx - frameLayout.layoutParams = lp - } - }, - modifier = modifier - ) -} @Composable @OptIn(ExperimentalMaterial3Api::class) @@ -423,7 +384,7 @@ fun CollapsiblePreviewPane( text = "${brushSize.toInt()}px", modifier = Modifier .menuAnchor( - ExposedDropdownMenuAnchorType.PrimaryNotEditable, + type = ExposedDropdownMenuAnchorType.PrimaryNotEditable, enabled = true ) .clickable { sizeExpanded = true }, @@ -593,36 +554,32 @@ fun CollapsiblePreviewPane( ) } - ClippedCanvasContainer( - currentHeight = contentHeight, + Box( modifier = Modifier .fillMaxSize() + .clipToBounds() .onGloballyPositioned { coordinates -> containerPositionInWindow = coordinates.positionInWindow() containerSize = coordinates.size - } + }, + contentAlignment = Alignment.BottomCenter ) { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.BottomCenter - ) { - TestCanvas( - strokeList = strokeList, - strokeRenderer = strokeRenderer, - brush = brush, - isInvertedCanvas = isInvertedCanvas, - maskPath = maskPath, - onGetNextBrush = onGetNextBrush, - onStrokesAdded = onStrokesAdded, - modifier = Modifier - .fillMaxWidth() - .requiredHeight(500.dp) - .onGloballyPositioned { coordinates -> - canvasPositionInWindow = coordinates.positionInWindow() - canvasSize = coordinates.size - } - ) - } + TestCanvas( + strokeList = strokeList, + strokeRenderer = strokeRenderer, + brush = brush, + isInvertedCanvas = isInvertedCanvas, + maskPath = maskPath, + onGetNextBrush = onGetNextBrush, + onStrokesAdded = onStrokesAdded, + modifier = Modifier + .fillMaxWidth() + .requiredHeight(500.dp) + .onGloballyPositioned { coordinates -> + canvasPositionInWindow = coordinates.positionInWindow() + canvasSize = coordinates.size + } + ) } } } From 70f5545b8b709d3c23d926719abbe4c83379e61b Mon Sep 17 00:00:00 2001 From: Maxwell Metzger Mitchell <60010512+maxmmitchell@users.noreply.github.com> Date: Mon, 6 Jul 2026 19:39:07 +0000 Subject: [PATCH 7/7] Comments from Gemini --- .../brushgraph/ui/BrushGraphScreen.kt | 12 ++-- .../developer/brushgraph/ui/TestCanvas.kt | 66 +++++++++---------- 2 files changed, 37 insertions(+), 41 deletions(-) diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/BrushGraphScreen.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/BrushGraphScreen.kt index 4d0a8f91..f0174c60 100644 --- a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/BrushGraphScreen.kt +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/BrushGraphScreen.kt @@ -268,7 +268,6 @@ fun BrushGraphScreen( val isSidePaneOpen = isWideScreen && (uiState.selectedNodeId != null || uiState.isErrorPaneOpen) var previewExpandedHeightDp by remember { mutableStateOf(200.dp) } - var isDraggingPreview by remember { mutableStateOf(false) } val currentPreviewHeightDp = if (uiState.isPreviewExpanded) { previewExpandedHeightDp } else { @@ -276,12 +275,10 @@ fun BrushGraphScreen( } val animatedPreviewHeight = remember { Animatable(currentPreviewHeightDp, Dp.VectorConverter) } - LaunchedEffect(uiState.isPreviewExpanded, previewExpandedHeightDp) { - if (!isDraggingPreview) { - val target = - if (uiState.isPreviewExpanded) previewExpandedHeightDp else PREVIEW_HEIGHT_COLLAPSED.dp - animatedPreviewHeight.animateTo(target) - } + LaunchedEffect(uiState.isPreviewExpanded) { + val target = + if (uiState.isPreviewExpanded) previewExpandedHeightDp else PREVIEW_HEIGHT_COLLAPSED.dp + animatedPreviewHeight.animateTo(target) } @@ -567,7 +564,6 @@ fun BrushGraphScreen( strokeRenderer = renderer, topIssue = topIssue, currentHeight = animatedPreviewHeight.value, - onDragStateChanged = { isDraggingPreview = it }, onHeightDrag = { newHeight -> previewExpandedHeightDp = newHeight scope.launch { diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/TestCanvas.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/TestCanvas.kt index 116913ee..da0151e7 100644 --- a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/TestCanvas.kt +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/TestCanvas.kt @@ -148,7 +148,6 @@ fun CollapsiblePreviewPane( strokeRenderer: CanvasStrokeRenderer, topIssue: GraphValidationException?, currentHeight: Dp, - onDragStateChanged: (Boolean) -> Unit, onHeightDrag: (Dp) -> Unit, modifier: Modifier = Modifier, exclusionRects: List = emptyList(), @@ -184,15 +183,12 @@ fun CollapsiblePreviewPane( onDragStart = { isDragging = true dragHeightState.value = currentHeightState.value - onDragStateChanged(true) }, onDragEnd = { isDragging = false - onDragStateChanged(false) }, onDragCancel = { isDragging = false - onDragStateChanged(false) }, onDrag = { change, dragAmount -> change.consume() @@ -296,6 +292,15 @@ fun CollapsiblePreviewPane( // Color picker var colorMenuExpanded by remember { mutableStateOf(false) } + val colors = remember { + listOf( + R.string.brush_designer_color_black to BrushBlack, + R.string.brush_designer_color_red to BrushRed, + R.string.brush_designer_color_blue to BrushBlue, + R.string.brush_designer_color_green to BrushGreen, + R.string.brush_designer_color_yellow to BrushYellow + ) + } Box { val iconTint = if (brushColor.luminance() < 0.5f) Color.White else Color.Black @@ -325,15 +330,6 @@ fun CollapsiblePreviewPane( expanded = colorMenuExpanded, onDismissRequest = { colorMenuExpanded = false } ) { - val colors = remember { - listOf( - R.string.brush_designer_color_black to BrushBlack, - R.string.brush_designer_color_red to BrushRed, - R.string.brush_designer_color_blue to BrushBlue, - R.string.brush_designer_color_green to BrushGreen, - R.string.brush_designer_color_yellow to BrushYellow - ) - } colors.forEach { (nameRes, color) -> val name = stringResource(nameRes) DropdownMenuItem( @@ -532,7 +528,6 @@ fun CollapsiblePreviewPane( MaterialTheme.colorScheme.surface }, ) { - val contentHeight = (currentHeight - 40.dp).coerceAtLeast(0.dp) var containerPositionInWindow by remember { mutableStateOf(Offset.Zero) } var containerSize by remember { mutableStateOf(IntSize.Zero) } var canvasPositionInWindow by remember { mutableStateOf(Offset.Zero) } @@ -652,8 +647,28 @@ private fun createMaskPath( val yVisibleBottomLocal = (yContainerBottom - yCanvasTop).coerceAtMost(canvasSize.height.toFloat()) + val hasTopMask = yVisibleTopLocal > 0f + val hasBottomMask = yVisibleBottomLocal < canvasSize.height.toFloat() + + val xCanvasLeft = canvasPositionInWindow.x + val activeExclusionRects = exclusionRects.mapNotNull { rect -> + val localLeft = (rect.left - xCanvasLeft).coerceAtLeast(0f) + val localTop = (rect.top - yCanvasTop).coerceAtLeast(0f) + val localRight = (rect.right - xCanvasLeft).coerceAtMost(canvasSize.width.toFloat()) + val localBottom = (rect.bottom - yCanvasTop).coerceAtMost(canvasSize.height.toFloat()) + if (localLeft < localRight && localTop < localBottom) { + Rect(localLeft, localTop, localRight, localBottom) + } else { + null + } + } + + if (!hasTopMask && !hasBottomMask && activeExclusionRects.isEmpty()) { + return null + } + return Path().apply { - if (yVisibleTopLocal > 0f) { + if (hasTopMask) { addRect( Rect( left = 0f, @@ -663,7 +678,7 @@ private fun createMaskPath( ) ) } - if (yVisibleBottomLocal < canvasSize.height.toFloat()) { + if (hasBottomMask) { addRect( Rect( left = 0f, @@ -673,23 +688,8 @@ private fun createMaskPath( ) ) } - val xCanvasLeft = canvasPositionInWindow.x - exclusionRects.forEach { rect -> - val localLeft = (rect.left - xCanvasLeft).coerceAtLeast(0f) - val localTop = (rect.top - yCanvasTop).coerceAtLeast(0f) - val localRight = (rect.right - xCanvasLeft).coerceAtMost(canvasSize.width.toFloat()) - val localBottom = (rect.bottom - yCanvasTop).coerceAtMost(canvasSize.height.toFloat()) - - if (localLeft < localRight && localTop < localBottom) { - addRect( - Rect( - left = localLeft, - top = localTop, - right = localRight, - bottom = localBottom - ) - ) - } + activeExclusionRects.forEach { localRect -> + addRect(localRect) } } }