From e543ec286a59b535ffe9c27d7c9c0ecdcbdf1511 Mon Sep 17 00:00:00 2001 From: Maxwell Metzger Mitchell <60010512+maxmmitchell@users.noreply.github.com> Date: Mon, 27 Apr 2026 06:53:41 +0000 Subject: [PATCH 1/4] Add Graph Canvas and core UI components --- .../developer/brushgraph/ui/GeometryUtils.kt | 72 +++ .../brushgraph/ui/GraphCameraController.kt | 136 +++++ .../developer/brushgraph/ui/GraphCanvas.kt | 571 ++++++++++++++++++ .../developer/brushgraph/ui/Inspector.kt | 348 +++++++++++ .../developer/brushgraph/ui/TestCanvas.kt | 258 ++++++++ 5 files changed, 1385 insertions(+) create mode 100644 app/src/main/java/com/example/cahier/developer/brushgraph/ui/GeometryUtils.kt create mode 100644 app/src/main/java/com/example/cahier/developer/brushgraph/ui/GraphCameraController.kt create mode 100644 app/src/main/java/com/example/cahier/developer/brushgraph/ui/GraphCanvas.kt create mode 100644 app/src/main/java/com/example/cahier/developer/brushgraph/ui/Inspector.kt create mode 100644 app/src/main/java/com/example/cahier/developer/brushgraph/ui/TestCanvas.kt diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/GeometryUtils.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/GeometryUtils.kt new file mode 100644 index 0000000..43019aa --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/GeometryUtils.kt @@ -0,0 +1,72 @@ +/* + * * Copyright 2026 Google LLC. All rights reserved. + * * + * * Licensed under the Apache License, Version 2.0 (the "License"); + * * you may not use this file except in compliance with the License. + * * You may obtain a copy of the License at + * * + * * http://www.apache.org/licenses/LICENSE-2.0 + * * + * * Unless required by applicable law or agreed to in writing, software + * * distributed under the License is distributed on an "AS IS" BASIS, + * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * See the License for the specific language governing permissions and + * * limitations under the License. + */ +package com.example.cahier.developer.brushgraph.ui + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Path +import kotlin.math.max +import kotlin.math.min +import kotlin.math.abs + +internal const val SPLINE_HIT_SEGMENTS = 50 + +/** Calculates the shortest distance from point [p] to the line segment from [a] to [b] using vector projection. */ +internal fun distanceToSegment(p: Offset, a: Offset, b: Offset): Float { + val l2 = (b - a).getDistanceSquared() + if (l2 == 0f) return (p - a).getDistance() + var t = ((p.x - a.x) * (b.x - a.x) + (p.y - a.y) * (b.y - a.y)) / l2 + t = max(0f, min(1f, t)) + return (p - (a + (b - a) * t)).getDistance() +} + +/** Creates a cubic Bezier curve [Path] between [start] and [end] with horizontal control points for an S-curve. */ +internal fun createSplinePath(start: Offset, end: Offset): Path { + val horizontalOffset = maxOf(50f, abs(end.x - start.x) / 2f).coerceAtMost(200f) + return Path().apply { + moveTo(start.x, start.y) + cubicTo(start.x + horizontalOffset, start.y, end.x - horizontalOffset, end.y, end.x, end.y) + } +} + +/** Approximates the shortest distance from point [p] to the spline by dividing it into [SPLINE_HIT_SEGMENTS] linear segments. */ +internal fun distanceToSpline(p: Offset, start: Offset, end: Offset): Float { + val horizontalOffset = maxOf(50f, abs(end.x - start.x) / 2f).coerceAtMost(200f) + val cp1 = Offset(start.x + horizontalOffset, start.y) + val cp2 = Offset(end.x - horizontalOffset, end.y) + + var minDistance = Float.MAX_VALUE + var prevPoint = start + for (i in 1..SPLINE_HIT_SEGMENTS) { + val t = i.toFloat() / SPLINE_HIT_SEGMENTS + val currentPoint = cubicBezier(t, start, cp1, cp2, end) + minDistance = min(minDistance, distanceToSegment(p, prevPoint, currentPoint)) + prevPoint = currentPoint + } + return minDistance +} + +/** Evaluates a point on a cubic Bezier curve at time [t] (0.0 to 1.0) using the standard polynomial formula. */ +internal fun cubicBezier(t: Float, p0: Offset, p1: Offset, p2: Offset, p3: Offset): Offset { + val u = 1 - t + val tt = t * t + val uu = u * u + val uuu = uu * u + val ttt = tt * t + + val x = uuu * p0.x + 3 * uu * t * p1.x + 3 * u * tt * p2.x + ttt * p3.x + val y = uuu * p0.y + 3 * uu * t * p1.y + 3 * u * tt * p2.y + ttt * p3.y + return Offset(x, y) +} 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 new file mode 100644 index 0000000..3dd140b --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/GraphCameraController.kt @@ -0,0 +1,136 @@ +/* + * * Copyright 2026 Google LLC. All rights reserved. + * * + * * Licensed under the Apache License, Version 2.0 (the "License"); + * * you may not use this file except in compliance with the License. + * * You may obtain a copy of the License at + * * + * * http://www.apache.org/licenses/LICENSE-2.0 + * * + * * Unless required by applicable law or agreed to in writing, software + * * distributed under the License is distributed on an "AS IS" BASIS, + * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * See the License for the specific language governing permissions and + * * limitations under the License. + */ +@file:OptIn(androidx.ink.brush.ExperimentalInkCustomBrushApi::class) + +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 +import androidx.compose.runtime.Composable +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.unit.Dp +import com.example.cahier.developer.brushgraph.data.BrushGraph +import com.example.cahier.developer.brushgraph.data.GraphNode +import com.example.cahier.developer.brushgraph.data.TutorialAnchor +import com.example.cahier.developer.brushgraph.data.TutorialStep +import com.example.cahier.developer.brushgraph.ui.node.NodeRegistry + +@Composable +fun GraphCameraController( + offset: Offset, + tutorialStep: TutorialStep?, + focusTrigger: Int, + graph: BrushGraph, + zoom: Float, + isPreviewExpanded: Boolean, + selectedNodeId: String?, + updateOffset: (Offset) -> Unit, + viewportSize: Size, + context: Context, + isLandscape: Boolean, + maxWidthDp: Dp, + nodeRegistry: NodeRegistry +) { + val animatableOffset = remember { Animatable(offset, Offset.VectorConverter) } + + // 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 (node != null) { + val density = context.resources.displayMetrics.density + val targetY = 280f * density + val targetX = maxWidthDp.value * density / 2f + + val newOffset = calculateFocusOffset( + node = node, + position = nodeRegistry.getNodePosition(node.id) ?: Offset.Zero, + zoom = zoom, + targetScreenPos = Offset(targetX, targetY) + ) + + animatableOffset.snapTo(offset) + animatableOffset.animateTo(newOffset, animationSpec = tween(500)) { + updateOffset(this.value) + } + } + } + } + + // Listen for ViewModel events (e.g. center on node) + LaunchedEffect(focusTrigger) { + if (focusTrigger > 0) { + 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, + zoom = zoom, + viewportSize = viewportSize, + density = density, + isLandscape = isLandscape, + isPreviewExpanded = isPreviewExpanded + ) + animatableOffset.snapTo(offset) + animatableOffset.animateTo(newOffset, animationSpec = tween(500)) { + updateOffset(this.value) + } + } + } + } + } +} + +private fun calculateFocusOffset( + node: GraphNode, + position: Offset, + zoom: Float, + viewportSize: Size = Size.Zero, + density: Float = 1f, + isLandscape: Boolean = false, + isPreviewExpanded: Boolean = false, + targetScreenPos: Offset? = null +): Offset { + val nodeCenterX = position.x + node.data.width() / 2f + val nodeCenterY = position.y + node.data.height() / 2f + + val targetPos = if (targetScreenPos != null) { + Pair(targetScreenPos.x, targetScreenPos.y) + } else { + val previewHeightPx = (if (isPreviewExpanded) PREVIEW_HEIGHT_EXPANDED else PREVIEW_HEIGHT_COLLAPSED) * density + val safeSize = if (isLandscape) { + val inspectorWidthPx = INSPECTOR_WIDTH_LANDSCAPE * density + Pair(viewportSize.width - inspectorWidthPx, viewportSize.height - previewHeightPx) + } else { + val inspectorHeightPx = INSPECTOR_HEIGHT_PORTRAIT * density + Pair(viewportSize.width, viewportSize.height - maxOf(inspectorHeightPx, previewHeightPx)) + } + Pair(safeSize.first / 2f, safeSize.second / 2f) + } + + val targetX = targetPos.first + val targetY = targetPos.second + + return Offset(targetX - nodeCenterX * zoom, targetY - nodeCenterY * zoom) +} diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/GraphCanvas.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/GraphCanvas.kt new file mode 100644 index 0000000..ddfbefb --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/GraphCanvas.kt @@ -0,0 +1,571 @@ +/* + * * Copyright 2026 Google LLC. All rights reserved. + * * + * * Licensed under the Apache License, Version 2.0 (the "License"); + * * you may not use this file except in compliance with the License. + * * You may obtain a copy of the License at + * * + * * http://www.apache.org/licenses/LICENSE-2.0 + * * + * * Unless required by applicable law or agreed to in writing, software + * * distributed under the License is distributed on an "AS IS" BASIS, + * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * See the License for the specific language governing permissions and + * * limitations under the License. + */ +@file:OptIn(androidx.ink.brush.ExperimentalInkCustomBrushApi::class) + +package com.example.cahier.developer.brushgraph.ui + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.gestures.detectTransformGestures +import androidx.compose.foundation.gestures.drag +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +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.graphics.Color +import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.graphics.drawscope.Stroke as DrawStroke +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.PointerInputChange +import androidx.compose.ui.input.pointer.changedToUpIgnoreConsumed +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.input.pointer.positionChange +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import androidx.ink.brush.Brush +import androidx.ink.rendering.android.canvas.CanvasStrokeRenderer +import androidx.ink.brush.TextureBitmapStore +import com.example.cahier.R +import com.example.cahier.developer.brushgraph.ui.node.NodeRegistry +import com.example.cahier.developer.brushgraph.ui.node.NodeWidget +import com.example.cahier.developer.brushgraph.data.BrushGraph +import com.example.cahier.developer.brushgraph.data.GraphEdge +import com.example.cahier.developer.brushgraph.data.GraphNode +import com.example.cahier.developer.brushgraph.data.NodeData +import com.example.cahier.developer.brushgraph.data.Port +import com.example.cahier.developer.brushgraph.data.PortSide +import kotlin.math.roundToInt + +/** + * A composable that renders an infinite canvas for the node graph. Handles panning, zooming, and + * node interaction. + */ +@Composable +fun GraphCanvas( + graph: BrushGraph, + zoom: Float, + offset: Offset, + onZoomChange: (Float) -> Unit, + onOffsetChange: (Offset) -> Unit, + onNodeClick: (String, Offset) -> Unit, + onNodeDelete: (String) -> Unit, + onAddEdge: (String, String, String) -> Unit, + onEdgeClick: (GraphEdge) -> Unit, + onEdgeDelete: (GraphEdge) -> Unit, + nodeRegistry: NodeRegistry, + onChooseColor: (Color, (Color) -> Unit) -> Unit, + textureStore: TextureBitmapStore, + allTextureIds: Set, + onLoadTexture: () -> Unit, + strokeRenderer: CanvasStrokeRenderer, + textFieldsLocked: Boolean, + brush: Brush, + modifier: Modifier = Modifier, + onNodeMoveFinished: () -> Unit = {}, + onNodeLongPress: (String) -> Unit = {}, + onEdgeDetach: (GraphEdge) -> Unit = {}, + onFinalizeEdgeEdit: (GraphEdge, String, String, String) -> Unit = { _, _, _, _ -> }, + onCanvasClick: () -> Unit = {}, + onPortClick: (String, Port) -> Unit = { _, _ -> }, + onReorderPorts: (String, Int, Int) -> Unit = { _, _, _ -> }, + activeEdgeSourceId: String? = null, + selectedNodeId: String? = null, + selectedEdge: GraphEdge? = null, + detachedEdge: GraphEdge? = null, + onNodeDataUpdate: (String, NodeData) -> Unit = { _, _ -> }, + bottomPadding: Dp = 16.dp, + isSelectionMode: Boolean = false, + selectedNodeIds: Set = emptySet(), + onSelectAll: () -> Unit = {}, + onDuplicateSelected: () -> Unit = {}, + onDeleteSelected: () -> Unit = {}, + onDoneSelection: () -> Unit = {}, +) { + var pointerPos by remember { mutableStateOf(null) } + var draggingNodeId by remember { mutableStateOf(null) } + var draggingPointerPos by remember { mutableStateOf(null) } // In parent Box space + var activeSourcePort by remember { mutableStateOf(null) } + var canvasCoordinates by remember { mutableStateOf(null) } + + val currentZoom by androidx.compose.runtime.rememberUpdatedState(zoom) + val currentOffset by androidx.compose.runtime.rememberUpdatedState(offset) + val currentOnZoomChange by androidx.compose.runtime.rememberUpdatedState(onZoomChange) + val currentOnOffsetChange by androidx.compose.runtime.rememberUpdatedState(onOffsetChange) + val currentOnCanvasClick by androidx.compose.runtime.rememberUpdatedState(onCanvasClick) + + val currentGraph by androidx.compose.runtime.rememberUpdatedState(graph) + val currentOnEdgeClick by androidx.compose.runtime.rememberUpdatedState(onEdgeClick) + + val density = LocalDensity.current + val trashCenterPaddingPx = with(density) { (bottomPadding + 32.dp).toPx() } + + BoxWithConstraints(modifier = modifier.fillMaxSize()) { + val parentWidth = constraints.maxWidth.toFloat() + val parentHeight = constraints.maxHeight.toFloat() + + Box( + modifier = + Modifier.fillMaxSize() + .pointerInput(Unit) { + detectTransformGestures { centroid, pan, gestureZoom, _ -> + val newZoom = currentZoom * gestureZoom + currentOnZoomChange(newZoom) + // Ensure we zoom relative to the centroid. + val newOffset = (currentOffset - centroid) * gestureZoom + centroid + pan + currentOnOffsetChange(newOffset) + } + } + .pointerInput(Unit) { + detectTapGestures( + onTap = { tapOffset -> + val graphTap = (tapOffset - currentOffset) / currentZoom + currentGraph.edges + .find { edge -> + val fromNode = currentGraph.nodes.find { it.id == edge.fromNodeId } + val toNode = currentGraph.nodes.find { it.id == edge.toNodeId } + + val start = if (fromNode != null) { + nodeRegistry.getPortPosition(edge.fromNodeId, "output", currentGraph) + } else Offset.Zero + val end = if (toNode != null) { + nodeRegistry.getPortPosition(edge.toNodeId, edge.toPortId, currentGraph) + } else Offset.Zero + val threshold = 24f / currentZoom + val distance = distanceToSpline(graphTap, start, end) + distance < threshold + } + .let { edge -> + if (edge != null) { + currentOnEdgeClick(edge) + } else { + currentOnCanvasClick() + } + } + } + ) + } + ) { + Box( + modifier = + Modifier.fillMaxSize() + .graphicsLayer( + scaleX = zoom, + scaleY = zoom, + translationX = offset.x, + translationY = offset.y, + transformOrigin = TransformOrigin(0f, 0f), + ) + ) { + Box(modifier = Modifier.fillMaxSize().onGloballyPositioned { canvasCoordinates = it }) { + val outlineColor = MaterialTheme.colorScheme.outline + val activeEdgeColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.5f) + val selectedEdgeColor = MaterialTheme.colorScheme.primary + EdgeRenderer( + graph = graph, + detachedEdge = detachedEdge, + selectedEdge = selectedEdge, + activeSourcePort = activeSourcePort, + pointerPos = pointerPos, + nodeRegistry = nodeRegistry, + selectedEdgeColor = selectedEdgeColor, + outlineColor = outlineColor, + activeEdgeColor = activeEdgeColor + ) + + for (node in graph.nodes) { + key(node.id) { + NodeWidget( + node = node, + position = nodeRegistry.getNodePosition(node.id) ?: Offset.Zero, + graph = graph, + isActiveSource = node.id == activeSourcePort?.nodeId, + isSelected = node.id == selectedNodeId, + zoom = zoom, + onMove = { delta -> + val currentPos = nodeRegistry.getNodePosition(node.id) ?: Offset.Zero + nodeRegistry.updateNodePosition(node.id, currentPos + delta) + }, + onClick = { onNodeClick(node.id, nodeRegistry.getNodePosition(node.id) ?: Offset.Zero) }, + onUpdate = { onNodeDataUpdate(node.id, it) }, + onPortClick = onPortClick, + onReorderPorts = onReorderPorts, + onDragStart = { draggingNodeId = node.id }, + isSelectionMode = isSelectionMode, + isInSelectedSet = selectedNodeIds.contains(node.id), + onLongPress = { onNodeLongPress(node.id) }, + onDrag = { change -> + val nodePos = nodeRegistry.getNodePosition(node.id) ?: Offset.Zero + val nodePosInParent = Offset(nodePos.x * zoom, nodePos.y * zoom) + offset + draggingPointerPos = nodePosInParent + change.position * zoom + }, + onDragEnd = { + draggingPointerPos?.let { pos -> + val trashCenter = Offset(parentWidth / 2f, parentHeight - trashCenterPaddingPx) + if ((pos - trashCenter).getDistance() < 100f) { + onNodeDelete(node.id) + } + } + draggingNodeId = null + draggingPointerPos = null + onNodeMoveFinished() + }, + onPortDrag = { side, portId, isStart -> + if (!isSelectionMode) { + if (isStart) { + if (side == PortSide.OUTPUT) { + activeSourcePort = Port.Output(node.id, portId) + } else if (side == PortSide.INPUT) { + val edge = graph.edges.find { it.toNodeId == node.id && it.toPortId == portId && !it.isDisabled } + + if (edge != null) { + activeSourcePort = Port.Output(edge.fromNodeId, "output") + onEdgeDetach(edge) + } + } + } + } + }, + onPortDragUpdate = { pos -> + activeSourcePort?.let { sourcePort -> + val fromNodeId = sourcePort.nodeId + val fromNode = graph.nodes.find { it.id == fromNodeId } + if (fromNode != null) { + val snappedPort = nodeRegistry.findNearestPort(pos, fromNodeId, graph) + if (snappedPort != null) { + pointerPos = + nodeRegistry.getPortPosition( + snappedPort.nodeId, + snappedPort.id, + graph + ) + } else { + pointerPos = pos + } + } else { + pointerPos = pos + } + } + }, + onPortDragEnd = { + + val sourcePort = activeSourcePort + if (sourcePort != null) { + pointerPos?.let { pos -> + val fromNodeId = sourcePort.nodeId + val fromNode = graph.nodes.find { it.id == fromNodeId } + if (fromNode != null) { + val target = nodeRegistry.findNearestPort(pos, fromNodeId, graph) + + if (target != null) { + val currentDetached = detachedEdge + if (currentDetached != null) { + onFinalizeEdgeEdit(currentDetached, fromNodeId, target.nodeId, target.id) + } else { + onAddEdge(fromNodeId, target.nodeId, target.id) + } + } else { + detachedEdge?.let { + onEdgeDelete(it) + } + } + } + } + } + activeSourcePort = null + pointerPos = null + }, + getPortPosition = { portId, fallback -> nodeRegistry.getPortPosition(node.id, portId, graph, fallback) }, + onPortPositioned = { portId, pos -> nodeRegistry.updatePort(node.id, portId, pos) }, + onClearNodeCache = { nodeRegistry.clearNode(node.id) }, + canvasCoordinates = canvasCoordinates, + onChooseColor = onChooseColor, + allTextureIds = allTextureIds, + onLoadTexture = onLoadTexture, + strokeRenderer = strokeRenderer, + textFieldsLocked = textFieldsLocked, + brush = brush, + ) + } + } + } + } + + TrashCanArea( + graph = graph, + draggingNodeId = draggingNodeId, + draggingPointerPos = draggingPointerPos, + parentWidth = parentWidth, + parentHeight = parentHeight, + trashCenterPaddingPx = trashCenterPaddingPx, + bottomPadding = bottomPadding, + onNodeDelete = onNodeDelete, + modifier = Modifier.align(Alignment.BottomCenter) + ) + + SelectionActionMenu( + isSelectionMode = isSelectionMode, + onSelectAll = onSelectAll, + onDuplicateSelected = onDuplicateSelected, + onDeleteSelected = onDeleteSelected, + onDoneSelection = onDoneSelection, + modifier = Modifier.align(Alignment.TopStart) + ) + } + } +} + +/** + * Custom drag gesture detector that allows for a zoom-adjusted touch slop. This ensures that + * dragging to create an edge starts reliably even when the canvas is significantly zoomed in. + */ +suspend fun androidx.compose.ui.input.pointer.PointerInputScope.detectPortDragGestures( + zoom: Float, + onDragStart: (Offset) -> Unit = {}, + onDragEnd: () -> Unit = {}, + onDragCancel: () -> Unit = {}, + onDrag: (change: PointerInputChange, dragAmount: Offset) -> Unit, +) { + val touchSlop = viewConfiguration.touchSlop / zoom + + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false) + var drag: PointerInputChange? = null + var dragStartedCalled = false + + // Wait for the pointer to move beyond the (zoom-adjusted) touch slop. + val pointerId = down.id + var totalMainPositionChange = Offset.Zero + while (true) { + val event = awaitPointerEvent() + val dragEvent = event.changes.firstOrNull { it.id == pointerId } ?: break + if (dragEvent.isConsumed) break + if (dragEvent.changedToUpIgnoreConsumed()) break + + val positionChange = dragEvent.positionChange() + totalMainPositionChange += positionChange + val distance = totalMainPositionChange.getDistance() + if (distance >= touchSlop) { + onDragStart(dragEvent.position) + dragStartedCalled = true + onDrag(dragEvent, totalMainPositionChange) + if (dragEvent.isConsumed) { + drag = dragEvent + } + break + } + if (event.changes.all { it.changedToUpIgnoreConsumed() }) break + } + + if (drag != null || totalMainPositionChange.getDistance() >= touchSlop) { + val dragSuccessful = + drag(pointerId) { + onDrag(it, it.positionChange()) + it.consume() + } + if (!dragSuccessful) { + if (dragStartedCalled) onDragCancel() + } else { + if (dragStartedCalled) onDragEnd() + } + } + } +} + +@Composable +fun EdgeRenderer( + graph: BrushGraph, + detachedEdge: GraphEdge?, + selectedEdge: GraphEdge?, + activeSourcePort: Port?, + pointerPos: Offset?, + nodeRegistry: NodeRegistry, + selectedEdgeColor: Color, + outlineColor: Color, + activeEdgeColor: Color, +) { + Canvas(modifier = Modifier.fillMaxSize()) { + for (edge in graph.edges) { + if (edge == detachedEdge) continue + val fromNode = graph.nodes.find { it.id == edge.fromNodeId } + val toNode = graph.nodes.find { it.id == edge.toNodeId } + + val start = if (fromNode != null) { + nodeRegistry.getPortPosition(edge.fromNodeId, "output", graph) + } else Offset.Zero + val end = if (toNode != null) { + nodeRegistry.getPortPosition(edge.toNodeId, edge.toPortId, graph) + } else Offset.Zero + val isSelected = edge == selectedEdge + drawPath( + path = createSplinePath(start, end), + color = if (isSelected) selectedEdgeColor else outlineColor, + style = DrawStroke(width = if (isSelected) 6f else 3f), + alpha = if (edge.isDisabled) 0.38f else 1f, + ) + } + + // Draw temporary edge + val sourcePort = activeSourcePort + if (sourcePort != null && pointerPos != null) { + graph.nodes + .find { it.id == sourcePort.nodeId } + ?.let { node -> + val start = + nodeRegistry.getPortPosition(sourcePort.nodeId, sourcePort.id, graph) + drawPath( + path = createSplinePath(start, pointerPos), + color = activeEdgeColor, + style = DrawStroke(width = 3f), + ) + } + } + } +} + +@Composable +fun SelectionActionMenu( + isSelectionMode: Boolean, + onSelectAll: () -> Unit, + onDuplicateSelected: () -> Unit, + onDeleteSelected: () -> Unit, + onDoneSelection: () -> Unit, + modifier: Modifier = Modifier, +) { + var showDeleteConfirmation by remember { mutableStateOf(false) } + + if (isSelectionMode) { + if (showDeleteConfirmation) { + androidx.compose.material3.AlertDialog( + onDismissRequest = { showDeleteConfirmation = false }, + title = { Text(stringResource(R.string.bg_delete_nodes)) }, + text = { Text(stringResource(R.string.bg_delete_nodes_confirmation)) }, + confirmButton = { + TextButton( + onClick = { + onDeleteSelected() + showDeleteConfirmation = false + } + ) { + Text(stringResource(R.string.delete), color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { + TextButton(onClick = { showDeleteConfirmation = false }) { Text(stringResource(R.string.bg_cancel)) } + }, + ) + } + + Surface( + modifier = modifier + .padding(start = 16.dp, top = 80.dp) + .wrapContentSize(), + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.surfaceVariant, + tonalElevation = 4.dp + ) { + Row( + modifier = Modifier.padding(8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Button(onClick = onSelectAll) { + Text(stringResource(R.string.bg_select_all)) + } + Button(onClick = onDuplicateSelected) { + Text(stringResource(R.string.bg_duplicate)) + } + Button(onClick = { showDeleteConfirmation = true }) { + Text(stringResource(R.string.delete)) + } + Button(onClick = onDoneSelection) { + Text(stringResource(R.string.done)) + } + } + } + } +} + +@Composable +fun TrashCanArea( + graph: BrushGraph, + draggingNodeId: String?, + draggingPointerPos: Offset?, + parentWidth: Float, + parentHeight: Float, + trashCenterPaddingPx: Float, + bottomPadding: Dp, + onNodeDelete: (String) -> Unit, + modifier: Modifier = Modifier, +) { + val draggingNode = graph.nodes.find { it.id == draggingNodeId } + if (draggingNode != null && draggingNode.data !is NodeData.Family) { + Surface( + modifier = + modifier.padding(bottom = bottomPadding).graphicsLayer { + val isOver = + draggingPointerPos?.let { pos -> + val trashCenter = Offset(parentWidth / 2f, parentHeight - trashCenterPaddingPx) + (pos - trashCenter).getDistance() < 100f + } ?: false + scaleX = if (isOver) 1.2f else 1.0f + scaleY = if (isOver) 1.2f else 1.0f + }, + shape = CircleShape, + color = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.8f), + tonalElevation = 8.dp, + ) { + Box(modifier = Modifier.size(64.dp), contentAlignment = Alignment.Center) { + Icon( + Icons.Default.Delete, + contentDescription = stringResource(R.string.bg_cd_delete), + tint = MaterialTheme.colorScheme.onErrorContainer, + ) + } + } + } +} diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/Inspector.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/Inspector.kt new file mode 100644 index 0000000..d058d65 --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/Inspector.kt @@ -0,0 +1,348 @@ +/* + * * Copyright 2026 Google LLC. All rights reserved. + * * + * * Licensed under the Apache License, Version 2.0 (the "License"); + * * you may not use this file except in compliance with the License. + * * You may obtain a copy of the License at + * * + * * http://www.apache.org/licenses/LICENSE-2.0 + * * + * * Unless required by applicable law or agreed to in writing, software + * * distributed under the License is distributed on an "AS IS" BASIS, + * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * See the License for the specific language governing permissions and + * * limitations under the License. + */ +@file:OptIn(androidx.ink.brush.ExperimentalInkCustomBrushApi::class) + +package com.example.cahier.developer.brushgraph.ui + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Help +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.* +import com.example.cahier.developer.brushgraph.ui.asString +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import androidx.ink.rendering.android.canvas.CanvasStrokeRenderer +import com.example.cahier.developer.brushgraph.data.GraphEdge +import com.example.cahier.developer.brushgraph.data.GraphNode +import com.example.cahier.developer.brushgraph.data.NodeData +import com.example.cahier.developer.brushgraph.ui.fields.NodeFields +import com.example.cahier.developer.brushgraph.ui.TooltipDialog +import com.example.cahier.developer.brushgraph.ui.getTooltip +import androidx.compose.ui.res.stringResource +import com.example.cahier.R +import com.example.cahier.developer.brushgraph.data.DisplayText + +/** Shows connection details between two nodes and allows deletion. */ +@Composable +fun EdgeInspector( + edge: GraphEdge, + fromNode: GraphNode, + toNode: GraphNode, + onNodeFocus: (String) -> Unit, + onDisableChange: (Boolean) -> Unit, + onDelete: () -> Unit, + onAddNodeBetween: () -> Unit, + modifier: Modifier = Modifier, + inputLabel: DisplayText? = null, +) { + var showDeleteConfirmation by remember { mutableStateOf(false) } + + if (showDeleteConfirmation) { + AlertDialog( + onDismissRequest = { showDeleteConfirmation = false }, + title = { Text(stringResource(R.string.bg_delete_edge)) }, + text = { Text(stringResource(R.string.bg_delete_edge_confirmation)) }, + confirmButton = { + TextButton( + onClick = { + onDelete() + showDeleteConfirmation = false + } + ) { + Text(stringResource(R.string.delete), color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { + TextButton(onClick = { showDeleteConfirmation = false }) { Text(stringResource(R.string.bg_cancel)) } + }, + ) + } + + Column(modifier = modifier.fillMaxSize().padding(16.dp)) { + // From Node Section + EdgeNodeInfo(label = DisplayText.Resource(R.string.bg_label_from), node = fromNode, onClick = { onNodeFocus(fromNode.id) }) + if (fromNode.data is NodeData.Behavior && toNode.data is NodeData.Behavior) { + Spacer(Modifier.height(8.dp)) + Button( + onClick = { onAddNodeBetween() }, + modifier = Modifier.align(Alignment.CenterHorizontally) + ) { + Text(stringResource(R.string.bg_add_node_between)) + } + Spacer(Modifier.height(8.dp)) + } else { + Spacer(Modifier.height(16.dp)) + } + + // To Node Section + EdgeNodeInfo( + label = DisplayText.Resource(R.string.bg_label_to), + node = toNode, + inputLabel = inputLabel, + onClick = { onNodeFocus(toNode.id) }, + ) + + Spacer(modifier = Modifier.weight(1f)) + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp) + ) { + Checkbox( + checked = edge.isDisabled, + onCheckedChange = { checked -> + onDisableChange(checked) + } + ) + Spacer(Modifier.width(8.dp)) + Text(stringResource(R.string.bg_disable_edge)) + } + Spacer(Modifier.height(8.dp)) + + Button( + onClick = { showDeleteConfirmation = true }, + modifier = Modifier.fillMaxWidth().height(48.dp), + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError, + ), + ) { + Text(stringResource(R.string.delete)) + } + } +} + +@Composable +private fun EdgeNodeInfo( + label: DisplayText, + node: GraphNode, + inputLabel: DisplayText? = null, + onClick: () -> Unit, +) { + val title = node.data.title() + val subtitles = node.data.subtitles() + + Column(modifier = Modifier.fillMaxWidth().clickable { onClick() }.padding(8.dp)) { + Text( + text = label.asString(), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + ) + Text(text = stringResource(title), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + for (subtitle in subtitles) { + val text = subtitle.asString() + Text( + text = text, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (inputLabel != null) { + val labelText = inputLabel.asString() + Text( + text = stringResource(R.string.bg_label_input_with_value, labelText), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +/** Renders the content of the node inspector. */ +@Composable +fun NodeInspector( + node: GraphNode, + onUpdate: (NodeData) -> Unit, + onDisableChange: (Boolean) -> Unit, + onChooseColor: (Color, (Color) -> Unit) -> Unit, + allTextureIds: Set, + onLoadTexture: () -> Unit, + strokeRenderer: CanvasStrokeRenderer, + textFieldsLocked: Boolean, + onDelete: () -> Unit, + modifier: Modifier = Modifier, + onFieldEditComplete: () -> Unit = {}, + onDropdownEditComplete: () -> Unit = {}, +) { + var showDeleteConfirmation by remember { mutableStateOf(false) } + + if (showDeleteConfirmation) { + AlertDialog( + onDismissRequest = { showDeleteConfirmation = false }, + title = { Text(stringResource(R.string.bg_delete_node)) }, + text = { Text(stringResource(R.string.bg_delete_node_confirmation)) }, + confirmButton = { + TextButton( + onClick = { + onDelete() + showDeleteConfirmation = false + } + ) { + Text(stringResource(R.string.delete), color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { + TextButton(onClick = { showDeleteConfirmation = false }) { Text(stringResource(R.string.bg_cancel)) } + }, + ) + } + + Column(modifier = modifier.fillMaxSize().padding(16.dp)) { + Box(modifier = Modifier.weight(1f)) { + NodeFields( + node = node, + onUpdate = onUpdate, + onChooseColor = onChooseColor, + allTextureIds = allTextureIds, + onLoadTexture = onLoadTexture, + strokeRenderer = strokeRenderer, + textFieldsLocked = textFieldsLocked, + onFieldEditComplete = onFieldEditComplete, + onDropdownEditComplete = onDropdownEditComplete, + ) + } + + Spacer(Modifier.height(16.dp)) + + if (node.data !is NodeData.Family) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp) + ) { + Checkbox( + checked = node.isDisabled, + onCheckedChange = { checked -> + onDisableChange(checked) + } + ) + Spacer(Modifier.width(8.dp)) + Text(stringResource(R.string.bg_disable_node)) + } + Spacer(Modifier.height(8.dp)) + + Button( + onClick = { showDeleteConfirmation = true }, + modifier = Modifier.fillMaxWidth().height(48.dp), + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError, + ), + ) { + Text(stringResource(R.string.delete)) + } + } + } +} + +@Composable +fun AdaptiveInspectorPane( + isLandscape: Boolean, + visible: Boolean, + title: String, + onClose: () -> Unit, + modifier: Modifier = Modifier, + tooltipText: String? = null, + content: @Composable () -> Unit +) { + AnimatedVisibility( + visible = visible, + enter = + if (isLandscape) { + slideInHorizontally(initialOffsetX = { it }) + } else { + slideInVertically(initialOffsetY = { it }) + }, + exit = + if (isLandscape) { + slideOutHorizontally(targetOffsetX = { it }) + } else { + slideOutVertically(targetOffsetY = { it }) + }, + modifier = modifier.zIndex(10f), + ) { + Surface( + modifier = + if (isLandscape) { + Modifier.fillMaxHeight().width(INSPECTOR_WIDTH_LANDSCAPE.dp) + } else { + Modifier.fillMaxWidth().height(INSPECTOR_HEIGHT_PORTRAIT.dp) + }, + tonalElevation = 8.dp, + shadowElevation = 8.dp, + color = MaterialTheme.colorScheme.surface, + ) { + Column { + // Title bar with close button + Surface(color = MaterialTheme.colorScheme.surfaceVariant, tonalElevation = 2.dp) { + var showTooltip by remember { mutableStateOf(false) } + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp).fillMaxWidth(), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.weight(1f) + ) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + if (tooltipText != null) { + Spacer(Modifier.width(4.dp)) + IconButton(onClick = { showTooltip = true }, modifier = Modifier.size(24.dp)) { + Icon( + Icons.AutoMirrored.Filled.Help, + contentDescription = stringResource(R.string.bg_cd_help), + modifier = Modifier.size(16.dp) + ) + } + } + } + IconButton(onClick = onClose) { + Icon(Icons.Default.Close, contentDescription = stringResource(R.string.bg_content_description_close_inspector)) + } + if (showTooltip && tooltipText != null) { + TooltipDialog( + title = title, + text = tooltipText, + onDismiss = { showTooltip = false } + ) + } + } + } + Box(modifier = Modifier.fillMaxSize()) { + content() + } + } + } + } +} 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 new file mode 100644 index 0000000..eebf439 --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/TestCanvas.kt @@ -0,0 +1,258 @@ +/* + * * Copyright 2026 Google LLC. All rights reserved. + * * + * * Licensed under the Apache License, Version 2.0 (the "License"); + * * you may not use this file except in compliance with the License. + * * You may obtain a copy of the License at + * * + * * http://www.apache.org/licenses/LICENSE-2.0 + * * + * * Unless required by applicable law or agreed to in writing, software + * * distributed under the License is distributed on an "AS IS" BASIS, + * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * See the License for the specific language governing permissions and + * * limitations under the License. + */ +@file:OptIn(androidx.ink.brush.ExperimentalInkCustomBrushApi::class) + +package com.example.cahier.developer.brushgraph.ui + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.material3.Checkbox +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.ink.brush.Brush +import androidx.ink.brush.TextureBitmapStore +import androidx.ink.rendering.android.canvas.CanvasStrokeRenderer +import androidx.ink.strokes.Stroke +import com.example.cahier.core.ui.DrawingSurface +import com.example.cahier.developer.brushgraph.viewmodel.BrushGraphViewModel +import com.example.cahier.developer.brushgraph.data.TutorialAction +import androidx.compose.ui.res.stringResource +import com.example.cahier.R + +@Composable +fun TestCanvas( + viewModel: BrushGraphViewModel, + strokeList: List, + strokeRenderer: CanvasStrokeRenderer, + textureStore: TextureBitmapStore, + brush: Brush, + onStrokesAdded: (List) -> Unit, + isDark: Boolean = false, +) { + Box(modifier = Modifier.fillMaxSize()) { + Text( + stringResource(R.string.bg_test_canvas_draw_prompt), + modifier = Modifier.align(Alignment.Center), + style = MaterialTheme.typography.labelMedium, + color = + if (isDark) { + MaterialTheme.colorScheme.inverseOnSurface + } else { + MaterialTheme.colorScheme.onSurface + }, + ) + DrawingSurface( + strokes = strokeList, + canvasStrokeRenderer = strokeRenderer, + textureStore = textureStore, + onStrokesFinished = onStrokesAdded, + onErase = { _, _ -> }, + onEraseStart = {}, + onEraseEnd = {}, + currentBrush = brush, + onGetNextBrush = { viewModel.brush.value }, + isEraserMode = false, + backgroundImageUri = null, + onStartDrag = {}, + ) + } +} + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +fun CollapsiblePreviewPane( + viewModel: BrushGraphViewModel, + strokeRenderer: CanvasStrokeRenderer, + textureStore: TextureBitmapStore, + onChooseColor: (Color, (Color) -> Unit) -> Unit, +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + Column(modifier = Modifier.fillMaxWidth()) { + // Toggle Tab (always visible) + Surface( + modifier = + Modifier.fillMaxWidth().height(40.dp).clickable { viewModel.togglePreviewExpanded() }, + color = MaterialTheme.colorScheme.surfaceVariant, + tonalElevation = 4.dp, + shadowElevation = 8.dp, + ) { + Box( + modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp), + contentAlignment = Alignment.Center, + ) { + Row( + modifier = Modifier.align(Alignment.CenterStart), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + if (uiState.isPreviewExpanded) { + Icons.Default.KeyboardArrowDown + } else { + Icons.Default.KeyboardArrowUp + }, + contentDescription = if (uiState.isPreviewExpanded) stringResource(R.string.bg_test_canvas_collapse) else stringResource(R.string.bg_test_canvas_expand), + ) + Spacer(Modifier.width(8.dp)) + Text( + stringResource(R.string.bg_test_canvas_title), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + ) + if (uiState.isPreviewExpanded) { + Spacer(Modifier.width(16.dp)) + Text( + stringResource(R.string.bg_test_canvas_reset), + modifier = Modifier.clickable { viewModel.clearStrokes() }, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold, + ) + Spacer(Modifier.width(16.dp)) + Text( + stringResource(R.string.bg_test_canvas_invert), + modifier = Modifier.clickable { viewModel.toggleCanvasTheme() }, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold, + ) + Spacer(Modifier.width(16.dp)) + + // Auto-update toggle + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox( + checked = uiState.testAutoUpdateStrokes, + onCheckedChange = { viewModel.setTestAutoUpdateStrokes(it) } + ) + Spacer(Modifier.width(4.dp)) + Text(stringResource(R.string.bg_auto_update), style = MaterialTheme.typography.labelLarge) + } + Spacer(Modifier.width(16.dp)) + + // Color picker + Box( + modifier = Modifier + .size(20.dp) + .background(Color(uiState.testBrushColor ?: 0)) + .border(1.dp, MaterialTheme.colorScheme.outline) + .clickable { + onChooseColor(Color(uiState.testBrushColor ?: 0)) { newColor -> + viewModel.updateTestBrushColor(newColor.toArgb()) + } + } + ) + Spacer(Modifier.width(16.dp)) + + // Size selector + var sizeExpanded by remember { mutableStateOf(false) } + ExposedDropdownMenuBox( + expanded = sizeExpanded, + onExpandedChange = { sizeExpanded = it }, + modifier = Modifier.width(80.dp) + ) { + Text( + text = "${uiState.testBrushSize.toInt()}px", + modifier = Modifier.menuAnchor().clickable { sizeExpanded = true }, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold, + ) + ExposedDropdownMenu( + expanded = sizeExpanded, + onDismissRequest = { sizeExpanded = false } + ) { + for (size in 10..50 step 10) { + DropdownMenuItem( + text = { Text(stringResource(R.string.bg_size_px, size)) }, + onClick = { + viewModel.updateTestBrushSize(size.toFloat()) + sizeExpanded = false + } + ) + } + } + } + } + } + } + } + + // Expanding Drawer Content + AnimatedVisibility( + visible = uiState.isPreviewExpanded, + enter = expandVertically(), + exit = shrinkVertically(), + ) { + Surface( + modifier = + Modifier.fillMaxWidth().height((PREVIEW_HEIGHT_EXPANDED - PREVIEW_HEIGHT_COLLAPSED).dp), + tonalElevation = 8.dp, + color = + if (uiState.isDarkCanvas) { + MaterialTheme.colorScheme.inverseSurface + } else { + MaterialTheme.colorScheme.surface + }, + ) { + TestCanvas( + viewModel = viewModel, + strokeList = viewModel.strokeList, + strokeRenderer = strokeRenderer, + textureStore = textureStore, + brush = viewModel.brush.collectAsStateWithLifecycle().value, + onStrokesAdded = { + viewModel.strokeList.addAll(it) + viewModel.advanceTutorial(TutorialAction.DRAW_ON_CANVAS) + }, + isDark = uiState.isDarkCanvas, + ) + } + } + } +} From 54ad028dc58d189a0e520f95c68f14428e1abf2b Mon Sep 17 00:00:00 2001 From: Maxwell Metzger Mitchell <60010512+maxmmitchell@users.noreply.github.com> Date: Tue, 28 Apr 2026 07:26:36 +0000 Subject: [PATCH 2/4] Gemini review comments --- .../cahier/developer/brushgraph/data/GraphDataModel.kt | 6 +++++- .../cahier/developer/brushgraph/ui/GraphCameraController.kt | 4 +++- .../example/cahier/developer/brushgraph/ui/GraphCanvas.kt | 6 +++--- .../example/cahier/developer/brushgraph/ui/GraphLayout.kt | 2 +- 4 files changed, 12 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/data/GraphDataModel.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/data/GraphDataModel.kt index cd5bb6c..dc93d46 100644 --- a/app/src/main/java/com/example/cahier/developer/brushgraph/data/GraphDataModel.kt +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/data/GraphDataModel.kt @@ -395,7 +395,11 @@ sealed class Port( ) { abstract val side: PortSide - class Output(nodeId: String, id: String = "output", label: DisplayText? = null) : + companion object { + const val OUTPUT_PORT_ID = "output" + } + + class Output(nodeId: String, id: String = OUTPUT_PORT_ID, label: DisplayText? = null) : Port(nodeId, id, label, isAddPort = false) { override val side = PortSide.OUTPUT } 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 3dd140b..500b30b 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 @@ -33,6 +33,8 @@ import com.example.cahier.developer.brushgraph.data.TutorialAnchor import com.example.cahier.developer.brushgraph.data.TutorialStep import com.example.cahier.developer.brushgraph.ui.node.NodeRegistry +private const val TUTORIAL_TARGET_Y = 280f + @Composable fun GraphCameraController( offset: Offset, @@ -58,7 +60,7 @@ fun GraphCameraController( val node = step.getTargetNode(graph) if (node != null) { val density = context.resources.displayMetrics.density - val targetY = 280f * density + val targetY = TUTORIAL_TARGET_Y * density val targetX = maxWidthDp.value * density / 2f val newOffset = calculateFocusOffset( diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/GraphCanvas.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/GraphCanvas.kt index ddfbefb..dbbbdea 100644 --- a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/GraphCanvas.kt +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/GraphCanvas.kt @@ -170,7 +170,7 @@ fun GraphCanvas( val toNode = currentGraph.nodes.find { it.id == edge.toNodeId } val start = if (fromNode != null) { - nodeRegistry.getPortPosition(edge.fromNodeId, "output", currentGraph) + nodeRegistry.getPortPosition(edge.fromNodeId, Port.OUTPUT_PORT_ID, currentGraph) } else Offset.Zero val end = if (toNode != null) { nodeRegistry.getPortPosition(edge.toNodeId, edge.toPortId, currentGraph) @@ -263,7 +263,7 @@ fun GraphCanvas( val edge = graph.edges.find { it.toNodeId == node.id && it.toPortId == portId && !it.isDisabled } if (edge != null) { - activeSourcePort = Port.Output(edge.fromNodeId, "output") + activeSourcePort = Port.Output(edge.fromNodeId) onEdgeDetach(edge) } } @@ -435,7 +435,7 @@ fun EdgeRenderer( val toNode = graph.nodes.find { it.id == edge.toNodeId } val start = if (fromNode != null) { - nodeRegistry.getPortPosition(edge.fromNodeId, "output", graph) + nodeRegistry.getPortPosition(edge.fromNodeId, Port.OUTPUT_PORT_ID, graph) } else Offset.Zero val end = if (toNode != null) { nodeRegistry.getPortPosition(edge.toNodeId, edge.toPortId, graph) diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/GraphLayout.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/GraphLayout.kt index e39f8b0..6a32758 100644 --- a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/GraphLayout.kt +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/GraphLayout.kt @@ -20,7 +20,6 @@ import com.example.cahier.developer.brushgraph.data.BrushGraph import com.example.cahier.developer.brushgraph.data.GraphNode import com.example.cahier.developer.brushgraph.data.NodeData import com.example.cahier.developer.brushgraph.data.Port -import java.util.UUID const val NODE_WIDTH = 300f const val NODE_PADDING_VERTICAL = 8f @@ -180,6 +179,7 @@ object GraphLayout { nodeSubtreeMaxY: MutableMap ): Float { val node = graph.nodes.find { it.id == nodeId } ?: return desiredY + if (depth > 100) return desiredY // Prevent stack overflow on extremely deep graphs val data = node.data as? NodeData.Behavior ?: return desiredY if (assignedNodeIds.contains(nodeId)) { From ff0524307ace09b7fca883855d8d8a8901e77f27 Mon Sep 17 00:00:00 2001 From: Maxwell Metzger Mitchell <60010512+maxmmitchell@users.noreply.github.com> Date: Fri, 1 May 2026 18:20:31 +0000 Subject: [PATCH 3/4] Support more brush sizes + display error in test canvas --- .../developer/brushgraph/ui/TestCanvas.kt | 113 +++++++++++++----- app/src/main/res/values/strings.xml | 2 + 2 files changed, 85 insertions(+), 30 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 eebf439..f75d1bd 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 @@ -28,14 +28,19 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Error +import androidx.compose.material.icons.filled.Warning import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.Box import androidx.compose.material3.Checkbox import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api @@ -63,18 +68,22 @@ import androidx.ink.strokes.Stroke import com.example.cahier.core.ui.DrawingSurface import com.example.cahier.developer.brushgraph.viewmodel.BrushGraphViewModel import com.example.cahier.developer.brushgraph.data.TutorialAction +import com.example.cahier.developer.brushgraph.data.GraphValidationException +import com.example.cahier.developer.brushgraph.data.ValidationSeverity +import com.example.cahier.core.ui.theme.extendedColorScheme import androidx.compose.ui.res.stringResource import com.example.cahier.R @Composable fun TestCanvas( - viewModel: BrushGraphViewModel, + isInvertedCanvas: Boolean, strokeList: List, strokeRenderer: CanvasStrokeRenderer, textureStore: TextureBitmapStore, brush: Brush, + modifier: Modifier = Modifier, + onGetNextBrush: () -> Brush, onStrokesAdded: (List) -> Unit, - isDark: Boolean = false, ) { Box(modifier = Modifier.fillMaxSize()) { Text( @@ -82,7 +91,7 @@ fun TestCanvas( modifier = Modifier.align(Alignment.Center), style = MaterialTheme.typography.labelMedium, color = - if (isDark) { + if (isInvertedCanvas) { MaterialTheme.colorScheme.inverseOnSurface } else { MaterialTheme.colorScheme.onSurface @@ -97,7 +106,7 @@ fun TestCanvas( onEraseStart = {}, onEraseEnd = {}, currentBrush = brush, - onGetNextBrush = { viewModel.brush.value }, + onGetNextBrush = onGetNextBrush, isEraserMode = false, backgroundImageUri = null, onStartDrag = {}, @@ -108,17 +117,33 @@ fun TestCanvas( @Composable @OptIn(ExperimentalMaterial3Api::class) fun CollapsiblePreviewPane( - viewModel: BrushGraphViewModel, + isPreviewExpanded: Boolean, + isInvertedCanvas: Boolean, + testAutoUpdateStrokes: Boolean, + brushColor: Int, + brushSize: Float, + brush: Brush, + strokeList: List, strokeRenderer: CanvasStrokeRenderer, textureStore: TextureBitmapStore, + topIssue: GraphValidationException?, + modifier: Modifier = Modifier, + onGetNextBrush: () -> Brush, + onTogglePreviewExpanded: () -> Unit, + onClearStrokes: () -> Unit, + onToggleCanvasTheme: () -> Unit, + onSetTestAutoUpdateStrokes: (Boolean) -> Unit, + onUpdateTestBrushColor: (Int) -> Unit, + onUpdateTestBrushSize: (Float) -> Unit, + onStrokesAdded: (List) -> Unit, onChooseColor: (Color, (Color) -> Unit) -> Unit, + onToggleNotificationPane: () -> Unit, ) { - val uiState by viewModel.uiState.collectAsStateWithLifecycle() Column(modifier = Modifier.fillMaxWidth()) { // Toggle Tab (always visible) Surface( modifier = - Modifier.fillMaxWidth().height(40.dp).clickable { viewModel.togglePreviewExpanded() }, + modifier.fillMaxWidth().height(40.dp).clickable { onTogglePreviewExpanded() }, color = MaterialTheme.colorScheme.surfaceVariant, tonalElevation = 4.dp, shadowElevation = 8.dp, @@ -132,12 +157,12 @@ fun CollapsiblePreviewPane( verticalAlignment = Alignment.CenterVertically, ) { Icon( - if (uiState.isPreviewExpanded) { + if (isPreviewExpanded) { Icons.Default.KeyboardArrowDown } else { Icons.Default.KeyboardArrowUp }, - contentDescription = if (uiState.isPreviewExpanded) stringResource(R.string.bg_test_canvas_collapse) else stringResource(R.string.bg_test_canvas_expand), + contentDescription = if (isPreviewExpanded) stringResource(R.string.bg_test_canvas_collapse) else stringResource(R.string.bg_test_canvas_expand), ) Spacer(Modifier.width(8.dp)) Text( @@ -145,11 +170,11 @@ fun CollapsiblePreviewPane( style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.Bold, ) - if (uiState.isPreviewExpanded) { + if (isPreviewExpanded) { Spacer(Modifier.width(16.dp)) Text( stringResource(R.string.bg_test_canvas_reset), - modifier = Modifier.clickable { viewModel.clearStrokes() }, + modifier = Modifier.clickable { onClearStrokes() }, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.Bold, @@ -157,7 +182,7 @@ fun CollapsiblePreviewPane( Spacer(Modifier.width(16.dp)) Text( stringResource(R.string.bg_test_canvas_invert), - modifier = Modifier.clickable { viewModel.toggleCanvasTheme() }, + modifier = Modifier.clickable { onToggleCanvasTheme() }, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.Bold, @@ -167,8 +192,8 @@ fun CollapsiblePreviewPane( // Auto-update toggle Row(verticalAlignment = Alignment.CenterVertically) { Checkbox( - checked = uiState.testAutoUpdateStrokes, - onCheckedChange = { viewModel.setTestAutoUpdateStrokes(it) } + checked = testAutoUpdateStrokes, + onCheckedChange = { onSetTestAutoUpdateStrokes(it) } ) Spacer(Modifier.width(4.dp)) Text(stringResource(R.string.bg_auto_update), style = MaterialTheme.typography.labelLarge) @@ -179,11 +204,11 @@ fun CollapsiblePreviewPane( Box( modifier = Modifier .size(20.dp) - .background(Color(uiState.testBrushColor ?: 0)) + .background(Color(brushColor)) .border(1.dp, MaterialTheme.colorScheme.outline) .clickable { - onChooseColor(Color(uiState.testBrushColor ?: 0)) { newColor -> - viewModel.updateTestBrushColor(newColor.toArgb()) + onChooseColor(Color(brushColor)) { newColor -> + onUpdateTestBrushColor(newColor.toArgb()) } } ) @@ -197,7 +222,7 @@ fun CollapsiblePreviewPane( modifier = Modifier.width(80.dp) ) { Text( - text = "${uiState.testBrushSize.toInt()}px", + text = "${brushSize.toInt()}px", modifier = Modifier.menuAnchor().clickable { sizeExpanded = true }, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary, @@ -207,17 +232,48 @@ fun CollapsiblePreviewPane( expanded = sizeExpanded, onDismissRequest = { sizeExpanded = false } ) { - for (size in 10..50 step 10) { + for (size in (2..4 step 1) + (6..10 step 2) + (20..40 step 10) + (50..100 step 25)) { DropdownMenuItem( text = { Text(stringResource(R.string.bg_size_px, size)) }, onClick = { - viewModel.updateTestBrushSize(size.toFloat()) + onUpdateTestBrushSize(size.toFloat()) sizeExpanded = false } ) } } } + + // Top issue, if one is present + if (topIssue != null) { + val isError = topIssue.severity == ValidationSeverity.ERROR + Surface( + color = if (isError) MaterialTheme.colorScheme.error else MaterialTheme.extendedColorScheme.warning, + tonalElevation = 2.dp, + modifier = Modifier.weight(1f).fillMaxHeight().clickable { onToggleNotificationPane() } + ) { + Box( + modifier = Modifier.fillMaxSize().padding(start = 16.dp), + contentAlignment = Alignment.CenterStart + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = if (isError) Icons.Default.Error else Icons.Default.Warning, + contentDescription = null, + tint = if (isError) MaterialTheme.colorScheme.onError else MaterialTheme.extendedColorScheme.onWarning, + modifier = Modifier.size(20.dp) + ) + Spacer(Modifier.width(8.dp)) + Text( + text = stringResource(if (isError) R.string.bg_error else R.string.bg_warning, topIssue.displayMessage.asString()), + style = MaterialTheme.typography.labelLarge, + color = if (isError) MaterialTheme.colorScheme.onError else MaterialTheme.extendedColorScheme.onWarning, + fontWeight = FontWeight.Bold, + ) + } + } + } + } } } } @@ -225,7 +281,7 @@ fun CollapsiblePreviewPane( // Expanding Drawer Content AnimatedVisibility( - visible = uiState.isPreviewExpanded, + visible = isPreviewExpanded, enter = expandVertically(), exit = shrinkVertically(), ) { @@ -234,23 +290,20 @@ fun CollapsiblePreviewPane( Modifier.fillMaxWidth().height((PREVIEW_HEIGHT_EXPANDED - PREVIEW_HEIGHT_COLLAPSED).dp), tonalElevation = 8.dp, color = - if (uiState.isDarkCanvas) { + if (isInvertedCanvas) { MaterialTheme.colorScheme.inverseSurface } else { MaterialTheme.colorScheme.surface }, ) { TestCanvas( - viewModel = viewModel, - strokeList = viewModel.strokeList, + strokeList = strokeList, strokeRenderer = strokeRenderer, textureStore = textureStore, - brush = viewModel.brush.collectAsStateWithLifecycle().value, - onStrokesAdded = { - viewModel.strokeList.addAll(it) - viewModel.advanceTutorial(TutorialAction.DRAW_ON_CANVAS) - }, - isDark = uiState.isDarkCanvas, + brush = brush, + isInvertedCanvas = isInvertedCanvas, + onGetNextBrush = onGetNextBrush, + onStrokesAdded = onStrokesAdded, ) } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b9206c7..4805c40 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -389,7 +389,9 @@ Close Pane Notifications (%1$d) Errors + Error: %1$s Warnings + Warning: %1$s Debug Add Point Remove Point From c2505db505d364b77788f68bab6da0cfd80697d2 Mon Sep 17 00:00:00 2001 From: Maxwell Metzger Mitchell <60010512+maxmmitchell@users.noreply.github.com> Date: Fri, 1 May 2026 19:54:24 +0000 Subject: [PATCH 4/4] Fix multi node movement --- .../cahier/developer/brushgraph/ui/GraphCanvas.kt | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/GraphCanvas.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/GraphCanvas.kt index dbbbdea..bc99442 100644 --- a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/GraphCanvas.kt +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/GraphCanvas.kt @@ -227,8 +227,15 @@ fun GraphCanvas( isSelected = node.id == selectedNodeId, zoom = zoom, onMove = { delta -> - val currentPos = nodeRegistry.getNodePosition(node.id) ?: Offset.Zero - nodeRegistry.updateNodePosition(node.id, currentPos + delta) + if (isSelectionMode && selectedNodeIds.contains(node.id)) { + selectedNodeIds.forEach { selId -> + val currentPos = nodeRegistry.getNodePosition(selId) ?: Offset.Zero + nodeRegistry.updateNodePosition(selId, currentPos + delta) + } + } else { + val currentPos = nodeRegistry.getNodePosition(node.id) ?: Offset.Zero + nodeRegistry.updateNodePosition(node.id, currentPos + delta) + } }, onClick = { onNodeClick(node.id, nodeRegistry.getNodePosition(node.id) ?: Offset.Zero) }, onUpdate = { onNodeDataUpdate(node.id, it) },